@@ -5,17 +5,33 @@ import * as NodePath from "node:path";
55
66import * as NodeServices from "@effect/platform-node/NodeServices" ;
77import { assert , it } from "@effect/vitest" ;
8- import { EnvironmentInternalError } from "@t3tools/contracts" ;
8+ import {
9+ CommandId ,
10+ EnvironmentInternalError ,
11+ EventId ,
12+ ProviderInstanceId ,
13+ ThreadId ,
14+ type OrchestrationV2AppThread ,
15+ type ProjectId ,
16+ } from "@t3tools/contracts" ;
917import * as NetService from "@t3tools/shared/Net" ;
18+ import * as DateTime from "effect/DateTime" ;
1019import * as Effect from "effect/Effect" ;
1120import * as FileSystem from "effect/FileSystem" ;
1221import * as Layer from "effect/Layer" ;
1322import * as References from "effect/References" ;
23+ import * as Stream from "effect/Stream" ;
1424import { Command } from "effect/unstable/cli" ;
1525
1626import { cli } from "../bin.ts" ;
1727import * as ServerConfig from "../config.ts" ;
18- import { ProjectServiceLayerLive } from "../orchestration-v2/runtimeLayer.ts" ;
28+ import { EventSinkV2 } from "../orchestration-v2/EventSink.ts" ;
29+ import * as EventStore from "../orchestration-v2/EventStore.ts" ;
30+ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts" ;
31+ import {
32+ OrchestrationV2EventSinkLayerLive ,
33+ ProjectServiceLayerLive ,
34+ } from "../orchestration-v2/runtimeLayer.ts" ;
1935import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts" ;
2036import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts" ;
2137import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts" ;
@@ -146,6 +162,189 @@ const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixt
146162 return { baseDir, workspaceRoot, project : project ! } ;
147163} ) ;
148164
165+ const makeThreadPersistenceLayer = Effect . fn ( "ProjectCliTest.makeThreadPersistenceLayer" ) (
166+ function * ( baseDir : string ) {
167+ const config = yield * makeConfig ( baseDir ) ;
168+ return Layer . mergeAll (
169+ OrchestrationV2EventSinkLayerLive ,
170+ ProjectionStore . layer ,
171+ EventStore . layer ,
172+ ) . pipe (
173+ Layer . provideMerge ( SqlitePersistenceLayerLive ) ,
174+ Layer . provideMerge ( NodeServices . layer ) ,
175+ Layer . provide ( ServerConfig . layer ( config ) ) ,
176+ Layer . provide ( Layer . succeed ( References . MinimumLogLevel , config . logLevel ) ) ,
177+ ) ;
178+ } ,
179+ ) ;
180+
181+ const seedNativeThreads = Effect . fn ( "ProjectCliTest.seedNativeThreads" ) ( function * (
182+ baseDir : string ,
183+ threads : ReadonlyArray < {
184+ readonly id : ThreadId ;
185+ readonly projectId : ProjectId ;
186+ readonly archived : boolean ;
187+ } > ,
188+ ) {
189+ const layer = yield * makeThreadPersistenceLayer ( baseDir ) ;
190+ const createdAt = DateTime . makeUnsafe ( "2026-09-04T12:00:00.000Z" ) ;
191+ const providerInstanceId = ProviderInstanceId . make ( "codex" ) ;
192+ yield * Effect . gen ( function * ( ) {
193+ const eventSink = yield * EventSinkV2 ;
194+ yield * eventSink . write ( {
195+ commandId : CommandId . make ( "project-cli-seed-threads" ) ,
196+ events : threads . map ( ( { id, projectId, archived } ) => {
197+ const payload : OrchestrationV2AppThread = {
198+ createdBy : "user" ,
199+ creationSource : "web" ,
200+ id,
201+ projectId,
202+ title : id ,
203+ providerInstanceId,
204+ modelSelection : { instanceId : providerInstanceId , model : "gpt-5" } ,
205+ runtimeMode : "full-access" ,
206+ interactionMode : "default" ,
207+ branch : null ,
208+ worktreePath : null ,
209+ activeProviderThreadId : null ,
210+ lineage : { parentThreadId : null , relationshipToParent : null , rootThreadId : id } ,
211+ forkedFrom : null ,
212+ createdAt,
213+ updatedAt : createdAt ,
214+ archivedAt : archived ? createdAt : null ,
215+ settledOverride : null ,
216+ settledAt : null ,
217+ lastVisitedAt : null ,
218+ deletedAt : null ,
219+ } ;
220+ return {
221+ id : EventId . make ( `project-cli-create-${ id } ` ) ,
222+ type : "thread.created" as const ,
223+ threadId : id ,
224+ providerInstanceId,
225+ occurredAt : createdAt ,
226+ payload,
227+ } ;
228+ } ) ,
229+ } ) ;
230+ } ) . pipe ( Effect . provide ( layer ) ) ;
231+ } ) ;
232+
233+ const readNativeThreadState = Effect . fn ( "ProjectCliTest.readNativeThreadState" ) ( function * (
234+ baseDir : string ,
235+ threadId : ThreadId ,
236+ ) {
237+ const layer = yield * makeThreadPersistenceLayer ( baseDir ) ;
238+ return yield * Effect . gen ( function * ( ) {
239+ const projections = yield * ProjectionStore . ProjectionStoreV2 ;
240+ const events = yield * EventStore . EventStoreV2 ;
241+ return {
242+ thread : yield * projections . getThread ( threadId ) ,
243+ events : yield * events . read ( { threadId } ) . pipe ( Stream . runCollect ) ,
244+ } ;
245+ } ) . pipe ( Effect . provide ( layer ) ) ;
246+ } ) ;
247+
248+ it . layer ( NodeServices . layer ) ( "project deletion with native V2 threads" , ( it ) => {
249+ it . effect . each ( [
250+ { label : "an active thread" , archived : false , missing : false } ,
251+ { label : "an archived thread" , archived : true , missing : false } ,
252+ {
253+ label : "an active thread after its workspace disappears" ,
254+ archived : false ,
255+ missing : true ,
256+ } ,
257+ {
258+ label : "an archived thread after its workspace disappears" ,
259+ archived : true ,
260+ missing : true ,
261+ } ,
262+ ] ) ( "rejects unforced removal of a project with $label" , ( { archived, missing } ) =>
263+ Effect . gen ( function * ( ) {
264+ const fs = yield * FileSystem . FileSystem ;
265+ const { baseDir, workspaceRoot, project } = yield * makeProjectLookupFixture ( ) ;
266+ const threadId = ThreadId . make ( "project-cli-preserved-thread" ) ;
267+ yield * seedNativeThreads ( baseDir , [ { id : threadId , projectId : project . id , archived } ] ) ;
268+ const before = yield * readNativeThreadState ( baseDir , threadId ) ;
269+ if ( missing ) yield * fs . rename ( workspaceRoot , `${ workspaceRoot } -removed` ) ;
270+
271+ const error = yield * runCli ( [
272+ "project" ,
273+ "remove" ,
274+ missing ? workspaceRoot : project . id ,
275+ "--base-dir" ,
276+ baseDir ,
277+ ] ) . pipe (
278+ Effect . match ( {
279+ onFailure : ( error ) => error ,
280+ onSuccess : ( ) => assert . fail ( "Removing a nonempty project must require --force." ) ,
281+ } ) ,
282+ ) ;
283+
284+ assert . include ( error . message , "not empty" ) ;
285+ assert . deepEqual (
286+ ( yield * readProjects ( baseDir ) ) . projects . map ( ( entry ) => entry . id ) ,
287+ [ project . id ] ,
288+ ) ;
289+ assert . deepEqual ( yield * readNativeThreadState ( baseDir , threadId ) , before ) ;
290+ assert . equal ( yield * fs . exists ( workspaceRoot ) , ! missing ) ;
291+ } ) ,
292+ ) ;
293+
294+ it . effect . each ( [ "present" , "missing" ] as const ) (
295+ "force-removes active and archived V2 threads with the workspace %s, preserving unrelated projects" ,
296+ ( workspace ) =>
297+ Effect . gen ( function * ( ) {
298+ const fs = yield * FileSystem . FileSystem ;
299+ const { baseDir, workspaceRoot, project } = yield * makeProjectLookupFixture ( ) ;
300+ const otherWorkspace = `${ workspaceRoot } -other` ;
301+ yield * fs . makeDirectory ( otherWorkspace ) ;
302+ yield * runCli ( [ "project" , "add" , otherWorkspace , "--base-dir" , baseDir ] ) ;
303+ const otherProject = ( yield * readProjects ( baseDir ) ) . projects . find (
304+ ( entry ) => entry . workspaceRoot === otherWorkspace ,
305+ ) ;
306+ assert . isDefined ( otherProject ) ;
307+ const activeId = ThreadId . make ( "project-cli-deleted-active" ) ;
308+ const archivedId = ThreadId . make ( "project-cli-deleted-archived" ) ;
309+ const unrelatedId = ThreadId . make ( "project-cli-unrelated-thread" ) ;
310+ yield * seedNativeThreads ( baseDir , [
311+ { id : activeId , projectId : project . id , archived : false } ,
312+ { id : archivedId , projectId : project . id , archived : true } ,
313+ { id : unrelatedId , projectId : otherProject ! . id , archived : false } ,
314+ ] ) ;
315+ const unrelatedBefore = yield * readNativeThreadState ( baseDir , unrelatedId ) ;
316+ if ( workspace === "missing" ) {
317+ yield * fs . rename ( workspaceRoot , `${ workspaceRoot } -removed` ) ;
318+ }
319+
320+ yield * runCli ( [
321+ "project" ,
322+ "remove" ,
323+ workspace === "missing" ? workspaceRoot : project . id ,
324+ "--force" ,
325+ "--base-dir" ,
326+ baseDir ,
327+ ] ) ;
328+
329+ assert . deepEqual (
330+ ( yield * readProjects ( baseDir ) ) . projects . map ( ( entry ) => entry . id ) ,
331+ [ otherProject ! . id ] ,
332+ ) ;
333+ for ( const threadId of [ activeId , archivedId ] ) {
334+ const state = yield * readNativeThreadState ( baseDir , threadId ) ;
335+ assert . isNotNull ( state . thread . deletedAt ) ;
336+ assert . lengthOf (
337+ state . events . filter ( ( record ) => record . event . type === "thread.deleted" ) ,
338+ 1 ,
339+ ) ;
340+ }
341+ assert . deepEqual ( yield * readNativeThreadState ( baseDir , unrelatedId ) , unrelatedBefore ) ;
342+ assert . equal ( yield * fs . exists ( workspaceRoot ) , workspace === "present" ) ;
343+ assert . isTrue ( yield * fs . exists ( otherWorkspace ) ) ;
344+ } ) ,
345+ ) ;
346+ } ) ;
347+
149348it . layer ( NodeServices . layer ) ( "project lookup with unavailable workspaces" , ( it ) => {
150349 it . effect . each ( [ "id" , "stored path" ] as const ) (
151350 "removes an empty project by %s after its directory is gone" ,
0 commit comments