| author | |
| committer | |
| log | 9013c07f6d679813777462466e9051e4ffc27e03 |
| tree | 003b4c6a59750f8e7e99bdef551fe70b383daa26 |
| parent | 085340588ac87d14e8cad7c5a8bd620e0c8826da |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 144 insertions(+), 8 deletions(-)
apps/server/src/server.test.ts+35| ... | ... | @@ -1193,6 +1193,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1193 | 1193 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1194 | 1194 | ); |
| 1195 | 1195 | |
| 1196 | it.effect("closes thread terminals after a successful archive command", () => | |
| 1197 | Effect.gen(function* () { | |
| 1198 | const threadId = ThreadId.makeUnsafe("thread-archive"); | |
| 1199 | const closeInputs: Array<Parameters<TerminalManagerShape["close"]>[0]> = []; | |
| 1200 | ||
| 1201 | yield* buildAppUnderTest({ | |
| 1202 | layers: { | |
| 1203 | terminalManager: { | |
| 1204 | close: (input) => | |
| 1205 | Effect.sync(() => { | |
| 1206 | closeInputs.push(input); | |
| 1207 | }), | |
| 1208 | }, | |
| 1209 | orchestrationEngine: { | |
| 1210 | dispatch: () => Effect.succeed({ sequence: 8 }), | |
| 1211 | }, | |
| 1212 | }, | |
| 1213 | }); | |
| 1214 | ||
| 1215 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 1216 | const dispatchResult = yield* Effect.scoped( | |
| 1217 | withWsRpcClient(wsUrl, (client) => | |
| 1218 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | |
| 1219 | type: "thread.archive", | |
| 1220 | commandId: CommandId.makeUnsafe("cmd-thread-archive"), | |
| 1221 | threadId, | |
| 1222 | }), | |
| 1223 | ), | |
| 1224 | ); | |
| 1225 | ||
| 1226 | assert.equal(dispatchResult.sequence, 8); | |
| 1227 | assert.deepEqual(closeInputs, [{ threadId }]); | |
| 1228 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1229 | ); | |
| 1230 | ||
| 1196 | 1231 | it.effect( |
| 1197 | 1232 | "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", |
| 1198 | 1233 | () => |
apps/server/src/ws.ts+14-1| ... | ... | @@ -85,7 +85,20 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 85 | 85 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => |
| 86 | 86 | Effect.gen(function* () { |
| 87 | 87 | const normalizedCommand = yield* normalizeDispatchCommand(command); |
| 88 | return yield* startup.enqueueCommand(orchestrationEngine.dispatch(normalizedCommand)); | |
| 88 | const result = yield* startup.enqueueCommand( | |
| 89 | orchestrationEngine.dispatch(normalizedCommand), | |
| 90 | ); | |
| 91 | if (normalizedCommand.type === "thread.archive") { | |
| 92 | yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( | |
| 93 | Effect.catch((error) => | |
| 94 | Effect.logWarning("failed to close thread terminals after archive", { | |
| 95 | threadId: normalizedCommand.threadId, | |
| 96 | error: error.message, | |
| 97 | }), | |
| 98 | ), | |
| 99 | ); | |
| 100 | } | |
| 101 | return result; | |
| 89 | 102 | }).pipe( |
| 90 | 103 | Effect.mapError((cause) => |
| 91 | 104 | Schema.is(OrchestrationDispatchCommandError)(cause) |
apps/web/src/lib/terminalStateCleanup.test.ts+31-5| ... | ... | @@ -9,8 +9,8 @@ describe("collectActiveTerminalThreadIds", () => { |
| 9 | 9 | it("retains non-deleted server threads", () => { |
| 10 | 10 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 11 | 11 | snapshotThreads: [ |
| 12 | { id: threadId("server-1"), deletedAt: null }, | |
| 13 | { id: threadId("server-2"), deletedAt: null }, | |
| 12 | { id: threadId("server-1"), deletedAt: null, archivedAt: null }, | |
| 13 | { id: threadId("server-2"), deletedAt: null, archivedAt: null }, | |
| 14 | 14 | ], |
| 15 | 15 | draftThreadIds: [], |
| 16 | 16 | }); |
| ... | ... | @@ -18,15 +18,41 @@ describe("collectActiveTerminalThreadIds", () => { |
| 18 | 18 | expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")])); |
| 19 | 19 | }); |
| 20 | 20 | |
| 21 | it("ignores deleted server threads and keeps local draft threads", () => { | |
| 21 | it("ignores deleted and archived server threads and keeps local draft threads", () => { | |
| 22 | 22 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 23 | 23 | snapshotThreads: [ |
| 24 | { id: threadId("server-active"), deletedAt: null }, | |
| 25 | { id: threadId("server-deleted"), deletedAt: "2026-03-05T08:00:00.000Z" }, | |
| 24 | { id: threadId("server-active"), deletedAt: null, archivedAt: null }, | |
| 25 | { | |
| 26 | id: threadId("server-deleted"), | |
| 27 | deletedAt: "2026-03-05T08:00:00.000Z", | |
| 28 | archivedAt: null, | |
| 29 | }, | |
| 30 | { | |
| 31 | id: threadId("server-archived"), | |
| 32 | deletedAt: null, | |
| 33 | archivedAt: "2026-03-05T09:00:00.000Z", | |
| 34 | }, | |
| 26 | 35 | ], |
| 27 | 36 | draftThreadIds: [threadId("local-draft")], |
| 28 | 37 | }); |
| 29 | 38 | |
| 30 | 39 | expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")])); |
| 31 | 40 | }); |
| 41 | ||
| 42 | it("does not keep draft-linked terminal state for archived server threads", () => { | |
| 43 | const archivedThreadId = threadId("server-archived"); | |
| 44 | ||
| 45 | const activeThreadIds = collectActiveTerminalThreadIds({ | |
| 46 | snapshotThreads: [ | |
| 47 | { | |
| 48 | id: archivedThreadId, | |
| 49 | deletedAt: null, | |
| 50 | archivedAt: "2026-03-05T09:00:00.000Z", | |
| 51 | }, | |
| 52 | ], | |
| 53 | draftThreadIds: [archivedThreadId, threadId("local-draft")], | |
| 54 | }); | |
| 55 | ||
| 56 | expect(activeThreadIds).toEqual(new Set([threadId("local-draft")])); | |
| 57 | }); | |
| 32 | 58 | }); |
apps/web/src/lib/terminalStateCleanup.ts+10| ... | ... | @@ -3,6 +3,7 @@ import type { ThreadId } from "@t3tools/contracts"; |
| 3 | 3 | interface TerminalRetentionThread { |
| 4 | 4 | id: ThreadId; |
| 5 | 5 | deletedAt: string | null; |
| 6 | archivedAt: string | null; | |
| 6 | 7 | } |
| 7 | 8 | |
| 8 | 9 | interface CollectActiveTerminalThreadIdsInput { |
| ... | ... | @@ -14,11 +15,20 @@ export function collectActiveTerminalThreadIds( |
| 14 | 15 | input: CollectActiveTerminalThreadIdsInput, |
| 15 | 16 | ): Set<ThreadId> { |
| 16 | 17 | const activeThreadIds = new Set<ThreadId>(); |
| 18 | const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread])); | |
| 17 | 19 | for (const thread of input.snapshotThreads) { |
| 18 | 20 | if (thread.deletedAt !== null) continue; |
| 21 | if (thread.archivedAt !== null) continue; | |
| 19 | 22 | activeThreadIds.add(thread.id); |
| 20 | 23 | } |
| 21 | 24 | for (const draftThreadId of input.draftThreadIds) { |
| 25 | const snapshotThread = snapshotThreadById.get(draftThreadId); | |
| 26 | if ( | |
| 27 | snapshotThread && | |
| 28 | (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) | |
| 29 | ) { | |
| 30 | continue; | |
| 31 | } | |
| 22 | 32 | activeThreadIds.add(draftThreadId); |
| 23 | 33 | } |
| 24 | 34 | return activeThreadIds; |
apps/web/src/orchestrationEventEffects.test.ts+27-1| ... | ... | @@ -42,6 +42,7 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 42 | 42 | it("targets draft promotion and terminal cleanup from thread lifecycle events", () => { |
| 43 | 43 | const createdThreadId = ThreadId.makeUnsafe("thread-created"); |
| 44 | 44 | const deletedThreadId = ThreadId.makeUnsafe("thread-deleted"); |
| 45 | const archivedThreadId = ThreadId.makeUnsafe("thread-archived"); | |
| 45 | 46 | |
| 46 | 47 | const effects = deriveOrchestrationBatchEffects([ |
| 47 | 48 | makeEvent("thread.created", { |
| ... | ... | @@ -60,11 +61,16 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 60 | 61 | threadId: deletedThreadId, |
| 61 | 62 | deletedAt: "2026-02-27T00:00:01.000Z", |
| 62 | 63 | }), |
| 64 | makeEvent("thread.archived", { | |
| 65 | threadId: archivedThreadId, | |
| 66 | archivedAt: "2026-02-27T00:00:02.000Z", | |
| 67 | updatedAt: "2026-02-27T00:00:02.000Z", | |
| 68 | }), | |
| 63 | 69 | ]); |
| 64 | 70 | |
| 65 | 71 | expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); |
| 66 | 72 | expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); |
| 67 | expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId]); | |
| 73 | expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); | |
| 68 | 74 | expect(effects.needsProviderInvalidation).toBe(false); |
| 69 | 75 | }); |
| 70 | 76 | |
| ... | ... | @@ -105,4 +111,24 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 105 | 111 | expect(effects.removeTerminalStateThreadIds).toEqual([]); |
| 106 | 112 | expect(effects.needsProviderInvalidation).toBe(true); |
| 107 | 113 | }); |
| 114 | ||
| 115 | it("does not retain archive cleanup when a thread is unarchived later in the same batch", () => { | |
| 116 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 117 | ||
| 118 | const effects = deriveOrchestrationBatchEffects([ | |
| 119 | makeEvent("thread.archived", { | |
| 120 | threadId, | |
| 121 | archivedAt: "2026-02-27T00:00:01.000Z", | |
| 122 | updatedAt: "2026-02-27T00:00:01.000Z", | |
| 123 | }), | |
| 124 | makeEvent("thread.unarchived", { | |
| 125 | threadId, | |
| 126 | updatedAt: "2026-02-27T00:00:02.000Z", | |
| 127 | }), | |
| 128 | ]); | |
| 129 | ||
| 130 | expect(effects.clearPromotedDraftThreadIds).toEqual([]); | |
| 131 | expect(effects.clearDeletedThreadIds).toEqual([]); | |
| 132 | expect(effects.removeTerminalStateThreadIds).toEqual([]); | |
| 133 | }); | |
| 108 | 134 | }); |
apps/web/src/orchestrationEventEffects.ts+18| ... | ... | @@ -46,6 +46,24 @@ export function deriveOrchestrationBatchEffects( |
| 46 | 46 | break; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | case "thread.archived": { | |
| 50 | threadLifecycleEffects.set(event.payload.threadId, { | |
| 51 | clearPromotedDraft: false, | |
| 52 | clearDeletedThread: false, | |
| 53 | removeTerminalState: true, | |
| 54 | }); | |
| 55 | break; | |
| 56 | } | |
| 57 | ||
| 58 | case "thread.unarchived": { | |
| 59 | threadLifecycleEffects.set(event.payload.threadId, { | |
| 60 | clearPromotedDraft: false, | |
| 61 | clearDeletedThread: false, | |
| 62 | removeTerminalState: false, | |
| 63 | }); | |
| 64 | break; | |
| 65 | } | |
| 66 | ||
| 49 | 67 | default: { |
| 50 | 68 | break; |
| 51 | 69 | } |
apps/web/src/routes/__root.tsx+9-1| ... | ... | @@ -328,7 +328,11 @@ function EventRouter() { |
| 328 | 328 | useComposerDraftStore.getState().draftThreadsByThreadId, |
| 329 | 329 | ) as ThreadId[]; |
| 330 | 330 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 331 | snapshotThreads: threads.map((thread) => ({ id: thread.id, deletedAt: null })), | |
| 331 | snapshotThreads: threads.map((thread) => ({ | |
| 332 | id: thread.id, | |
| 333 | deletedAt: null, | |
| 334 | archivedAt: thread.archivedAt, | |
| 335 | })), | |
| 332 | 336 | draftThreadIds, |
| 333 | 337 | }); |
| 334 | 338 | removeOrphanedTerminalStates(activeThreadIds); |
| ... | ... | @@ -497,6 +501,10 @@ function EventRouter() { |
| 497 | 501 | } |
| 498 | 502 | }); |
| 499 | 503 | const unsubTerminalEvent = api.terminal.onEvent((event) => { |
| 504 | const thread = useStore.getState().threads.find((entry) => entry.id === event.threadId); | |
| 505 | if (thread && thread.archivedAt !== null) { | |
| 506 | return; | |
| 507 | } | |
| 500 | 508 | useTerminalStateStore.getState().recordTerminalEvent(event); |
| 501 | 509 | const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); |
| 502 | 510 | if (hasRunningSubprocess === null) { |