| author | |
| committer | |
| log | 4e0c003e78b451c25b69f43f9c7b93e760f6c371 |
| tree | 1081f0906bfcd16c749a8543c093154e2217ba57 |
| parent | 60387f67e99d68eba414d2c2e00c8ccdb145b0c3 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>15 files changed, 679 insertions(+), 52 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+7| ... | ... | @@ -58,6 +58,7 @@ import { |
| 58 | 58 | OrchestrationEngineService, |
| 59 | 59 | type OrchestrationEngineShape, |
| 60 | 60 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; |
| 61 | import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; | |
| 61 | 62 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; |
| 62 | 63 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 63 | 64 | import { |
| ... | ... | @@ -351,6 +352,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 351 | 352 | Layer.provideMerge(runtimeIngestionLayer), |
| 352 | 353 | Layer.provideMerge(providerCommandReactorLayer), |
| 353 | 354 | Layer.provideMerge(checkpointReactorLayer), |
| 355 | Layer.provideMerge( | |
| 356 | Layer.succeed(ThreadDeletionReactor, { | |
| 357 | start: () => Effect.void, | |
| 358 | drain: Effect.void, | |
| 359 | }), | |
| 360 | ), | |
| 354 | 361 | ); |
| 355 | 362 | const layer = Layer.empty.pipe( |
| 356 | 363 | Layer.provideMerge(runtimeServicesLayer), |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+13-2| ... | ... | @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; |
| 4 | 4 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; |
| 5 | 5 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 6 | 6 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 7 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | |
| 7 | 8 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 8 | 9 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| 9 | 10 | |
| ... | ... | @@ -17,7 +18,7 @@ describe("OrchestrationReactor", () => { |
| 17 | 18 | runtime = null; |
| 18 | 19 | }); |
| 19 | 20 | |
| 20 | it("starts provider ingestion, provider command, and checkpoint reactors", async () => { | |
| 21 | it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { | |
| 21 | 22 | const started: string[] = []; |
| 22 | 23 | |
| 23 | 24 | runtime = ManagedRuntime.make( |
| ... | ... | @@ -49,10 +50,19 @@ describe("OrchestrationReactor", () => { |
| 49 | 50 | drain: Effect.void, |
| 50 | 51 | }), |
| 51 | 52 | ), |
| 53 | Layer.provideMerge( | |
| 54 | Layer.succeed(ThreadDeletionReactor, { | |
| 55 | start: () => { | |
| 56 | started.push("thread-deletion-reactor"); | |
| 57 | return Effect.void; | |
| 58 | }, | |
| 59 | drain: Effect.void, | |
| 60 | }), | |
| 61 | ), | |
| 52 | 62 | ), |
| 53 | 63 | ); |
| 54 | 64 | |
| 55 | const reactor = await runtime.runPromise(Effect.service(OrchestrationReactor)); | |
| 65 | const reactor = await runtime!.runPromise(Effect.service(OrchestrationReactor)); | |
| 56 | 66 | const scope = await Effect.runPromise(Scope.make("sequential")); |
| 57 | 67 | await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); |
| 58 | 68 | |
| ... | ... | @@ -60,6 +70,7 @@ describe("OrchestrationReactor", () => { |
| 60 | 70 | "provider-runtime-ingestion", |
| 61 | 71 | "provider-command-reactor", |
| 62 | 72 | "checkpoint-reactor", |
| 73 | "thread-deletion-reactor", | |
| 63 | 74 | ]); |
| 64 | 75 | |
| 65 | 76 | await Effect.runPromise(Scope.close(scope, Exit.void)); |
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3| ... | ... | @@ -7,16 +7,19 @@ import { |
| 7 | 7 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; |
| 8 | 8 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 9 | 9 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 10 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | |
| 10 | 11 | |
| 11 | 12 | export const makeOrchestrationReactor = Effect.gen(function* () { |
| 12 | 13 | const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; |
| 13 | 14 | const providerCommandReactor = yield* ProviderCommandReactor; |
| 14 | 15 | const checkpointReactor = yield* CheckpointReactor; |
| 16 | const threadDeletionReactor = yield* ThreadDeletionReactor; | |
| 15 | 17 | |
| 16 | 18 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { |
| 17 | 19 | yield* providerRuntimeIngestion.start(); |
| 18 | 20 | yield* providerCommandReactor.start(); |
| 19 | 21 | yield* checkpointReactor.start(); |
| 22 | yield* threadDeletionReactor.start(); | |
| 20 | 23 | }); |
| 21 | 24 | |
| 22 | 25 | return { |
apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { Cause, Effect, Exit } from "effect"; | |
| 3 | import { describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts"; | |
| 6 | ||
| 7 | describe("logCleanupCauseUnlessInterrupted", () => { | |
| 8 | const threadId = ThreadId.make("thread-deletion-reactor-test"); | |
| 9 | ||
| 10 | it("swallows ordinary cleanup failures", async () => { | |
| 11 | const exit = await Effect.runPromiseExit( | |
| 12 | logCleanupCauseUnlessInterrupted({ | |
| 13 | effect: Effect.fail("cleanup failed"), | |
| 14 | message: "thread deletion cleanup skipped provider session stop", | |
| 15 | threadId, | |
| 16 | }), | |
| 17 | ); | |
| 18 | ||
| 19 | expect(Exit.isSuccess(exit)).toBe(true); | |
| 20 | }); | |
| 21 | ||
| 22 | it("preserves interrupt causes", async () => { | |
| 23 | const exit = await Effect.runPromiseExit( | |
| 24 | logCleanupCauseUnlessInterrupted({ | |
| 25 | effect: Effect.interrupt, | |
| 26 | message: "thread deletion cleanup skipped provider session stop", | |
| 27 | threadId, | |
| 28 | }), | |
| 29 | ); | |
| 30 | ||
| 31 | expect(Exit.isFailure(exit)).toBe(true); | |
| 32 | if (Exit.isFailure(exit)) { | |
| 33 | expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); | |
| 34 | } | |
| 35 | }); | |
| 36 | }); |
apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts created+96| ... | ... | @@ -0,0 +1,96 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | |
| 3 | import { Cause, Effect, Layer, Stream } from "effect"; | |
| 4 | ||
| 5 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | |
| 6 | import { TerminalManager } from "../../terminal/Services/Manager.ts"; | |
| 7 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 8 | import { | |
| 9 | ThreadDeletionReactor, | |
| 10 | type ThreadDeletionReactorShape, | |
| 11 | } from "../Services/ThreadDeletionReactor.ts"; | |
| 12 | ||
| 13 | type ThreadDeletedEvent = Extract<OrchestrationEvent, { type: "thread.deleted" }>; | |
| 14 | ||
| 15 | export const logCleanupCauseUnlessInterrupted = <R, E>({ | |
| 16 | effect, | |
| 17 | message, | |
| 18 | threadId, | |
| 19 | }: { | |
| 20 | readonly effect: Effect.Effect<void, E, R>; | |
| 21 | readonly message: string; | |
| 22 | readonly threadId: ThreadDeletedEvent["payload"]["threadId"]; | |
| 23 | }): Effect.Effect<void, E, R> => | |
| 24 | effect.pipe( | |
| 25 | Effect.catchCause((cause) => { | |
| 26 | if (Cause.hasInterruptsOnly(cause)) { | |
| 27 | return Effect.failCause(cause); | |
| 28 | } | |
| 29 | return Effect.logDebug(message, { | |
| 30 | threadId, | |
| 31 | cause: Cause.pretty(cause), | |
| 32 | }); | |
| 33 | }), | |
| 34 | ); | |
| 35 | ||
| 36 | const make = Effect.gen(function* () { | |
| 37 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 38 | const providerService = yield* ProviderService; | |
| 39 | const terminalManager = yield* TerminalManager; | |
| 40 | ||
| 41 | const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => | |
| 42 | logCleanupCauseUnlessInterrupted({ | |
| 43 | effect: providerService.stopSession({ threadId }), | |
| 44 | message: "thread deletion cleanup skipped provider session stop", | |
| 45 | threadId, | |
| 46 | }); | |
| 47 | ||
| 48 | const closeThreadTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => | |
| 49 | logCleanupCauseUnlessInterrupted({ | |
| 50 | effect: terminalManager.close({ threadId, deleteHistory: true }), | |
| 51 | message: "thread deletion cleanup skipped terminal close", | |
| 52 | threadId, | |
| 53 | }); | |
| 54 | ||
| 55 | const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( | |
| 56 | event: ThreadDeletedEvent, | |
| 57 | ) { | |
| 58 | const { threadId } = event.payload; | |
| 59 | yield* stopProviderSession(threadId); | |
| 60 | yield* closeThreadTerminals(threadId); | |
| 61 | }); | |
| 62 | ||
| 63 | const processThreadDeletedSafely = (event: ThreadDeletedEvent) => | |
| 64 | processThreadDeleted(event).pipe( | |
| 65 | Effect.catchCause((cause) => { | |
| 66 | if (Cause.hasInterruptsOnly(cause)) { | |
| 67 | return Effect.failCause(cause); | |
| 68 | } | |
| 69 | return Effect.logWarning("thread deletion reactor failed to process event", { | |
| 70 | eventType: event.type, | |
| 71 | threadId: event.payload.threadId, | |
| 72 | cause: Cause.pretty(cause), | |
| 73 | }); | |
| 74 | }), | |
| 75 | ); | |
| 76 | ||
| 77 | const worker = yield* makeDrainableWorker(processThreadDeletedSafely); | |
| 78 | ||
| 79 | const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { | |
| 80 | yield* Effect.forkScoped( | |
| 81 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | |
| 82 | if (event.type !== "thread.deleted") { | |
| 83 | return Effect.void; | |
| 84 | } | |
| 85 | return worker.enqueue(event); | |
| 86 | }), | |
| 87 | ); | |
| 88 | }); | |
| 89 | ||
| 90 | return { | |
| 91 | start, | |
| 92 | drain: worker.drain, | |
| 93 | } satisfies ThreadDeletionReactorShape; | |
| 94 | }); | |
| 95 | ||
| 96 | export const ThreadDeletionReactorLive = Layer.effect(ThreadDeletionReactor, make); |
apps/server/src/orchestration/Services/ThreadDeletionReactor.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | /** | |
| 2 | * ThreadDeletionReactor - Thread deletion cleanup reactor service interface. | |
| 3 | * | |
| 4 | * Owns background workers that react to thread deletion domain events and | |
| 5 | * perform best-effort runtime cleanup for provider sessions and terminals. | |
| 6 | * | |
| 7 | * @module ThreadDeletionReactor | |
| 8 | */ | |
| 9 | import { Context } from "effect"; | |
| 10 | import type { Effect, Scope } from "effect"; | |
| 11 | ||
| 12 | /** | |
| 13 | * ThreadDeletionReactorShape - Service API for thread deletion cleanup. | |
| 14 | */ | |
| 15 | export interface ThreadDeletionReactorShape { | |
| 16 | /** | |
| 17 | * Start reacting to thread.deleted orchestration domain events. | |
| 18 | * | |
| 19 | * The returned effect must be run in a scope so all worker fibers can be | |
| 20 | * finalized on shutdown. | |
| 21 | */ | |
| 22 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; | |
| 23 | ||
| 24 | /** | |
| 25 | * Resolves when the internal processing queue is empty and idle. | |
| 26 | * Intended for test use to replace timing-sensitive sleeps. | |
| 27 | */ | |
| 28 | readonly drain: Effect.Effect<void>; | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * ThreadDeletionReactor - Service tag for thread deletion cleanup workers. | |
| 33 | */ | |
| 34 | export class ThreadDeletionReactor extends Context.Service< | |
| 35 | ThreadDeletionReactor, | |
| 36 | ThreadDeletionReactorShape | |
| 37 | >()("t3/orchestration/Services/ThreadDeletionReactor") {} |
apps/server/src/orchestration/decider.delete.test.ts created+226| ... | ... | @@ -0,0 +1,226 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 4 | EventId, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | type OrchestrationCommand, | |
| 8 | type OrchestrationEvent, | |
| 9 | type OrchestrationReadModel, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { Effect } from "effect"; | |
| 12 | import { describe, expect, it } from "vitest"; | |
| 13 | ||
| 14 | import { decideOrchestrationCommand } from "./decider.ts"; | |
| 15 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | |
| 16 | ||
| 17 | const asCommandId = (value: string): CommandId => CommandId.make(value); | |
| 18 | const asEventId = (value: string): EventId => EventId.make(value); | |
| 19 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); | |
| 20 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); | |
| 21 | ||
| 22 | async function seedReadModel(): Promise<OrchestrationReadModel> { | |
| 23 | const now = new Date().toISOString(); | |
| 24 | const initial = createEmptyReadModel(now); | |
| 25 | const withProject = await Effect.runPromise( | |
| 26 | projectEvent(initial, { | |
| 27 | sequence: 1, | |
| 28 | eventId: asEventId("evt-project-create"), | |
| 29 | aggregateKind: "project", | |
| 30 | aggregateId: asProjectId("project-delete"), | |
| 31 | type: "project.created", | |
| 32 | occurredAt: now, | |
| 33 | commandId: asCommandId("cmd-project-create"), | |
| 34 | causationEventId: null, | |
| 35 | correlationId: asCommandId("cmd-project-create"), | |
| 36 | metadata: {}, | |
| 37 | payload: { | |
| 38 | projectId: asProjectId("project-delete"), | |
| 39 | title: "Project Delete", | |
| 40 | workspaceRoot: "/tmp/project-delete", | |
| 41 | defaultModelSelection: null, | |
| 42 | scripts: [], | |
| 43 | createdAt: now, | |
| 44 | updatedAt: now, | |
| 45 | }, | |
| 46 | }), | |
| 47 | ); | |
| 48 | ||
| 49 | const withFirstThread = await Effect.runPromise( | |
| 50 | projectEvent(withProject, { | |
| 51 | sequence: 2, | |
| 52 | eventId: asEventId("evt-thread-create-1"), | |
| 53 | aggregateKind: "thread", | |
| 54 | aggregateId: asThreadId("thread-delete-1"), | |
| 55 | type: "thread.created", | |
| 56 | occurredAt: now, | |
| 57 | commandId: asCommandId("cmd-thread-create-1"), | |
| 58 | causationEventId: null, | |
| 59 | correlationId: asCommandId("cmd-thread-create-1"), | |
| 60 | metadata: {}, | |
| 61 | payload: { | |
| 62 | threadId: asThreadId("thread-delete-1"), | |
| 63 | projectId: asProjectId("project-delete"), | |
| 64 | title: "Thread Delete 1", | |
| 65 | modelSelection: { | |
| 66 | provider: "codex", | |
| 67 | model: "gpt-5-codex", | |
| 68 | }, | |
| 69 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 70 | runtimeMode: "approval-required", | |
| 71 | branch: null, | |
| 72 | worktreePath: null, | |
| 73 | createdAt: now, | |
| 74 | updatedAt: now, | |
| 75 | }, | |
| 76 | }), | |
| 77 | ); | |
| 78 | ||
| 79 | return Effect.runPromise( | |
| 80 | projectEvent(withFirstThread, { | |
| 81 | sequence: 3, | |
| 82 | eventId: asEventId("evt-thread-create-2"), | |
| 83 | aggregateKind: "thread", | |
| 84 | aggregateId: asThreadId("thread-delete-2"), | |
| 85 | type: "thread.created", | |
| 86 | occurredAt: now, | |
| 87 | commandId: asCommandId("cmd-thread-create-2"), | |
| 88 | causationEventId: null, | |
| 89 | correlationId: asCommandId("cmd-thread-create-2"), | |
| 90 | metadata: {}, | |
| 91 | payload: { | |
| 92 | threadId: asThreadId("thread-delete-2"), | |
| 93 | projectId: asProjectId("project-delete"), | |
| 94 | title: "Thread Delete 2", | |
| 95 | modelSelection: { | |
| 96 | provider: "codex", | |
| 97 | model: "gpt-5-codex", | |
| 98 | }, | |
| 99 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 100 | runtimeMode: "approval-required", | |
| 101 | branch: null, | |
| 102 | worktreePath: null, | |
| 103 | createdAt: now, | |
| 104 | updatedAt: now, | |
| 105 | }, | |
| 106 | }), | |
| 107 | ); | |
| 108 | } | |
| 109 | ||
| 110 | type PlannedEvent = Omit<OrchestrationEvent, "sequence">; | |
| 111 | ||
| 112 | function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray<PlannedEvent>) { | |
| 113 | const events = Array.isArray(event) ? event : [event]; | |
| 114 | return events.map((entry) => { | |
| 115 | switch (entry.type) { | |
| 116 | case "thread.deleted": | |
| 117 | return { | |
| 118 | type: entry.type, | |
| 119 | aggregateKind: entry.aggregateKind, | |
| 120 | aggregateId: entry.aggregateId, | |
| 121 | commandId: entry.commandId, | |
| 122 | correlationId: entry.correlationId, | |
| 123 | payload: { | |
| 124 | threadId: entry.payload.threadId, | |
| 125 | }, | |
| 126 | }; | |
| 127 | case "project.deleted": | |
| 128 | return { | |
| 129 | type: entry.type, | |
| 130 | aggregateKind: entry.aggregateKind, | |
| 131 | aggregateId: entry.aggregateId, | |
| 132 | commandId: entry.commandId, | |
| 133 | correlationId: entry.correlationId, | |
| 134 | payload: { | |
| 135 | projectId: entry.payload.projectId, | |
| 136 | }, | |
| 137 | }; | |
| 138 | default: | |
| 139 | return entry; | |
| 140 | } | |
| 141 | }); | |
| 142 | } | |
| 143 | ||
| 144 | describe("decider deletion flows", () => { | |
| 145 | it("rejects deleting a non-empty project without force", async () => { | |
| 146 | const readModel = await seedReadModel(); | |
| 147 | ||
| 148 | await expect( | |
| 149 | Effect.runPromise( | |
| 150 | decideOrchestrationCommand({ | |
| 151 | command: { | |
| 152 | type: "project.delete", | |
| 153 | commandId: asCommandId("cmd-project-delete-no-force"), | |
| 154 | projectId: asProjectId("project-delete"), | |
| 155 | }, | |
| 156 | readModel, | |
| 157 | }), | |
| 158 | ), | |
| 159 | ).rejects.toThrow("cannot be deleted without force=true"); | |
| 160 | }); | |
| 161 | ||
| 162 | it("reuses thread.delete semantics when force-deleting a non-empty project", async () => { | |
| 163 | const readModel = await seedReadModel(); | |
| 164 | const projectDeleteCommand: Extract<OrchestrationCommand, { type: "project.delete" }> = { | |
| 165 | type: "project.delete", | |
| 166 | commandId: asCommandId("cmd-project-delete-force"), | |
| 167 | projectId: asProjectId("project-delete"), | |
| 168 | force: true, | |
| 169 | }; | |
| 170 | ||
| 171 | const forcedResult = await Effect.runPromise( | |
| 172 | decideOrchestrationCommand({ | |
| 173 | command: projectDeleteCommand, | |
| 174 | readModel, | |
| 175 | }), | |
| 176 | ); | |
| 177 | const forcedEvents = Array.isArray(forcedResult) ? forcedResult : [forcedResult]; | |
| 178 | ||
| 179 | expect(forcedEvents.map((event) => event.type)).toEqual([ | |
| 180 | "thread.deleted", | |
| 181 | "thread.deleted", | |
| 182 | "project.deleted", | |
| 183 | ]); | |
| 184 | ||
| 185 | let sequentialReadModel = readModel; | |
| 186 | let nextSequence = readModel.snapshotSequence; | |
| 187 | const sequentialEvents: PlannedEvent[] = []; | |
| 188 | for (const nextCommand of [ | |
| 189 | { | |
| 190 | type: "thread.delete", | |
| 191 | commandId: projectDeleteCommand.commandId, | |
| 192 | threadId: asThreadId("thread-delete-1"), | |
| 193 | }, | |
| 194 | { | |
| 195 | type: "thread.delete", | |
| 196 | commandId: projectDeleteCommand.commandId, | |
| 197 | threadId: asThreadId("thread-delete-2"), | |
| 198 | }, | |
| 199 | { | |
| 200 | type: "project.delete", | |
| 201 | commandId: projectDeleteCommand.commandId, | |
| 202 | projectId: asProjectId("project-delete"), | |
| 203 | }, | |
| 204 | ] satisfies ReadonlyArray<OrchestrationCommand>) { | |
| 205 | const decided = await Effect.runPromise( | |
| 206 | decideOrchestrationCommand({ | |
| 207 | command: nextCommand, | |
| 208 | readModel: sequentialReadModel, | |
| 209 | }), | |
| 210 | ); | |
| 211 | const nextEvents = Array.isArray(decided) ? decided : [decided]; | |
| 212 | sequentialEvents.push(...nextEvents); | |
| 213 | for (const nextEvent of nextEvents) { | |
| 214 | nextSequence += 1; | |
| 215 | sequentialReadModel = await Effect.runPromise( | |
| 216 | projectEvent(sequentialReadModel, { | |
| 217 | ...nextEvent, | |
| 218 | sequence: nextSequence, | |
| 219 | }), | |
| 220 | ); | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | expect(normalizeDeleteEvent(forcedResult)).toEqual(normalizeDeleteEvent(sequentialEvents)); | |
| 225 | }); | |
| 226 | }); |
apps/server/src/orchestration/decider.ts+69-5| ... | ... | @@ -7,6 +7,7 @@ import { Effect } from "effect"; |
| 7 | 7 | |
| 8 | 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| 9 | 9 | import { |
| 10 | listThreadsByProjectId, | |
| 10 | 11 | requireProject, |
| 11 | 12 | requireProjectAbsent, |
| 12 | 13 | requireThread, |
| ... | ... | @@ -14,6 +15,7 @@ import { |
| 14 | 15 | requireThreadAbsent, |
| 15 | 16 | requireThreadNotArchived, |
| 16 | 17 | } from "./commandInvariants.ts"; |
| 18 | import { projectEvent } from "./projector.ts"; | |
| 17 | 19 | |
| 18 | 20 | const nowIso = () => new Date().toISOString(); |
| 19 | 21 | const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = { |
| ... | ... | @@ -47,16 +49,49 @@ function withEventBase( |
| 47 | 49 | }; |
| 48 | 50 | } |
| 49 | 51 | |
| 52 | type PlannedOrchestrationEvent = Omit<OrchestrationEvent, "sequence">; | |
| 53 | ||
| 54 | type DecideOrchestrationCommandResult = | |
| 55 | | PlannedOrchestrationEvent | |
| 56 | | ReadonlyArray<PlannedOrchestrationEvent>; | |
| 57 | ||
| 58 | const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ | |
| 59 | commands, | |
| 60 | readModel, | |
| 61 | }: { | |
| 62 | readonly commands: ReadonlyArray<OrchestrationCommand>; | |
| 63 | readonly readModel: OrchestrationReadModel; | |
| 64 | }): Effect.fn.Return<ReadonlyArray<PlannedOrchestrationEvent>, OrchestrationCommandInvariantError> { | |
| 65 | let nextReadModel = readModel; | |
| 66 | let nextSequence = readModel.snapshotSequence; | |
| 67 | const plannedEvents: PlannedOrchestrationEvent[] = []; | |
| 68 | ||
| 69 | for (const nextCommand of commands) { | |
| 70 | const decided = yield* decideOrchestrationCommand({ | |
| 71 | command: nextCommand, | |
| 72 | readModel: nextReadModel, | |
| 73 | }); | |
| 74 | const nextEvents = Array.isArray(decided) ? decided : [decided]; | |
| 75 | for (const nextEvent of nextEvents) { | |
| 76 | plannedEvents.push(nextEvent); | |
| 77 | nextSequence += 1; | |
| 78 | nextReadModel = yield* projectEvent(nextReadModel, { | |
| 79 | ...nextEvent, | |
| 80 | sequence: nextSequence, | |
| 81 | }).pipe(Effect.orDie); | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | return plannedEvents; | |
| 86 | }); | |
| 87 | ||
| 50 | 88 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ |
| 51 | 89 | command, |
| 52 | 90 | readModel, |
| 53 | 91 | }: { |
| 54 | 92 | readonly command: OrchestrationCommand; |
| 55 | 93 | readonly readModel: OrchestrationReadModel; |
| 56 | }): Effect.fn.Return< | |
| 57 | Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>, | |
| 58 | OrchestrationCommandInvariantError | |
| 59 | > { | |
| 94 | }): Effect.fn.Return<DecideOrchestrationCommandResult, OrchestrationCommandInvariantError> { | |
| 60 | 95 | switch (command.type) { |
| 61 | 96 | case "project.create": { |
| 62 | 97 | yield* requireProjectAbsent({ |
| ... | ... | @@ -119,6 +154,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 119 | 154 | command, |
| 120 | 155 | projectId: command.projectId, |
| 121 | 156 | }); |
| 157 | const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter( | |
| 158 | (thread) => thread.deletedAt === null, | |
| 159 | ); | |
| 160 | if (activeThreads.length > 0 && command.force !== true) { | |
| 161 | return yield* new OrchestrationCommandInvariantError({ | |
| 162 | commandType: command.type, | |
| 163 | detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`, | |
| 164 | }); | |
| 165 | } | |
| 166 | if (activeThreads.length > 0) { | |
| 167 | return yield* decideCommandSequence({ | |
| 168 | readModel, | |
| 169 | commands: [ | |
| 170 | ...activeThreads.map( | |
| 171 | (thread): Extract<OrchestrationCommand, { type: "thread.delete" }> => ({ | |
| 172 | type: "thread.delete", | |
| 173 | commandId: command.commandId, | |
| 174 | threadId: thread.id, | |
| 175 | }), | |
| 176 | ), | |
| 177 | { | |
| 178 | type: "project.delete", | |
| 179 | commandId: command.commandId, | |
| 180 | projectId: command.projectId, | |
| 181 | }, | |
| 182 | ], | |
| 183 | }); | |
| 184 | } | |
| 185 | ||
| 122 | 186 | const occurredAt = nowIso(); |
| 123 | 187 | return { |
| 124 | 188 | ...withEventBase({ |
| ... | ... | @@ -127,7 +191,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 127 | 191 | occurredAt, |
| 128 | 192 | commandId: command.commandId, |
| 129 | 193 | }), |
| 130 | type: "project.deleted", | |
| 194 | type: "project.deleted" as const, | |
| 131 | 195 | payload: { |
| 132 | 196 | projectId: command.projectId, |
| 133 | 197 | deletedAt: occurredAt, |
apps/server/src/server.ts+2| ... | ... | @@ -40,6 +40,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus. |
| 40 | 40 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; |
| 41 | 41 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; |
| 42 | 42 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 43 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; | |
| 43 | 44 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 44 | 45 | import { ServerSettingsLive } from "./serverSettings.ts"; |
| 45 | 46 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; |
| ... | ... | @@ -128,6 +129,7 @@ const ReactorLayerLive = Layer.empty.pipe( |
| 128 | 129 | Layer.provideMerge(ProviderRuntimeIngestionLive), |
| 129 | 130 | Layer.provideMerge(ProviderCommandReactorLive), |
| 130 | 131 | Layer.provideMerge(CheckpointReactorLive), |
| 132 | Layer.provideMerge(ThreadDeletionReactorLive), | |
| 131 | 133 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 132 | 134 | ); |
| 133 | 135 |
apps/web/src/components/Sidebar.tsx+92-34| ... | ... | @@ -951,13 +951,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 951 | 951 | const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); |
| 952 | 952 | const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); |
| 953 | 953 | const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); |
| 954 | const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread); | |
| 955 | const getDraftThreadByProjectRef = useComposerDraftStore( | |
| 956 | (state) => state.getDraftThreadByProjectRef, | |
| 957 | ); | |
| 958 | const clearProjectDraftThreadId = useComposerDraftStore( | |
| 959 | (state) => state.clearProjectDraftThreadId, | |
| 960 | ); | |
| 961 | 954 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ |
| 962 | 955 | threadId: ThreadId; |
| 963 | 956 | }>({ |
| ... | ... | @@ -1283,6 +1276,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1283 | 1276 | [projectGroupingSettings.sidebarProjectGroupingOverrides], |
| 1284 | 1277 | ); |
| 1285 | 1278 | |
| 1279 | const removeProject = useCallback( | |
| 1280 | async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}): Promise<void> => { | |
| 1281 | const memberProjectRef = scopeProjectRef(member.environmentId, member.id); | |
| 1282 | const draftStore = useComposerDraftStore.getState(); | |
| 1283 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); | |
| 1284 | if (projectDraftThread) { | |
| 1285 | draftStore.clearDraftThread(projectDraftThread.draftId); | |
| 1286 | } | |
| 1287 | draftStore.clearProjectDraftThreadId(memberProjectRef); | |
| 1288 | ||
| 1289 | const projectApi = readEnvironmentApi(member.environmentId); | |
| 1290 | if (!projectApi) { | |
| 1291 | throw new Error("Project API unavailable."); | |
| 1292 | } | |
| 1293 | ||
| 1294 | await projectApi.orchestration.dispatchCommand({ | |
| 1295 | type: "project.delete", | |
| 1296 | commandId: newCommandId(), | |
| 1297 | projectId: member.id, | |
| 1298 | ...(options.force === true ? { force: true } : {}), | |
| 1299 | }); | |
| 1300 | }, | |
| 1301 | [], | |
| 1302 | ); | |
| 1303 | ||
| 1286 | 1304 | const handleRemoveProject = useCallback( |
| 1287 | 1305 | async (member: SidebarProjectGroupMember) => { |
| 1288 | 1306 | const api = readLocalApi(); |
| ... | ... | @@ -1290,11 +1308,74 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1290 | 1308 | return; |
| 1291 | 1309 | } |
| 1292 | 1310 | |
| 1293 | if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) { | |
| 1294 | toastManager.add({ | |
| 1311 | const memberProjectRef = scopeProjectRef(member.environmentId, member.id); | |
| 1312 | const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; | |
| 1313 | if (memberThreadCount > 0) { | |
| 1314 | const warningToastId = toastManager.add({ | |
| 1295 | 1315 | type: "warning", |
| 1296 | 1316 | title: "Project is not empty", |
| 1297 | 1317 | description: "Delete all threads in this project before removing it.", |
| 1318 | data: { | |
| 1319 | actionLayout: "stacked-end", | |
| 1320 | actionVariant: "destructive", | |
| 1321 | }, | |
| 1322 | actionProps: { | |
| 1323 | children: "Delete anyway", | |
| 1324 | onClick: () => { | |
| 1325 | void (async () => { | |
| 1326 | toastManager.close(warningToastId); | |
| 1327 | await new Promise<void>((resolve) => { | |
| 1328 | window.setTimeout(resolve, 180); | |
| 1329 | }); | |
| 1330 | ||
| 1331 | const latestProjectThreads = selectSidebarThreadsForProjectRefs( | |
| 1332 | useStore.getState(), | |
| 1333 | [memberProjectRef], | |
| 1334 | ); | |
| 1335 | const confirmed = await api.dialogs.confirm( | |
| 1336 | latestProjectThreads.length > 0 | |
| 1337 | ? [ | |
| 1338 | `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${ | |
| 1339 | latestProjectThreads.length === 1 ? "" : "s" | |
| 1340 | }?`, | |
| 1341 | `Path: ${member.cwd}`, | |
| 1342 | ...(member.environmentLabel | |
| 1343 | ? [`Environment: ${member.environmentLabel}`] | |
| 1344 | : []), | |
| 1345 | "This permanently clears conversation history for those threads.", | |
| 1346 | "This removes only this project entry.", | |
| 1347 | "This action cannot be undone.", | |
| 1348 | ].join("\n") | |
| 1349 | : [ | |
| 1350 | `Remove project "${member.name}"?`, | |
| 1351 | `Path: ${member.cwd}`, | |
| 1352 | ...(member.environmentLabel | |
| 1353 | ? [`Environment: ${member.environmentLabel}`] | |
| 1354 | : []), | |
| 1355 | "This removes only this project entry.", | |
| 1356 | ].join("\n"), | |
| 1357 | ); | |
| 1358 | if (!confirmed) { | |
| 1359 | return; | |
| 1360 | } | |
| 1361 | ||
| 1362 | await removeProject(member, { force: true }); | |
| 1363 | })().catch((error) => { | |
| 1364 | const message = | |
| 1365 | error instanceof Error ? error.message : "Unknown error removing project."; | |
| 1366 | console.error("Failed to remove project", { | |
| 1367 | projectId: member.id, | |
| 1368 | environmentId: member.environmentId, | |
| 1369 | error, | |
| 1370 | }); | |
| 1371 | toastManager.add({ | |
| 1372 | type: "error", | |
| 1373 | title: `Failed to remove "${member.name}"`, | |
| 1374 | description: message, | |
| 1375 | }); | |
| 1376 | }); | |
| 1377 | }, | |
| 1378 | }, | |
| 1298 | 1379 | }); |
| 1299 | 1380 | return; |
| 1300 | 1381 | } |
| ... | ... | @@ -1310,23 +1391,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1310 | 1391 | return; |
| 1311 | 1392 | } |
| 1312 | 1393 | |
| 1313 | const memberProjectRef = scopeProjectRef(member.environmentId, member.id); | |
| 1314 | ||
| 1315 | 1394 | try { |
| 1316 | const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef); | |
| 1317 | if (projectDraftThread) { | |
| 1318 | clearComposerDraftForThread(projectDraftThread.draftId); | |
| 1319 | } | |
| 1320 | clearProjectDraftThreadId(memberProjectRef); | |
| 1321 | const projectApi = readEnvironmentApi(member.environmentId); | |
| 1322 | if (!projectApi) { | |
| 1323 | throw new Error("Project API unavailable."); | |
| 1324 | } | |
| 1325 | await projectApi.orchestration.dispatchCommand({ | |
| 1326 | type: "project.delete", | |
| 1327 | commandId: newCommandId(), | |
| 1328 | projectId: member.id, | |
| 1329 | }); | |
| 1395 | await removeProject(member); | |
| 1330 | 1396 | } catch (error) { |
| 1331 | 1397 | const message = error instanceof Error ? error.message : "Unknown error removing project."; |
| 1332 | 1398 | console.error("Failed to remove project", { |
| ... | ... | @@ -1341,12 +1407,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1341 | 1407 | }); |
| 1342 | 1408 | } |
| 1343 | 1409 | }, |
| 1344 | [ | |
| 1345 | clearComposerDraftForThread, | |
| 1346 | clearProjectDraftThreadId, | |
| 1347 | getDraftThreadByProjectRef, | |
| 1348 | memberThreadCountByPhysicalKey, | |
| 1349 | ], | |
| 1410 | [memberThreadCountByPhysicalKey, removeProject], | |
| 1350 | 1411 | ); |
| 1351 | 1412 | |
| 1352 | 1413 | const handleProjectButtonContextMenu = useCallback( |
| ... | ... | @@ -1429,8 +1490,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1429 | 1490 | buildTargetedItem("copy-path", "Copy Project Path"), |
| 1430 | 1491 | buildTargetedItem("delete", "Remove project", { |
| 1431 | 1492 | destructive: true, |
| 1432 | isDisabled: (member) => | |
| 1433 | (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0, | |
| 1434 | 1493 | }), |
| 1435 | 1494 | ], |
| 1436 | 1495 | { |
| ... | ... | @@ -1449,7 +1508,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1449 | 1508 | [ |
| 1450 | 1509 | copyPathToClipboard, |
| 1451 | 1510 | handleRemoveProject, |
| 1452 | memberThreadCountByPhysicalKey, | |
| 1453 | 1511 | openProjectGroupingDialog, |
| 1454 | 1512 | openProjectRenameDialog, |
| 1455 | 1513 | project.groupedProjectCount, |
apps/web/src/components/ui/toast.tsx+37-4| ... | ... | @@ -31,6 +31,15 @@ export type ThreadToastData = { |
| 31 | 31 | tooltipStyle?: boolean; |
| 32 | 32 | dismissAfterVisibleMs?: number; |
| 33 | 33 | hideCopyButton?: boolean; |
| 34 | actionLayout?: "inline" | "stacked-end"; | |
| 35 | actionVariant?: | |
| 36 | | "default" | |
| 37 | | "destructive" | |
| 38 | | "destructive-outline" | |
| 39 | | "ghost" | |
| 40 | | "link" | |
| 41 | | "outline" | |
| 42 | | "secondary"; | |
| 34 | 43 | }; |
| 35 | 44 | |
| 36 | 45 | const toastManager = Toast.createToastManager<ThreadToastData>(); |
| ... | ... | @@ -232,6 +241,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 232 | 241 | visibleIndex, |
| 233 | 242 | visibleToastLayout.items.length, |
| 234 | 243 | ); |
| 244 | const stackedActionLayout = | |
| 245 | toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; | |
| 246 | const actionVariant = toast.data?.actionVariant ?? "default"; | |
| 235 | 247 | |
| 236 | 248 | return ( |
| 237 | 249 | <Toast.Root |
| ... | ... | @@ -304,7 +316,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 304 | 316 | /> |
| 305 | 317 | <Toast.Content |
| 306 | 318 | className={cn( |
| 307 | "pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-expanded:opacity-100", | |
| 319 | "pointer-events-auto overflow-hidden px-3.5 text-sm transition-opacity duration-250 data-expanded:opacity-100", | |
| 320 | stackedActionLayout | |
| 321 | ? "flex flex-col gap-2 py-2.5" | |
| 322 | : "flex items-center justify-between gap-1.5 py-3", | |
| 308 | 323 | hideCollapsedContent && |
| 309 | 324 | "not-data-expanded:pointer-events-none not-data-expanded:opacity-0", |
| 310 | 325 | )} |
| ... | ... | @@ -337,7 +352,11 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 337 | 352 | </div> |
| 338 | 353 | {toast.actionProps && ( |
| 339 | 354 | <Toast.Action |
| 340 | className={cn(buttonVariants({ size: "xs" }), "shrink-0")} | |
| 355 | className={cn( | |
| 356 | buttonVariants({ size: "xs", variant: actionVariant }), | |
| 357 | "shrink-0", | |
| 358 | stackedActionLayout && "self-end", | |
| 359 | )} | |
| 341 | 360 | data-slot="toast-action" |
| 342 | 361 | > |
| 343 | 362 | {toast.actionProps.children} |
| ... | ... | @@ -374,6 +393,9 @@ function AnchoredToasts() { |
| 374 | 393 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; |
| 375 | 394 | const tooltipStyle = toast.data?.tooltipStyle ?? false; |
| 376 | 395 | const positionerProps = toast.positionerProps; |
| 396 | const stackedActionLayout = | |
| 397 | toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; | |
| 398 | const actionVariant = toast.data?.actionVariant ?? "default"; | |
| 377 | 399 | |
| 378 | 400 | if (!positionerProps?.anchor) { |
| 379 | 401 | return null; |
| ... | ... | @@ -402,7 +424,14 @@ function AnchoredToasts() { |
| 402 | 424 | <Toast.Title data-slot="toast-title" /> |
| 403 | 425 | </Toast.Content> |
| 404 | 426 | ) : ( |
| 405 | <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm"> | |
| 427 | <Toast.Content | |
| 428 | className={cn( | |
| 429 | "pointer-events-auto overflow-hidden px-3.5 text-sm", | |
| 430 | stackedActionLayout | |
| 431 | ? "flex flex-col gap-2 py-2.5" | |
| 432 | : "flex items-center justify-between gap-1.5 py-3", | |
| 433 | )} | |
| 434 | > | |
| 406 | 435 | <div className="flex min-w-0 flex-1 gap-2"> |
| 407 | 436 | {Icon && ( |
| 408 | 437 | <div |
| ... | ... | @@ -433,7 +462,11 @@ function AnchoredToasts() { |
| 433 | 462 | </div> |
| 434 | 463 | {toast.actionProps && ( |
| 435 | 464 | <Toast.Action |
| 436 | className={cn(buttonVariants({ size: "xs" }), "shrink-0")} | |
| 465 | className={cn( | |
| 466 | buttonVariants({ size: "xs", variant: actionVariant }), | |
| 467 | "shrink-0", | |
| 468 | stackedActionLayout && "self-end", | |
| 469 | )} | |
| 437 | 470 | data-slot="toast-action" |
| 438 | 471 | > |
| 439 | 472 | {toast.actionProps.children} |
apps/web/src/composerDraftStore.test.ts+43| ... | ... | @@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 561 | 561 | expect(draftByKey(draftId)).toBeUndefined(); |
| 562 | 562 | }); |
| 563 | 563 | |
| 564 | it("revokes draft image blob URLs when clearing a project's draft thread", () => { | |
| 565 | const store = useComposerDraftStore.getState(); | |
| 566 | const originalRevokeObjectUrl = URL.revokeObjectURL; | |
| 567 | const revokeSpy = vi.fn<(url: string) => void>(); | |
| 568 | URL.revokeObjectURL = revokeSpy; | |
| 569 | ||
| 570 | try { | |
| 571 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 572 | store.addImage(draftId, makeImage({ id: "img-project-clear", previewUrl: "blob:clear" })); | |
| 573 | ||
| 574 | store.clearProjectDraftThreadId(projectRef); | |
| 575 | ||
| 576 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 577 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | |
| 578 | expect(revokeSpy).toHaveBeenCalledWith("blob:clear"); | |
| 579 | } finally { | |
| 580 | URL.revokeObjectURL = originalRevokeObjectUrl; | |
| 581 | } | |
| 582 | }); | |
| 583 | ||
| 584 | it("revokes draft image blob URLs when clearing a matching project draft thread by id", () => { | |
| 585 | const store = useComposerDraftStore.getState(); | |
| 586 | const originalRevokeObjectUrl = URL.revokeObjectURL; | |
| 587 | const revokeSpy = vi.fn<(url: string) => void>(); | |
| 588 | URL.revokeObjectURL = revokeSpy; | |
| 589 | ||
| 590 | try { | |
| 591 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 592 | store.addImage( | |
| 593 | draftId, | |
| 594 | makeImage({ id: "img-project-clear-by-id", previewUrl: "blob:clear-by-id" }), | |
| 595 | ); | |
| 596 | ||
| 597 | store.clearProjectDraftThreadById(projectRef, draftId); | |
| 598 | ||
| 599 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 600 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | |
| 601 | expect(revokeSpy).toHaveBeenCalledWith("blob:clear-by-id"); | |
| 602 | } finally { | |
| 603 | URL.revokeObjectURL = originalRevokeObjectUrl; | |
| 604 | } | |
| 605 | }); | |
| 606 | ||
| 564 | 607 | it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { |
| 565 | 608 | const store = useComposerDraftStore.getState(); |
| 566 | 609 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
apps/web/src/composerDraftStore.ts+11-7| ... | ... | @@ -782,6 +782,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void { |
| 782 | 782 | URL.revokeObjectURL(previewUrl); |
| 783 | 783 | } |
| 784 | 784 | |
| 785 | function revokeDraftThreadPreviewUrls(draft: ComposerThreadDraftState | undefined): void { | |
| 786 | if (!draft) { | |
| 787 | return; | |
| 788 | } | |
| 789 | for (const image of draft.images) { | |
| 790 | revokeObjectPreviewUrl(image.previewUrl); | |
| 791 | } | |
| 792 | } | |
| 793 | ||
| 785 | 794 | function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { |
| 786 | 795 | if (!value || typeof value !== "object") { |
| 787 | 796 | return null; |
| ... | ... | @@ -1101,7 +1110,8 @@ function removeDraftThreadReferences( |
| 1101 | 1110 | ) as Record<string, string>; |
| 1102 | 1111 | const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = |
| 1103 | 1112 | state.draftThreadsByThreadKey; |
| 1104 | const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; | |
| 1113 | const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; | |
| 1114 | revokeDraftThreadPreviewUrls(removedComposerDraft); | |
| 1105 | 1115 | return { |
| 1106 | 1116 | draftsByThreadKey: restDraftsByThreadKey, |
| 1107 | 1117 | draftThreadsByThreadKey: restDraftThreadsByThreadKey, |
| ... | ... | @@ -2068,12 +2078,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2068 | 2078 | if (threadKey.length === 0) { |
| 2069 | 2079 | return; |
| 2070 | 2080 | } |
| 2071 | const existing = get().draftsByThreadKey[threadKey]; | |
| 2072 | if (existing) { | |
| 2073 | for (const image of existing.images) { | |
| 2074 | revokeObjectPreviewUrl(image.previewUrl); | |
| 2075 | } | |
| 2076 | } | |
| 2077 | 2081 | set((state) => { |
| 2078 | 2082 | const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; |
| 2079 | 2083 | const hasLogicalProjectMapping = Object.values( |
apps/web/src/environments/runtime/service.ts+6| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | createKnownEnvironment, |
| 15 | 15 | getKnownEnvironmentWsBaseUrl, |
| 16 | 16 | scopedThreadKey, |
| 17 | scopeProjectRef, | |
| 17 | 18 | scopeThreadRef, |
| 18 | 19 | } from "@t3tools/client-runtime"; |
| 19 | 20 | |
| ... | ... | @@ -571,6 +572,11 @@ function applyRecoveredEventBatch( |
| 571 | 572 | .getState() |
| 572 | 573 | .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); |
| 573 | 574 | } |
| 575 | for (const event of events) { | |
| 576 | if (event.type === "project.deleted") { | |
| 577 | draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); | |
| 578 | } | |
| 579 | } | |
| 574 | 580 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { |
| 575 | 581 | useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); |
| 576 | 582 | } |
packages/contracts/src/orchestration.ts+1| ... | ... | @@ -432,6 +432,7 @@ const ProjectDeleteCommand = Schema.Struct({ |
| 432 | 432 | type: Schema.Literal("project.delete"), |
| 433 | 433 | commandId: CommandId, |
| 434 | 434 | projectId: ProjectId, |
| 435 | force: Schema.optional(Schema.Boolean), | |
| 435 | 436 | }); |
| 436 | 437 | |
| 437 | 438 | const ThreadCreateCommand = Schema.Struct({ |