| 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,6 +58,7 @@ import { |
| 58 | OrchestrationEngineService, | 58 | OrchestrationEngineService, |
| 59 | type OrchestrationEngineShape, | 59 | type OrchestrationEngineShape, |
| 60 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; | 60 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; |
| 61 | import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; | ||
| 61 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; | 62 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; |
| 62 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; | 63 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 63 | import { | 64 | import { |
| ... | @@ -351,6 +352,12 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -351,6 +352,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 351 | Layer.provideMerge(runtimeIngestionLayer), | 352 | Layer.provideMerge(runtimeIngestionLayer), |
| 352 | Layer.provideMerge(providerCommandReactorLayer), | 353 | Layer.provideMerge(providerCommandReactorLayer), |
| 353 | Layer.provideMerge(checkpointReactorLayer), | 354 | Layer.provideMerge(checkpointReactorLayer), |
| 355 | Layer.provideMerge( | ||
| 356 | Layer.succeed(ThreadDeletionReactor, { | ||
| 357 | start: () => Effect.void, | ||
| 358 | drain: Effect.void, | ||
| 359 | }), | ||
| 360 | ), | ||
| 354 | ); | 361 | ); |
| 355 | const layer = Layer.empty.pipe( | 362 | const layer = Layer.empty.pipe( |
| 356 | Layer.provideMerge(runtimeServicesLayer), | 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,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; |
| 4 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | 4 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; |
| 5 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 5 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 6 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 6 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 7 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | ||
| 7 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; | 8 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 8 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; | 9 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| 9 | 10 | ||
| ... | @@ -17,7 +18,7 @@ describe("OrchestrationReactor", () => { | ... | @@ -17,7 +18,7 @@ describe("OrchestrationReactor", () => { |
| 17 | runtime = null; | 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 | const started: string[] = []; | 22 | const started: string[] = []; |
| 22 | 23 | ||
| 23 | runtime = ManagedRuntime.make( | 24 | runtime = ManagedRuntime.make( |
| ... | @@ -49,10 +50,19 @@ describe("OrchestrationReactor", () => { | ... | @@ -49,10 +50,19 @@ describe("OrchestrationReactor", () => { |
| 49 | drain: Effect.void, | 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 | const scope = await Effect.runPromise(Scope.make("sequential")); | 66 | const scope = await Effect.runPromise(Scope.make("sequential")); |
| 57 | await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); | 67 | await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); |
| 58 | 68 | ||
| ... | @@ -60,6 +70,7 @@ describe("OrchestrationReactor", () => { | ... | @@ -60,6 +70,7 @@ describe("OrchestrationReactor", () => { |
| 60 | "provider-runtime-ingestion", | 70 | "provider-runtime-ingestion", |
| 61 | "provider-command-reactor", | 71 | "provider-command-reactor", |
| 62 | "checkpoint-reactor", | 72 | "checkpoint-reactor", |
| 73 | "thread-deletion-reactor", | ||
| 63 | ]); | 74 | ]); |
| 64 | 75 | ||
| 65 | await Effect.runPromise(Scope.close(scope, Exit.void)); | 76 | await Effect.runPromise(Scope.close(scope, Exit.void)); |
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3| ... | @@ -7,16 +7,19 @@ import { | ... | @@ -7,16 +7,19 @@ import { |
| 7 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | 7 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; |
| 8 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 8 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 9 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 9 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 10 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | ||
| 10 | 11 | ||
| 11 | export const makeOrchestrationReactor = Effect.gen(function* () { | 12 | export const makeOrchestrationReactor = Effect.gen(function* () { |
| 12 | const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; | 13 | const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; |
| 13 | const providerCommandReactor = yield* ProviderCommandReactor; | 14 | const providerCommandReactor = yield* ProviderCommandReactor; |
| 14 | const checkpointReactor = yield* CheckpointReactor; | 15 | const checkpointReactor = yield* CheckpointReactor; |
| 16 | const threadDeletionReactor = yield* ThreadDeletionReactor; | ||
| 15 | 17 | ||
| 16 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { | 18 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { |
| 17 | yield* providerRuntimeIngestion.start(); | 19 | yield* providerRuntimeIngestion.start(); |
| 18 | yield* providerCommandReactor.start(); | 20 | yield* providerCommandReactor.start(); |
| 19 | yield* checkpointReactor.start(); | 21 | yield* checkpointReactor.start(); |
| 22 | yield* threadDeletionReactor.start(); | ||
| 20 | }); | 23 | }); |
| 21 | 24 | ||
| 22 | return { | 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,6 +7,7 @@ import { Effect } from "effect"; |
| 7 | 7 | ||
| 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| 9 | import { | 9 | import { |
| 10 | listThreadsByProjectId, | ||
| 10 | requireProject, | 11 | requireProject, |
| 11 | requireProjectAbsent, | 12 | requireProjectAbsent, |
| 12 | requireThread, | 13 | requireThread, |
| ... | @@ -14,6 +15,7 @@ import { | ... | @@ -14,6 +15,7 @@ import { |
| 14 | requireThreadAbsent, | 15 | requireThreadAbsent, |
| 15 | requireThreadNotArchived, | 16 | requireThreadNotArchived, |
| 16 | } from "./commandInvariants.ts"; | 17 | } from "./commandInvariants.ts"; |
| 18 | import { projectEvent } from "./projector.ts"; | ||
| 17 | 19 | ||
| 18 | const nowIso = () => new Date().toISOString(); | 20 | const nowIso = () => new Date().toISOString(); |
| 19 | const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = { | 21 | const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = { |
| ... | @@ -47,16 +49,49 @@ function withEventBase( | ... | @@ -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 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ | 88 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ |
| 51 | command, | 89 | command, |
| 52 | readModel, | 90 | readModel, |
| 53 | }: { | 91 | }: { |
| 54 | readonly command: OrchestrationCommand; | 92 | readonly command: OrchestrationCommand; |
| 55 | readonly readModel: OrchestrationReadModel; | 93 | readonly readModel: OrchestrationReadModel; |
| 56 | }): Effect.fn.Return< | 94 | }): Effect.fn.Return<DecideOrchestrationCommandResult, OrchestrationCommandInvariantError> { |
| 57 | Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>, | ||
| 58 | OrchestrationCommandInvariantError | ||
| 59 | > { | ||
| 60 | switch (command.type) { | 95 | switch (command.type) { |
| 61 | case "project.create": { | 96 | case "project.create": { |
| 62 | yield* requireProjectAbsent({ | 97 | yield* requireProjectAbsent({ |
| ... | @@ -119,6 +154,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -119,6 +154,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 119 | command, | 154 | command, |
| 120 | projectId: command.projectId, | 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 | const occurredAt = nowIso(); | 186 | const occurredAt = nowIso(); |
| 123 | return { | 187 | return { |
| 124 | ...withEventBase({ | 188 | ...withEventBase({ |
| ... | @@ -127,7 +191,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -127,7 +191,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 127 | occurredAt, | 191 | occurredAt, |
| 128 | commandId: command.commandId, | 192 | commandId: command.commandId, |
| 129 | }), | 193 | }), |
| 130 | type: "project.deleted", | 194 | type: "project.deleted" as const, |
| 131 | payload: { | 195 | payload: { |
| 132 | projectId: command.projectId, | 196 | projectId: command.projectId, |
| 133 | deletedAt: occurredAt, | 197 | deletedAt: occurredAt, |
apps/server/src/server.ts+2| ... | @@ -40,6 +40,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus. | ... | @@ -40,6 +40,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus. |
| 40 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; | 40 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; |
| 41 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; | 41 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; |
| 42 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; | 42 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 43 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; | ||
| 43 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; | 44 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 44 | import { ServerSettingsLive } from "./serverSettings.ts"; | 45 | import { ServerSettingsLive } from "./serverSettings.ts"; |
| 45 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | 46 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; |
| ... | @@ -128,6 +129,7 @@ const ReactorLayerLive = Layer.empty.pipe( | ... | @@ -128,6 +129,7 @@ const ReactorLayerLive = Layer.empty.pipe( |
| 128 | Layer.provideMerge(ProviderRuntimeIngestionLive), | 129 | Layer.provideMerge(ProviderRuntimeIngestionLive), |
| 129 | Layer.provideMerge(ProviderCommandReactorLive), | 130 | Layer.provideMerge(ProviderCommandReactorLive), |
| 130 | Layer.provideMerge(CheckpointReactorLive), | 131 | Layer.provideMerge(CheckpointReactorLive), |
| 132 | Layer.provideMerge(ThreadDeletionReactorLive), | ||
| 131 | Layer.provideMerge(RuntimeReceiptBusLive), | 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,13 +951,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 951 | const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); | 951 | const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); |
| 952 | const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); | 952 | const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); |
| 953 | const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); | 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 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ | 954 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ |
| 962 | threadId: ThreadId; | 955 | threadId: ThreadId; |
| 963 | }>({ | 956 | }>({ |
| ... | @@ -1283,6 +1276,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1283,6 +1276,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1283 | [projectGroupingSettings.sidebarProjectGroupingOverrides], | 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 | const handleRemoveProject = useCallback( | 1304 | const handleRemoveProject = useCallback( |
| 1287 | async (member: SidebarProjectGroupMember) => { | 1305 | async (member: SidebarProjectGroupMember) => { |
| 1288 | const api = readLocalApi(); | 1306 | const api = readLocalApi(); |
| ... | @@ -1290,11 +1308,74 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1290,11 +1308,74 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1290 | return; | 1308 | return; |
| 1291 | } | 1309 | } |
| 1292 | 1310 | ||
| 1293 | if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) { | 1311 | const memberProjectRef = scopeProjectRef(member.environmentId, member.id); |
| 1294 | toastManager.add({ | 1312 | const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; |
| 1313 | if (memberThreadCount > 0) { | ||
| 1314 | const warningToastId = toastManager.add({ | ||
| 1295 | type: "warning", | 1315 | type: "warning", |
| 1296 | title: "Project is not empty", | 1316 | title: "Project is not empty", |
| 1297 | description: "Delete all threads in this project before removing it.", | 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 | return; | 1380 | return; |
| 1300 | } | 1381 | } |
| ... | @@ -1310,23 +1391,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1310,23 +1391,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1310 | return; | 1391 | return; |
| 1311 | } | 1392 | } |
| 1312 | 1393 | ||
| 1313 | const memberProjectRef = scopeProjectRef(member.environmentId, member.id); | ||
| 1314 | |||
| 1315 | try { | 1394 | try { |
| 1316 | const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef); | 1395 | await removeProject(member); |
| 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 | }); | ||
| 1330 | } catch (error) { | 1396 | } catch (error) { |
| 1331 | const message = error instanceof Error ? error.message : "Unknown error removing project."; | 1397 | const message = error instanceof Error ? error.message : "Unknown error removing project."; |
| 1332 | console.error("Failed to remove project", { | 1398 | console.error("Failed to remove project", { |
| ... | @@ -1341,12 +1407,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1341,12 +1407,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1341 | }); | 1407 | }); |
| 1342 | } | 1408 | } |
| 1343 | }, | 1409 | }, |
| 1344 | [ | 1410 | [memberThreadCountByPhysicalKey, removeProject], |
| 1345 | clearComposerDraftForThread, | ||
| 1346 | clearProjectDraftThreadId, | ||
| 1347 | getDraftThreadByProjectRef, | ||
| 1348 | memberThreadCountByPhysicalKey, | ||
| 1349 | ], | ||
| 1350 | ); | 1411 | ); |
| 1351 | 1412 | ||
| 1352 | const handleProjectButtonContextMenu = useCallback( | 1413 | const handleProjectButtonContextMenu = useCallback( |
| ... | @@ -1429,8 +1490,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1429,8 +1490,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1429 | buildTargetedItem("copy-path", "Copy Project Path"), | 1490 | buildTargetedItem("copy-path", "Copy Project Path"), |
| 1430 | buildTargetedItem("delete", "Remove project", { | 1491 | buildTargetedItem("delete", "Remove project", { |
| 1431 | destructive: true, | 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,7 +1508,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1449 | [ | 1508 | [ |
| 1450 | copyPathToClipboard, | 1509 | copyPathToClipboard, |
| 1451 | handleRemoveProject, | 1510 | handleRemoveProject, |
| 1452 | memberThreadCountByPhysicalKey, | ||
| 1453 | openProjectGroupingDialog, | 1511 | openProjectGroupingDialog, |
| 1454 | openProjectRenameDialog, | 1512 | openProjectRenameDialog, |
| 1455 | project.groupedProjectCount, | 1513 | project.groupedProjectCount, |
apps/web/src/components/ui/toast.tsx+37-4| ... | @@ -31,6 +31,15 @@ export type ThreadToastData = { | ... | @@ -31,6 +31,15 @@ export type ThreadToastData = { |
| 31 | tooltipStyle?: boolean; | 31 | tooltipStyle?: boolean; |
| 32 | dismissAfterVisibleMs?: number; | 32 | dismissAfterVisibleMs?: number; |
| 33 | hideCopyButton?: boolean; | 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 | const toastManager = Toast.createToastManager<ThreadToastData>(); | 45 | const toastManager = Toast.createToastManager<ThreadToastData>(); |
| ... | @@ -232,6 +241,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -232,6 +241,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 232 | visibleIndex, | 241 | visibleIndex, |
| 233 | visibleToastLayout.items.length, | 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 | return ( | 248 | return ( |
| 237 | <Toast.Root | 249 | <Toast.Root |
| ... | @@ -304,7 +316,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -304,7 +316,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 304 | /> | 316 | /> |
| 305 | <Toast.Content | 317 | <Toast.Content |
| 306 | className={cn( | 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 | hideCollapsedContent && | 323 | hideCollapsedContent && |
| 309 | "not-data-expanded:pointer-events-none not-data-expanded:opacity-0", | 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,7 +352,11 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 337 | </div> | 352 | </div> |
| 338 | {toast.actionProps && ( | 353 | {toast.actionProps && ( |
| 339 | <Toast.Action | 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 | data-slot="toast-action" | 360 | data-slot="toast-action" |
| 342 | > | 361 | > |
| 343 | {toast.actionProps.children} | 362 | {toast.actionProps.children} |
| ... | @@ -374,6 +393,9 @@ function AnchoredToasts() { | ... | @@ -374,6 +393,9 @@ function AnchoredToasts() { |
| 374 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; | 393 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; |
| 375 | const tooltipStyle = toast.data?.tooltipStyle ?? false; | 394 | const tooltipStyle = toast.data?.tooltipStyle ?? false; |
| 376 | const positionerProps = toast.positionerProps; | 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 | if (!positionerProps?.anchor) { | 400 | if (!positionerProps?.anchor) { |
| 379 | return null; | 401 | return null; |
| ... | @@ -402,7 +424,14 @@ function AnchoredToasts() { | ... | @@ -402,7 +424,14 @@ function AnchoredToasts() { |
| 402 | <Toast.Title data-slot="toast-title" /> | 424 | <Toast.Title data-slot="toast-title" /> |
| 403 | </Toast.Content> | 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 | <div className="flex min-w-0 flex-1 gap-2"> | 435 | <div className="flex min-w-0 flex-1 gap-2"> |
| 407 | {Icon && ( | 436 | {Icon && ( |
| 408 | <div | 437 | <div |
| ... | @@ -433,7 +462,11 @@ function AnchoredToasts() { | ... | @@ -433,7 +462,11 @@ function AnchoredToasts() { |
| 433 | </div> | 462 | </div> |
| 434 | {toast.actionProps && ( | 463 | {toast.actionProps && ( |
| 435 | <Toast.Action | 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 | data-slot="toast-action" | 470 | data-slot="toast-action" |
| 438 | > | 471 | > |
| 439 | {toast.actionProps.children} | 472 | {toast.actionProps.children} |
apps/web/src/composerDraftStore.test.ts+43| ... | @@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 561 | expect(draftByKey(draftId)).toBeUndefined(); | 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 | it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { | 607 | it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { |
| 565 | const store = useComposerDraftStore.getState(); | 608 | const store = useComposerDraftStore.getState(); |
| 566 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | 609 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
apps/web/src/composerDraftStore.ts+11-7| ... | @@ -782,6 +782,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void { | ... | @@ -782,6 +782,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void { |
| 782 | URL.revokeObjectURL(previewUrl); | 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 | function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { | 794 | function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { |
| 786 | if (!value || typeof value !== "object") { | 795 | if (!value || typeof value !== "object") { |
| 787 | return null; | 796 | return null; |
| ... | @@ -1101,7 +1110,8 @@ function removeDraftThreadReferences( | ... | @@ -1101,7 +1110,8 @@ function removeDraftThreadReferences( |
| 1101 | ) as Record<string, string>; | 1110 | ) as Record<string, string>; |
| 1102 | const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = | 1111 | const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = |
| 1103 | state.draftThreadsByThreadKey; | 1112 | state.draftThreadsByThreadKey; |
| 1104 | const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; | 1113 | const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; |
| 1114 | revokeDraftThreadPreviewUrls(removedComposerDraft); | ||
| 1105 | return { | 1115 | return { |
| 1106 | draftsByThreadKey: restDraftsByThreadKey, | 1116 | draftsByThreadKey: restDraftsByThreadKey, |
| 1107 | draftThreadsByThreadKey: restDraftThreadsByThreadKey, | 1117 | draftThreadsByThreadKey: restDraftThreadsByThreadKey, |
| ... | @@ -2068,12 +2078,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2068,12 +2078,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2068 | if (threadKey.length === 0) { | 2078 | if (threadKey.length === 0) { |
| 2069 | return; | 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 | set((state) => { | 2081 | set((state) => { |
| 2078 | const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; | 2082 | const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; |
| 2079 | const hasLogicalProjectMapping = Object.values( | 2083 | const hasLogicalProjectMapping = Object.values( |
apps/web/src/environments/runtime/service.ts+6| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | createKnownEnvironment, | 14 | createKnownEnvironment, |
| 15 | getKnownEnvironmentWsBaseUrl, | 15 | getKnownEnvironmentWsBaseUrl, |
| 16 | scopedThreadKey, | 16 | scopedThreadKey, |
| 17 | scopeProjectRef, | ||
| 17 | scopeThreadRef, | 18 | scopeThreadRef, |
| 18 | } from "@t3tools/client-runtime"; | 19 | } from "@t3tools/client-runtime"; |
| 19 | 20 | ||
| ... | @@ -571,6 +572,11 @@ function applyRecoveredEventBatch( | ... | @@ -571,6 +572,11 @@ function applyRecoveredEventBatch( |
| 571 | .getState() | 572 | .getState() |
| 572 | .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); | 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 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { | 580 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { |
| 575 | useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); | 581 | useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); |
| 576 | } | 582 | } |
packages/contracts/src/orchestration.ts+1| ... | @@ -432,6 +432,7 @@ const ProjectDeleteCommand = Schema.Struct({ | ... | @@ -432,6 +432,7 @@ const ProjectDeleteCommand = Schema.Struct({ |
| 432 | type: Schema.Literal("project.delete"), | 432 | type: Schema.Literal("project.delete"), |
| 433 | commandId: CommandId, | 433 | commandId: CommandId, |
| 434 | projectId: ProjectId, | 434 | projectId: ProjectId, |
| 435 | force: Schema.optional(Schema.Boolean), | ||
| 435 | }); | 436 | }); |
| 436 | 437 | ||
| 437 | const ThreadCreateCommand = Schema.Struct({ | 438 | const ThreadCreateCommand = Schema.Struct({ |