1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 CommandId,
5 EventId,
6 type OrchestrationV2AppThread,
7 ProjectId,
8 ProviderInstanceId,
9 ThreadId,
10} from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16import { TestClock } from "effect/testing";
17import * as SqlClient from "effect/sql/SqlClient";
18
19import * as ServerConfig from "../config.ts";
20import { OrchestrationEffectRequestV2 } from "../orchestration-v2/EffectOutbox.ts";
21import * as EventSink from "../orchestration-v2/EventSink.ts";
22import * as EventStore from "../orchestration-v2/EventStore.ts";
23import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
24import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts";
25import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts";
26import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
27import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
28import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
29import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
30import * as SqlitePersistence from "../persistence/Sqlite.ts";
31import * as ServerSettings from "../serverSettings.ts";
32import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
33import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
34import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
35import * as ProjectService from "./ProjectService.ts";
36import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
37
38const layerEventPersistence = EventSink.layer.pipe(
39 Layer.provideMerge(Layer.merge(EventStore.layer, ProjectionStore.layer)),
40);
41const layerServices = Layer.mergeAll(
42 LegacyV1ThreadImporter.layer.pipe(Layer.provideMerge(layerEventPersistence)),
43 ProjectionMaintenance.layer.pipe(Layer.provide(layerEventPersistence)),
44 ProjectStore.layer,
45 IdAllocator.layer,
46 ThreadCommandExecutor.layer,
47 Layer.succeed(WorkspacePaths.WorkspacePaths, {
48 normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot),
49 resolveRelativePathWithinRoot: ({ workspaceRoot, relativePath }) =>
50 Effect.succeed({ absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }),
51 }),
52).pipe(
53 Layer.provideMerge(
54 ProjectEnrichmentService.layer.pipe(
55 Layer.provide(
56 Layer.merge(
57 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
58 resolve: () => Effect.succeed(null),
59 }),
60 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
61 resolvePath: () => Effect.succeed(null),
62 }),
63 ),
64 ),
65 ),
66 ),
67 Layer.provideMerge(ServerSettings.layerTest()),
68);
69const layerDatabase = SqlitePersistence.layerMemory.pipe(
70 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-deletion-test-" })),
71 Layer.provideMerge(NodeServices.layer),
72);
73const decodeEffectRequest = Schema.decodeUnknownEffect(
74 Schema.fromJsonString(OrchestrationEffectRequestV2),
75);
76
77const seedProject = Effect.fn("ProjectDeletionTest.seedProject")(function* (projectId: ProjectId) {
78 const sql = yield* SqlClient.SqlClient;
79 yield* sql`
80 INSERT INTO projection_projects (
81 project_id, title, workspace_root, default_model_selection_json,
82 scripts_json, created_at, updated_at, deleted_at
83 ) VALUES (
84 ${projectId}, 'Deletion test', ${`/work/${projectId}`}, NULL,
85 '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL
86 )
87 `;
88});
89
90function nativeThreadCreated(projectId: ProjectId, threadId: ThreadId) {
91 const createdAt = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z");
92 const providerInstanceId = ProviderInstanceId.make("codex");
93 const payload: OrchestrationV2AppThread = {
94 createdBy: "user",
95 creationSource: "web",
96 id: threadId,
97 projectId,
98 title: threadId,
99 providerInstanceId,
100 modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
101 runtimeMode: "full-access",
102 interactionMode: "default",
103 branch: null,
104 worktreePath: null,
105 activeProviderThreadId: null,
106 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
107 forkedFrom: null,
108 createdAt,
109 updatedAt: createdAt,
110 archivedAt: null,
111 settledOverride: null,
112 settledAt: null,
113 lastVisitedAt: null,
114 deletedAt: null,
115 };
116 return {
117 id: EventId.make(`created:${threadId}`),
118 type: "thread.created" as const,
119 threadId,
120 providerInstanceId,
121 occurredAt: createdAt,
122 payload,
123 };
124}
125
126it.effect("retries a partial project deletion without repeating child events or cleanup", () =>
127 Effect.gen(function* () {
128 const sql = yield* SqlClient.SqlClient;
129 const projectId = ProjectId.make("project:partial-deletion");
130 const threadIds = [ThreadId.make("thread:delete-a"), ThreadId.make("thread:delete-b")] as const;
131 const commandId = CommandId.make("command:partial-project-delete");
132 yield* seedProject(projectId);
133 yield* TestClock.setTime(Date.parse("2026-09-04T12:00:00.000Z"));
134
135 yield* Effect.gen(function* () {
136 const eventSink = yield* EventSink.EventSinkV2;
137 const projections = yield* ProjectionStore.ProjectionStoreV2;
138 yield* eventSink.write({
139 events: threadIds.map((threadId) => nativeThreadCreated(projectId, threadId)),
140 });
141 const attempts: ThreadId[] = [];
142 const failingEventSink = EventSink.EventSinkV2.of({
143 ...eventSink,
144 commitCommand: Effect.fn("ProjectDeletionTest.failSecondChild")(function* (
145 input: Parameters<EventSink.EventSinkV2Shape["commitCommand"]>[0],
146 ) {
147 attempts.push(input.threadId);
148 if (attempts.length === 2) {
149 return yield* new EventSink.EventSinkWriteError({
150 commandId: input.commandId,
151 eventCount: input.events.length,
152 cause: new Error("Injected failure for the second child"),
153 });
154 }
155 return yield* eventSink.commitCommand(input);
156 }),
157 });
158 const service = yield* ProjectService.make.pipe(
159 Effect.provideService(EventSink.EventSinkV2, failingEventSink),
160 );
161 const input = { commandId, projectId, force: true };
162 const failure = yield* service.delete(input).pipe(Effect.flip);
163 assert.instanceOf(failure, ProjectService.ProjectOperationError);
164 if (failure._tag !== "ProjectOperationError") return assert.fail("Expected a child failure");
165 assert.equal(failure.operation, "delete-thread");
166 assert.lengthOf(attempts, 2);
167 const firstThreadId = attempts[0] ?? assert.fail("The first child was not attempted");
168 const failedThreadId = attempts[1] ?? assert.fail("The second child was not attempted");
169 assert.notEqual(firstThreadId, failedThreadId);
170 assert.isTrue(Option.isSome(yield* service.getById(projectId)));
171 assert.isNotNull((yield* projections.getThreadProjection(firstThreadId)).thread.deletedAt);
172 assert.isNull((yield* projections.getThreadProjection(failedThreadId)).thread.deletedAt);
173
174 const readDeletions = sql<{
175 readonly sequence: number;
176 readonly stream_id: string;
177 readonly command_id: string;
178 readonly event_type: string;
179 }>`
180 SELECT sequence, stream_id, command_id, event_type
181 FROM orchestration_events
182 WHERE event_type IN ('thread.deleted', 'project.deleted')
183 AND stream_id IN (${threadIds[0]}, ${threadIds[1]}, ${projectId})
184 ORDER BY sequence ASC
185 `;
186 const readCleanup = sql<{
187 readonly effect_id: string;
188 readonly thread_id: string;
189 readonly command_id: string;
190 readonly effect_type: string;
191 }>`
192 SELECT effect_id, thread_id, command_id, effect_type
193 FROM orchestration_v2_effect_outbox
194 WHERE thread_id IN (${threadIds[0]}, ${threadIds[1]})
195 ORDER BY effect_id ASC
196 `;
197 const partialEvents = yield* readDeletions;
198 const partialCleanup = yield* readCleanup;
199 assert.lengthOf(partialEvents, 1);
200 assert.equal(partialEvents[0]?.stream_id, firstThreadId);
201 assert.equal(partialEvents[0]?.event_type, "thread.deleted");
202 assert.deepEqual(
203 partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]),
204 [
205 [firstThreadId, "preview.cleanup"],
206 [firstThreadId, "terminal.cleanup"],
207 ],
208 );
209
210 const deletedProject = yield* service.delete(input);
211 assert.isNotNull(deletedProject.deletedAt);
212 assert.isTrue(Option.isNone(yield* service.getById(projectId)));
213 assert.deepEqual(attempts, [firstThreadId, failedThreadId, failedThreadId]);
214 for (const threadId of threadIds) {
215 assert.isNotNull((yield* projections.getThreadProjection(threadId)).thread.deletedAt);
216 }
217 const finalEvents = yield* readDeletions;
218 assert.deepEqual(
219 finalEvents.map((event) => [event.stream_id, event.event_type]),
220 [
221 [firstThreadId, "thread.deleted"],
222 [failedThreadId, "thread.deleted"],
223 [projectId, "project.deleted"],
224 ],
225 );
226 assert.deepEqual(finalEvents[0], partialEvents[0]);
227 assert.equal(finalEvents[2]?.command_id, commandId);
228 const finalCleanup = yield* readCleanup;
229 assert.lengthOf(finalCleanup, 4);
230 assert.deepEqual(
231 finalCleanup.filter((effect) => effect.thread_id === firstThreadId),
232 partialCleanup,
233 );
234 for (const threadId of threadIds) {
235 const expectedCommandId = `${commandId}:delete-thread:${threadId}`;
236 assert.deepEqual(
237 finalCleanup.filter((effect) => effect.thread_id === threadId),
238 [
239 {
240 effect_id: `effect:${expectedCommandId}:preview.cleanup`,
241 thread_id: threadId,
242 command_id: expectedCommandId,
243 effect_type: "preview.cleanup",
244 },
245 {
246 effect_id: `effect:${expectedCommandId}:terminal.cleanup`,
247 thread_id: threadId,
248 command_id: expectedCommandId,
249 effect_type: "terminal.cleanup",
250 },
251 ],
252 );
253 }
254 }).pipe(Effect.provide(layerServices));
255 }).pipe(Effect.provide(layerDatabase)),
256);
257
258it.effect(
259 "hydrates a migrated transcript before deleting its project and cleaning attachments",
260 () =>
261 Effect.gen(function* () {
262 const sql = yield* SqlClient.SqlClient;
263 const projectId = ProjectId.make("project:legacy-deletion");
264 const threadId = ThreadId.make("thread:legacy-deletion");
265 const commandId = CommandId.make("command:legacy-project-delete");
266 yield* seedProject(projectId);
267 yield* sql`
268 INSERT INTO projection_threads (
269 thread_id, project_id, title, model_selection_json,
270 runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id,
271 created_at, updated_at, archived_at, deleted_at
272 ) VALUES (
273 ${threadId}, ${projectId}, 'Legacy thread', '{"instanceId":"codex","model":"gpt-5.4"}',
274 'full-access', 'default', NULL, NULL, NULL,
275 '2026-01-01T00:00:00.000Z', '2026-01-04T00:00:00.000Z', NULL, NULL
276 )
277 `;
278 yield* sql`
279 INSERT INTO projection_thread_messages (
280 message_id, thread_id, turn_id, role, text, attachments_json,
281 is_streaming, created_at, updated_at
282 ) VALUES
283 (
284 'message:legacy-delete:1', ${threadId}, NULL, 'user', 'First question with screenshot',
285 '[{"type":"image","id":"legacy_screenshot","name":"screenshot.png","mimeType":"image/png","sizeBytes":128}]',
286 0, '2026-01-01T01:00:00.000Z', '2026-01-01T01:00:00.000Z'
287 ),
288 (
289 'message:legacy-delete:2', ${threadId}, NULL, 'assistant', 'First answer', '[]',
290 0, '2026-01-02T01:00:00.000Z', '2026-01-02T01:00:00.000Z'
291 ),
292 (
293 'message:legacy-delete:3', ${threadId}, NULL, 'user', 'Follow-up question', '[]',
294 0, '2026-01-03T01:00:00.000Z', '2026-01-03T01:00:00.000Z'
295 ),
296 (
297 'message:legacy-delete:4', ${threadId}, NULL, 'assistant', 'Follow-up answer', '[]',
298 0, '2026-01-04T01:00:00.000Z', '2026-01-04T01:00:00.000Z'
299 )
300 `;
301 yield* TestClock.setTime(Date.parse("2026-09-04T12:00:00.000Z"));
302
303 // Build the engine after seeding the legacy database, as on a real restart.
304 yield* Effect.gen(function* () {
305 const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
306 const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
307 const projections = yield* ProjectionStore.ProjectionStoreV2;
308 const service = yield* ProjectService.make;
309 assert.deepEqual(yield* importer.reconcileShells, {
310 importedThreadCount: 1,
311 importedMessageCount: 2,
312 });
313 assert.equal(yield* importer.pendingThreadCount, 1);
314 assert.isTrue((yield* maintenance.rebuild).valid);
315 const shellProjection = yield* projections.getThreadProjection(threadId);
316 assert.deepEqual(
317 shellProjection.messages.map((message) => message.id),
318 ["message:legacy-delete:3", "message:legacy-delete:4"],
319 );
320 assert.deepEqual(
321 shellProjection.messages.flatMap((message) => message.attachments),
322 [],
323 );
324
325 const deletedProject = yield* service.delete({ commandId, projectId, force: true });
326 assert.isNotNull(deletedProject.deletedAt);
327 assert.isTrue(Option.isNone(yield* service.getById(projectId)));
328 const projection = yield* projections.getThreadProjection(threadId);
329 assert.isNotNull(projection.thread.deletedAt);
330 assert.lengthOf(projection.messages, 4);
331 assert.equal(yield* importer.pendingThreadCount, 0);
332 const rows = yield* sql<{
333 readonly v2_deleted_at: string | null;
334 readonly project_deleted_at: string | null;
335 }>`
336 SELECT v2.deleted_at AS v2_deleted_at,
337 project.deleted_at AS project_deleted_at
338 FROM orchestration_v2_projection_threads AS v2
339 JOIN projection_projects AS project ON project.project_id = v2.project_id
340 WHERE v2.thread_id = ${threadId}
341 `;
342 assert.lengthOf(rows, 1);
343 assert.isNotNull(rows[0]?.v2_deleted_at);
344 assert.isNotNull(rows[0]?.project_deleted_at);
345 // The legacy V1 row is only an import source; deletion writes V2 events only.
346 const legacyEvents = yield* sql`
347 SELECT sequence FROM orchestration_events
348 WHERE application_event_version = 1 AND stream_id = ${threadId}
349 `;
350 assert.deepEqual(legacyEvents, []);
351
352 const cleanup = yield* sql<{
353 readonly command_id: string;
354 readonly payload_json: string;
355 readonly status: string;
356 }>`
357 SELECT command_id, payload_json, status
358 FROM orchestration_v2_effect_outbox
359 WHERE thread_id = ${threadId} AND effect_type = 'attachment.cleanup'
360 `;
361 assert.lengthOf(cleanup, 1);
362 assert.equal(cleanup[0]?.command_id, `${commandId}:delete-thread:${threadId}`);
363 assert.equal(cleanup[0]?.status, "pending");
364 const request = yield* decodeEffectRequest(cleanup[0]?.payload_json);
365 assert.deepEqual(request, {
366 type: "attachment.cleanup",
367 attachmentIds: ["legacy_screenshot"],
368 });
369 }).pipe(Effect.provide(layerServices));
370 }).pipe(Effect.provide(layerDatabase)),
371);
372
373it.effect("rejects a child deletion command ID already accepted for an unrelated thread", () =>
374 Effect.gen(function* () {
375 const sql = yield* SqlClient.SqlClient;
376 const projectId = ProjectId.make("project:receipt-collision");
377 const otherProjectId = ProjectId.make("project:unrelated-receipt");
378 const threadId = ThreadId.make("thread:receipt-collision");
379 const otherThreadId = ThreadId.make("thread:unrelated-receipt");
380 const commandId = CommandId.make("command:collision-project-delete");
381 yield* seedProject(projectId);
382 yield* seedProject(otherProjectId);
383
384 yield* Effect.gen(function* () {
385 const eventSink = yield* EventSink.EventSinkV2;
386 const projections = yield* ProjectionStore.ProjectionStoreV2;
387 const service = yield* ProjectService.make;
388 yield* eventSink.write({ events: [nativeThreadCreated(projectId, threadId)] });
389 const accepted = yield* eventSink.commitCommand({
390 commandId: CommandId.make(`${commandId}:delete-thread:${threadId}`),
391 commandType: "thread.create",
392 threadId: otherThreadId,
393 acceptedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"),
394 events: [nativeThreadCreated(otherProjectId, otherThreadId)],
395 effects: [],
396 });
397 assert.equal(accepted.receipt.status, "accepted");
398
399 const failure = yield* service
400 .delete({ commandId, projectId, force: true })
401 .pipe(Effect.flip);
402 assert.instanceOf(failure, ProjectService.ProjectOperationError);
403 if (failure._tag !== "ProjectOperationError") return assert.fail("Expected a child failure");
404 assert.equal(failure.operation, "delete-thread");
405 assert.isTrue(Option.isSome(yield* service.getById(projectId)));
406 assert.isNull((yield* projections.getThreadProjection(threadId)).thread.deletedAt);
407 assert.isNull((yield* projections.getThreadProjection(otherThreadId)).thread.deletedAt);
408 const deletions = yield* sql`
409 SELECT sequence FROM orchestration_events
410 WHERE event_type IN ('thread.deleted', 'project.deleted')
411 AND stream_id IN (${threadId}, ${otherThreadId}, ${projectId})
412 `;
413 assert.deepEqual(deletions, []);
414 const cleanup = yield* sql`
415 SELECT effect_id FROM orchestration_v2_effect_outbox
416 WHERE thread_id = ${threadId}
417 `;
418 assert.deepEqual(cleanup, []);
419 }).pipe(Effect.provide(layerServices));
420 }).pipe(Effect.provide(layerDatabase)),
421);
422
423it.effect("deletes a project without force once its imported threads were deleted in V2", () =>
424 Effect.gen(function* () {
425 const sql = yield* SqlClient.SqlClient;
426 const projectId = ProjectId.make("project:imported-emptied");
427 const threadId = ThreadId.make("thread:imported-emptied");
428 yield* seedProject(projectId);
429 // V2 never writes the V1 thread table, so this row stays live there.
430 yield* sql`
431 INSERT INTO projection_threads (
432 thread_id, project_id, title, model_selection_json,
433 runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id,
434 created_at, updated_at, archived_at, deleted_at
435 ) VALUES (
436 ${threadId}, ${projectId}, 'Imported thread', '{"instanceId":"codex","model":"gpt-5.4"}',
437 'full-access', 'default', NULL, NULL, NULL,
438 '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, NULL
439 )
440 `;
441 yield* TestClock.setTime(Date.parse("2026-09-04T12:00:00.000Z"));
442
443 yield* Effect.gen(function* () {
444 const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
445 const projections = yield* ProjectionStore.ProjectionStoreV2;
446 const eventSink = yield* EventSink.EventSinkV2;
447 const idAllocator = yield* IdAllocator.IdAllocatorV2;
448 const service = yield* ProjectService.make;
449 yield* importer.reconcileShells;
450 const early = yield* service
451 .delete({ commandId: CommandId.make("command:emptied:early"), projectId })
452 .pipe(Effect.flip);
453 assert.equal(early._tag, "ProjectNotEmptyError");
454
455 // Delete the imported thread the way thread.delete does.
456 yield* importer.ensureTranscript(threadId);
457 const command = {
458 type: "thread.delete" as const,
459 commandId: CommandId.make("command:emptied:thread-delete"),
460 threadId,
461 };
462 const now = yield* DateTime.now;
463 const plan = yield* planThreadDeletion({
464 command,
465 projection: yield* projections.getThreadRecords(threadId, [
466 "runs",
467 "attempts",
468 "nodes",
469 "runtimeRequests",
470 "subagents",
471 "providerSessions",
472 ]),
473 attachmentIds: [],
474 now,
475 idAllocator,
476 });
477 yield* eventSink.commitCommand({
478 commandId: command.commandId,
479 commandType: command.type,
480 threadId,
481 acceptedAt: now,
482 events: plan.events,
483 effects: plan.effects,
484 });
485 const legacy = yield* sql<{ readonly deleted_at: string | null }>`
486 SELECT deleted_at FROM projection_threads WHERE thread_id = ${threadId}
487 `;
488 assert.deepEqual(legacy, [{ deleted_at: null }]);
489
490 const deleted = yield* service.delete({
491 commandId: CommandId.make("command:emptied:project-delete"),
492 projectId,
493 });
494 assert.isNotNull(deleted.deletedAt);
495 assert.isTrue(Option.isNone(yield* service.getById(projectId)));
496 }).pipe(Effect.provide(layerServices));
497 }).pipe(Effect.provide(layerDatabase)),
498);
499
500it.effect(
501 "clears a deleted project's settings overrides so a machine-disabled instance cannot stay alive",
502 () =>
503 Effect.gen(function* () {
504 const projectId = ProjectId.make("project:overrides-cleanup");
505 const instanceId = ProviderInstanceId.make("codex_work");
506 yield* seedProject(projectId);
507
508 yield* Effect.gen(function* () {
509 const service = yield* ProjectService.make;
510 const serverSettings = yield* ServerSettings.ServerSettingsService;
511 yield* serverSettings.updateSettings({
512 projectSettingsOverrides: {
513 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
514 },
515 });
516
517 yield* service.delete({
518 commandId: CommandId.make("command:overrides-cleanup:project-delete"),
519 projectId,
520 });
521
522 const settingsAfterDelete = yield* serverSettings.getSettings;
523 assert.isUndefined(settingsAfterDelete.projectSettingsOverrides[projectId]);
524 }).pipe(Effect.provide(layerServices));
525 }).pipe(Effect.provide(layerDatabase)),
526);