| author | |
| committer | |
| log | c2b9680a5efc0855d24fb5a4bdcf9320469354d4 |
| tree | a07436d65c5e019f4da1b97dbe62a18eb3a65ea7 |
| parent | a973e43421c9829a7450a7fe06d5f0e81c1b98c1 |
69 files changed, 1386 insertions(+), 1101 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts-6| ... | ... | @@ -6,7 +6,6 @@ import { execFileSync } from "node:child_process"; |
| 6 | 6 | import { NodeServices } from "@effect/platform-node"; |
| 7 | 7 | import { |
| 8 | 8 | ApprovalRequestId, |
| 9 | CheckpointRef, | |
| 10 | 9 | type OrchestrationEvent, |
| 11 | 10 | type OrchestrationThread, |
| 12 | 11 | } from "@t3tools/contracts"; |
| ... | ... | @@ -63,11 +62,6 @@ function initializeGitWorkspace(cwd: string) { |
| 63 | 62 | runGit(cwd, ["commit", "-m", "Initial"]); |
| 64 | 63 | } |
| 65 | 64 | |
| 66 | export function checkpointRefForTurn(threadId: string, turnCount: number): CheckpointRef { | |
| 67 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | |
| 68 | return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`); | |
| 69 | } | |
| 70 | ||
| 71 | 65 | export function gitRefExists(cwd: string, ref: string): boolean { |
| 72 | 66 | try { |
| 73 | 67 | runGit(cwd, ["show-ref", "--verify", "--quiet", ref]); |
apps/server/integration/orchestrationEngine.integration.test.ts+32-20| ... | ... | @@ -17,17 +17,15 @@ import { Effect, Option } from "effect"; |
| 17 | 17 | |
| 18 | 18 | import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts"; |
| 19 | 19 | import { |
| 20 | checkpointRefForTurn, | |
| 21 | 20 | gitRefExists, |
| 22 | 21 | gitShowFileAtRef, |
| 23 | 22 | makeOrchestrationIntegrationHarness, |
| 24 | 23 | type OrchestrationIntegrationHarness, |
| 25 | 24 | } from "./OrchestrationEngineHarness.integration.ts"; |
| 25 | import { checkpointRefForThreadTurn } from "../src/checkpointing/Refs.ts"; | |
| 26 | 26 | |
| 27 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 28 | 27 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 29 | 28 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 30 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 31 | 29 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 32 | 30 | const asProviderSessionId = (value: string): ProviderSessionId => |
| 33 | 31 | ProviderSessionId.makeUnsafe(value); |
| ... | ... | @@ -37,7 +35,7 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => |
| 37 | 35 | ApprovalRequestId.makeUnsafe(value); |
| 38 | 36 | |
| 39 | 37 | const PROJECT_ID = asProjectId("project-1"); |
| 40 | const THREAD_ID = asThreadId("thread-1"); | |
| 38 | const THREAD_ID = ThreadId.makeUnsafe("thread-1"); | |
| 41 | 39 | const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); |
| 42 | 40 | const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); |
| 43 | 41 | const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); |
| ... | ... | @@ -95,7 +93,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 95 | 93 | |
| 96 | 94 | yield* harness.engine.dispatch({ |
| 97 | 95 | type: "project.create", |
| 98 | commandId: asCommandId("cmd-project-create"), | |
| 96 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 99 | 97 | projectId: PROJECT_ID, |
| 100 | 98 | title: "Integration Project", |
| 101 | 99 | workspaceRoot: harness.workspaceDir, |
| ... | ... | @@ -105,7 +103,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 105 | 103 | |
| 106 | 104 | yield* harness.engine.dispatch({ |
| 107 | 105 | type: "thread.create", |
| 108 | commandId: asCommandId("cmd-thread-create"), | |
| 106 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 109 | 107 | threadId: THREAD_ID, |
| 110 | 108 | projectId: PROJECT_ID, |
| 111 | 109 | title: "Integration Thread", |
| ... | ... | @@ -124,7 +122,7 @@ const startTurn = (input: { |
| 124 | 122 | }) => |
| 125 | 123 | input.harness.engine.dispatch({ |
| 126 | 124 | type: "thread.turn.start", |
| 127 | commandId: asCommandId(input.commandId), | |
| 125 | commandId: CommandId.makeUnsafe(input.commandId), | |
| 128 | 126 | threadId: THREAD_ID, |
| 129 | 127 | message: { |
| 130 | 128 | messageId: asMessageId(input.messageId), |
| ... | ... | @@ -195,8 +193,8 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + |
| 195 | 193 | |
| 196 | 194 | yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed"); |
| 197 | 195 | |
| 198 | const ref0 = checkpointRefForTurn(THREAD_ID, 0); | |
| 199 | const ref1 = checkpointRefForTurn(THREAD_ID, 1); | |
| 196 | const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); | |
| 197 | const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); | |
| 200 | 198 | assert.equal(gitRefExists(harness.workspaceDir, ref0), true); |
| 201 | 199 | assert.equal(gitRefExists(harness.workspaceDir, ref1), true); |
| 202 | 200 | assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n"); |
| ... | ... | @@ -335,26 +333,34 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 335 | 333 | |
| 336 | 334 | const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({ |
| 337 | 335 | cwd: harness.workspaceDir, |
| 338 | fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 1), | |
| 339 | toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2), | |
| 336 | fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1), | |
| 337 | toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), | |
| 340 | 338 | fallbackFromToHead: false, |
| 341 | 339 | }); |
| 342 | 340 | assert.equal(incrementalDiff.includes("README.md"), true); |
| 343 | 341 | |
| 344 | 342 | const fullDiff = yield* harness.checkpointStore.diffCheckpoints({ |
| 345 | 343 | cwd: harness.workspaceDir, |
| 346 | fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 0), | |
| 347 | toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2), | |
| 344 | fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0), | |
| 345 | toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), | |
| 348 | 346 | fallbackFromToHead: false, |
| 349 | 347 | }); |
| 350 | 348 | assert.equal(fullDiff.includes("README.md"), true); |
| 351 | 349 | |
| 352 | 350 | assert.equal( |
| 353 | gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1), "README.md"), | |
| 351 | gitShowFileAtRef( | |
| 352 | harness.workspaceDir, | |
| 353 | checkpointRefForThreadTurn(THREAD_ID, 1), | |
| 354 | "README.md", | |
| 355 | ), | |
| 354 | 356 | "v2\n", |
| 355 | 357 | ); |
| 356 | 358 | assert.equal( |
| 357 | gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2), "README.md"), | |
| 359 | gitShowFileAtRef( | |
| 360 | harness.workspaceDir, | |
| 361 | checkpointRefForThreadTurn(THREAD_ID, 2), | |
| 362 | "README.md", | |
| 363 | ), | |
| 358 | 364 | "v3\n", |
| 359 | 365 | ); |
| 360 | 366 | }), |
| ... | ... | @@ -416,7 +422,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons |
| 416 | 422 | |
| 417 | 423 | yield* harness.engine.dispatch({ |
| 418 | 424 | type: "thread.approval.respond", |
| 419 | commandId: asCommandId("cmd-approval-respond"), | |
| 425 | commandId: CommandId.makeUnsafe("cmd-approval-respond"), | |
| 420 | 426 | threadId: THREAD_ID, |
| 421 | 427 | requestId: APPROVAL_REQUEST_ID, |
| 422 | 428 | decision: "accept", |
| ... | ... | @@ -507,7 +513,10 @@ it.live("records failed turn runtime state and checkpoint status as error", () = |
| 507 | 513 | if (Option.isSome(checkpointRow)) { |
| 508 | 514 | assert.equal(checkpointRow.value.status, "error"); |
| 509 | 515 | } |
| 510 | assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1)), true); | |
| 516 | assert.equal( | |
| 517 | gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), | |
| 518 | true, | |
| 519 | ); | |
| 511 | 520 | }), |
| 512 | 521 | ), |
| 513 | 522 | ); |
| ... | ... | @@ -644,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 644 | 653 | |
| 645 | 654 | yield* harness.engine.dispatch({ |
| 646 | 655 | type: "thread.checkpoint.revert", |
| 647 | commandId: asCommandId("cmd-checkpoint-revert"), | |
| 656 | commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), | |
| 648 | 657 | threadId: THREAD_ID, |
| 649 | 658 | turnCount: 1, |
| 650 | 659 | createdAt: nowIso(), |
| ... | ... | @@ -681,7 +690,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 681 | 690 | true, |
| 682 | 691 | ); |
| 683 | 692 | assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); |
| 684 | assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2)), false); | |
| 693 | assert.equal( | |
| 694 | gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), | |
| 695 | false, | |
| 696 | ); | |
| 685 | 697 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); |
| 686 | 698 | |
| 687 | 699 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ |
| ... | ... | @@ -701,7 +713,7 @@ it.live( |
| 701 | 713 | |
| 702 | 714 | yield* harness.engine.dispatch({ |
| 703 | 715 | type: "thread.checkpoint.revert", |
| 704 | commandId: asCommandId("cmd-checkpoint-revert-no-session"), | |
| 716 | commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"), | |
| 705 | 717 | threadId: THREAD_ID, |
| 706 | 718 | turnCount: 0, |
| 707 | 719 | createdAt: nowIso(), |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts created+190| ... | ... | @@ -0,0 +1,190 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | ProjectId, | |
| 4 | ThreadId, | |
| 5 | TurnId, | |
| 6 | type OrchestrationReadModel, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Effect, Layer } from "effect"; | |
| 9 | import { describe, expect, it } from "vitest"; | |
| 10 | ||
| 11 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 12 | import { checkpointRefForThreadTurn } from "../Refs.ts"; | |
| 13 | import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; | |
| 14 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | |
| 15 | import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; | |
| 16 | ||
| 17 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 18 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 19 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | |
| 20 | ||
| 21 | function makeSnapshot(input: { | |
| 22 | readonly projectId: ProjectId; | |
| 23 | readonly threadId: ThreadId; | |
| 24 | readonly workspaceRoot: string; | |
| 25 | readonly worktreePath: string | null; | |
| 26 | readonly checkpointTurnCount: number; | |
| 27 | readonly checkpointRef: CheckpointRef; | |
| 28 | }): OrchestrationReadModel { | |
| 29 | return { | |
| 30 | snapshotSequence: 0, | |
| 31 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 32 | projects: [ | |
| 33 | { | |
| 34 | id: input.projectId, | |
| 35 | title: "Project", | |
| 36 | workspaceRoot: input.workspaceRoot, | |
| 37 | defaultModel: null, | |
| 38 | scripts: [], | |
| 39 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 40 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 41 | deletedAt: null, | |
| 42 | }, | |
| 43 | ], | |
| 44 | threads: [ | |
| 45 | { | |
| 46 | id: input.threadId, | |
| 47 | projectId: input.projectId, | |
| 48 | title: "Thread", | |
| 49 | model: "gpt-5-codex", | |
| 50 | branch: null, | |
| 51 | worktreePath: input.worktreePath, | |
| 52 | latestTurnId: asTurnId("turn-1"), | |
| 53 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 54 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 55 | deletedAt: null, | |
| 56 | messages: [], | |
| 57 | activities: [], | |
| 58 | checkpoints: [ | |
| 59 | { | |
| 60 | turnId: asTurnId("turn-1"), | |
| 61 | checkpointTurnCount: input.checkpointTurnCount, | |
| 62 | checkpointRef: input.checkpointRef, | |
| 63 | status: "ready", | |
| 64 | files: [], | |
| 65 | assistantMessageId: null, | |
| 66 | completedAt: "2026-01-01T00:00:00.000Z", | |
| 67 | }, | |
| 68 | ], | |
| 69 | session: null, | |
| 70 | }, | |
| 71 | ], | |
| 72 | }; | |
| 73 | } | |
| 74 | ||
| 75 | describe("CheckpointDiffQueryLive", () => { | |
| 76 | it("computes diffs using canonical turn-0 checkpoint refs", async () => { | |
| 77 | const projectId = asProjectId("project-1"); | |
| 78 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 79 | const toCheckpointRef = asCheckpointRef("refs/t3/checkpoints/thread-1/turn/1"); | |
| 80 | const hasCheckpointRefCalls: Array<CheckpointRef> = []; | |
| 81 | const diffCheckpointsCalls: Array<{ | |
| 82 | readonly fromCheckpointRef: CheckpointRef; | |
| 83 | readonly toCheckpointRef: CheckpointRef; | |
| 84 | readonly cwd: string; | |
| 85 | }> = []; | |
| 86 | ||
| 87 | const snapshot = makeSnapshot({ | |
| 88 | projectId, | |
| 89 | threadId, | |
| 90 | workspaceRoot: "/tmp/workspace", | |
| 91 | worktreePath: null, | |
| 92 | checkpointTurnCount: 1, | |
| 93 | checkpointRef: toCheckpointRef, | |
| 94 | }); | |
| 95 | ||
| 96 | const checkpointStore: CheckpointStoreShape = { | |
| 97 | isGitRepository: () => Effect.succeed(true), | |
| 98 | captureCheckpoint: () => Effect.void, | |
| 99 | hasCheckpointRef: ({ checkpointRef }) => | |
| 100 | Effect.sync(() => { | |
| 101 | hasCheckpointRefCalls.push(checkpointRef); | |
| 102 | return true; | |
| 103 | }), | |
| 104 | restoreCheckpoint: () => Effect.succeed(true), | |
| 105 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) => | |
| 106 | Effect.sync(() => { | |
| 107 | diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd }); | |
| 108 | return "diff patch"; | |
| 109 | }), | |
| 110 | deleteCheckpointRefs: () => Effect.void, | |
| 111 | }; | |
| 112 | ||
| 113 | const layer = CheckpointDiffQueryLive.pipe( | |
| 114 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 115 | Layer.provideMerge( | |
| 116 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 117 | getSnapshot: () => Effect.succeed(snapshot), | |
| 118 | }), | |
| 119 | ), | |
| 120 | ); | |
| 121 | ||
| 122 | const result = await Effect.runPromise( | |
| 123 | Effect.gen(function* () { | |
| 124 | const query = yield* CheckpointDiffQuery; | |
| 125 | return yield* query.getTurnDiff({ | |
| 126 | threadId, | |
| 127 | fromTurnCount: 0, | |
| 128 | toTurnCount: 1, | |
| 129 | }); | |
| 130 | }).pipe(Effect.provide(layer)), | |
| 131 | ); | |
| 132 | ||
| 133 | const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); | |
| 134 | expect(hasCheckpointRefCalls).toEqual([expectedFromRef, toCheckpointRef]); | |
| 135 | expect(diffCheckpointsCalls).toEqual([ | |
| 136 | { | |
| 137 | cwd: "/tmp/workspace", | |
| 138 | fromCheckpointRef: expectedFromRef, | |
| 139 | toCheckpointRef, | |
| 140 | }, | |
| 141 | ]); | |
| 142 | expect(result).toEqual({ | |
| 143 | threadId, | |
| 144 | fromTurnCount: 0, | |
| 145 | toTurnCount: 1, | |
| 146 | diff: "diff patch", | |
| 147 | }); | |
| 148 | }); | |
| 149 | ||
| 150 | it("fails when the thread is missing from the snapshot", async () => { | |
| 151 | const threadId = ThreadId.makeUnsafe("thread-missing"); | |
| 152 | ||
| 153 | const checkpointStore: CheckpointStoreShape = { | |
| 154 | isGitRepository: () => Effect.succeed(true), | |
| 155 | captureCheckpoint: () => Effect.void, | |
| 156 | hasCheckpointRef: () => Effect.succeed(true), | |
| 157 | restoreCheckpoint: () => Effect.succeed(true), | |
| 158 | diffCheckpoints: () => Effect.succeed(""), | |
| 159 | deleteCheckpointRefs: () => Effect.void, | |
| 160 | }; | |
| 161 | ||
| 162 | const layer = CheckpointDiffQueryLive.pipe( | |
| 163 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 164 | Layer.provideMerge( | |
| 165 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 166 | getSnapshot: () => | |
| 167 | Effect.succeed({ | |
| 168 | snapshotSequence: 0, | |
| 169 | projects: [], | |
| 170 | threads: [], | |
| 171 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 172 | } satisfies OrchestrationReadModel), | |
| 173 | }), | |
| 174 | ), | |
| 175 | ); | |
| 176 | ||
| 177 | await expect( | |
| 178 | Effect.runPromise( | |
| 179 | Effect.gen(function* () { | |
| 180 | const query = yield* CheckpointDiffQuery; | |
| 181 | return yield* query.getTurnDiff({ | |
| 182 | threadId, | |
| 183 | fromTurnCount: 0, | |
| 184 | toTurnCount: 1, | |
| 185 | }); | |
| 186 | }).pipe(Effect.provide(layer)), | |
| 187 | ), | |
| 188 | ).rejects.toThrow("Thread 'thread-missing' not found."); | |
| 189 | }); | |
| 190 | }); |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts created+193| ... | ... | @@ -0,0 +1,193 @@ |
| 1 | import { | |
| 2 | OrchestrationGetTurnDiffResult, | |
| 3 | type ProjectId, | |
| 4 | type OrchestrationGetFullThreadDiffInput, | |
| 5 | type OrchestrationGetFullThreadDiffResult, | |
| 6 | type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Effect, Layer, Schema } from "effect"; | |
| 9 | ||
| 10 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 11 | import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; | |
| 12 | import { checkpointRefForThreadTurn } from "../Refs.ts"; | |
| 13 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | |
| 14 | import { CheckpointDiffQuery, type CheckpointDiffQueryShape } from "../Services/CheckpointDiffQuery.ts"; | |
| 15 | ||
| 16 | function trimToNonEmpty(value: string | null | undefined): string | undefined { | |
| 17 | const normalized = value?.trim(); | |
| 18 | return normalized && normalized.length > 0 ? normalized : undefined; | |
| 19 | } | |
| 20 | ||
| 21 | function resolveThreadWorkspaceCwd(input: { | |
| 22 | readonly thread: { | |
| 23 | readonly projectId: ProjectId; | |
| 24 | readonly worktreePath: string | null; | |
| 25 | }; | |
| 26 | readonly projects: ReadonlyArray<{ | |
| 27 | readonly id: ProjectId; | |
| 28 | readonly workspaceRoot: string; | |
| 29 | }>; | |
| 30 | }): string | undefined { | |
| 31 | const worktreeCwd = trimToNonEmpty(input.thread.worktreePath); | |
| 32 | if (worktreeCwd) { | |
| 33 | return worktreeCwd; | |
| 34 | } | |
| 35 | ||
| 36 | const workspaceRoot = input.projects.find( | |
| 37 | (project) => project.id === input.thread.projectId, | |
| 38 | )?.workspaceRoot; | |
| 39 | return trimToNonEmpty(workspaceRoot); | |
| 40 | } | |
| 41 | ||
| 42 | const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); | |
| 43 | ||
| 44 | const make = Effect.gen(function* () { | |
| 45 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 46 | const checkpointStore = yield* CheckpointStore; | |
| 47 | ||
| 48 | const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = (input) => | |
| 49 | Effect.gen(function* () { | |
| 50 | const operation = "CheckpointDiffQuery.getTurnDiff"; | |
| 51 | ||
| 52 | if (input.fromTurnCount === input.toTurnCount) { | |
| 53 | const emptyDiff: OrchestrationGetTurnDiffResultType = { | |
| 54 | threadId: input.threadId, | |
| 55 | fromTurnCount: input.fromTurnCount, | |
| 56 | toTurnCount: input.toTurnCount, | |
| 57 | diff: "", | |
| 58 | }; | |
| 59 | if (!isTurnDiffResult(emptyDiff)) { | |
| 60 | return yield* new CheckpointInvariantError({ | |
| 61 | operation, | |
| 62 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 63 | }); | |
| 64 | } | |
| 65 | return emptyDiff; | |
| 66 | } | |
| 67 | ||
| 68 | const snapshot = yield* projectionSnapshotQuery.getSnapshot(); | |
| 69 | const thread = snapshot.threads.find((entry) => entry.id === input.threadId); | |
| 70 | if (!thread) { | |
| 71 | return yield* new CheckpointInvariantError({ | |
| 72 | operation, | |
| 73 | detail: `Thread '${input.threadId}' not found.`, | |
| 74 | }); | |
| 75 | } | |
| 76 | ||
| 77 | const maxTurnCount = thread.checkpoints.reduce( | |
| 78 | (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), | |
| 79 | 0, | |
| 80 | ); | |
| 81 | if (input.toTurnCount > maxTurnCount) { | |
| 82 | return yield* new CheckpointUnavailableError({ | |
| 83 | threadId: input.threadId, | |
| 84 | turnCount: input.toTurnCount, | |
| 85 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | const workspaceCwd = resolveThreadWorkspaceCwd({ | |
| 90 | thread, | |
| 91 | projects: snapshot.projects, | |
| 92 | }); | |
| 93 | if (!workspaceCwd) { | |
| 94 | return yield* new CheckpointInvariantError({ | |
| 95 | operation, | |
| 96 | detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, | |
| 97 | }); | |
| 98 | } | |
| 99 | ||
| 100 | const fromCheckpointRef = | |
| 101 | input.fromTurnCount === 0 | |
| 102 | ? checkpointRefForThreadTurn(input.threadId, 0) | |
| 103 | : thread.checkpoints.find( | |
| 104 | (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, | |
| 105 | )?.checkpointRef; | |
| 106 | if (!fromCheckpointRef) { | |
| 107 | return yield* new CheckpointUnavailableError({ | |
| 108 | threadId: input.threadId, | |
| 109 | turnCount: input.fromTurnCount, | |
| 110 | detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, | |
| 111 | }); | |
| 112 | } | |
| 113 | ||
| 114 | const toCheckpointRef = thread.checkpoints.find( | |
| 115 | (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, | |
| 116 | )?.checkpointRef; | |
| 117 | if (!toCheckpointRef) { | |
| 118 | return yield* new CheckpointUnavailableError({ | |
| 119 | threadId: input.threadId, | |
| 120 | turnCount: input.toTurnCount, | |
| 121 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | |
| 122 | }); | |
| 123 | } | |
| 124 | ||
| 125 | const [fromExists, toExists] = yield* Effect.all( | |
| 126 | [ | |
| 127 | checkpointStore.hasCheckpointRef({ | |
| 128 | cwd: workspaceCwd, | |
| 129 | checkpointRef: fromCheckpointRef, | |
| 130 | }), | |
| 131 | checkpointStore.hasCheckpointRef({ | |
| 132 | cwd: workspaceCwd, | |
| 133 | checkpointRef: toCheckpointRef, | |
| 134 | }), | |
| 135 | ], | |
| 136 | { concurrency: "unbounded" }, | |
| 137 | ); | |
| 138 | ||
| 139 | if (!fromExists) { | |
| 140 | return yield* new CheckpointUnavailableError({ | |
| 141 | threadId: input.threadId, | |
| 142 | turnCount: input.fromTurnCount, | |
| 143 | detail: `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount}.`, | |
| 144 | }); | |
| 145 | } | |
| 146 | ||
| 147 | if (!toExists) { | |
| 148 | return yield* new CheckpointUnavailableError({ | |
| 149 | threadId: input.threadId, | |
| 150 | turnCount: input.toTurnCount, | |
| 151 | detail: `Filesystem checkpoint is unavailable for turn ${input.toTurnCount}.`, | |
| 152 | }); | |
| 153 | } | |
| 154 | ||
| 155 | const diff = yield* checkpointStore.diffCheckpoints({ | |
| 156 | cwd: workspaceCwd, | |
| 157 | fromCheckpointRef, | |
| 158 | toCheckpointRef, | |
| 159 | fallbackFromToHead: false, | |
| 160 | }); | |
| 161 | ||
| 162 | const turnDiff: OrchestrationGetTurnDiffResultType = { | |
| 163 | threadId: input.threadId, | |
| 164 | fromTurnCount: input.fromTurnCount, | |
| 165 | toTurnCount: input.toTurnCount, | |
| 166 | diff, | |
| 167 | }; | |
| 168 | if (!isTurnDiffResult(turnDiff)) { | |
| 169 | return yield* new CheckpointInvariantError({ | |
| 170 | operation, | |
| 171 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 172 | }); | |
| 173 | } | |
| 174 | ||
| 175 | return turnDiff; | |
| 176 | }); | |
| 177 | ||
| 178 | const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = ( | |
| 179 | input: OrchestrationGetFullThreadDiffInput, | |
| 180 | ) => | |
| 181 | getTurnDiff({ | |
| 182 | threadId: input.threadId, | |
| 183 | fromTurnCount: 0, | |
| 184 | toTurnCount: input.toTurnCount, | |
| 185 | }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); | |
| 186 | ||
| 187 | return { | |
| 188 | getTurnDiff, | |
| 189 | getFullThreadDiff, | |
| 190 | } satisfies CheckpointDiffQueryShape; | |
| 191 | }); | |
| 192 | ||
| 193 | export const CheckpointDiffQueryLive = Layer.effect(CheckpointDiffQuery, make); |
apps/server/src/checkpointing/Refs.ts created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | import { Encoding } from "effect"; | |
| 2 | import { CheckpointRef, type ThreadId } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 5 | ||
| 6 | export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { | |
| 7 | return CheckpointRef.makeUnsafe( | |
| 8 | `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`, | |
| 9 | ); | |
| 10 | } |
apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import type { | |
| 2 | OrchestrationGetFullThreadDiffInput, | |
| 3 | OrchestrationGetFullThreadDiffResult, | |
| 4 | OrchestrationGetTurnDiffInput, | |
| 5 | OrchestrationGetTurnDiffResult, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { ServiceMap } from "effect"; | |
| 8 | import type { Effect } from "effect"; | |
| 9 | ||
| 10 | import type { CheckpointServiceError } from "../Errors.ts"; | |
| 11 | ||
| 12 | export interface CheckpointDiffQueryShape { | |
| 13 | readonly getTurnDiff: ( | |
| 14 | input: OrchestrationGetTurnDiffInput, | |
| 15 | ) => Effect.Effect<OrchestrationGetTurnDiffResult, CheckpointServiceError>; | |
| 16 | readonly getFullThreadDiff: ( | |
| 17 | input: OrchestrationGetFullThreadDiffInput, | |
| 18 | ) => Effect.Effect<OrchestrationGetFullThreadDiffResult, CheckpointServiceError>; | |
| 19 | } | |
| 20 | ||
| 21 | export class CheckpointDiffQuery extends ServiceMap.Service< | |
| 22 | CheckpointDiffQuery, | |
| 23 | CheckpointDiffQueryShape | |
| 24 | >()("checkpointing/CheckpointDiffQuery") {} |
apps/server/src/git.ts+4-4| ... | ... | @@ -216,7 +216,7 @@ export class GitCoreService { |
| 216 | 216 | |
| 217 | 217 | async status(input: GitStatusInput): Promise<GitStatusResult> { |
| 218 | 218 | const details = await this.statusDetails(input.cwd); |
| 219 | return GitStatusResult.makeUnsafe({ | |
| 219 | return { | |
| 220 | 220 | branch: details.branch, |
| 221 | 221 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 222 | 222 | workingTree: details.workingTree, |
| ... | ... | @@ -224,7 +224,7 @@ export class GitCoreService { |
| 224 | 224 | aheadCount: details.aheadCount, |
| 225 | 225 | behindCount: details.behindCount, |
| 226 | 226 | openPr: null, |
| 227 | }); | |
| 227 | }; | |
| 228 | 228 | } |
| 229 | 229 | |
| 230 | 230 | async statusDetails(cwd: string): Promise<GitStatusDetails> { |
| ... | ... | @@ -385,11 +385,11 @@ export class GitCoreService { |
| 385 | 385 | }); |
| 386 | 386 | const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); |
| 387 | 387 | const refreshed = await this.statusDetails(cwd); |
| 388 | return GitPullResult.makeUnsafe({ | |
| 388 | return { | |
| 389 | 389 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", |
| 390 | 390 | branch, |
| 391 | 391 | upstreamBranch: refreshed.upstreamRef, |
| 392 | }); | |
| 392 | }; | |
| 393 | 393 | } |
| 394 | 394 | |
| 395 | 395 | async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> { |
apps/server/src/keybindings.test.ts+1-1| ... | ... | @@ -40,7 +40,7 @@ const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRu |
| 40 | 40 | Effect.gen(function* () { |
| 41 | 41 | const fileSystem = yield* FileSystem.FileSystem; |
| 42 | 42 | const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)( |
| 43 | rules.map((rule) => KeybindingRule.makeUnsafe(rule)), | |
| 43 | rules, | |
| 44 | 44 | ); |
| 45 | 45 | yield* fileSystem.writeFileString(configPath, encoded); |
| 46 | 46 | }); |
apps/server/src/keybindings.ts+2-2| ... | ... | @@ -313,7 +313,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( |
| 313 | 313 | title: "Invalid keybinding rule", |
| 314 | 314 | }), |
| 315 | 315 | ), |
| 316 | Effect.map((resolved) => ResolvedKeybindingRule.makeUnsafe(resolved)), | |
| 316 | Effect.map((resolved) => resolved), | |
| 317 | 317 | ), |
| 318 | 318 | |
| 319 | 319 | encode: (resolved) => |
| ... | ... | @@ -456,7 +456,7 @@ const makeKeybindings = Effect.gen(function* () { |
| 456 | 456 | const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; |
| 457 | 457 | |
| 458 | 458 | return Schema.encodeEffect(KeybindingsConfigJson)( |
| 459 | rules.map((rule) => KeybindingRule.makeUnsafe(rule)), | |
| 459 | rules, | |
| 460 | 460 | ).pipe( |
| 461 | 461 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), |
| 462 | 462 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), |
apps/server/src/orchestration/Layer.test.ts+33-35| ... | ... | @@ -22,9 +22,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; |
| 22 | 22 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; |
| 23 | 23 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 24 | 24 | |
| 25 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 26 | 25 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 27 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 28 | 26 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 29 | 27 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 30 | 28 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); |
| ... | ... | @@ -58,7 +56,7 @@ describe("OrchestrationEngine", () => { |
| 58 | 56 | await system.run( |
| 59 | 57 | engine.dispatch({ |
| 60 | 58 | type: "project.create", |
| 61 | commandId: asCommandId("cmd-project-1-create"), | |
| 59 | commandId: CommandId.makeUnsafe("cmd-project-1-create"), | |
| 62 | 60 | projectId: asProjectId("project-1"), |
| 63 | 61 | title: "Project 1", |
| 64 | 62 | workspaceRoot: "/tmp/project-1", |
| ... | ... | @@ -69,8 +67,8 @@ describe("OrchestrationEngine", () => { |
| 69 | 67 | await system.run( |
| 70 | 68 | engine.dispatch({ |
| 71 | 69 | type: "thread.create", |
| 72 | commandId: asCommandId("cmd-thread-1-create"), | |
| 73 | threadId: asThreadId("thread-1"), | |
| 70 | commandId: CommandId.makeUnsafe("cmd-thread-1-create"), | |
| 71 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 74 | 72 | projectId: asProjectId("project-1"), |
| 75 | 73 | title: "Thread", |
| 76 | 74 | model: "gpt-5-codex", |
| ... | ... | @@ -82,8 +80,8 @@ describe("OrchestrationEngine", () => { |
| 82 | 80 | await system.run( |
| 83 | 81 | engine.dispatch({ |
| 84 | 82 | type: "thread.turn.start", |
| 85 | commandId: asCommandId("cmd-turn-start-1"), | |
| 86 | threadId: asThreadId("thread-1"), | |
| 83 | commandId: CommandId.makeUnsafe("cmd-turn-start-1"), | |
| 84 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 87 | 85 | message: { |
| 88 | 86 | messageId: asMessageId("msg-1"), |
| 89 | 87 | role: "user", |
| ... | ... | @@ -108,7 +106,7 @@ describe("OrchestrationEngine", () => { |
| 108 | 106 | await system.run( |
| 109 | 107 | engine.dispatch({ |
| 110 | 108 | type: "project.create", |
| 111 | commandId: asCommandId("cmd-project-replay-create"), | |
| 109 | commandId: CommandId.makeUnsafe("cmd-project-replay-create"), | |
| 112 | 110 | projectId: asProjectId("project-replay"), |
| 113 | 111 | title: "Replay Project", |
| 114 | 112 | workspaceRoot: "/tmp/project-replay", |
| ... | ... | @@ -119,8 +117,8 @@ describe("OrchestrationEngine", () => { |
| 119 | 117 | await system.run( |
| 120 | 118 | engine.dispatch({ |
| 121 | 119 | type: "thread.create", |
| 122 | commandId: asCommandId("cmd-thread-replay-create"), | |
| 123 | threadId: asThreadId("thread-replay"), | |
| 120 | commandId: CommandId.makeUnsafe("cmd-thread-replay-create"), | |
| 121 | threadId: ThreadId.makeUnsafe("thread-replay"), | |
| 124 | 122 | projectId: asProjectId("project-replay"), |
| 125 | 123 | title: "replay", |
| 126 | 124 | model: "gpt-5-codex", |
| ... | ... | @@ -132,8 +130,8 @@ describe("OrchestrationEngine", () => { |
| 132 | 130 | await system.run( |
| 133 | 131 | engine.dispatch({ |
| 134 | 132 | type: "thread.delete", |
| 135 | commandId: asCommandId("cmd-thread-replay-delete"), | |
| 136 | threadId: asThreadId("thread-replay"), | |
| 133 | commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"), | |
| 134 | threadId: ThreadId.makeUnsafe("thread-replay"), | |
| 137 | 135 | }), |
| 138 | 136 | ); |
| 139 | 137 | |
| ... | ... | @@ -158,7 +156,7 @@ describe("OrchestrationEngine", () => { |
| 158 | 156 | await system.run( |
| 159 | 157 | engine.dispatch({ |
| 160 | 158 | type: "project.create", |
| 161 | commandId: asCommandId("cmd-project-stream-create"), | |
| 159 | commandId: CommandId.makeUnsafe("cmd-project-stream-create"), | |
| 162 | 160 | projectId: asProjectId("project-stream"), |
| 163 | 161 | title: "Stream Project", |
| 164 | 162 | workspaceRoot: "/tmp/project-stream", |
| ... | ... | @@ -179,8 +177,8 @@ describe("OrchestrationEngine", () => { |
| 179 | 177 | yield* Effect.sleep("10 millis"); |
| 180 | 178 | yield* engine.dispatch({ |
| 181 | 179 | type: "thread.create", |
| 182 | commandId: asCommandId("cmd-stream-thread-create"), | |
| 183 | threadId: asThreadId("thread-stream"), | |
| 180 | commandId: CommandId.makeUnsafe("cmd-stream-thread-create"), | |
| 181 | threadId: ThreadId.makeUnsafe("thread-stream"), | |
| 184 | 182 | projectId: asProjectId("project-stream"), |
| 185 | 183 | title: "domain-stream", |
| 186 | 184 | model: "gpt-5-codex", |
| ... | ... | @@ -190,8 +188,8 @@ describe("OrchestrationEngine", () => { |
| 190 | 188 | }); |
| 191 | 189 | yield* engine.dispatch({ |
| 192 | 190 | type: "thread.meta.update", |
| 193 | commandId: asCommandId("cmd-stream-thread-update"), | |
| 194 | threadId: asThreadId("thread-stream"), | |
| 191 | commandId: CommandId.makeUnsafe("cmd-stream-thread-update"), | |
| 192 | threadId: ThreadId.makeUnsafe("thread-stream"), | |
| 195 | 193 | title: "domain-stream-updated", |
| 196 | 194 | }); |
| 197 | 195 | eventTypes.push((yield* Queue.take(eventQueue)).type); |
| ... | ... | @@ -211,7 +209,7 @@ describe("OrchestrationEngine", () => { |
| 211 | 209 | await system.run( |
| 212 | 210 | engine.dispatch({ |
| 213 | 211 | type: "project.create", |
| 214 | commandId: asCommandId("cmd-project-turn-diff-create"), | |
| 212 | commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"), | |
| 215 | 213 | projectId: asProjectId("project-turn-diff"), |
| 216 | 214 | title: "Turn Diff Project", |
| 217 | 215 | workspaceRoot: "/tmp/project-turn-diff", |
| ... | ... | @@ -222,8 +220,8 @@ describe("OrchestrationEngine", () => { |
| 222 | 220 | await system.run( |
| 223 | 221 | engine.dispatch({ |
| 224 | 222 | type: "thread.create", |
| 225 | commandId: asCommandId("cmd-thread-turn-diff-create"), | |
| 226 | threadId: asThreadId("thread-turn-diff"), | |
| 223 | commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"), | |
| 224 | threadId: ThreadId.makeUnsafe("thread-turn-diff"), | |
| 227 | 225 | projectId: asProjectId("project-turn-diff"), |
| 228 | 226 | title: "Turn diff thread", |
| 229 | 227 | model: "gpt-5-codex", |
| ... | ... | @@ -235,8 +233,8 @@ describe("OrchestrationEngine", () => { |
| 235 | 233 | await system.run( |
| 236 | 234 | engine.dispatch({ |
| 237 | 235 | type: "thread.turn.diff.complete", |
| 238 | commandId: asCommandId("cmd-turn-diff-complete"), | |
| 239 | threadId: asThreadId("thread-turn-diff"), | |
| 236 | commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"), | |
| 237 | threadId: ThreadId.makeUnsafe("thread-turn-diff"), | |
| 240 | 238 | turnId: asTurnId("turn-1"), |
| 241 | 239 | completedAt: createdAt, |
| 242 | 240 | checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), |
| ... | ... | @@ -275,7 +273,7 @@ describe("OrchestrationEngine", () => { |
| 275 | 273 | |
| 276 | 274 | const flakyStore: OrchestrationEventStoreShape = { |
| 277 | 275 | append(event) { |
| 278 | if (shouldFailFirstAppend && event.commandId === asCommandId("cmd-flaky-1")) { | |
| 276 | if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) { | |
| 279 | 277 | shouldFailFirstAppend = false; |
| 280 | 278 | return Effect.fail( |
| 281 | 279 | new PersistenceSqlError({ |
| ... | ... | @@ -314,7 +312,7 @@ describe("OrchestrationEngine", () => { |
| 314 | 312 | await runtime.runPromise( |
| 315 | 313 | engine.dispatch({ |
| 316 | 314 | type: "project.create", |
| 317 | commandId: asCommandId("cmd-project-flaky-create"), | |
| 315 | commandId: CommandId.makeUnsafe("cmd-project-flaky-create"), | |
| 318 | 316 | projectId: asProjectId("project-flaky"), |
| 319 | 317 | title: "Flaky Project", |
| 320 | 318 | workspaceRoot: "/tmp/project-flaky", |
| ... | ... | @@ -327,8 +325,8 @@ describe("OrchestrationEngine", () => { |
| 327 | 325 | runtime.runPromise( |
| 328 | 326 | engine.dispatch({ |
| 329 | 327 | type: "thread.create", |
| 330 | commandId: asCommandId("cmd-flaky-1"), | |
| 331 | threadId: asThreadId("thread-flaky-fail"), | |
| 328 | commandId: CommandId.makeUnsafe("cmd-flaky-1"), | |
| 329 | threadId: ThreadId.makeUnsafe("thread-flaky-fail"), | |
| 332 | 330 | projectId: asProjectId("project-flaky"), |
| 333 | 331 | title: "flaky-fail", |
| 334 | 332 | model: "gpt-5-codex", |
| ... | ... | @@ -342,8 +340,8 @@ describe("OrchestrationEngine", () => { |
| 342 | 340 | const result = await runtime.runPromise( |
| 343 | 341 | engine.dispatch({ |
| 344 | 342 | type: "thread.create", |
| 345 | commandId: asCommandId("cmd-flaky-2"), | |
| 346 | threadId: asThreadId("thread-flaky-ok"), | |
| 343 | commandId: CommandId.makeUnsafe("cmd-flaky-2"), | |
| 344 | threadId: ThreadId.makeUnsafe("thread-flaky-ok"), | |
| 347 | 345 | projectId: asProjectId("project-flaky"), |
| 348 | 346 | title: "flaky-ok", |
| 349 | 347 | model: "gpt-5-codex", |
| ... | ... | @@ -366,8 +364,8 @@ describe("OrchestrationEngine", () => { |
| 366 | 364 | system.run( |
| 367 | 365 | engine.dispatch({ |
| 368 | 366 | type: "thread.turn.start", |
| 369 | commandId: asCommandId("cmd-invariant-missing-thread"), | |
| 370 | threadId: asThreadId("thread-missing"), | |
| 367 | commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"), | |
| 368 | threadId: ThreadId.makeUnsafe("thread-missing"), | |
| 371 | 369 | message: { |
| 372 | 370 | messageId: asMessageId("msg-missing"), |
| 373 | 371 | role: "user", |
| ... | ... | @@ -390,7 +388,7 @@ describe("OrchestrationEngine", () => { |
| 390 | 388 | await system.run( |
| 391 | 389 | engine.dispatch({ |
| 392 | 390 | type: "project.create", |
| 393 | commandId: asCommandId("cmd-project-duplicate-create"), | |
| 391 | commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"), | |
| 394 | 392 | projectId: asProjectId("project-duplicate"), |
| 395 | 393 | title: "Duplicate Project", |
| 396 | 394 | workspaceRoot: "/tmp/project-duplicate", |
| ... | ... | @@ -402,8 +400,8 @@ describe("OrchestrationEngine", () => { |
| 402 | 400 | await system.run( |
| 403 | 401 | engine.dispatch({ |
| 404 | 402 | type: "thread.create", |
| 405 | commandId: asCommandId("cmd-thread-duplicate-1"), | |
| 406 | threadId: asThreadId("thread-duplicate"), | |
| 403 | commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"), | |
| 404 | threadId: ThreadId.makeUnsafe("thread-duplicate"), | |
| 407 | 405 | projectId: asProjectId("project-duplicate"), |
| 408 | 406 | title: "duplicate", |
| 409 | 407 | model: "gpt-5-codex", |
| ... | ... | @@ -417,8 +415,8 @@ describe("OrchestrationEngine", () => { |
| 417 | 415 | system.run( |
| 418 | 416 | engine.dispatch({ |
| 419 | 417 | type: "thread.create", |
| 420 | commandId: asCommandId("cmd-thread-duplicate-2"), | |
| 421 | threadId: asThreadId("thread-duplicate"), | |
| 418 | commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"), | |
| 419 | threadId: ThreadId.makeUnsafe("thread-duplicate"), | |
| 422 | 420 | projectId: asProjectId("project-duplicate"), |
| 423 | 421 | title: "duplicate", |
| 424 | 422 | model: "gpt-5-codex", |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+66-49| ... | ... | @@ -5,7 +5,6 @@ import { execFileSync } from "node:child_process"; |
| 5 | 5 | |
| 6 | 6 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| 8 | CheckpointRef, | |
| 9 | 8 | CommandId, |
| 10 | 9 | EventId, |
| 11 | 10 | MessageId, |
| ... | ... | @@ -36,19 +35,13 @@ import { |
| 36 | 35 | ProviderService, |
| 37 | 36 | type ProviderServiceShape, |
| 38 | 37 | } from "../../provider/Services/ProviderService.ts"; |
| 38 | import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts"; | |
| 39 | 39 | |
| 40 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 41 | 40 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 42 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 43 | 41 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 44 | 42 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); |
| 45 | 43 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 46 | 44 | |
| 47 | function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef { | |
| 48 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | |
| 49 | return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`); | |
| 50 | } | |
| 51 | ||
| 52 | 45 | function createProviderServiceHarness(cwd: string, hasSession = true) { |
| 53 | 46 | const now = new Date().toISOString(); |
| 54 | 47 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| ... | ... | @@ -109,7 +102,7 @@ async function waitForThread( |
| 109 | 102 | activities: ReadonlyArray<{ kind: string }>; |
| 110 | 103 | }> => { |
| 111 | 104 | const readModel = await Effect.runPromise(engine.getReadModel()); |
| 112 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); | |
| 105 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 113 | 106 | if (thread && predicate(thread)) { |
| 114 | 107 | return thread; |
| 115 | 108 | } |
| ... | ... | @@ -250,7 +243,7 @@ describe("CheckpointReactor", () => { |
| 250 | 243 | await Effect.runPromise( |
| 251 | 244 | engine.dispatch({ |
| 252 | 245 | type: "project.create", |
| 253 | commandId: asCommandId("cmd-project-create"), | |
| 246 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 254 | 247 | projectId: asProjectId("project-1"), |
| 255 | 248 | title: "Test Project", |
| 256 | 249 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, |
| ... | ... | @@ -261,8 +254,8 @@ describe("CheckpointReactor", () => { |
| 261 | 254 | await Effect.runPromise( |
| 262 | 255 | engine.dispatch({ |
| 263 | 256 | type: "thread.create", |
| 264 | commandId: asCommandId("cmd-thread-create"), | |
| 265 | threadId: asThreadId("thread-1"), | |
| 257 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 258 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 266 | 259 | projectId: asProjectId("project-1"), |
| 267 | 260 | title: "Thread", |
| 268 | 261 | model: "gpt-5-codex", |
| ... | ... | @@ -276,21 +269,21 @@ describe("CheckpointReactor", () => { |
| 276 | 269 | await runtime.runPromise( |
| 277 | 270 | checkpointStore.captureCheckpoint({ |
| 278 | 271 | cwd, |
| 279 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 0), | |
| 272 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 280 | 273 | }), |
| 281 | 274 | ); |
| 282 | 275 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); |
| 283 | 276 | await runtime.runPromise( |
| 284 | 277 | checkpointStore.captureCheckpoint({ |
| 285 | 278 | cwd, |
| 286 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1), | |
| 279 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 287 | 280 | }), |
| 288 | 281 | ); |
| 289 | 282 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); |
| 290 | 283 | await runtime.runPromise( |
| 291 | 284 | checkpointStore.captureCheckpoint({ |
| 292 | 285 | cwd, |
| 293 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2), | |
| 286 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 294 | 287 | }), |
| 295 | 288 | ); |
| 296 | 289 | } |
| ... | ... | @@ -309,10 +302,10 @@ describe("CheckpointReactor", () => { |
| 309 | 302 | await Effect.runPromise( |
| 310 | 303 | harness.engine.dispatch({ |
| 311 | 304 | type: "thread.session.set", |
| 312 | commandId: asCommandId("cmd-session-set-capture"), | |
| 313 | threadId: asThreadId("thread-1"), | |
| 305 | commandId: CommandId.makeUnsafe("cmd-session-set-capture"), | |
| 306 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 314 | 307 | session: { |
| 315 | threadId: asThreadId("thread-1"), | |
| 308 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 316 | 309 | status: "ready", |
| 317 | 310 | providerName: "codex", |
| 318 | 311 | providerSessionId: asSessionId("sess-1"), |
| ... | ... | @@ -334,7 +327,7 @@ describe("CheckpointReactor", () => { |
| 334 | 327 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), |
| 335 | 328 | turnId: asTurnId("turn-1"), |
| 336 | 329 | }); |
| 337 | await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0)); | |
| 330 | await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)); | |
| 338 | 331 | |
| 339 | 332 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); |
| 340 | 333 | harness.provider.emit({ |
| ... | ... | @@ -354,13 +347,25 @@ describe("CheckpointReactor", () => { |
| 354 | 347 | (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1, |
| 355 | 348 | ); |
| 356 | 349 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 357 | expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0))).toBe(true); | |
| 358 | expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true); | |
| 350 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0))).toBe( | |
| 351 | true, | |
| 352 | ); | |
| 353 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1))).toBe( | |
| 354 | true, | |
| 355 | ); | |
| 359 | 356 | expect( |
| 360 | gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"), | |
| 357 | gitShowFileAtRef( | |
| 358 | harness.cwd, | |
| 359 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 360 | "README.md", | |
| 361 | ), | |
| 361 | 362 | ).toBe("v1\n"); |
| 362 | 363 | expect( |
| 363 | gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"), | |
| 364 | gitShowFileAtRef( | |
| 365 | harness.cwd, | |
| 366 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 367 | "README.md", | |
| 368 | ), | |
| 364 | 369 | ).toBe("v2\n"); |
| 365 | 370 | }); |
| 366 | 371 | |
| ... | ... | @@ -374,8 +379,8 @@ describe("CheckpointReactor", () => { |
| 374 | 379 | await Effect.runPromise( |
| 375 | 380 | harness.engine.dispatch({ |
| 376 | 381 | type: "thread.turn.start", |
| 377 | commandId: asCommandId("cmd-turn-start-for-baseline"), | |
| 378 | threadId: asThreadId("thread-1"), | |
| 382 | commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"), | |
| 383 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 379 | 384 | message: { |
| 380 | 385 | messageId: MessageId.makeUnsafe("message-user-1"), |
| 381 | 386 | role: "user", |
| ... | ... | @@ -386,9 +391,13 @@ describe("CheckpointReactor", () => { |
| 386 | 391 | }), |
| 387 | 392 | ); |
| 388 | 393 | |
| 389 | await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0)); | |
| 394 | await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)); | |
| 390 | 395 | expect( |
| 391 | gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"), | |
| 396 | gitShowFileAtRef( | |
| 397 | harness.cwd, | |
| 398 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 399 | "README.md", | |
| 400 | ), | |
| 392 | 401 | ).toBe("v1\n"); |
| 393 | 402 | }); |
| 394 | 403 | |
| ... | ... | @@ -403,10 +412,10 @@ describe("CheckpointReactor", () => { |
| 403 | 412 | await Effect.runPromise( |
| 404 | 413 | harness.engine.dispatch({ |
| 405 | 414 | type: "thread.session.set", |
| 406 | commandId: asCommandId("cmd-session-set-missing-provider-cwd"), | |
| 407 | threadId: asThreadId("thread-1"), | |
| 415 | commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"), | |
| 416 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 408 | 417 | session: { |
| 409 | threadId: asThreadId("thread-1"), | |
| 418 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 410 | 419 | status: "running", |
| 411 | 420 | providerName: "codex", |
| 412 | 421 | providerSessionId: asSessionId("sess-missing"), |
| ... | ... | @@ -432,9 +441,15 @@ describe("CheckpointReactor", () => { |
| 432 | 441 | }); |
| 433 | 442 | |
| 434 | 443 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); |
| 435 | expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true); | |
| 444 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1))).toBe( | |
| 445 | true, | |
| 446 | ); | |
| 436 | 447 | expect( |
| 437 | gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"), | |
| 448 | gitShowFileAtRef( | |
| 449 | harness.cwd, | |
| 450 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 451 | "README.md", | |
| 452 | ), | |
| 438 | 453 | ).toBe("v2\n"); |
| 439 | 454 | }); |
| 440 | 455 | |
| ... | ... | @@ -445,10 +460,10 @@ describe("CheckpointReactor", () => { |
| 445 | 460 | await Effect.runPromise( |
| 446 | 461 | harness.engine.dispatch({ |
| 447 | 462 | type: "thread.session.set", |
| 448 | commandId: asCommandId("cmd-session-set-checkpoint-captured"), | |
| 449 | threadId: asThreadId("thread-1"), | |
| 463 | commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"), | |
| 464 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 450 | 465 | session: { |
| 451 | threadId: asThreadId("thread-1"), | |
| 466 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 452 | 467 | status: "ready", |
| 453 | 468 | providerName: "codex", |
| 454 | 469 | providerSessionId: asSessionId("sess-1"), |
| ... | ... | @@ -488,10 +503,10 @@ describe("CheckpointReactor", () => { |
| 488 | 503 | await Effect.runPromise( |
| 489 | 504 | harness.engine.dispatch({ |
| 490 | 505 | type: "thread.session.set", |
| 491 | commandId: asCommandId("cmd-session-set"), | |
| 492 | threadId: asThreadId("thread-1"), | |
| 506 | commandId: CommandId.makeUnsafe("cmd-session-set"), | |
| 507 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 493 | 508 | session: { |
| 494 | threadId: asThreadId("thread-1"), | |
| 509 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 495 | 510 | status: "ready", |
| 496 | 511 | providerName: "codex", |
| 497 | 512 | providerSessionId: asSessionId("sess-1"), |
| ... | ... | @@ -507,11 +522,11 @@ describe("CheckpointReactor", () => { |
| 507 | 522 | await Effect.runPromise( |
| 508 | 523 | harness.engine.dispatch({ |
| 509 | 524 | type: "thread.turn.diff.complete", |
| 510 | commandId: asCommandId("cmd-diff-1"), | |
| 511 | threadId: asThreadId("thread-1"), | |
| 525 | commandId: CommandId.makeUnsafe("cmd-diff-1"), | |
| 526 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 512 | 527 | turnId: asTurnId("turn-1"), |
| 513 | 528 | completedAt: createdAt, |
| 514 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1), | |
| 529 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 515 | 530 | status: "ready", |
| 516 | 531 | files: [], |
| 517 | 532 | checkpointTurnCount: 1, |
| ... | ... | @@ -521,11 +536,11 @@ describe("CheckpointReactor", () => { |
| 521 | 536 | await Effect.runPromise( |
| 522 | 537 | harness.engine.dispatch({ |
| 523 | 538 | type: "thread.turn.diff.complete", |
| 524 | commandId: asCommandId("cmd-diff-2"), | |
| 525 | threadId: asThreadId("thread-1"), | |
| 539 | commandId: CommandId.makeUnsafe("cmd-diff-2"), | |
| 540 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 526 | 541 | turnId: asTurnId("turn-2"), |
| 527 | 542 | completedAt: createdAt, |
| 528 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2), | |
| 543 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 529 | 544 | status: "ready", |
| 530 | 545 | files: [], |
| 531 | 546 | checkpointTurnCount: 2, |
| ... | ... | @@ -536,8 +551,8 @@ describe("CheckpointReactor", () => { |
| 536 | 551 | await Effect.runPromise( |
| 537 | 552 | harness.engine.dispatch({ |
| 538 | 553 | type: "thread.checkpoint.revert", |
| 539 | commandId: asCommandId("cmd-revert-request"), | |
| 540 | threadId: asThreadId("thread-1"), | |
| 554 | commandId: CommandId.makeUnsafe("cmd-revert-request"), | |
| 555 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 541 | 556 | turnCount: 1, |
| 542 | 557 | createdAt, |
| 543 | 558 | }), |
| ... | ... | @@ -555,7 +570,9 @@ describe("CheckpointReactor", () => { |
| 555 | 570 | numTurns: 1, |
| 556 | 571 | }); |
| 557 | 572 | expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); |
| 558 | expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 2))).toBe(false); | |
| 573 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2))).toBe( | |
| 574 | false, | |
| 575 | ); | |
| 559 | 576 | }); |
| 560 | 577 | |
| 561 | 578 | it("appends an error activity when revert is requested without an active session", async () => { |
| ... | ... | @@ -565,8 +582,8 @@ describe("CheckpointReactor", () => { |
| 565 | 582 | await Effect.runPromise( |
| 566 | 583 | harness.engine.dispatch({ |
| 567 | 584 | type: "thread.checkpoint.revert", |
| 568 | commandId: asCommandId("cmd-revert-no-session"), | |
| 569 | threadId: asThreadId("thread-1"), | |
| 585 | commandId: CommandId.makeUnsafe("cmd-revert-no-session"), | |
| 586 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 570 | 587 | turnCount: 1, |
| 571 | 588 | createdAt, |
| 572 | 589 | }), |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+4-10| ... | ... | @@ -1,17 +1,18 @@ |
| 1 | 1 | import { |
| 2 | CheckpointRef, | |
| 3 | 2 | CommandId, |
| 4 | 3 | EventId, |
| 5 | 4 | MessageId, |
| 6 | 5 | ProviderSessionId, |
| 7 | 6 | ThreadId, |
| 8 | 7 | TurnId, |
| 8 | type ProjectId, | |
| 9 | 9 | type OrchestrationEvent, |
| 10 | 10 | type ProviderRuntimeEvent, |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | import { Effect, Layer, Option, Queue, Stream } from "effect"; |
| 13 | 13 | |
| 14 | 14 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; |
| 15 | import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts"; | |
| 15 | 16 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 16 | 17 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 17 | 18 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; |
| ... | ... | @@ -19,8 +20,6 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 19 | 20 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 20 | 21 | import { OrchestrationDispatchError } from "../Errors.ts"; |
| 21 | 22 | |
| 22 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 23 | ||
| 24 | 23 | type ReactorInput = |
| 25 | 24 | | { |
| 26 | 25 | readonly source: "runtime"; |
| ... | ... | @@ -31,11 +30,6 @@ type ReactorInput = |
| 31 | 30 | readonly event: OrchestrationEvent; |
| 32 | 31 | }; |
| 33 | 32 | |
| 34 | function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { | |
| 35 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | |
| 36 | return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`); | |
| 37 | } | |
| 38 | ||
| 39 | 33 | function toTurnId(value: string | undefined): TurnId | null { |
| 40 | 34 | const normalized = value?.trim(); |
| 41 | 35 | return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null; |
| ... | ... | @@ -48,11 +42,11 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined { |
| 48 | 42 | |
| 49 | 43 | function resolveThreadWorkspaceCwd(input: { |
| 50 | 44 | readonly thread: { |
| 51 | readonly projectId: string; | |
| 45 | readonly projectId: ProjectId; | |
| 52 | 46 | readonly worktreePath: string | null; |
| 53 | 47 | }; |
| 54 | 48 | readonly projects: ReadonlyArray<{ |
| 55 | readonly id: string; | |
| 49 | readonly id: ProjectId; | |
| 56 | 50 | readonly workspaceRoot: string; |
| 57 | 51 | }>; |
| 58 | 52 | }): string | undefined { |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+1-23| ... | ... | @@ -5,13 +5,11 @@ import type { |
| 5 | 5 | ThreadId, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { OrchestrationCommand } from "@t3tools/contracts"; |
| 8 | import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"; | |
| 8 | import { Deferred, Effect, Layer, Option, PubSub, Queue, Stream } from "effect"; | |
| 9 | 9 | |
| 10 | 10 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 11 | 11 | import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; |
| 12 | 12 | import { |
| 13 | toOrchestrationCommandDecodeError, | |
| 14 | toOrchestrationJsonParseError, | |
| 15 | 13 | OrchestrationCommandInvariantError, |
| 16 | 14 | OrchestrationCommandPreviouslyRejectedError, |
| 17 | 15 | type OrchestrationDispatchError, |
| ... | ... | @@ -29,8 +27,6 @@ interface CommandEnvelope { |
| 29 | 27 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; |
| 30 | 28 | } |
| 31 | 29 | |
| 32 | const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommand); | |
| 33 | ||
| 34 | 30 | function commandToAggregateRef(command: OrchestrationCommand): { |
| 35 | 31 | readonly aggregateKind: "project" | "thread"; |
| 36 | 32 | readonly aggregateId: ProjectId | ThreadId; |
| ... | ... | @@ -175,30 +171,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 175 | 171 | return yield* Deferred.await(result); |
| 176 | 172 | }); |
| 177 | 173 | |
| 178 | const dispatchUnknownCommand: OrchestrationEngineShape["dispatchUnknownCommand"] = (command) => | |
| 179 | Effect.gen(function* () { | |
| 180 | const payload = | |
| 181 | typeof command === "string" | |
| 182 | ? yield* Effect.try({ | |
| 183 | try: () => JSON.parse(command) as unknown, | |
| 184 | catch: toOrchestrationJsonParseError, | |
| 185 | }) | |
| 186 | : command; | |
| 187 | ||
| 188 | const decoded = yield* decodeUnknownCommand(payload).pipe( | |
| 189 | Effect.mapError(toOrchestrationCommandDecodeError), | |
| 190 | ); | |
| 191 | ||
| 192 | return yield* dispatch(decoded); | |
| 193 | }); | |
| 194 | ||
| 195 | 174 | const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] = |
| 196 | 175 | Stream.fromPubSub(eventPubSub); |
| 197 | 176 | |
| 198 | 177 | return { |
| 199 | 178 | getReadModel, |
| 200 | 179 | readEvents, |
| 201 | dispatchUnknownCommand, | |
| 202 | 180 | dispatch, |
| 203 | 181 | streamDomainEvents, |
| 204 | 182 | } satisfies OrchestrationEngineShape; |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2-3| ... | ... | @@ -18,7 +18,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu |
| 18 | 18 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 19 | 19 | |
| 20 | 20 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 21 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 22 | 21 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 23 | 22 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 24 | 23 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| ... | ... | @@ -225,7 +224,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 225 | 224 | ]); |
| 226 | 225 | assert.deepEqual(snapshot.threads, [ |
| 227 | 226 | { |
| 228 | id: asThreadId("thread-1"), | |
| 227 | id: ThreadId.makeUnsafe("thread-1"), | |
| 229 | 228 | projectId: asProjectId("project-1"), |
| 230 | 229 | title: "Thread 1", |
| 231 | 230 | model: "gpt-5-codex", |
| ... | ... | @@ -269,7 +268,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 269 | 268 | }, |
| 270 | 269 | ], |
| 271 | 270 | session: { |
| 272 | threadId: asThreadId("thread-1"), | |
| 271 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 273 | 272 | status: "running", |
| 274 | 273 | providerName: "codex", |
| 275 | 274 | providerSessionId: asProviderSessionId("provider-session-1"), |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+22-24| ... | ... | @@ -26,9 +26,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; |
| 26 | 26 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 27 | 27 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 28 | 28 | |
| 29 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 30 | 29 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 31 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 32 | 30 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 33 | 31 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 34 | 32 | const asApprovalRequestId = (value: string): ApprovalRequestId => |
| ... | ... | @@ -126,7 +124,7 @@ describe("ProviderCommandReactor", () => { |
| 126 | 124 | await Effect.runPromise( |
| 127 | 125 | engine.dispatch({ |
| 128 | 126 | type: "project.create", |
| 129 | commandId: asCommandId("cmd-project-create"), | |
| 127 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 130 | 128 | projectId: asProjectId("project-1"), |
| 131 | 129 | title: "Provider Project", |
| 132 | 130 | workspaceRoot: "/tmp/provider-project", |
| ... | ... | @@ -137,8 +135,8 @@ describe("ProviderCommandReactor", () => { |
| 137 | 135 | await Effect.runPromise( |
| 138 | 136 | engine.dispatch({ |
| 139 | 137 | type: "thread.create", |
| 140 | commandId: asCommandId("cmd-thread-create"), | |
| 141 | threadId: asThreadId("thread-1"), | |
| 138 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 139 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 142 | 140 | projectId: asProjectId("project-1"), |
| 143 | 141 | title: "Thread", |
| 144 | 142 | model: "gpt-5-codex", |
| ... | ... | @@ -165,8 +163,8 @@ describe("ProviderCommandReactor", () => { |
| 165 | 163 | await Effect.runPromise( |
| 166 | 164 | harness.engine.dispatch({ |
| 167 | 165 | type: "thread.turn.start", |
| 168 | commandId: asCommandId("cmd-turn-start-1"), | |
| 169 | threadId: asThreadId("thread-1"), | |
| 166 | commandId: CommandId.makeUnsafe("cmd-turn-start-1"), | |
| 167 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 170 | 168 | message: { |
| 171 | 169 | messageId: asMessageId("user-message-1"), |
| 172 | 170 | role: "user", |
| ... | ... | @@ -185,7 +183,7 @@ describe("ProviderCommandReactor", () => { |
| 185 | 183 | }); |
| 186 | 184 | |
| 187 | 185 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 188 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); | |
| 186 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 189 | 187 | expect(thread?.session?.providerSessionId).toBe("sess-1"); |
| 190 | 188 | }); |
| 191 | 189 | |
| ... | ... | @@ -196,10 +194,10 @@ describe("ProviderCommandReactor", () => { |
| 196 | 194 | await Effect.runPromise( |
| 197 | 195 | harness.engine.dispatch({ |
| 198 | 196 | type: "thread.session.set", |
| 199 | commandId: asCommandId("cmd-session-set"), | |
| 200 | threadId: asThreadId("thread-1"), | |
| 197 | commandId: CommandId.makeUnsafe("cmd-session-set"), | |
| 198 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 201 | 199 | session: { |
| 202 | threadId: asThreadId("thread-1"), | |
| 200 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 203 | 201 | status: "running", |
| 204 | 202 | providerName: "codex", |
| 205 | 203 | providerSessionId: asSessionId("sess-1"), |
| ... | ... | @@ -215,8 +213,8 @@ describe("ProviderCommandReactor", () => { |
| 215 | 213 | await Effect.runPromise( |
| 216 | 214 | harness.engine.dispatch({ |
| 217 | 215 | type: "thread.turn.interrupt", |
| 218 | commandId: asCommandId("cmd-turn-interrupt"), | |
| 219 | threadId: asThreadId("thread-1"), | |
| 216 | commandId: CommandId.makeUnsafe("cmd-turn-interrupt"), | |
| 217 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 220 | 218 | turnId: asTurnId("turn-1"), |
| 221 | 219 | createdAt: now, |
| 222 | 220 | }), |
| ... | ... | @@ -235,10 +233,10 @@ describe("ProviderCommandReactor", () => { |
| 235 | 233 | await Effect.runPromise( |
| 236 | 234 | harness.engine.dispatch({ |
| 237 | 235 | type: "thread.session.set", |
| 238 | commandId: asCommandId("cmd-session-set-for-approval"), | |
| 239 | threadId: asThreadId("thread-1"), | |
| 236 | commandId: CommandId.makeUnsafe("cmd-session-set-for-approval"), | |
| 237 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 240 | 238 | session: { |
| 241 | threadId: asThreadId("thread-1"), | |
| 239 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 242 | 240 | status: "running", |
| 243 | 241 | providerName: "codex", |
| 244 | 242 | providerSessionId: asSessionId("sess-1"), |
| ... | ... | @@ -254,8 +252,8 @@ describe("ProviderCommandReactor", () => { |
| 254 | 252 | await Effect.runPromise( |
| 255 | 253 | harness.engine.dispatch({ |
| 256 | 254 | type: "thread.approval.respond", |
| 257 | commandId: asCommandId("cmd-approval-respond"), | |
| 258 | threadId: asThreadId("thread-1"), | |
| 255 | commandId: CommandId.makeUnsafe("cmd-approval-respond"), | |
| 256 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 259 | 257 | requestId: asApprovalRequestId("approval-request-1"), |
| 260 | 258 | decision: "accept", |
| 261 | 259 | createdAt: now, |
| ... | ... | @@ -277,10 +275,10 @@ describe("ProviderCommandReactor", () => { |
| 277 | 275 | await Effect.runPromise( |
| 278 | 276 | harness.engine.dispatch({ |
| 279 | 277 | type: "thread.session.set", |
| 280 | commandId: asCommandId("cmd-session-set-for-stop"), | |
| 281 | threadId: asThreadId("thread-1"), | |
| 278 | commandId: CommandId.makeUnsafe("cmd-session-set-for-stop"), | |
| 279 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 282 | 280 | session: { |
| 283 | threadId: asThreadId("thread-1"), | |
| 281 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 284 | 282 | status: "ready", |
| 285 | 283 | providerName: "codex", |
| 286 | 284 | providerSessionId: asSessionId("sess-1"), |
| ... | ... | @@ -296,15 +294,15 @@ describe("ProviderCommandReactor", () => { |
| 296 | 294 | await Effect.runPromise( |
| 297 | 295 | harness.engine.dispatch({ |
| 298 | 296 | type: "thread.session.stop", |
| 299 | commandId: asCommandId("cmd-session-stop"), | |
| 300 | threadId: asThreadId("thread-1"), | |
| 297 | commandId: CommandId.makeUnsafe("cmd-session-stop"), | |
| 298 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 301 | 299 | createdAt: now, |
| 302 | 300 | }), |
| 303 | 301 | ); |
| 304 | 302 | |
| 305 | 303 | await waitFor(() => harness.stopSession.mock.calls.length === 1); |
| 306 | 304 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 307 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); | |
| 305 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 308 | 306 | expect(thread?.session).not.toBeNull(); |
| 309 | 307 | expect(thread?.session?.status).toBe("stopped"); |
| 310 | 308 | expect(thread?.session?.providerSessionId).toBeNull(); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+7-9| ... | ... | @@ -28,9 +28,7 @@ import { |
| 28 | 28 | } from "../Services/OrchestrationEngine.ts"; |
| 29 | 29 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 30 | 30 | |
| 31 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 32 | 31 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 33 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 34 | 32 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 35 | 33 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 36 | 34 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| ... | ... | @@ -78,7 +76,7 @@ async function waitForThread( |
| 78 | 76 | activities: ReadonlyArray<{ kind: string }>; |
| 79 | 77 | }> => { |
| 80 | 78 | const readModel = await Effect.runPromise(engine.getReadModel()); |
| 81 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); | |
| 79 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 82 | 80 | if (thread && predicate(thread)) { |
| 83 | 81 | return thread; |
| 84 | 82 | } |
| ... | ... | @@ -132,7 +130,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 132 | 130 | await Effect.runPromise( |
| 133 | 131 | engine.dispatch({ |
| 134 | 132 | type: "project.create", |
| 135 | commandId: asCommandId("cmd-provider-project-create"), | |
| 133 | commandId: CommandId.makeUnsafe("cmd-provider-project-create"), | |
| 136 | 134 | projectId: asProjectId("project-1"), |
| 137 | 135 | title: "Provider Project", |
| 138 | 136 | workspaceRoot: "/tmp/provider-project", |
| ... | ... | @@ -143,8 +141,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 143 | 141 | await Effect.runPromise( |
| 144 | 142 | engine.dispatch({ |
| 145 | 143 | type: "thread.create", |
| 146 | commandId: asCommandId("cmd-thread-create"), | |
| 147 | threadId: asThreadId("thread-1"), | |
| 144 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 145 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 148 | 146 | projectId: asProjectId("project-1"), |
| 149 | 147 | title: "Thread", |
| 150 | 148 | model: "gpt-5-codex", |
| ... | ... | @@ -156,10 +154,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 156 | 154 | await Effect.runPromise( |
| 157 | 155 | engine.dispatch({ |
| 158 | 156 | type: "thread.session.set", |
| 159 | commandId: asCommandId("cmd-session-seed"), | |
| 160 | threadId: asThreadId("thread-1"), | |
| 157 | commandId: CommandId.makeUnsafe("cmd-session-seed"), | |
| 158 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 161 | 159 | session: { |
| 162 | threadId: asThreadId("thread-1"), | |
| 160 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 163 | 161 | status: "ready", |
| 164 | 162 | providerName: "codex", |
| 165 | 163 | providerSessionId: asSessionId("sess-1"), |
apps/server/src/orchestration/Services/OrchestrationEngine.ts+1-13| ... | ... | @@ -18,7 +18,7 @@ import type { |
| 18 | 18 | import { ServiceMap } from "effect"; |
| 19 | 19 | import type { Effect, Stream } from "effect"; |
| 20 | 20 | |
| 21 | import type { OrchestrationDispatchError, OrchestrationEngineError } from "../Errors.ts"; | |
| 21 | import type { OrchestrationDispatchError } from "../Errors.ts"; | |
| 22 | 22 | import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; |
| 23 | 23 | |
| 24 | 24 | export interface OrchestrationEngineShape { |
| ... | ... | @@ -39,18 +39,6 @@ export interface OrchestrationEngineShape { |
| 39 | 39 | fromSequenceExclusive: number, |
| 40 | 40 | ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>; |
| 41 | 41 | |
| 42 | /** | |
| 43 | * Validate and dispatch an unknown command payload. | |
| 44 | * | |
| 45 | * Accepts either parsed values or raw JSON strings. | |
| 46 | * | |
| 47 | * @param command - Unknown command payload. | |
| 48 | * @returns Effect containing the sequence of the persisted event. | |
| 49 | */ | |
| 50 | readonly dispatchUnknownCommand: ( | |
| 51 | command: unknown, | |
| 52 | ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError, never>; | |
| 53 | ||
| 54 | 42 | /** |
| 55 | 43 | * Dispatch a validated orchestration command. |
| 56 | 44 | * |
apps/server/src/orchestration/decider.projectScripts.test.ts+12-14| ... | ... | @@ -5,10 +5,8 @@ import { Effect } from "effect"; |
| 5 | 5 | import { decideOrchestrationCommand } from "./decider.ts"; |
| 6 | 6 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; |
| 7 | 7 | |
| 8 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 9 | 8 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 10 | 9 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 11 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 12 | 10 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 13 | 11 | |
| 14 | 12 | describe("decider project scripts", () => { |
| ... | ... | @@ -20,7 +18,7 @@ describe("decider project scripts", () => { |
| 20 | 18 | decideOrchestrationCommand({ |
| 21 | 19 | command: { |
| 22 | 20 | type: "project.create", |
| 23 | commandId: asCommandId("cmd-project-create-scripts"), | |
| 21 | commandId: CommandId.makeUnsafe("cmd-project-create-scripts"), | |
| 24 | 22 | projectId: asProjectId("project-scripts"), |
| 25 | 23 | title: "Scripts", |
| 26 | 24 | workspaceRoot: "/tmp/scripts", |
| ... | ... | @@ -46,9 +44,9 @@ describe("decider project scripts", () => { |
| 46 | 44 | aggregateId: asProjectId("project-scripts"), |
| 47 | 45 | type: "project.created", |
| 48 | 46 | occurredAt: now, |
| 49 | commandId: asCommandId("cmd-project-create-scripts"), | |
| 47 | commandId: CommandId.makeUnsafe("cmd-project-create-scripts"), | |
| 50 | 48 | causationEventId: null, |
| 51 | correlationId: asCommandId("cmd-project-create-scripts"), | |
| 49 | correlationId: CommandId.makeUnsafe("cmd-project-create-scripts"), | |
| 52 | 50 | metadata: {}, |
| 53 | 51 | payload: { |
| 54 | 52 | projectId: asProjectId("project-scripts"), |
| ... | ... | @@ -76,7 +74,7 @@ describe("decider project scripts", () => { |
| 76 | 74 | decideOrchestrationCommand({ |
| 77 | 75 | command: { |
| 78 | 76 | type: "project.meta.update", |
| 79 | commandId: asCommandId("cmd-project-update-scripts"), | |
| 77 | commandId: CommandId.makeUnsafe("cmd-project-update-scripts"), | |
| 80 | 78 | projectId: asProjectId("project-scripts"), |
| 81 | 79 | scripts: Array.from(scripts), |
| 82 | 80 | }, |
| ... | ... | @@ -100,9 +98,9 @@ describe("decider project scripts", () => { |
| 100 | 98 | aggregateId: asProjectId("project-1"), |
| 101 | 99 | type: "project.created", |
| 102 | 100 | occurredAt: now, |
| 103 | commandId: asCommandId("cmd-project-create"), | |
| 101 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 104 | 102 | causationEventId: null, |
| 105 | correlationId: asCommandId("cmd-project-create"), | |
| 103 | correlationId: CommandId.makeUnsafe("cmd-project-create"), | |
| 106 | 104 | metadata: {}, |
| 107 | 105 | payload: { |
| 108 | 106 | projectId: asProjectId("project-1"), |
| ... | ... | @@ -120,15 +118,15 @@ describe("decider project scripts", () => { |
| 120 | 118 | sequence: 2, |
| 121 | 119 | eventId: asEventId("evt-thread-create"), |
| 122 | 120 | aggregateKind: "thread", |
| 123 | aggregateId: asThreadId("thread-1"), | |
| 121 | aggregateId: ThreadId.makeUnsafe("thread-1"), | |
| 124 | 122 | type: "thread.created", |
| 125 | 123 | occurredAt: now, |
| 126 | commandId: asCommandId("cmd-thread-create"), | |
| 124 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 127 | 125 | causationEventId: null, |
| 128 | correlationId: asCommandId("cmd-thread-create"), | |
| 126 | correlationId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 129 | 127 | metadata: {}, |
| 130 | 128 | payload: { |
| 131 | threadId: asThreadId("thread-1"), | |
| 129 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 132 | 130 | projectId: asProjectId("project-1"), |
| 133 | 131 | title: "Thread", |
| 134 | 132 | model: "gpt-5-codex", |
| ... | ... | @@ -144,8 +142,8 @@ describe("decider project scripts", () => { |
| 144 | 142 | decideOrchestrationCommand({ |
| 145 | 143 | command: { |
| 146 | 144 | type: "thread.turn.start", |
| 147 | commandId: asCommandId("cmd-turn-start"), | |
| 148 | threadId: asThreadId("thread-1"), | |
| 145 | commandId: CommandId.makeUnsafe("cmd-turn-start"), | |
| 146 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 149 | 147 | message: { |
| 150 | 148 | messageId: asMessageId("message-user-1"), |
| 151 | 149 | role: "user", |
apps/server/src/provider/Layers/CodexAdapter.test.ts+5-6| ... | ... | @@ -23,7 +23,6 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 23 | 23 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; |
| 24 | 24 | |
| 25 | 25 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 26 | const asThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 27 | 26 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 28 | 27 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 29 | 28 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| ... | ... | @@ -36,7 +35,7 @@ class FakeCodexManager extends CodexAppServerManager { |
| 36 | 35 | sessionId: asSessionId("sess-1"), |
| 37 | 36 | provider: "codex", |
| 38 | 37 | status: "ready", |
| 39 | threadId: asThreadId("thread-1"), | |
| 38 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 40 | 39 | cwd: input.cwd, |
| 41 | 40 | createdAt: now, |
| 42 | 41 | updatedAt: now, |
| ... | ... | @@ -46,7 +45,7 @@ class FakeCodexManager extends CodexAppServerManager { |
| 46 | 45 | |
| 47 | 46 | public sendTurnImpl = vi.fn( |
| 48 | 47 | async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({ |
| 49 | threadId: asThreadId("thread-1"), | |
| 48 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 50 | 49 | turnId: asTurnId("turn-1"), |
| 51 | 50 | }), |
| 52 | 51 | ); |
| ... | ... | @@ -56,12 +55,12 @@ class FakeCodexManager extends CodexAppServerManager { |
| 56 | 55 | ); |
| 57 | 56 | |
| 58 | 57 | public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({ |
| 59 | threadId: asThreadId("thread-1"), | |
| 58 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 60 | 59 | turns: [], |
| 61 | 60 | })); |
| 62 | 61 | |
| 63 | 62 | public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({ |
| 64 | threadId: asThreadId("thread-1"), | |
| 63 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 65 | 64 | turns: [], |
| 66 | 65 | })); |
| 67 | 66 | |
| ... | ... | @@ -194,7 +193,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 194 | 193 | sessionId: asSessionId("sess-1"), |
| 195 | 194 | createdAt: new Date().toISOString(), |
| 196 | 195 | method: "item/completed", |
| 197 | threadId: asThreadId("thread-1"), | |
| 196 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 198 | 197 | turnId: asTurnId("turn-1"), |
| 199 | 198 | itemId: asItemId("msg_1"), |
| 200 | 199 | payload: { |
apps/server/src/provider/Layers/ProviderService.test.ts+5-6| ... | ... | @@ -21,7 +21,7 @@ import { |
| 21 | 21 | import { it, assert, vi } from "@effect/vitest"; |
| 22 | 22 | import { assertFailure } from "@effect/vitest/utils"; |
| 23 | 23 | |
| 24 | import { Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Stream } from "effect"; | |
| 24 | import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect"; | |
| 25 | 25 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 26 | 26 | |
| 27 | 27 | import { |
| ... | ... | @@ -46,7 +46,6 @@ import { |
| 46 | 46 | } from "../../persistence/Layers/Sqlite.ts"; |
| 47 | 47 | |
| 48 | 48 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 49 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 50 | 49 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 51 | 50 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); |
| 52 | 51 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| ... | ... | @@ -568,7 +567,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 568 | 567 | provider: "codex", |
| 569 | 568 | sessionId: session.sessionId, |
| 570 | 569 | createdAt: new Date().toISOString(), |
| 571 | threadId: asThreadId("thread-1"), | |
| 570 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 572 | 571 | turnId: asTurnId("turn-1"), |
| 573 | 572 | status: "completed", |
| 574 | 573 | }; |
| ... | ... | @@ -616,7 +615,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 616 | 615 | provider: "codex", |
| 617 | 616 | sessionId: session.sessionId, |
| 618 | 617 | createdAt: new Date().toISOString(), |
| 619 | threadId: asThreadId("thread-1"), | |
| 618 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 620 | 619 | turnId: asTurnId("turn-1"), |
| 621 | 620 | toolKind: "command", |
| 622 | 621 | title: "Command run", |
| ... | ... | @@ -628,7 +627,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 628 | 627 | provider: "codex", |
| 629 | 628 | sessionId: session.sessionId, |
| 630 | 629 | createdAt: new Date().toISOString(), |
| 631 | threadId: asThreadId("thread-1"), | |
| 630 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 632 | 631 | turnId: asTurnId("turn-1"), |
| 633 | 632 | delta: "hello", |
| 634 | 633 | }, |
| ... | ... | @@ -638,7 +637,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 638 | 637 | provider: "codex", |
| 639 | 638 | sessionId: session.sessionId, |
| 640 | 639 | createdAt: new Date().toISOString(), |
| 641 | threadId: asThreadId("thread-1"), | |
| 640 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 642 | 641 | turnId: asTurnId("turn-1"), |
| 643 | 642 | status: "completed", |
| 644 | 643 | }, |
apps/server/src/serverLayers.ts+8| ... | ... | @@ -4,8 +4,10 @@ import { NodeServices } from "@effect/platform-node"; |
| 4 | 4 | import { Effect, Layer } from "effect"; |
| 5 | 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 6 | 6 | |
| 7 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; | |
| 7 | 8 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 8 | 9 | import { ServerConfig } from "./config"; |
| 10 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | |
| 9 | 11 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; |
| 10 | 12 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; |
| 11 | 13 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; |
| ... | ... | @@ -55,6 +57,7 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 55 | 57 | | OrchestrationEngineService |
| 56 | 58 | | ProjectionSnapshotQuery |
| 57 | 59 | | CheckpointStore |
| 60 | | CheckpointDiffQuery | |
| 58 | 61 | | OrchestrationReactor |
| 59 | 62 | | TerminalManager |
| 60 | 63 | | Keybindings, |
| ... | ... | @@ -67,11 +70,16 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 67 | 70 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 68 | 71 | ); |
| 69 | 72 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); |
| 73 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( | |
| 74 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | |
| 75 | Layer.provideMerge(checkpointStoreLayer), | |
| 76 | ); | |
| 70 | 77 | |
| 71 | 78 | const runtimeServicesLayer = Layer.mergeAll( |
| 72 | 79 | orchestrationLayer, |
| 73 | 80 | OrchestrationProjectionSnapshotQueryLive, |
| 74 | 81 | checkpointStoreLayer, |
| 82 | checkpointDiffQueryLayer, | |
| 75 | 83 | ); |
| 76 | 84 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 77 | 85 | Layer.provideMerge(runtimeServicesLayer), |
apps/server/src/terminalManager.test.ts+4-3| ... | ... | @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from "vitest"; |
| 11 | 11 | |
| 12 | 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; |
| 13 | 13 | import { TerminalManagerRuntime } from "./terminalManager"; |
| 14 | import { Encoding } from "effect"; | |
| 14 | 15 | |
| 15 | 16 | class FakePtyProcess implements PtyProcess { |
| 16 | 17 | readonly writes: string[] = []; |
| ... | ... | @@ -107,15 +108,15 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu |
| 107 | 108 | } |
| 108 | 109 | |
| 109 | 110 | function historyLogName(threadId: string): string { |
| 110 | return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`; | |
| 111 | return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; | |
| 111 | 112 | } |
| 112 | 113 | |
| 113 | 114 | function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { |
| 114 | const threadPart = `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; | |
| 115 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 115 | 116 | if (terminalId === DEFAULT_TERMINAL_ID) { |
| 116 | 117 | return `${threadPart}.log`; |
| 117 | 118 | } |
| 118 | return `${threadPart}_${Buffer.from(terminalId, "utf8").toString("base64url")}.log`; | |
| 119 | return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; | |
| 119 | 120 | } |
| 120 | 121 | |
| 121 | 122 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { |
apps/server/src/terminalManager.ts+3-3| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | type TerminalSessionSnapshot, |
| 14 | 14 | type TerminalSessionStatus, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | import { Effect, Layer, Schema, ServiceMap } from "effect"; | |
| 16 | import { Effect, Encoding, Layer, Schema, ServiceMap } from "effect"; | |
| 17 | 17 | |
| 18 | 18 | import { createLogger } from "./logger"; |
| 19 | 19 | import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; |
| ... | ... | @@ -269,11 +269,11 @@ function legacySafeThreadId(threadId: string): string { |
| 269 | 269 | } |
| 270 | 270 | |
| 271 | 271 | function toSafeThreadId(threadId: string): string { |
| 272 | return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; | |
| 272 | return `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 273 | 273 | } |
| 274 | 274 | |
| 275 | 275 | function toSafeTerminalId(terminalId: string): string { |
| 276 | return Buffer.from(terminalId, "utf8").toString("base64url"); | |
| 276 | return Encoding.encodeBase64Url(terminalId); | |
| 277 | 277 | } |
| 278 | 278 | |
| 279 | 279 | function toSessionKey(threadId: string, terminalId: string): string { |
apps/server/src/wsServer.test.ts+21-1| ... | ... | @@ -175,7 +175,7 @@ class MockTerminalManager implements TerminalManagerShape { |
| 175 | 175 | this.sessions.delete(this.key(input.threadId, input.terminalId)); |
| 176 | 176 | return; |
| 177 | 177 | } |
| 178 | for (const key of [...this.sessions.keys()]) { | |
| 178 | for (const key of this.sessions.keys()) { | |
| 179 | 179 | if (key.startsWith(`${input.threadId}\u0000`)) { |
| 180 | 180 | this.sessions.delete(key); |
| 181 | 181 | } |
| ... | ... | @@ -837,6 +837,26 @@ describe("WebSocket Server", () => { |
| 837 | 837 | expect(response.error?.message).toContain("Thread 'thread-missing' not found."); |
| 838 | 838 | }); |
| 839 | 839 | |
| 840 | it("returns error when requesting turn diff with an inverted range", async () => { | |
| 841 | server = await createTestServer({ cwd: "/test" }); | |
| 842 | const addr = server.address(); | |
| 843 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 844 | ||
| 845 | const ws = await connectWs(port); | |
| 846 | connections.push(ws); | |
| 847 | await waitForMessage(ws); | |
| 848 | ||
| 849 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | |
| 850 | threadId: "thread-any", | |
| 851 | fromTurnCount: 2, | |
| 852 | toTurnCount: 1, | |
| 853 | }); | |
| 854 | expect(response.result).toBeUndefined(); | |
| 855 | expect(response.error?.message).toContain( | |
| 856 | "fromTurnCount must be less than or equal to toTurnCount", | |
| 857 | ); | |
| 858 | }); | |
| 859 | ||
| 840 | 860 | it("returns error when requesting full thread diff for unknown thread", async () => { |
| 841 | 861 | server = await createTestServer({ cwd: "/test" }); |
| 842 | 862 | const addr = server.address(); |
apps/server/src/wsServer.ts+29-153| ... | ... | @@ -4,19 +4,19 @@ import path from "node:path"; |
| 4 | 4 | import type { Duplex } from "node:stream"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | CheckpointRef, | |
| 7 | CommandId, | |
| 8 | 8 | ORCHESTRATION_WS_CHANNELS, |
| 9 | 9 | ORCHESTRATION_WS_METHODS, |
| 10 | ProjectId, | |
| 10 | 11 | TerminalEvent, |
| 11 | 12 | WS_CHANNELS, |
| 12 | 13 | WS_METHODS, |
| 13 | 14 | WebSocketRequest, |
| 14 | WebSocketResponse, | |
| 15 | 15 | WsPush, |
| 16 | 16 | WsResponse, |
| 17 | 17 | } from "@t3tools/contracts"; |
| 18 | 18 | import { NodeHttpServer } from "@effect/platform-node"; |
| 19 | import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect"; | |
| 19 | import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream, Struct } from "effect"; | |
| 20 | 20 | import { WebSocketServer, type WebSocket } from "ws"; |
| 21 | 21 | |
| 22 | 22 | import { createLogger } from "./logger"; |
| ... | ... | @@ -37,7 +37,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati |
| 37 | 37 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 38 | 38 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 39 | 39 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 40 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; | |
| 40 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | |
| 41 | 41 | import { clamp } from "effect/Number"; |
| 42 | 42 | import { Open } from "./open"; |
| 43 | 43 | import { ServerConfig } from "./config"; |
| ... | ... | @@ -133,22 +133,14 @@ function websocketRawToString(raw: unknown): string | null { |
| 133 | 133 | return null; |
| 134 | 134 | } |
| 135 | 135 | |
| 136 | function stripRequestTag<T extends { _tag: string }>(body: T): Omit<T, "_tag"> { | |
| 137 | const { _tag: _ignoredTag, ...rest } = body; | |
| 138 | return rest; | |
| 139 | } | |
| 140 | ||
| 141 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 142 | ||
| 143 | function checkpointRefForThreadTurn(threadId: string, turnCount: number): CheckpointRef { | |
| 144 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | |
| 145 | return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`); | |
| 136 | function stripRequestTag<T extends { _tag: string }>(body: T) { | |
| 137 | return Struct.omit(body, ["_tag"]); | |
| 146 | 138 | } |
| 147 | 139 | |
| 148 | 140 | export type ServerCoreRuntimeServices = |
| 149 | 141 | | OrchestrationEngineService |
| 150 | 142 | | ProjectionSnapshotQuery |
| 151 | | CheckpointStore | |
| 143 | | CheckpointDiffQuery | |
| 152 | 144 | | OrchestrationReactor |
| 153 | 145 | | ProviderService; |
| 154 | 146 | |
| ... | ... | @@ -301,7 +293,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 301 | 293 | |
| 302 | 294 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 303 | 295 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; |
| 304 | const liveCheckpointStore = yield* CheckpointStore; | |
| 296 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | |
| 305 | 297 | const liveProviderService = yield* ProviderService; |
| 306 | 298 | const orchestrationReactor = yield* OrchestrationReactor; |
| 307 | 299 | const { openInEditor } = yield* Open; |
| ... | ... | @@ -338,10 +330,10 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 338 | 330 | if (!existing) { |
| 339 | 331 | const createdAt = new Date().toISOString(); |
| 340 | 332 | const projectName = path.basename(cwd) || "project"; |
| 341 | yield* orchestrationEngine.dispatchUnknownCommand({ | |
| 333 | yield* orchestrationEngine.dispatch({ | |
| 342 | 334 | type: "project.create", |
| 343 | commandId: crypto.randomUUID(), | |
| 344 | projectId: crypto.randomUUID(), | |
| 335 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | |
| 336 | projectId: ProjectId.makeUnsafe(crypto.randomUUID()), | |
| 345 | 337 | title: projectName, |
| 346 | 338 | workspaceRoot: cwd, |
| 347 | 339 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -351,110 +343,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 351 | 343 | } |
| 352 | 344 | |
| 353 | 345 | const routeRequest = Effect.fn(function* (request: WebSocketRequest) { |
| 354 | const computeTurnDiff = Effect.fn(function* (input: { | |
| 355 | readonly threadId: string; | |
| 356 | readonly fromTurnCount: number; | |
| 357 | readonly toTurnCount: number; | |
| 358 | }) { | |
| 359 | if (input.fromTurnCount > input.toTurnCount) { | |
| 360 | throw new Error( | |
| 361 | `Invalid turn diff range for thread '${input.threadId}': from ${input.fromTurnCount} exceeds to ${input.toTurnCount}.`, | |
| 362 | ); | |
| 363 | } | |
| 364 | ||
| 365 | if (input.fromTurnCount === input.toTurnCount) { | |
| 366 | return { | |
| 367 | threadId: input.threadId, | |
| 368 | fromTurnCount: input.fromTurnCount, | |
| 369 | toTurnCount: input.toTurnCount, | |
| 370 | diff: "", | |
| 371 | }; | |
| 372 | } | |
| 373 | ||
| 374 | const snapshot = yield* projectionReadModelQuery.getSnapshot(); | |
| 375 | const thread = snapshot.threads.find((entry) => entry.id === input.threadId); | |
| 376 | if (!thread) { | |
| 377 | throw new Error(`Thread '${input.threadId}' not found.`); | |
| 378 | } | |
| 379 | ||
| 380 | const maxTurnCount = thread.checkpoints.reduce( | |
| 381 | (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), | |
| 382 | 0, | |
| 383 | ); | |
| 384 | if (input.toTurnCount > maxTurnCount) { | |
| 385 | throw new Error( | |
| 386 | `Turn diff range exceeds current turn count for thread '${input.threadId}': requested ${input.toTurnCount}, current ${maxTurnCount}.`, | |
| 387 | ); | |
| 388 | } | |
| 389 | ||
| 390 | const workspaceCwd = | |
| 391 | thread.worktreePath?.trim() || | |
| 392 | snapshot.projects.find((project) => project.id === thread.projectId)?.workspaceRoot?.trim(); | |
| 393 | if (!workspaceCwd) { | |
| 394 | throw new Error( | |
| 395 | `Invariant violation: workspace path missing for thread '${input.threadId}' when computing turn diff.`, | |
| 396 | ); | |
| 397 | } | |
| 398 | ||
| 399 | const fromCheckpointRef = | |
| 400 | input.fromTurnCount === 0 | |
| 401 | ? checkpointRefForThreadTurn(input.threadId, 0) | |
| 402 | : thread.checkpoints.find( | |
| 403 | (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, | |
| 404 | )?.checkpointRef; | |
| 405 | if (!fromCheckpointRef) { | |
| 406 | throw new Error( | |
| 407 | `Checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`, | |
| 408 | ); | |
| 409 | } | |
| 410 | ||
| 411 | const toCheckpointRef = thread.checkpoints.find( | |
| 412 | (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, | |
| 413 | )?.checkpointRef; | |
| 414 | if (!toCheckpointRef) { | |
| 415 | throw new Error( | |
| 416 | `Checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`, | |
| 417 | ); | |
| 418 | } | |
| 419 | ||
| 420 | const [fromExists, toExists] = yield* Effect.all( | |
| 421 | [ | |
| 422 | liveCheckpointStore.hasCheckpointRef({ | |
| 423 | cwd: workspaceCwd, | |
| 424 | checkpointRef: fromCheckpointRef, | |
| 425 | }), | |
| 426 | liveCheckpointStore.hasCheckpointRef({ | |
| 427 | cwd: workspaceCwd, | |
| 428 | checkpointRef: toCheckpointRef, | |
| 429 | }), | |
| 430 | ], | |
| 431 | { concurrency: "unbounded" }, | |
| 432 | ); | |
| 433 | if (!fromExists) { | |
| 434 | throw new Error( | |
| 435 | `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`, | |
| 436 | ); | |
| 437 | } | |
| 438 | if (!toExists) { | |
| 439 | throw new Error( | |
| 440 | `Filesystem checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`, | |
| 441 | ); | |
| 442 | } | |
| 443 | ||
| 444 | const diff = yield* liveCheckpointStore.diffCheckpoints({ | |
| 445 | cwd: workspaceCwd, | |
| 446 | fromCheckpointRef, | |
| 447 | toCheckpointRef, | |
| 448 | fallbackFromToHead: false, | |
| 449 | }); | |
| 450 | return { | |
| 451 | threadId: input.threadId, | |
| 452 | fromTurnCount: input.fromTurnCount, | |
| 453 | toTurnCount: input.toTurnCount, | |
| 454 | diff, | |
| 455 | }; | |
| 456 | }); | |
| 457 | ||
| 458 | 346 | switch (request.body._tag) { |
| 459 | 347 | case ORCHESTRATION_WS_METHODS.getSnapshot: |
| 460 | 348 | return yield* projectionReadModelQuery.getSnapshot(); |
| ... | ... | @@ -466,16 +354,12 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 466 | 354 | |
| 467 | 355 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { |
| 468 | 356 | const body = stripRequestTag(request.body); |
| 469 | return yield* computeTurnDiff(body); | |
| 357 | return yield* checkpointDiffQuery.getTurnDiff(body); | |
| 470 | 358 | } |
| 471 | 359 | |
| 472 | 360 | case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { |
| 473 | 361 | const body = stripRequestTag(request.body); |
| 474 | return yield* computeTurnDiff({ | |
| 475 | threadId: body.threadId, | |
| 476 | fromTurnCount: 0, | |
| 477 | toTurnCount: body.toTurnCount, | |
| 478 | }); | |
| 362 | return yield* checkpointDiffQuery.getFullThreadDiff(body); | |
| 479 | 363 | } |
| 480 | 364 | |
| 481 | 365 | case ORCHESTRATION_WS_METHODS.replayEvents: { |
| ... | ... | @@ -599,46 +483,38 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 599 | 483 | |
| 600 | 484 | const messageText = websocketRawToString(raw); |
| 601 | 485 | if (messageText === null) { |
| 602 | const errorResponse = yield* encodeResponse( | |
| 603 | new WebSocketResponse({ | |
| 604 | id: "unknown", | |
| 605 | error: { message: "Invalid request format: Failed to read message" }, | |
| 606 | }), | |
| 607 | ); | |
| 486 | const errorResponse = yield* encodeResponse({ | |
| 487 | id: "unknown", | |
| 488 | error: { message: "Invalid request format: Failed to read message" }, | |
| 489 | }); | |
| 608 | 490 | ws.send(errorResponse); |
| 609 | 491 | return; |
| 610 | 492 | } |
| 611 | 493 | |
| 612 | 494 | const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText); |
| 613 | 495 | if (request._tag === "Failure") { |
| 614 | const errorResponse = yield* encodeResponse( | |
| 615 | new WebSocketResponse({ | |
| 616 | id: "unknown", | |
| 617 | error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, | |
| 618 | }), | |
| 619 | ); | |
| 496 | const errorResponse = yield* encodeResponse({ | |
| 497 | id: "unknown", | |
| 498 | error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, | |
| 499 | }); | |
| 620 | 500 | ws.send(errorResponse); |
| 621 | 501 | return; |
| 622 | 502 | } |
| 623 | 503 | |
| 624 | 504 | const result = yield* Effect.exit(routeRequest(request.value)); |
| 625 | 505 | if (result._tag === "Failure") { |
| 626 | const errorResponse = yield* encodeResponse( | |
| 627 | new WebSocketResponse({ | |
| 628 | id: request.value.id, | |
| 629 | error: { message: Cause.pretty(result.cause) }, | |
| 630 | }), | |
| 631 | ); | |
| 506 | const errorResponse = yield* encodeResponse({ | |
| 507 | id: request.value.id, | |
| 508 | error: { message: Cause.pretty(result.cause) }, | |
| 509 | }); | |
| 632 | 510 | ws.send(errorResponse); |
| 633 | 511 | return; |
| 634 | 512 | } |
| 635 | 513 | |
| 636 | const response = yield* encodeResponse( | |
| 637 | new WebSocketResponse({ | |
| 638 | id: request.value.id, | |
| 639 | result: result.value, | |
| 640 | }), | |
| 641 | ); | |
| 514 | const response = yield* encodeResponse({ | |
| 515 | id: request.value.id, | |
| 516 | result: result.value, | |
| 517 | }); | |
| 642 | 518 | ws.send(response); |
| 643 | 519 | }); |
| 644 | 520 |
apps/web/src/components/BranchToolbar.logic.test.ts+7-4| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 1 | import { ThreadId, type GitBranch } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; |
| 4 | 4 | |
| ... | ... | @@ -19,8 +19,9 @@ const branches: GitBranch[] = [ |
| 19 | 19 | |
| 20 | 20 | describe("deriveSyncedLocalBranch", () => { |
| 21 | 21 | it("syncs to git current branch in local mode", () => { |
| 22 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 22 | 23 | const result = deriveSyncedLocalBranch({ |
| 23 | activeThreadId: "thread-1", | |
| 24 | activeThreadId: threadId, | |
| 24 | 25 | activeWorktreePath: null, |
| 25 | 26 | envMode: "local", |
| 26 | 27 | activeThreadBranch: "feature/demo", |
| ... | ... | @@ -31,8 +32,9 @@ describe("deriveSyncedLocalBranch", () => { |
| 31 | 32 | }); |
| 32 | 33 | |
| 33 | 34 | it("does not sync when creating a new worktree", () => { |
| 35 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 34 | 36 | const result = deriveSyncedLocalBranch({ |
| 35 | activeThreadId: "thread-1", | |
| 37 | activeThreadId: threadId, | |
| 36 | 38 | activeWorktreePath: null, |
| 37 | 39 | envMode: "worktree", |
| 38 | 40 | activeThreadBranch: "feature/demo", |
| ... | ... | @@ -43,8 +45,9 @@ describe("deriveSyncedLocalBranch", () => { |
| 43 | 45 | }); |
| 44 | 46 | |
| 45 | 47 | it("does not sync when thread already targets a worktree path", () => { |
| 48 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 46 | 49 | const result = deriveSyncedLocalBranch({ |
| 47 | activeThreadId: "thread-1", | |
| 50 | activeThreadId: threadId, | |
| 48 | 51 | activeWorktreePath: "/tmp/repo/worktrees/feature-demo", |
| 49 | 52 | envMode: "local", |
| 50 | 53 | activeThreadBranch: "feature/demo", |
apps/web/src/components/BranchToolbar.logic.ts+2-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | interface DeriveSyncedLocalBranchInput { |
| 4 | activeThreadId: string | undefined; | |
| 4 | activeThreadId: ThreadId | undefined; | |
| 5 | 5 | activeWorktreePath: string | null; |
| 6 | 6 | envMode: "local" | "worktree"; |
| 7 | 7 | activeThreadBranch: string | null; |
apps/web/src/components/BranchToolbar.tsx+14-12| ... | ... | @@ -1,14 +1,14 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | |
| 5 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 6 | import { asThreadId, newCommandId } from "../lib/orchestrationIds"; | |
| 5 | import { newCommandId } from "../lib/orchestrationIds"; | |
| 7 | 6 | import { |
| 8 | 7 | gitBranchesQueryOptions, |
| 9 | 8 | gitCheckoutMutationOptions, |
| 10 | 9 | gitCreateBranchAndCheckoutMutationOptions, |
| 11 | 10 | } from "../lib/gitReactQuery"; |
| 11 | import { readNativeApi } from "../nativeApi"; | |
| 12 | 12 | import { useStore } from "../store"; |
| 13 | 13 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; |
| 14 | 14 | import { Button } from "./ui/button"; |
| ... | ... | @@ -24,7 +24,7 @@ import { |
| 24 | 24 | import { ChevronDownIcon } from "lucide-react"; |
| 25 | 25 | |
| 26 | 26 | interface BranchToolbarProps { |
| 27 | threadId: string; | |
| 27 | threadId: ThreadId; | |
| 28 | 28 | envMode: "local" | "worktree"; |
| 29 | 29 | onEnvModeChange: (mode: "local" | "worktree") => void; |
| 30 | 30 | envLocked: boolean; |
| ... | ... | @@ -39,7 +39,6 @@ export default function BranchToolbar({ |
| 39 | 39 | onComposerFocusRequest, |
| 40 | 40 | }: BranchToolbarProps) { |
| 41 | 41 | const { state, dispatch } = useStore(); |
| 42 | const api = useNativeApi(); | |
| 43 | 42 | const queryClient = useQueryClient(); |
| 44 | 43 | |
| 45 | 44 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| ... | ... | @@ -54,7 +53,7 @@ export default function BranchToolbar({ |
| 54 | 53 | |
| 55 | 54 | // ── Queries ─────────────────────────────────────────────────────────── |
| 56 | 55 | |
| 57 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd)); | |
| 56 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | |
| 58 | 57 | |
| 59 | 58 | const branches = branchesQuery.data?.branches ?? []; |
| 60 | 59 | const branchNames = branches.map((branch) => branch.name); |
| ... | ... | @@ -72,11 +71,11 @@ export default function BranchToolbar({ |
| 72 | 71 | // ── Mutations ───────────────────────────────────────────────────────── |
| 73 | 72 | |
| 74 | 73 | const checkoutMutation = useMutation( |
| 75 | gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 74 | gitCheckoutMutationOptions({ cwd: branchCwd, queryClient }), | |
| 76 | 75 | ); |
| 77 | 76 | |
| 78 | 77 | const createBranchMutation = useMutation( |
| 79 | gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 78 | gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }), | |
| 80 | 79 | ); |
| 81 | 80 | |
| 82 | 81 | // ── Effects ─────────────────────────────────────────────────────────── |
| ... | ... | @@ -92,12 +91,13 @@ export default function BranchToolbar({ |
| 92 | 91 | queryBranches, |
| 93 | 92 | }); |
| 94 | 93 | if (!activeThreadId || !syncedBranch) return; |
| 94 | const api = readNativeApi(); | |
| 95 | 95 | |
| 96 | 96 | if (api) { |
| 97 | 97 | void api.orchestration.dispatchCommand({ |
| 98 | 98 | type: "thread.meta.update", |
| 99 | 99 | commandId: newCommandId(), |
| 100 | threadId: asThreadId(activeThreadId), | |
| 100 | threadId: activeThreadId, | |
| 101 | 101 | branch: syncedBranch, |
| 102 | 102 | worktreePath: null, |
| 103 | 103 | }); |
| ... | ... | @@ -115,7 +115,6 @@ export default function BranchToolbar({ |
| 115 | 115 | queryBranches, |
| 116 | 116 | envMode, |
| 117 | 117 | dispatch, |
| 118 | api, | |
| 119 | 118 | ]); |
| 120 | 119 | |
| 121 | 120 | useEffect(() => { |
| ... | ... | @@ -132,6 +131,7 @@ export default function BranchToolbar({ |
| 132 | 131 | |
| 133 | 132 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { |
| 134 | 133 | if (!activeThreadId) return; |
| 134 | const api = readNativeApi(); | |
| 135 | 135 | // If the effective cwd is about to change, stop the running session so the |
| 136 | 136 | // next message creates a new one with the correct cwd. |
| 137 | 137 | const sessionId = activeThread?.session?.sessionId; |
| ... | ... | @@ -140,7 +140,7 @@ export default function BranchToolbar({ |
| 140 | 140 | .dispatchCommand({ |
| 141 | 141 | type: "thread.session.stop", |
| 142 | 142 | commandId: newCommandId(), |
| 143 | threadId: asThreadId(activeThreadId), | |
| 143 | threadId: activeThreadId, | |
| 144 | 144 | createdAt: new Date().toISOString(), |
| 145 | 145 | }) |
| 146 | 146 | .catch(() => undefined); |
| ... | ... | @@ -149,7 +149,7 @@ export default function BranchToolbar({ |
| 149 | 149 | void api.orchestration.dispatchCommand({ |
| 150 | 150 | type: "thread.meta.update", |
| 151 | 151 | commandId: newCommandId(), |
| 152 | threadId: asThreadId(activeThreadId), | |
| 152 | threadId: activeThreadId, | |
| 153 | 153 | branch, |
| 154 | 154 | worktreePath, |
| 155 | 155 | }); |
| ... | ... | @@ -158,6 +158,7 @@ export default function BranchToolbar({ |
| 158 | 158 | }; |
| 159 | 159 | |
| 160 | 160 | const selectBranch = (branch: GitBranch) => { |
| 161 | const api = readNativeApi(); | |
| 161 | 162 | if (!api || !activeThreadId || !branchCwd) return; |
| 162 | 163 | |
| 163 | 164 | // For new worktree mode, selecting a branch picks the base branch. |
| ... | ... | @@ -198,6 +199,7 @@ export default function BranchToolbar({ |
| 198 | 199 | |
| 199 | 200 | const createBranch = (rawName: string) => { |
| 200 | 201 | const name = rawName.trim(); |
| 202 | const api = readNativeApi(); | |
| 201 | 203 | if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return; |
| 202 | 204 | createBranchMutation.mutate(name, { |
| 203 | 205 | onSuccess: () => { |
apps/web/src/components/ChatMarkdown.tsx+9-5| ... | ... | @@ -3,7 +3,7 @@ import type { Components } from "react-markdown"; |
| 3 | 3 | import ReactMarkdown from "react-markdown"; |
| 4 | 4 | import rehypeHighlight from "rehype-highlight"; |
| 5 | 5 | import remarkGfm from "remark-gfm"; |
| 6 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 6 | import { readNativeApi } from "../nativeApi"; | |
| 7 | 7 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; |
| 8 | 8 | import { preferredTerminalEditor } from "../terminal-links"; |
| 9 | 9 | |
| ... | ... | @@ -13,12 +13,11 @@ interface ChatMarkdownProps { |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { |
| 16 | const api = useNativeApi(); | |
| 17 | 16 | const markdownComponents = useMemo<Components>( |
| 18 | 17 | () => ({ |
| 19 | 18 | a({ node: _node, href, ...props }) { |
| 20 | 19 | const targetPath = resolveMarkdownFileLinkTarget(href, cwd); |
| 21 | if (!targetPath || !api) { | |
| 20 | if (!targetPath) { | |
| 22 | 21 | return <a {...props} href={href} target="_blank" rel="noreferrer" />; |
| 23 | 22 | } |
| 24 | 23 | |
| ... | ... | @@ -29,13 +28,18 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { |
| 29 | 28 | onClick={(event) => { |
| 30 | 29 | event.preventDefault(); |
| 31 | 30 | event.stopPropagation(); |
| 32 | void api.shell.openInEditor(targetPath, preferredTerminalEditor()); | |
| 31 | const api = readNativeApi(); | |
| 32 | if (api) { | |
| 33 | void api.shell.openInEditor(targetPath, preferredTerminalEditor()); | |
| 34 | } else { | |
| 35 | console.warn("Native API not found. Unable to open file in editor."); | |
| 36 | } | |
| 33 | 37 | }} |
| 34 | 38 | /> |
| 35 | 39 | ); |
| 36 | 40 | }, |
| 37 | 41 | }), |
| 38 | [api, cwd], | |
| 42 | [cwd], | |
| 39 | 43 | ); |
| 40 | 44 | |
| 41 | 45 | return ( |
apps/web/src/components/ChatView.tsx+101-98| ... | ... | @@ -1,15 +1,25 @@ |
| 1 | 1 | import { |
| 2 | type ApprovalRequestId, | |
| 3 | DEFAULT_MODEL, | |
| 4 | DEFAULT_REASONING, | |
| 2 | 5 | EDITORS, |
| 3 | 6 | type EditorId, |
| 4 | 7 | type KeybindingCommand, |
| 5 | type NativeApi, | |
| 8 | type MessageId, | |
| 9 | MODEL_OPTIONS, | |
| 10 | type ProjectId, | |
| 6 | 11 | type ProjectEntry, |
| 7 | 12 | type ProjectScript, |
| 8 | 13 | ModelSlug, |
| 9 | 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 10 | 15 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 16 | REASONING_OPTIONS, | |
| 17 | type ReasoningEffort, | |
| 11 | 18 | type ResolvedKeybindingsConfig, |
| 12 | 19 | type ProviderApprovalDecision, |
| 20 | type ThreadId, | |
| 21 | type TurnId, | |
| 22 | resolveModelSlug, | |
| 13 | 23 | } from "@t3tools/contracts"; |
| 14 | 24 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 15 | 25 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| ... | ... | @@ -27,14 +37,6 @@ import { |
| 27 | 37 | detectComposerTrigger, |
| 28 | 38 | replaceTextRange, |
| 29 | 39 | } from "../composer-logic"; |
| 30 | import { | |
| 31 | DEFAULT_MODEL, | |
| 32 | DEFAULT_REASONING, | |
| 33 | MODEL_OPTIONS, | |
| 34 | REASONING_OPTIONS, | |
| 35 | ReasoningEffort, | |
| 36 | resolveModelSlug, | |
| 37 | } from "../model-logic"; | |
| 38 | 40 | import { |
| 39 | 41 | derivePendingApprovals, |
| 40 | 42 | derivePhase, |
| ... | ... | @@ -66,7 +68,6 @@ import { |
| 66 | 68 | } from "../keybindings"; |
| 67 | 69 | import ChatMarkdown from "./ChatMarkdown"; |
| 68 | 70 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 69 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 70 | 71 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 71 | 72 | import { |
| 72 | 73 | BotIcon, |
| ... | ... | @@ -101,13 +102,8 @@ import { |
| 101 | 102 | } from "~/projectScripts"; |
| 102 | 103 | import { Toggle } from "./ui/toggle"; |
| 103 | 104 | import { SidebarTrigger } from "./ui/sidebar"; |
| 104 | import { | |
| 105 | asApprovalRequestId, | |
| 106 | asProjectId, | |
| 107 | asThreadId, | |
| 108 | newCommandId, | |
| 109 | newMessageId, | |
| 110 | } from "~/lib/orchestrationIds"; | |
| 105 | import { newCommandId, newMessageId } from "~/lib/orchestrationIds"; | |
| 106 | import { readNativeApi } from "~/nativeApi"; | |
| 111 | 107 | |
| 112 | 108 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 113 | 109 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -326,7 +322,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 326 | 322 | }); |
| 327 | 323 | |
| 328 | 324 | interface ChatViewProps { |
| 329 | threadId: string; | |
| 325 | threadId: ThreadId; | |
| 330 | 326 | } |
| 331 | 327 | |
| 332 | 328 | export default function ChatView({ threadId }: ChatViewProps) { |
| ... | ... | @@ -336,12 +332,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 336 | 332 | strict: false, |
| 337 | 333 | select: (params) => parseDiffRouteSearch(params), |
| 338 | 334 | }); |
| 339 | const api = useNativeApi(); | |
| 340 | 335 | const { resolvedTheme } = useTheme(); |
| 341 | 336 | const queryClient = useQueryClient(); |
| 342 | const createWorktreeMutation = useMutation( | |
| 343 | gitCreateWorktreeMutationOptions({ api, queryClient }), | |
| 344 | ); | |
| 337 | const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); | |
| 345 | 338 | const [prompt, setPrompt] = useState(""); |
| 346 | 339 | const promptRef = useRef(prompt); |
| 347 | 340 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| ... | ... | @@ -353,7 +346,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 353 | 346 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| 354 | 347 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| 355 | 348 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| 356 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]); | |
| 349 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); | |
| 357 | 350 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 358 | 351 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 359 | 352 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| ... | ... | @@ -429,7 +422,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 429 | 422 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 430 | 423 | useTurnDiffSummaries(activeThread); |
| 431 | 424 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 432 | const byMessageId = new Map<string, TurnDiffSummary>(); | |
| 425 | const byMessageId = new Map<MessageId, TurnDiffSummary>(); | |
| 433 | 426 | for (const summary of turnDiffSummaries) { |
| 434 | 427 | if (!summary.assistantMessageId) continue; |
| 435 | 428 | byMessageId.set(summary.assistantMessageId, summary); |
| ... | ... | @@ -437,7 +430,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 437 | 430 | return byMessageId; |
| 438 | 431 | }, [turnDiffSummaries]); |
| 439 | 432 | const revertTurnCountByUserMessageId = useMemo(() => { |
| 440 | const byUserMessageId = new Map<string, number>(); | |
| 433 | const byUserMessageId = new Map<MessageId, number>(); | |
| 441 | 434 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| 442 | 435 | const entry = timelineEntries[index]; |
| 443 | 436 | if (!entry || entry.kind !== "message" || entry.message.role !== "user") { |
| ... | ... | @@ -536,14 +529,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 536 | 529 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 537 | 530 | ); |
| 538 | 531 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 539 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | |
| 532 | const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); | |
| 540 | 533 | const keybindingsQuery = useQuery({ |
| 541 | ...serverConfigQueryOptions(api), | |
| 534 | ...serverConfigQueryOptions(), | |
| 542 | 535 | select: (config) => config.keybindings, |
| 543 | 536 | }); |
| 544 | 537 | const workspaceEntriesQuery = useQuery( |
| 545 | 538 | projectSearchEntriesQueryOptions({ |
| 546 | api, | |
| 547 | 539 | cwd: gitCwd, |
| 548 | 540 | query: effectivePathQuery, |
| 549 | 541 | enabled: isPathTrigger, |
| ... | ... | @@ -689,6 +681,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 689 | 681 | ); |
| 690 | 682 | const closeTerminal = useCallback( |
| 691 | 683 | (terminalId: string) => { |
| 684 | const api = readNativeApi(); | |
| 692 | 685 | if (!activeThreadId || !api) return; |
| 693 | 686 | const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1; |
| 694 | 687 | const fallbackExitWrite = () => |
| ... | ... | @@ -714,7 +707,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 714 | 707 | }); |
| 715 | 708 | setTerminalFocusRequestId((value) => value + 1); |
| 716 | 709 | }, |
| 717 | [activeThread?.terminalIds.length, activeThreadId, api, dispatch], | |
| 710 | [activeThread?.terminalIds.length, activeThreadId, dispatch], | |
| 718 | 711 | ); |
| 719 | 712 | const runProjectScript = useCallback( |
| 720 | 713 | async ( |
| ... | ... | @@ -727,6 +720,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 727 | 720 | rememberAsLastInvoked?: boolean; |
| 728 | 721 | }, |
| 729 | 722 | ) => { |
| 723 | const api = readNativeApi(); | |
| 730 | 724 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 731 | 725 | if (options?.rememberAsLastInvoked !== false) { |
| 732 | 726 | setLastInvokedScriptByProjectId((current) => { |
| ... | ... | @@ -799,23 +793,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 799 | 793 | }); |
| 800 | 794 | } |
| 801 | 795 | }, |
| 802 | [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd], | |
| 796 | [activeProject, activeThread, activeThreadId, dispatch, gitCwd], | |
| 803 | 797 | ); |
| 804 | 798 | const persistProjectScripts = useCallback( |
| 805 | 799 | async (input: { |
| 806 | projectId: string; | |
| 800 | projectId: ProjectId; | |
| 807 | 801 | projectCwd: string; |
| 808 | 802 | previousScripts: ProjectScript[]; |
| 809 | 803 | nextScripts: ProjectScript[]; |
| 810 | 804 | keybinding?: string | null; |
| 811 | 805 | keybindingCommand: KeybindingCommand; |
| 812 | 806 | }) => { |
| 807 | const api = readNativeApi(); | |
| 813 | 808 | if (!api) return; |
| 814 | 809 | |
| 815 | 810 | await api.orchestration.dispatchCommand({ |
| 816 | 811 | type: "project.meta.update", |
| 817 | 812 | commandId: newCommandId(), |
| 818 | projectId: asProjectId(input.projectId), | |
| 813 | projectId: input.projectId, | |
| 819 | 814 | scripts: input.nextScripts, |
| 820 | 815 | }); |
| 821 | 816 | |
| ... | ... | @@ -836,7 +831,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 836 | 831 | ); |
| 837 | 832 | } |
| 838 | 833 | }, |
| 839 | [api, queryClient], | |
| 834 | [queryClient], | |
| 840 | 835 | ); |
| 841 | 836 | const saveProjectScript = useCallback( |
| 842 | 837 | async (input: NewProjectScriptInput) => { |
| ... | ... | @@ -911,6 +906,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 911 | 906 | if (mode === state.runtimeMode) return; |
| 912 | 907 | dispatch({ type: "SET_RUNTIME_MODE", mode }); |
| 913 | 908 | scheduleComposerFocus(); |
| 909 | const api = readNativeApi(); | |
| 914 | 910 | if (!api) return; |
| 915 | 911 | |
| 916 | 912 | const runningThreadIds = state.threads |
| ... | ... | @@ -927,7 +923,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 927 | 923 | .dispatchCommand({ |
| 928 | 924 | type: "thread.session.stop", |
| 929 | 925 | commandId: newCommandId(), |
| 930 | threadId: asThreadId(threadId), | |
| 926 | threadId, | |
| 931 | 927 | createdAt: new Date().toISOString(), |
| 932 | 928 | }) |
| 933 | 929 | .catch(() => undefined), |
| ... | ... | @@ -1182,7 +1178,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1182 | 1178 | ]); |
| 1183 | 1179 | |
| 1184 | 1180 | const setThreadError = useCallback( |
| 1185 | (threadId: string | null, error: string | null) => { | |
| 1181 | (threadId: ThreadId | null, error: string | null) => { | |
| 1186 | 1182 | if (!threadId) return; |
| 1187 | 1183 | dispatch({ |
| 1188 | 1184 | type: "SET_ERROR", |
| ... | ... | @@ -1298,9 +1294,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1298 | 1294 | |
| 1299 | 1295 | const onRevertToTurnCount = useCallback( |
| 1300 | 1296 | async (turnCount: number) => { |
| 1301 | if (!api || !activeThread || isRevertingCheckpoint) { | |
| 1302 | return; | |
| 1303 | } | |
| 1297 | const api = readNativeApi(); | |
| 1298 | if (!api || !activeThread || isRevertingCheckpoint) return; | |
| 1299 | ||
| 1304 | 1300 | if (phase === "running" || isSending || isConnecting) { |
| 1305 | 1301 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 1306 | 1302 | return; |
| ... | ... | @@ -1322,7 +1318,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1322 | 1318 | await api.orchestration.dispatchCommand({ |
| 1323 | 1319 | type: "thread.checkpoint.revert", |
| 1324 | 1320 | commandId: newCommandId(), |
| 1325 | threadId: asThreadId(activeThread.id), | |
| 1321 | threadId: activeThread.id, | |
| 1326 | 1322 | turnCount, |
| 1327 | 1323 | createdAt: new Date().toISOString(), |
| 1328 | 1324 | }); |
| ... | ... | @@ -1335,11 +1331,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1335 | 1331 | setIsRevertingCheckpoint(false); |
| 1336 | 1332 | } |
| 1337 | 1333 | }, |
| 1338 | [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], | |
| 1334 | [activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], | |
| 1339 | 1335 | ); |
| 1340 | 1336 | |
| 1341 | 1337 | const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => { |
| 1342 | 1338 | e.preventDefault(); |
| 1339 | const api = readNativeApi(); | |
| 1343 | 1340 | if (!api || !activeThread || isSending || isConnecting) return; |
| 1344 | 1341 | const trimmed = prompt.trim(); |
| 1345 | 1342 | if (!trimmed && composerImages.length === 0) return; |
| ... | ... | @@ -1363,7 +1360,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1363 | 1360 | await api.orchestration.dispatchCommand({ |
| 1364 | 1361 | type: "thread.meta.update", |
| 1365 | 1362 | commandId: newCommandId(), |
| 1366 | threadId: asThreadId(activeThread.id), | |
| 1363 | threadId: activeThread.id, | |
| 1367 | 1364 | branch: result.worktree.branch, |
| 1368 | 1365 | worktreePath: result.worktree.path, |
| 1369 | 1366 | }); |
| ... | ... | @@ -1397,7 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1397 | 1394 | await api.orchestration.dispatchCommand({ |
| 1398 | 1395 | type: "thread.meta.update", |
| 1399 | 1396 | commandId: newCommandId(), |
| 1400 | threadId: asThreadId(activeThread.id), | |
| 1397 | threadId: activeThread.id, | |
| 1401 | 1398 | title, |
| 1402 | 1399 | }); |
| 1403 | 1400 | } |
| ... | ... | @@ -1434,7 +1431,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1434 | 1431 | await api.orchestration.dispatchCommand({ |
| 1435 | 1432 | type: "thread.turn.start", |
| 1436 | 1433 | commandId: newCommandId(), |
| 1437 | threadId: asThreadId(activeThread.id), | |
| 1434 | threadId: activeThread.id, | |
| 1438 | 1435 | message: { |
| 1439 | 1436 | messageId: newMessageId(), |
| 1440 | 1437 | role: "user", |
| ... | ... | @@ -1456,17 +1453,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1456 | 1453 | }; |
| 1457 | 1454 | |
| 1458 | 1455 | const onInterrupt = async () => { |
| 1456 | const api = readNativeApi(); | |
| 1459 | 1457 | if (!api || !activeThread) return; |
| 1460 | 1458 | await api.orchestration.dispatchCommand({ |
| 1461 | 1459 | type: "thread.turn.interrupt", |
| 1462 | 1460 | commandId: newCommandId(), |
| 1463 | threadId: asThreadId(activeThread.id), | |
| 1461 | threadId: activeThread.id, | |
| 1464 | 1462 | createdAt: new Date().toISOString(), |
| 1465 | 1463 | }); |
| 1466 | 1464 | }; |
| 1467 | 1465 | |
| 1468 | 1466 | const onRespondToApproval = useCallback( |
| 1469 | async (requestId: string, decision: ProviderApprovalDecision) => { | |
| 1467 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | |
| 1468 | const api = readNativeApi(); | |
| 1470 | 1469 | if (!api || !activeThreadId) return; |
| 1471 | 1470 | |
| 1472 | 1471 | setRespondingRequestIds((existing) => |
| ... | ... | @@ -1476,8 +1475,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1476 | 1475 | await api.orchestration.dispatchCommand({ |
| 1477 | 1476 | type: "thread.approval.respond", |
| 1478 | 1477 | commandId: newCommandId(), |
| 1479 | threadId: asThreadId(activeThreadId), | |
| 1480 | requestId: asApprovalRequestId(requestId), | |
| 1478 | threadId: activeThreadId, | |
| 1479 | requestId, | |
| 1481 | 1480 | decision, |
| 1482 | 1481 | createdAt: new Date().toISOString(), |
| 1483 | 1482 | }); |
| ... | ... | @@ -1491,23 +1490,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1491 | 1490 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 1492 | 1491 | } |
| 1493 | 1492 | }, |
| 1494 | [activeThreadId, api, dispatch], | |
| 1493 | [activeThreadId, dispatch], | |
| 1495 | 1494 | ); |
| 1496 | 1495 | |
| 1497 | 1496 | const onModelSelect = useCallback( |
| 1498 | 1497 | (model: ModelSlug) => { |
| 1498 | const api = readNativeApi(); | |
| 1499 | 1499 | if (!activeThread) return; |
| 1500 | 1500 | if (api) { |
| 1501 | 1501 | void api.orchestration.dispatchCommand({ |
| 1502 | 1502 | type: "thread.meta.update", |
| 1503 | 1503 | commandId: newCommandId(), |
| 1504 | threadId: asThreadId(activeThread.id), | |
| 1504 | threadId: activeThread.id, | |
| 1505 | 1505 | model: resolveModelSlug(model), |
| 1506 | 1506 | }); |
| 1507 | 1507 | } |
| 1508 | 1508 | scheduleComposerFocus(); |
| 1509 | 1509 | }, |
| 1510 | [activeThread, api, scheduleComposerFocus], | |
| 1510 | [activeThread, scheduleComposerFocus], | |
| 1511 | 1511 | ); |
| 1512 | 1512 | const onEffortSelect = useCallback( |
| 1513 | 1513 | (effort: ReasoningEffort) => { |
| ... | ... | @@ -1629,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1629 | 1629 | }); |
| 1630 | 1630 | }, [diffOpen, navigate, threadId]); |
| 1631 | 1631 | const onOpenTurnDiff = useCallback( |
| 1632 | (turnId: string, filePath?: string) => { | |
| 1632 | (turnId: TurnId, filePath?: string) => { | |
| 1633 | 1633 | void navigate({ |
| 1634 | 1634 | to: "/$threadId", |
| 1635 | 1635 | params: { threadId }, |
| ... | ... | @@ -1644,7 +1644,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1644 | 1644 | [navigate, threadId], |
| 1645 | 1645 | ); |
| 1646 | 1646 | const onRevertUserMessage = useCallback( |
| 1647 | (messageId: string) => { | |
| 1647 | (messageId: MessageId) => { | |
| 1648 | 1648 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); |
| 1649 | 1649 | if (typeof targetTurnCount !== "number") { |
| 1650 | 1650 | return; |
| ... | ... | @@ -1698,7 +1698,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1698 | 1698 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null |
| 1699 | 1699 | } |
| 1700 | 1700 | keybindings={keybindings} |
| 1701 | api={api} | |
| 1702 | 1701 | gitCwd={gitCwd} |
| 1703 | 1702 | diffOpen={diffOpen} |
| 1704 | 1703 | onRunProjectScript={(script) => { |
| ... | ... | @@ -1971,35 +1970,39 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1971 | 1970 | /> |
| 1972 | 1971 | )} |
| 1973 | 1972 | |
| 1974 | {activeThread.terminalOpen && api && activeProject && ( | |
| 1975 | <ThreadTerminalDrawer | |
| 1976 | key={activeThread.id} | |
| 1977 | api={api} | |
| 1978 | threadId={activeThread.id} | |
| 1979 | cwd={gitCwd ?? activeProject.cwd} | |
| 1980 | runtimeEnv={threadTerminalRuntimeEnv} | |
| 1981 | height={activeThread.terminalHeight} | |
| 1982 | terminalIds={activeThread.terminalIds} | |
| 1983 | activeTerminalId={activeThread.activeTerminalId} | |
| 1984 | terminalGroups={activeThread.terminalGroups} | |
| 1985 | activeTerminalGroupId={activeThread.activeTerminalGroupId} | |
| 1986 | focusRequestId={terminalFocusRequestId} | |
| 1987 | onSplitTerminal={splitTerminal} | |
| 1988 | onNewTerminal={createNewTerminal} | |
| 1989 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 1990 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 1991 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 1992 | onActiveTerminalChange={activateTerminal} | |
| 1993 | onCloseTerminal={closeTerminal} | |
| 1994 | onHeightChange={(height) => | |
| 1995 | dispatch({ | |
| 1996 | type: "SET_THREAD_TERMINAL_HEIGHT", | |
| 1997 | threadId: activeThread.id, | |
| 1998 | height, | |
| 1999 | }) | |
| 2000 | } | |
| 2001 | /> | |
| 2002 | )} | |
| 1973 | {(() => { | |
| 1974 | if (!activeThread.terminalOpen || !activeProject) { | |
| 1975 | return null; | |
| 1976 | } | |
| 1977 | return ( | |
| 1978 | <ThreadTerminalDrawer | |
| 1979 | key={activeThread.id} | |
| 1980 | threadId={activeThread.id} | |
| 1981 | cwd={gitCwd ?? activeProject.cwd} | |
| 1982 | runtimeEnv={threadTerminalRuntimeEnv} | |
| 1983 | height={activeThread.terminalHeight} | |
| 1984 | terminalIds={activeThread.terminalIds} | |
| 1985 | activeTerminalId={activeThread.activeTerminalId} | |
| 1986 | terminalGroups={activeThread.terminalGroups} | |
| 1987 | activeTerminalGroupId={activeThread.activeTerminalGroupId} | |
| 1988 | focusRequestId={terminalFocusRequestId} | |
| 1989 | onSplitTerminal={splitTerminal} | |
| 1990 | onNewTerminal={createNewTerminal} | |
| 1991 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 1992 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 1993 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 1994 | onActiveTerminalChange={activateTerminal} | |
| 1995 | onCloseTerminal={closeTerminal} | |
| 1996 | onHeightChange={(height) => | |
| 1997 | dispatch({ | |
| 1998 | type: "SET_THREAD_TERMINAL_HEIGHT", | |
| 1999 | threadId: activeThread.id, | |
| 2000 | height, | |
| 2001 | }) | |
| 2002 | } | |
| 2003 | /> | |
| 2004 | ); | |
| 2005 | })()} | |
| 2003 | 2006 | |
| 2004 | 2007 | {expandedImage && ( |
| 2005 | 2008 | <div |
| ... | ... | @@ -2040,13 +2043,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2040 | 2043 | } |
| 2041 | 2044 | |
| 2042 | 2045 | interface ChatHeaderProps { |
| 2043 | activeThreadId: string; | |
| 2046 | activeThreadId: ThreadId; | |
| 2044 | 2047 | activeThreadTitle: string; |
| 2045 | 2048 | activeProjectName: string | undefined; |
| 2046 | 2049 | activeProjectScripts: ProjectScript[] | undefined; |
| 2047 | 2050 | preferredScriptId: string | null; |
| 2048 | 2051 | keybindings: ResolvedKeybindingsConfig; |
| 2049 | api: NativeApi | undefined; | |
| 2050 | 2052 | gitCwd: string | null; |
| 2051 | 2053 | diffOpen: boolean; |
| 2052 | 2054 | onRunProjectScript: (script: ProjectScript) => void; |
| ... | ... | @@ -2062,7 +2064,6 @@ const ChatHeader = memo(function ChatHeader({ |
| 2062 | 2064 | activeProjectScripts, |
| 2063 | 2065 | preferredScriptId, |
| 2064 | 2066 | keybindings, |
| 2065 | api, | |
| 2066 | 2067 | gitCwd, |
| 2067 | 2068 | diffOpen, |
| 2068 | 2069 | onRunProjectScript, |
| ... | ... | @@ -2100,9 +2101,7 @@ const ChatHeader = memo(function ChatHeader({ |
| 2100 | 2101 | {activeProjectName && ( |
| 2101 | 2102 | <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} /> |
| 2102 | 2103 | )} |
| 2103 | {activeProjectName && ( | |
| 2104 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> | |
| 2105 | )} | |
| 2104 | {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />} | |
| 2106 | 2105 | <Toggle |
| 2107 | 2106 | className="shrink-0" |
| 2108 | 2107 | pressed={diffOpen} |
| ... | ... | @@ -2134,8 +2133,11 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st |
| 2134 | 2133 | |
| 2135 | 2134 | interface PendingApprovalsPanelProps { |
| 2136 | 2135 | pendingApprovals: PendingApproval[]; |
| 2137 | respondingRequestIds: string[]; | |
| 2138 | onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>; | |
| 2136 | respondingRequestIds: ApprovalRequestId[]; | |
| 2137 | onRespondToApproval: ( | |
| 2138 | requestId: ApprovalRequestId, | |
| 2139 | decision: ProviderApprovalDecision, | |
| 2140 | ) => Promise<void>; | |
| 2139 | 2141 | } |
| 2140 | 2142 | |
| 2141 | 2143 | const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| ... | ... | @@ -2211,13 +2213,13 @@ interface MessagesTimelineProps { |
| 2211 | 2213 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 2212 | 2214 | completionDividerBeforeEntryId: string | null; |
| 2213 | 2215 | completionSummary: string | null; |
| 2214 | turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>; | |
| 2216 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; | |
| 2215 | 2217 | nowIso: string; |
| 2216 | 2218 | expandedWorkGroups: Record<string, boolean>; |
| 2217 | 2219 | onToggleWorkGroup: (groupId: string) => void; |
| 2218 | onOpenTurnDiff: (turnId: string, filePath?: string) => void; | |
| 2219 | revertTurnCountByUserMessageId: Map<string, number>; | |
| 2220 | onRevertUserMessage: (messageId: string) => void; | |
| 2220 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 2221 | revertTurnCountByUserMessageId: Map<MessageId, number>; | |
| 2222 | onRevertUserMessage: (messageId: MessageId) => void; | |
| 2221 | 2223 | isRevertingCheckpoint: boolean; |
| 2222 | 2224 | onImageExpand: (image: ExpandedImagePreview) => void; |
| 2223 | 2225 | markdownCwd: string | undefined; |
| ... | ... | @@ -2667,7 +2669,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2667 | 2669 | activeThreadId, |
| 2668 | 2670 | }: { |
| 2669 | 2671 | keybindings: ResolvedKeybindingsConfig; |
| 2670 | activeThreadId: string | null; | |
| 2672 | activeThreadId: ThreadId | null; | |
| 2671 | 2673 | }) { |
| 2672 | 2674 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 2673 | 2675 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| ... | ... | @@ -2692,13 +2694,13 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2692 | 2694 | ] satisfies { label: string; Icon: Icon; value: EditorId }[]; |
| 2693 | 2695 | const primaryOption = options.find(({ value }) => value === lastEditor); |
| 2694 | 2696 | |
| 2695 | const api = useNativeApi(); | |
| 2696 | 2697 | const { state } = useStore(); |
| 2697 | 2698 | const activeThread = state.threads.find((t) => t.id === activeThreadId); |
| 2698 | 2699 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 2699 | 2700 | |
| 2700 | 2701 | const openInEditor = useCallback( |
| 2701 | 2702 | (editorId: EditorId | null) => { |
| 2703 | const api = readNativeApi(); | |
| 2702 | 2704 | if (!api || !activeProject) return; |
| 2703 | 2705 | const editor = editorId ?? lastEditor; |
| 2704 | 2706 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; |
| ... | ... | @@ -2706,7 +2708,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2706 | 2708 | localStorage.setItem(LAST_EDITOR_KEY, editor); |
| 2707 | 2709 | setLastEditor(editor); |
| 2708 | 2710 | }, |
| 2709 | [api, activeProject, activeThread, lastEditor, setLastEditor], | |
| 2711 | [activeProject, activeThread, lastEditor, setLastEditor], | |
| 2710 | 2712 | ); |
| 2711 | 2713 | |
| 2712 | 2714 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | ... | @@ -2716,6 +2718,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2716 | 2718 | |
| 2717 | 2719 | useEffect(() => { |
| 2718 | 2720 | const handler = (e: globalThis.KeyboardEvent) => { |
| 2721 | const api = readNativeApi(); | |
| 2719 | 2722 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 2720 | 2723 | if (!api || !activeProject) return; |
| 2721 | 2724 | |
| ... | ... | @@ -2725,7 +2728,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2725 | 2728 | }; |
| 2726 | 2729 | window.addEventListener("keydown", handler); |
| 2727 | 2730 | return () => window.removeEventListener("keydown", handler); |
| 2728 | }, [api, activeProject, activeThread, keybindings, lastEditor]); | |
| 2731 | }, [activeProject, activeThread, keybindings, lastEditor]); | |
| 2729 | 2732 | |
| 2730 | 2733 | return ( |
| 2731 | 2734 | <Group aria-label="Subscription actions"> |
apps/web/src/components/DiffPanel.tsx+8-11| ... | ... | @@ -2,13 +2,13 @@ import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | 3 | import { useQuery } from "@tanstack/react-query"; |
| 4 | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; | |
| 5 | 6 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 6 | 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 7 | 8 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 8 | 9 | import { cn } from "~/lib/utils"; |
| 9 | 10 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 10 | 11 | import { isElectron } from "../env"; |
| 11 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 12 | 12 | import { useTheme } from "../hooks/useTheme"; |
| 13 | 13 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 14 | 14 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | ... | @@ -81,7 +81,6 @@ interface DiffPanelProps { |
| 81 | 81 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 82 | 82 | |
| 83 | 83 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 84 | const api = useNativeApi(); | |
| 85 | 84 | const navigate = useNavigate(); |
| 86 | 85 | const { resolvedTheme } = useTheme(); |
| 87 | 86 | const { state } = useStore(); |
| ... | ... | @@ -90,13 +89,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 90 | 89 | const turnStripRef = useRef<HTMLDivElement>(null); |
| 91 | 90 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); |
| 92 | 91 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); |
| 93 | const params = useParams({ strict: false }); | |
| 94 | const rawSearch = useSearch({ strict: false }); | |
| 95 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | |
| 96 | const diffSearch = useMemo( | |
| 97 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | |
| 98 | [rawSearch], | |
| 99 | ); | |
| 92 | const routeThreadId = useParams({ | |
| 93 | strict: false, | |
| 94 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | |
| 95 | }); | |
| 96 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); | |
| 100 | 97 | const activeThreadId = routeThreadId; |
| 101 | 98 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); |
| 102 | 99 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| ... | ... | @@ -169,7 +166,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 169 | 166 | return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; |
| 170 | 167 | }, [orderedTurnDiffSummaries, selectedTurn]); |
| 171 | 168 | const activeCheckpointDiffQuery = useQuery( |
| 172 | checkpointDiffQueryOptions(api, { | |
| 169 | checkpointDiffQueryOptions({ | |
| 173 | 170 | threadId: activeThreadId, |
| 174 | 171 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 175 | 172 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| ... | ... | @@ -216,7 +213,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 216 | 213 | target?.scrollIntoView({ block: "nearest" }); |
| 217 | 214 | }, [selectedFilePath, renderableFiles]); |
| 218 | 215 | |
| 219 | const selectTurn = (turnId: string) => { | |
| 216 | const selectTurn = (turnId: TurnId) => { | |
| 220 | 217 | if (!activeThread) return; |
| 221 | 218 | void navigate({ |
| 222 | 219 | to: "/$threadId", |
apps/web/src/components/GitActionsControl.tsx+19-17| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts"; | |
| 1 | import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | 4 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| ... | ... | @@ -39,11 +39,11 @@ import { |
| 39 | 39 | invalidateGitQueries, |
| 40 | 40 | } from "~/lib/gitReactQuery"; |
| 41 | 41 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; |
| 42 | import { readNativeApi } from "~/nativeApi"; | |
| 42 | 43 | |
| 43 | 44 | interface GitActionsControlProps { |
| 44 | api: NativeApi | undefined; | |
| 45 | 45 | gitCwd: string | null; |
| 46 | activeThreadId: string | null; | |
| 46 | activeThreadId: ThreadId | null; | |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | function getMenuActionDisabledReason( |
| ... | ... | @@ -136,7 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 136 | 136 | return <InfoIcon className={iconClassName} />; |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitActionsControlProps) { | |
| 139 | export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { | |
| 140 | 140 | const threadToastData = useMemo( |
| 141 | 141 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 142 | 142 | [activeThreadId], |
| ... | ... | @@ -145,11 +145,9 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 145 | 145 | const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null); |
| 146 | 146 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 147 | 147 | |
| 148 | const { data: gitStatus = null, error: gitStatusError } = useQuery( | |
| 149 | gitStatusQueryOptions(api, gitCwd), | |
| 150 | ); | |
| 148 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); | |
| 151 | 149 | |
| 152 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); | |
| 150 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); | |
| 153 | 151 | // Default to true while loading so we don't flash init controls. |
| 154 | 152 | const isRepo = branchList?.isRepo ?? true; |
| 155 | 153 | const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; |
| ... | ... | @@ -163,12 +161,12 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 163 | 161 | |
| 164 | 162 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; |
| 165 | 163 | |
| 166 | const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); | |
| 164 | const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); | |
| 167 | 165 | |
| 168 | 166 | const runImmediateGitActionMutation = useMutation( |
| 169 | gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }), | |
| 167 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), | |
| 170 | 168 | ); |
| 171 | const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient })); | |
| 169 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); | |
| 172 | 170 | |
| 173 | 171 | const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; |
| 174 | 172 | const isDefaultBranch = useMemo(() => { |
| ... | ... | @@ -192,6 +190,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 192 | 190 | |
| 193 | 191 | const maybeConfirmPushToDefaultBranch = useCallback( |
| 194 | 192 | async (action: GitStackedAction): Promise<boolean> => { |
| 193 | const api = readNativeApi(); | |
| 195 | 194 | if (!api) return false; |
| 196 | 195 | if ( |
| 197 | 196 | !requiresDefaultBranchConfirmation(action, isDefaultBranch) || |
| ... | ... | @@ -203,10 +202,11 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 203 | 202 | `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`, |
| 204 | 203 | ); |
| 205 | 204 | }, |
| 206 | [api, gitStatusForActions?.branch, isDefaultBranch], | |
| 205 | [gitStatusForActions?.branch, isDefaultBranch], | |
| 207 | 206 | ); |
| 208 | 207 | |
| 209 | 208 | const openExistingPr = useCallback(async () => { |
| 209 | const api = readNativeApi(); | |
| 210 | 210 | if (!api) { |
| 211 | 211 | toastManager.add({ |
| 212 | 212 | type: "error", |
| ... | ... | @@ -232,8 +232,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 232 | 232 | data: threadToastData, |
| 233 | 233 | }); |
| 234 | 234 | }); |
| 235 | }, [api, gitStatusForActions?.openPr?.url, threadToastData]); | |
| 236 | ||
| 235 | }, [gitStatusForActions?.openPr?.url, threadToastData]); | |
| 237 | 236 | const runGitActionWithToast = useCallback( |
| 238 | 237 | async ({ |
| 239 | 238 | action, |
| ... | ... | @@ -329,8 +328,10 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 329 | 328 | actionProps: { |
| 330 | 329 | children: "Open PR", |
| 331 | 330 | onClick: () => { |
| 331 | const api = readNativeApi(); | |
| 332 | if (!api) return; | |
| 332 | 333 | closeResultToast(); |
| 333 | void api?.shell.openExternal(prUrl); | |
| 334 | void api.shell.openExternal(prUrl); | |
| 334 | 335 | }, |
| 335 | 336 | }, |
| 336 | 337 | } |
| ... | ... | @@ -356,8 +357,8 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 356 | 357 | }); |
| 357 | 358 | } |
| 358 | 359 | }, |
| 360 | ||
| 359 | 361 | [ |
| 360 | api, | |
| 361 | 362 | gitStatusForActions?.branch, |
| 362 | 363 | gitStatusForActions?.hasWorkingTreeChanges, |
| 363 | 364 | gitStatusForActions?.openPr?.url, |
| ... | ... | @@ -447,6 +448,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 447 | 448 | |
| 448 | 449 | const openChangedFileInEditor = useCallback( |
| 449 | 450 | (filePath: string) => { |
| 451 | const api = readNativeApi(); | |
| 450 | 452 | if (!api || !gitCwd) { |
| 451 | 453 | toastManager.add({ |
| 452 | 454 | type: "error", |
| ... | ... | @@ -465,7 +467,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 465 | 467 | }); |
| 466 | 468 | }); |
| 467 | 469 | }, |
| 468 | [api, gitCwd, threadToastData], | |
| 470 | [gitCwd, threadToastData], | |
| 469 | 471 | ); |
| 470 | 472 | |
| 471 | 473 | if (!gitCwd) return null; |
apps/web/src/components/Sidebar.tsx+34-40| ... | ... | @@ -1,25 +1,23 @@ |
| 1 | 1 | import { ChevronRightIcon, TerminalIcon } from "lucide-react"; |
| 2 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | DEFAULT_MODEL, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | type ResolvedKeybindingsConfig, | |
| 8 | } from "@t3tools/contracts"; | |
| 4 | 9 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 5 | 10 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 6 | 11 | import { useAppSettings } from "../appSettings"; |
| 7 | 12 | import { isElectron } from "../env"; |
| 8 | 13 | import { APP_STAGE_LABEL } from "../branding"; |
| 9 | import { DEFAULT_MODEL } from "../model-logic"; | |
| 10 | import { | |
| 11 | asProjectId, | |
| 12 | asThreadId, | |
| 13 | newCommandId, | |
| 14 | newProjectId, | |
| 15 | newThreadId, | |
| 16 | } from "../lib/orchestrationIds"; | |
| 14 | import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds"; | |
| 17 | 15 | import { useStore } from "../store"; |
| 18 | 16 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 19 | 17 | import { type Thread } from "../types"; |
| 20 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 21 | 18 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; |
| 22 | 19 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 20 | import { readNativeApi } from "../nativeApi"; | |
| 23 | 21 | import { toastManager } from "./ui/toast"; |
| 24 | 22 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 25 | 23 | import { |
| ... | ... | @@ -37,6 +35,7 @@ import { |
| 37 | 35 | SidebarTrigger, |
| 38 | 36 | } from "./ui/sidebar"; |
| 39 | 37 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 38 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | |
| 40 | 39 | |
| 41 | 40 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 42 | 41 | |
| ... | ... | @@ -143,33 +142,33 @@ function T3Wordmark() { |
| 143 | 142 | |
| 144 | 143 | export default function Sidebar() { |
| 145 | 144 | const { state, dispatch } = useStore(); |
| 146 | const api = useNativeApi(); | |
| 147 | 145 | const navigate = useNavigate(); |
| 148 | 146 | const { settings: appSettings } = useAppSettings(); |
| 149 | const params = useParams({ strict: false }); | |
| 150 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | |
| 147 | const routeThreadId = useParams({ | |
| 148 | strict: false, | |
| 149 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | |
| 150 | }); | |
| 151 | 151 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ |
| 152 | ...serverConfigQueryOptions(api), | |
| 152 | ...serverConfigQueryOptions(), | |
| 153 | 153 | select: (config) => config.keybindings, |
| 154 | 154 | }); |
| 155 | 155 | const queryClient = useQueryClient(); |
| 156 | const removeWorktreeMutation = useMutation( | |
| 157 | gitRemoveWorktreeMutationOptions({ api, queryClient }), | |
| 158 | ); | |
| 156 | const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); | |
| 159 | 157 | const [addingProject, setAddingProject] = useState(false); |
| 160 | 158 | const [newCwd, setNewCwd] = useState(""); |
| 161 | 159 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 162 | 160 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 163 | const pendingApprovalByThreadId = useMemo(() => new Map<string, boolean>(), []); | |
| 161 | const pendingApprovalByThreadId = useMemo(() => new Map<ThreadId, boolean>(), []); | |
| 164 | 162 | |
| 165 | 163 | const handleNewThread = useCallback( |
| 166 | 164 | ( |
| 167 | projectId: string, | |
| 165 | projectId: ProjectId, | |
| 168 | 166 | options?: { |
| 169 | 167 | branch?: string | null; |
| 170 | 168 | worktreePath?: string | null; |
| 171 | 169 | }, |
| 172 | 170 | ): Promise<void> => { |
| 171 | const api = readNativeApi(); | |
| 173 | 172 | if (!api) return Promise.resolve(); |
| 174 | 173 | const threadId = newThreadId(); |
| 175 | 174 | const createdAt = new Date().toISOString(); |
| ... | ... | @@ -180,7 +179,7 @@ export default function Sidebar() { |
| 180 | 179 | type: "thread.create", |
| 181 | 180 | commandId: newCommandId(), |
| 182 | 181 | threadId, |
| 183 | projectId: asProjectId(projectId), | |
| 182 | projectId, | |
| 184 | 183 | title: "New thread", |
| 185 | 184 | model, |
| 186 | 185 | branch: options?.branch ?? null, |
| ... | ... | @@ -202,11 +201,11 @@ export default function Sidebar() { |
| 202 | 201 | }); |
| 203 | 202 | })(); |
| 204 | 203 | }, |
| 205 | [api, dispatch, navigate, state.projects], | |
| 204 | [dispatch, navigate, state.projects], | |
| 206 | 205 | ); |
| 207 | 206 | |
| 208 | 207 | const focusMostRecentThreadForProject = useCallback( |
| 209 | (projectId: string) => { | |
| 208 | (projectId: ProjectId) => { | |
| 210 | 209 | const latestThread = state.threads |
| 211 | 210 | .filter((thread) => thread.projectId === projectId) |
| 212 | 211 | .toSorted((a, b) => { |
| ... | ... | @@ -228,6 +227,7 @@ export default function Sidebar() { |
| 228 | 227 | async (rawCwd: string) => { |
| 229 | 228 | const cwd = rawCwd.trim(); |
| 230 | 229 | if (!cwd || isAddingProject) return; |
| 230 | const api = readNativeApi(); | |
| 231 | 231 | |
| 232 | 232 | setIsAddingProject(true); |
| 233 | 233 | try { |
| ... | ... | @@ -240,7 +240,7 @@ export default function Sidebar() { |
| 240 | 240 | |
| 241 | 241 | const projectId = newProjectId(); |
| 242 | 242 | const createdAt = new Date().toISOString(); |
| 243 | const title = cwd.split(/[/\\]/).filter(Boolean).at(-1) ?? cwd; | |
| 243 | const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; | |
| 244 | 244 | await api.orchestration.dispatchCommand({ |
| 245 | 245 | type: "project.create", |
| 246 | 246 | commandId: newCommandId(), |
| ... | ... | @@ -257,14 +257,7 @@ export default function Sidebar() { |
| 257 | 257 | setAddingProject(false); |
| 258 | 258 | } |
| 259 | 259 | }, |
| 260 | [ | |
| 261 | api, | |
| 262 | focusMostRecentThreadForProject, | |
| 263 | handleNewThread, | |
| 264 | isAddingProject, | |
| 265 | state.projects, | |
| 266 | state.threads, | |
| 267 | ], | |
| 260 | [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects], | |
| 268 | 261 | ); |
| 269 | 262 | |
| 270 | 263 | const handleAddProject = () => { |
| ... | ... | @@ -272,6 +265,7 @@ export default function Sidebar() { |
| 272 | 265 | }; |
| 273 | 266 | |
| 274 | 267 | const handlePickFolder = async () => { |
| 268 | const api = readNativeApi(); | |
| 275 | 269 | if (!api || isPickingFolder) return; |
| 276 | 270 | setIsPickingFolder(true); |
| 277 | 271 | try { |
| ... | ... | @@ -284,7 +278,8 @@ export default function Sidebar() { |
| 284 | 278 | }; |
| 285 | 279 | |
| 286 | 280 | const handleThreadContextMenu = useCallback( |
| 287 | async (threadId: string, position: { x: number; y: number }) => { | |
| 281 | async (threadId: ThreadId, position: { x: number; y: number }) => { | |
| 282 | const api = readNativeApi(); | |
| 288 | 283 | if (!api) return; |
| 289 | 284 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); |
| 290 | 285 | if (clicked !== "delete") return; |
| ... | ... | @@ -324,7 +319,7 @@ export default function Sidebar() { |
| 324 | 319 | .dispatchCommand({ |
| 325 | 320 | type: "thread.session.stop", |
| 326 | 321 | commandId: newCommandId(), |
| 327 | threadId: asThreadId(threadId), | |
| 322 | threadId, | |
| 328 | 323 | createdAt: new Date().toISOString(), |
| 329 | 324 | }) |
| 330 | 325 | .catch(() => undefined); |
| ... | ... | @@ -344,7 +339,7 @@ export default function Sidebar() { |
| 344 | 339 | await api.orchestration.dispatchCommand({ |
| 345 | 340 | type: "thread.delete", |
| 346 | 341 | commandId: newCommandId(), |
| 347 | threadId: asThreadId(threadId), | |
| 342 | threadId, | |
| 348 | 343 | }); |
| 349 | 344 | if (shouldNavigateToFallback) { |
| 350 | 345 | if (fallbackThreadId) { |
| ... | ... | @@ -384,9 +379,7 @@ export default function Sidebar() { |
| 384 | 379 | } |
| 385 | 380 | }, |
| 386 | 381 | [ |
| 387 | api, | |
| 388 | 382 | appSettings.confirmThreadDelete, |
| 389 | dispatch, | |
| 390 | 383 | navigate, |
| 391 | 384 | removeWorktreeMutation, |
| 392 | 385 | routeThreadId, |
| ... | ... | @@ -396,7 +389,8 @@ export default function Sidebar() { |
| 396 | 389 | ); |
| 397 | 390 | |
| 398 | 391 | const handleProjectContextMenu = useCallback( |
| 399 | async (projectId: string, position: { x: number; y: number }) => { | |
| 392 | async (projectId: ProjectId, position: { x: number; y: number }) => { | |
| 393 | const api = readNativeApi(); | |
| 400 | 394 | if (!api) return; |
| 401 | 395 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); |
| 402 | 396 | if (clicked !== "delete") return; |
| ... | ... | @@ -423,7 +417,7 @@ export default function Sidebar() { |
| 423 | 417 | await api.orchestration.dispatchCommand({ |
| 424 | 418 | type: "project.delete", |
| 425 | 419 | commandId: newCommandId(), |
| 426 | projectId: asProjectId(projectId), | |
| 420 | projectId, | |
| 427 | 421 | }); |
| 428 | 422 | } catch (error) { |
| 429 | 423 | const message = error instanceof Error ? error.message : "Unknown error deleting project."; |
| ... | ... | @@ -435,7 +429,7 @@ export default function Sidebar() { |
| 435 | 429 | }); |
| 436 | 430 | } |
| 437 | 431 | }, |
| 438 | [api, state.projects, state.threads], | |
| 432 | [state.projects, state.threads], | |
| 439 | 433 | ); |
| 440 | 434 | |
| 441 | 435 | useEffect(() => { |
| ... | ... | @@ -687,7 +681,7 @@ export default function Sidebar() { |
| 687 | 681 | if (event.key === "Escape") setAddingProject(false); |
| 688 | 682 | }} |
| 689 | 683 | /> |
| 690 | {isElectron && api && ( | |
| 684 | {isElectron && ( | |
| 691 | 685 | <button |
| 692 | 686 | type="button" |
| 693 | 687 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" |
apps/web/src/components/ThreadTerminalDrawer.tsx+14-20| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; |
| 3 | import { type NativeApi } from "@t3tools/contracts"; | |
| 3 | import { type ThreadId } from "@t3tools/contracts"; | |
| 4 | 4 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 5 | 5 | import { |
| 6 | 6 | type PointerEvent as ReactPointerEvent, |
| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | MAX_THREAD_TERMINAL_COUNT, |
| 26 | 26 | type ThreadTerminalGroup, |
| 27 | 27 | } from "../types"; |
| 28 | import { readNativeApi } from "~/nativeApi"; | |
| 28 | 29 | |
| 29 | 30 | const MIN_DRAWER_HEIGHT = 180; |
| 30 | 31 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | ... | @@ -107,8 +108,7 @@ function terminalThemeFromApp(): ITheme { |
| 107 | 108 | } |
| 108 | 109 | |
| 109 | 110 | interface TerminalViewportProps { |
| 110 | api: NativeApi; | |
| 111 | threadId: string; | |
| 111 | threadId: ThreadId; | |
| 112 | 112 | terminalId: string; |
| 113 | 113 | cwd: string; |
| 114 | 114 | runtimeEnv?: Record<string, string>; |
| ... | ... | @@ -119,7 +119,6 @@ interface TerminalViewportProps { |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | 121 | function TerminalViewport({ |
| 122 | api, | |
| 123 | 122 | threadId, |
| 124 | 123 | terminalId, |
| 125 | 124 | cwd, |
| ... | ... | @@ -155,6 +154,9 @@ function TerminalViewport({ |
| 155 | 154 | terminalRef.current = terminal; |
| 156 | 155 | fitAddonRef.current = fitAddon; |
| 157 | 156 | |
| 157 | const api = readNativeApi(); | |
| 158 | if (!api) return; | |
| 159 | ||
| 158 | 160 | const sendTerminalInput = async (data: string, fallbackError: string) => { |
| 159 | 161 | const activeTerminal = terminalRef.current; |
| 160 | 162 | if (!activeTerminal) return; |
| ... | ... | @@ -210,14 +212,10 @@ function TerminalViewport({ |
| 210 | 212 | end: { x: match.end, y: bufferLineNumber }, |
| 211 | 213 | }, |
| 212 | 214 | activate: (event: MouseEvent) => { |
| 213 | if (!isTerminalLinkActivation(event)) { | |
| 214 | return; | |
| 215 | } | |
| 215 | if (!isTerminalLinkActivation(event)) return; | |
| 216 | 216 | |
| 217 | 217 | const latestTerminal = terminalRef.current; |
| 218 | if (!latestTerminal) { | |
| 219 | return; | |
| 220 | } | |
| 218 | if (!latestTerminal) return; | |
| 221 | 219 | |
| 222 | 220 | if (match.kind === "url") { |
| 223 | 221 | void api.shell.openExternal(match.text).catch((error) => { |
| ... | ... | @@ -297,7 +295,7 @@ function TerminalViewport({ |
| 297 | 295 | } |
| 298 | 296 | }; |
| 299 | 297 | |
| 300 | const unsubscribe = api.terminal.onEvent((event) => { | |
| 298 | const unsubscribe = api?.terminal.onEvent((event) => { | |
| 301 | 299 | if (event.threadId !== threadId || event.terminalId !== terminalId) return; |
| 302 | 300 | const activeTerminal = terminalRef.current; |
| 303 | 301 | if (!activeTerminal) return; |
| ... | ... | @@ -372,7 +370,7 @@ function TerminalViewport({ |
| 372 | 370 | fitAddonRef.current = null; |
| 373 | 371 | terminal.dispose(); |
| 374 | 372 | }; |
| 375 | }, [api, cwd, runtimeEnv, terminalId, threadId]); | |
| 373 | }, [cwd, runtimeEnv, terminalId, threadId]); | |
| 376 | 374 | |
| 377 | 375 | useEffect(() => { |
| 378 | 376 | if (!autoFocus) return; |
| ... | ... | @@ -387,9 +385,10 @@ function TerminalViewport({ |
| 387 | 385 | }, [autoFocus, focusRequestId]); |
| 388 | 386 | |
| 389 | 387 | useEffect(() => { |
| 388 | const api = readNativeApi(); | |
| 390 | 389 | const terminal = terminalRef.current; |
| 391 | 390 | const fitAddon = fitAddonRef.current; |
| 392 | if (!terminal || !fitAddon) return; | |
| 391 | if (!api || !terminal || !fitAddon) return; | |
| 393 | 392 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; |
| 394 | 393 | const frame = window.requestAnimationFrame(() => { |
| 395 | 394 | fitAddon.fit(); |
| ... | ... | @@ -408,14 +407,12 @@ function TerminalViewport({ |
| 408 | 407 | return () => { |
| 409 | 408 | window.cancelAnimationFrame(frame); |
| 410 | 409 | }; |
| 411 | }, [api, drawerHeight, resizeEpoch, terminalId, threadId]); | |
| 412 | ||
| 410 | }, [drawerHeight, resizeEpoch, terminalId, threadId]); | |
| 413 | 411 | return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />; |
| 414 | 412 | } |
| 415 | 413 | |
| 416 | 414 | interface ThreadTerminalDrawerProps { |
| 417 | api: NativeApi; | |
| 418 | threadId: string; | |
| 415 | threadId: ThreadId; | |
| 419 | 416 | cwd: string; |
| 420 | 417 | runtimeEnv?: Record<string, string>; |
| 421 | 418 | height: number; |
| ... | ... | @@ -464,7 +461,6 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA |
| 464 | 461 | } |
| 465 | 462 | |
| 466 | 463 | export default function ThreadTerminalDrawer({ |
| 467 | api, | |
| 468 | 464 | threadId, |
| 469 | 465 | cwd, |
| 470 | 466 | runtimeEnv, |
| ... | ... | @@ -783,7 +779,6 @@ export default function ThreadTerminalDrawer({ |
| 783 | 779 | > |
| 784 | 780 | <div className="h-full p-1"> |
| 785 | 781 | <TerminalViewport |
| 786 | api={api} | |
| 787 | 782 | threadId={threadId} |
| 788 | 783 | terminalId={terminalId} |
| 789 | 784 | cwd={cwd} |
| ... | ... | @@ -801,7 +796,6 @@ export default function ThreadTerminalDrawer({ |
| 801 | 796 | <div className="h-full p-1"> |
| 802 | 797 | <TerminalViewport |
| 803 | 798 | key={resolvedActiveTerminalId} |
| 804 | api={api} | |
| 805 | 799 | threadId={threadId} |
| 806 | 800 | terminalId={resolvedActiveTerminalId} |
| 807 | 801 | cwd={cwd} |
apps/web/src/components/ui/command.tsx+4-1| ... | ... | @@ -133,7 +133,10 @@ function CommandEmpty({ className, ...props }: React.ComponentProps<typeof Autoc |
| 133 | 133 | function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { |
| 134 | 134 | return ( |
| 135 | 135 | <div |
| 136 | className="-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2" | |
| 136 | className={cn( | |
| 137 | "-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2", | |
| 138 | className, | |
| 139 | )} | |
| 137 | 140 | {...props} |
| 138 | 141 | /> |
| 139 | 142 | ); |
apps/web/src/components/ui/sheet.tsx+1| ... | ... | @@ -49,6 +49,7 @@ function SheetViewport({ |
| 49 | 49 | side === "left" && "flex justify-start", |
| 50 | 50 | side === "right" && "flex justify-end", |
| 51 | 51 | variant === "inset" && "sm:p-4", |
| 52 | className, | |
| 52 | 53 | )} |
| 53 | 54 | data-slot="sheet-viewport" |
| 54 | 55 | {...props} |
apps/web/src/components/ui/toast.tsx+6-4| ... | ... | @@ -3,6 +3,7 @@ |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | 4 | import { useEffect } from "react"; |
| 5 | 5 | import { useParams } from "@tanstack/react-router"; |
| 6 | import { ThreadId } from "@t3tools/contracts"; | |
| 6 | 7 | import { |
| 7 | 8 | CircleAlertIcon, |
| 8 | 9 | CircleCheckIcon, |
| ... | ... | @@ -16,7 +17,7 @@ import { buttonVariants } from "~/components/ui/button"; |
| 16 | 17 | import { shouldHideCollapsedToastContent } from "./toast.logic"; |
| 17 | 18 | |
| 18 | 19 | type ThreadToastData = { |
| 19 | threadId?: string | null; | |
| 20 | threadId?: ThreadId | null; | |
| 20 | 21 | tooltipStyle?: boolean; |
| 21 | 22 | dismissAfterVisibleMs?: number; |
| 22 | 23 | }; |
| ... | ... | @@ -48,17 +49,18 @@ interface ToastProviderProps extends Toast.Provider.Props { |
| 48 | 49 | |
| 49 | 50 | function shouldRenderForActiveThread( |
| 50 | 51 | data: ThreadToastData | undefined, |
| 51 | activeThreadId: string | null, | |
| 52 | activeThreadId: ThreadId | null, | |
| 52 | 53 | ): boolean { |
| 53 | 54 | const toastThreadId = data?.threadId; |
| 54 | 55 | if (!toastThreadId) return true; |
| 55 | 56 | return toastThreadId === activeThreadId; |
| 56 | 57 | } |
| 57 | 58 | |
| 58 | function useActiveThreadIdFromRoute(): string | null { | |
| 59 | function useActiveThreadIdFromRoute(): ThreadId | null { | |
| 59 | 60 | return useParams({ |
| 60 | 61 | strict: false, |
| 61 | select: (params) => params.threadId ?? null, | |
| 62 | select: (params) => | |
| 63 | typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null, | |
| 62 | 64 | }); |
| 63 | 65 | } |
| 64 | 66 |
apps/web/src/diffRouteSearch.ts+5-2| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | import { TurnId } from "@t3tools/contracts"; | |
| 2 | ||
| 1 | 3 | export interface DiffRouteSearch { |
| 2 | 4 | diff?: "1"; |
| 3 | diffTurnId?: string; | |
| 5 | diffTurnId?: TurnId; | |
| 4 | 6 | diffFilePath?: string; |
| 5 | 7 | } |
| 6 | 8 | |
| ... | ... | @@ -25,7 +27,8 @@ export function stripDiffSearchParams<T extends Record<string, unknown>>( |
| 25 | 27 | |
| 26 | 28 | export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch { |
| 27 | 29 | const diff = isDiffOpenValue(search.diff) ? "1" : undefined; |
| 28 | const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined; | |
| 30 | const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; | |
| 31 | const diffTurnId = diffTurnIdRaw ? TurnId.makeUnsafe(diffTurnIdRaw) : undefined; | |
| 29 | 32 | const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; |
| 30 | 33 | |
| 31 | 34 | return { |
apps/web/src/historyBootstrap.test.ts+10-7| ... | ... | @@ -1,20 +1,23 @@ |
| 1 | import { MessageId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | |
| 3 | 4 | import { buildBootstrapInput } from "./historyBootstrap"; |
| 4 | 5 | |
| 6 | const messageId = (value: string) => MessageId.makeUnsafe(value); | |
| 7 | ||
| 5 | 8 | describe("buildBootstrapInput", () => { |
| 6 | 9 | it("includes full transcript when under budget", () => { |
| 7 | 10 | const result = buildBootstrapInput( |
| 8 | 11 | [ |
| 9 | 12 | { |
| 10 | id: "u-1", | |
| 13 | id: messageId("u-1"), | |
| 11 | 14 | role: "user", |
| 12 | 15 | text: "hello", |
| 13 | 16 | createdAt: "2026-02-09T00:00:00.000Z", |
| 14 | 17 | streaming: false, |
| 15 | 18 | }, |
| 16 | 19 | { |
| 17 | id: "a-1", | |
| 20 | id: messageId("a-1"), | |
| 18 | 21 | role: "assistant", |
| 19 | 22 | text: "world", |
| 20 | 23 | createdAt: "2026-02-09T00:00:01.000Z", |
| ... | ... | @@ -38,21 +41,21 @@ describe("buildBootstrapInput", () => { |
| 38 | 41 | const result = buildBootstrapInput( |
| 39 | 42 | [ |
| 40 | 43 | { |
| 41 | id: "u-1", | |
| 44 | id: messageId("u-1"), | |
| 42 | 45 | role: "user", |
| 43 | 46 | text: "first question with details", |
| 44 | 47 | createdAt: "2026-02-09T00:00:00.000Z", |
| 45 | 48 | streaming: false, |
| 46 | 49 | }, |
| 47 | 50 | { |
| 48 | id: "a-1", | |
| 51 | id: messageId("a-1"), | |
| 49 | 52 | role: "assistant", |
| 50 | 53 | text: "first answer with details", |
| 51 | 54 | createdAt: "2026-02-09T00:00:01.000Z", |
| 52 | 55 | streaming: false, |
| 53 | 56 | }, |
| 54 | 57 | { |
| 55 | id: "u-2", | |
| 58 | id: messageId("u-2"), | |
| 56 | 59 | role: "user", |
| 57 | 60 | text: "second question with details", |
| 58 | 61 | createdAt: "2026-02-09T00:00:02.000Z", |
| ... | ... | @@ -75,7 +78,7 @@ describe("buildBootstrapInput", () => { |
| 75 | 78 | const result = buildBootstrapInput( |
| 76 | 79 | [ |
| 77 | 80 | { |
| 78 | id: "u-1", | |
| 81 | id: messageId("u-1"), | |
| 79 | 82 | role: "user", |
| 80 | 83 | text: "old context", |
| 81 | 84 | createdAt: "2026-02-09T00:00:00.000Z", |
| ... | ... | @@ -96,7 +99,7 @@ describe("buildBootstrapInput", () => { |
| 96 | 99 | const result = buildBootstrapInput( |
| 97 | 100 | [ |
| 98 | 101 | { |
| 99 | id: "u-image", | |
| 102 | id: messageId("u-image"), | |
| 100 | 103 | role: "user", |
| 101 | 104 | text: "", |
| 102 | 105 | attachments: [ |
apps/web/src/hooks/useNativeApi.ts deleted-6| ... | ... | @@ -1,6 +0,0 @@ |
| 1 | import { useMemo } from "react"; | |
| 2 | import { readNativeApi } from "../nativeApi"; | |
| 3 | ||
| 4 | export function useNativeApi() { | |
| 5 | return useMemo(() => readNativeApi(), []); | |
| 6 | } |
apps/web/src/lib/gitReactQuery.ts+36-53| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { GitStackedAction, NativeApi } from "@t3tools/contracts"; | |
| 1 | import type { GitStackedAction } from "@t3tools/contracts"; | |
| 2 | 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; |
| 3 | import { ensureNativeApi } from "~/nativeApi"; | |
| 3 | 4 | |
| 4 | 5 | const GIT_STATUS_STALE_TIME_MS = 5_000; |
| 5 | 6 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; |
| ... | ... | @@ -16,16 +17,15 @@ export function invalidateGitQueries(queryClient: QueryClient) { |
| 16 | 17 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); |
| 17 | 18 | } |
| 18 | 19 | |
| 19 | export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) { | |
| 20 | export function gitStatusQueryOptions(cwd: string | null) { | |
| 20 | 21 | return queryOptions({ |
| 21 | 22 | queryKey: gitQueryKeys.status(cwd), |
| 22 | 23 | queryFn: async () => { |
| 23 | if (!api || !cwd) { | |
| 24 | throw new Error("Git status is unavailable."); | |
| 25 | } | |
| 24 | const api = ensureNativeApi(); | |
| 25 | if (!cwd) throw new Error("Git status is unavailable."); | |
| 26 | 26 | return api.git.status({ cwd }); |
| 27 | 27 | }, |
| 28 | enabled: !!api && !!cwd, | |
| 28 | enabled: cwd !== null, | |
| 29 | 29 | staleTime: GIT_STATUS_STALE_TIME_MS, |
| 30 | 30 | refetchOnWindowFocus: "always", |
| 31 | 31 | refetchOnReconnect: "always", |
| ... | ... | @@ -33,16 +33,15 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | |
| 33 | 33 | }); |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) { | |
| 36 | export function gitBranchesQueryOptions(cwd: string | null) { | |
| 37 | 37 | return queryOptions({ |
| 38 | 38 | queryKey: gitQueryKeys.branches(cwd), |
| 39 | 39 | queryFn: async () => { |
| 40 | if (!api || !cwd) { | |
| 41 | throw new Error("Git branches are unavailable."); | |
| 42 | } | |
| 40 | const api = ensureNativeApi(); | |
| 41 | if (!cwd) throw new Error("Git branches are unavailable."); | |
| 43 | 42 | return api.git.listBranches({ cwd }); |
| 44 | 43 | }, |
| 45 | enabled: !!api && !!cwd, | |
| 44 | enabled: cwd !== null, | |
| 46 | 45 | staleTime: GIT_BRANCHES_STALE_TIME_MS, |
| 47 | 46 | refetchOnWindowFocus: true, |
| 48 | 47 | refetchOnReconnect: true, |
| ... | ... | @@ -50,15 +49,12 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string |
| 50 | 49 | }); |
| 51 | 50 | } |
| 52 | 51 | |
| 53 | export function gitInitMutationOptions(input: { | |
| 54 | api: NativeApi | undefined; | |
| 55 | cwd: string | null; | |
| 56 | queryClient: QueryClient; | |
| 57 | }) { | |
| 52 | export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | |
| 58 | 53 | return mutationOptions({ |
| 59 | 54 | mutationFn: async () => { |
| 60 | if (!input.api || !input.cwd) throw new Error("Git init is unavailable."); | |
| 61 | return input.api.git.init({ cwd: input.cwd }); | |
| 55 | const api = ensureNativeApi(); | |
| 56 | if (!input.cwd) throw new Error("Git init is unavailable."); | |
| 57 | return api.git.init({ cwd: input.cwd }); | |
| 62 | 58 | }, |
| 63 | 59 | onSuccess: async () => { |
| 64 | 60 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -67,14 +63,14 @@ export function gitInitMutationOptions(input: { |
| 67 | 63 | } |
| 68 | 64 | |
| 69 | 65 | export function gitCheckoutMutationOptions(input: { |
| 70 | api: NativeApi | undefined; | |
| 71 | 66 | cwd: string | null; |
| 72 | 67 | queryClient: QueryClient; |
| 73 | 68 | }) { |
| 74 | 69 | return mutationOptions({ |
| 75 | 70 | mutationFn: async (branch: string) => { |
| 76 | if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable."); | |
| 77 | return input.api.git.checkout({ cwd: input.cwd, branch }); | |
| 71 | const api = ensureNativeApi(); | |
| 72 | if (!input.cwd) throw new Error("Git checkout is unavailable."); | |
| 73 | return api.git.checkout({ cwd: input.cwd, branch }); | |
| 78 | 74 | }, |
| 79 | 75 | onSuccess: async () => { |
| 80 | 76 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -83,15 +79,15 @@ export function gitCheckoutMutationOptions(input: { |
| 83 | 79 | } |
| 84 | 80 | |
| 85 | 81 | export function gitCreateBranchAndCheckoutMutationOptions(input: { |
| 86 | api: NativeApi | undefined; | |
| 87 | 82 | cwd: string | null; |
| 88 | 83 | queryClient: QueryClient; |
| 89 | 84 | }) { |
| 90 | 85 | return mutationOptions({ |
| 91 | 86 | mutationFn: async (branch: string) => { |
| 92 | if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable."); | |
| 93 | await input.api.git.createBranch({ cwd: input.cwd, branch }); | |
| 94 | return input.api.git.checkout({ cwd: input.cwd, branch }); | |
| 87 | const api = ensureNativeApi(); | |
| 88 | if (!input.cwd) throw new Error("Git branch creation is unavailable."); | |
| 89 | await api.git.createBranch({ cwd: input.cwd, branch }); | |
| 90 | return api.git.checkout({ cwd: input.cwd, branch }); | |
| 95 | 91 | }, |
| 96 | 92 | onSuccess: async () => { |
| 97 | 93 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -100,7 +96,6 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: { |
| 100 | 96 | } |
| 101 | 97 | |
| 102 | 98 | export function gitRunStackedActionMutationOptions(input: { |
| 103 | api: NativeApi | undefined; | |
| 104 | 99 | cwd: string | null; |
| 105 | 100 | queryClient: QueryClient; |
| 106 | 101 | }) { |
| ... | ... | @@ -112,10 +107,9 @@ export function gitRunStackedActionMutationOptions(input: { |
| 112 | 107 | action: GitStackedAction; |
| 113 | 108 | commitMessage?: string; |
| 114 | 109 | }) => { |
| 115 | if (!input.api || !input.cwd) { | |
| 116 | throw new Error("Git action is unavailable."); | |
| 117 | } | |
| 118 | return input.api.git.runStackedAction({ | |
| 110 | const api = ensureNativeApi(); | |
| 111 | if (!input.cwd) throw new Error("Git action is unavailable."); | |
| 112 | return api.git.runStackedAction({ | |
| 119 | 113 | cwd: input.cwd, |
| 120 | 114 | action, |
| 121 | 115 | ...(commitMessage ? { commitMessage } : {}), |
| ... | ... | @@ -127,15 +121,12 @@ export function gitRunStackedActionMutationOptions(input: { |
| 127 | 121 | }); |
| 128 | 122 | } |
| 129 | 123 | |
| 130 | export function gitPullMutationOptions(input: { | |
| 131 | api: NativeApi | undefined; | |
| 132 | cwd: string | null; | |
| 133 | queryClient: QueryClient; | |
| 134 | }) { | |
| 124 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | |
| 135 | 125 | return mutationOptions({ |
| 136 | 126 | mutationFn: async () => { |
| 137 | if (!input.api || !input.cwd) throw new Error("Git pull is unavailable."); | |
| 138 | return input.api.git.pull({ cwd: input.cwd }); | |
| 127 | const api = ensureNativeApi(); | |
| 128 | if (!input.cwd) throw new Error("Git pull is unavailable."); | |
| 129 | return api.git.pull({ cwd: input.cwd }); | |
| 139 | 130 | }, |
| 140 | 131 | onSettled: async () => { |
| 141 | 132 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -143,10 +134,7 @@ export function gitPullMutationOptions(input: { |
| 143 | 134 | }); |
| 144 | 135 | } |
| 145 | 136 | |
| 146 | export function gitCreateWorktreeMutationOptions(input: { | |
| 147 | api: NativeApi | undefined; | |
| 148 | queryClient: QueryClient; | |
| 149 | }) { | |
| 137 | export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { | |
| 150 | 138 | return mutationOptions({ |
| 151 | 139 | mutationFn: async ({ |
| 152 | 140 | cwd, |
| ... | ... | @@ -159,10 +147,9 @@ export function gitCreateWorktreeMutationOptions(input: { |
| 159 | 147 | newBranch: string; |
| 160 | 148 | path?: string | null; |
| 161 | 149 | }) => { |
| 162 | if (!input.api) { | |
| 163 | throw new Error("Git worktree creation is unavailable."); | |
| 164 | } | |
| 165 | return input.api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); | |
| 150 | const api = ensureNativeApi(); | |
| 151 | if (!cwd) throw new Error("Git worktree creation is unavailable."); | |
| 152 | return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); | |
| 166 | 153 | }, |
| 167 | 154 | onSettled: async () => { |
| 168 | 155 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -170,16 +157,12 @@ export function gitCreateWorktreeMutationOptions(input: { |
| 170 | 157 | }); |
| 171 | 158 | } |
| 172 | 159 | |
| 173 | export function gitRemoveWorktreeMutationOptions(input: { | |
| 174 | api: NativeApi | undefined; | |
| 175 | queryClient: QueryClient; | |
| 176 | }) { | |
| 160 | export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { | |
| 177 | 161 | return mutationOptions({ |
| 178 | 162 | mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { |
| 179 | if (!input.api) { | |
| 180 | throw new Error("Git worktree removal is unavailable."); | |
| 181 | } | |
| 182 | return input.api.git.removeWorktree({ cwd, path, force }); | |
| 163 | const api = ensureNativeApi(); | |
| 164 | if (!cwd) throw new Error("Git worktree removal is unavailable."); | |
| 165 | return api.git.removeWorktree({ cwd, path, force }); | |
| 183 | 166 | }, |
| 184 | 167 | onSettled: async () => { |
| 185 | 168 | await invalidateGitQueries(input.queryClient); |
apps/web/src/lib/orchestrationIds.ts-1| ... | ... | @@ -8,7 +8,6 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID |
| 8 | 8 | |
| 9 | 9 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID()); |
| 10 | 10 | |
| 11 | export const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 12 | 11 | |
| 13 | 12 | export const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 14 | 13 |
apps/web/src/lib/projectReactQuery.ts+6-5| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 1 | import type { ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 2 | 2 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | import { ensureNativeApi } from "~/nativeApi"; | |
| 3 | 4 | |
| 4 | 5 | export const projectQueryKeys = { |
| 5 | 6 | all: ["projects"] as const, |
| ... | ... | @@ -15,7 +16,6 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { |
| 15 | 16 | }; |
| 16 | 17 | |
| 17 | 18 | export function projectSearchEntriesQueryOptions(input: { |
| 18 | api: NativeApi | undefined; | |
| 19 | 19 | cwd: string | null; |
| 20 | 20 | query: string; |
| 21 | 21 | enabled?: boolean; |
| ... | ... | @@ -26,16 +26,17 @@ export function projectSearchEntriesQueryOptions(input: { |
| 26 | 26 | return queryOptions({ |
| 27 | 27 | queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), |
| 28 | 28 | queryFn: async () => { |
| 29 | if (!input.api || !input.cwd) { | |
| 29 | const api = ensureNativeApi(); | |
| 30 | if (!input.cwd) { | |
| 30 | 31 | throw new Error("Workspace entry search is unavailable."); |
| 31 | 32 | } |
| 32 | return input.api.projects.searchEntries({ | |
| 33 | return api.projects.searchEntries({ | |
| 33 | 34 | cwd: input.cwd, |
| 34 | 35 | query: input.query, |
| 35 | 36 | limit, |
| 36 | 37 | }); |
| 37 | 38 | }, |
| 38 | enabled: (input.enabled ?? true) && Boolean(input.api && input.cwd), | |
| 39 | enabled: (input.enabled ?? true) && input.cwd !== null, | |
| 39 | 40 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, |
| 40 | 41 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, |
| 41 | 42 | }); |
apps/web/src/lib/providerReactQuery.test.ts+57-27| ... | ... | @@ -1,12 +1,31 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 1 | import { ThreadId, type NativeApi } from "@t3tools/contracts"; | |
| 2 | 2 | import { QueryClient } from "@tanstack/react-query"; |
| 3 | import { describe, expect, it, vi } from "vitest"; | |
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 4 | 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; |
| 5 | import * as nativeApi from "../nativeApi"; | |
| 6 | ||
| 7 | const threadId = ThreadId.makeUnsafe("thread-id"); | |
| 8 | ||
| 9 | function mockNativeApi(input: { | |
| 10 | getTurnDiff: ReturnType<typeof vi.fn>; | |
| 11 | getFullThreadDiff: ReturnType<typeof vi.fn>; | |
| 12 | }) { | |
| 13 | vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ | |
| 14 | orchestration: { | |
| 15 | getTurnDiff: input.getTurnDiff, | |
| 16 | getFullThreadDiff: input.getFullThreadDiff, | |
| 17 | }, | |
| 18 | } as unknown as NativeApi); | |
| 19 | } | |
| 20 | ||
| 21 | afterEach(() => { | |
| 22 | vi.restoreAllMocks(); | |
| 23 | }); | |
| 5 | 24 | |
| 6 | 25 | describe("providerQueryKeys.checkpointDiff", () => { |
| 7 | 26 | it("includes cacheScope so reused turn counts do not collide", () => { |
| 8 | 27 | const baseInput = { |
| 9 | threadId: "thread-id", | |
| 28 | threadId, | |
| 10 | 29 | fromTurnCount: 1, |
| 11 | 30 | toTurnCount: 2, |
| 12 | 31 | } as const; |
| ... | ... | @@ -29,15 +48,10 @@ describe("checkpointDiffQueryOptions", () => { |
| 29 | 48 | it("forwards checkpoint range to the provider API", async () => { |
| 30 | 49 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); |
| 31 | 50 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); |
| 32 | const api = { | |
| 33 | orchestration: { | |
| 34 | getTurnDiff, | |
| 35 | getFullThreadDiff, | |
| 36 | }, | |
| 37 | } as unknown as NativeApi; | |
| 38 | ||
| 39 | const options = checkpointDiffQueryOptions(api, { | |
| 40 | threadId: "thread-id", | |
| 51 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | |
| 52 | ||
| 53 | const options = checkpointDiffQueryOptions({ | |
| 54 | threadId, | |
| 41 | 55 | fromTurnCount: 3, |
| 42 | 56 | toTurnCount: 4, |
| 43 | 57 | cacheScope: "turn:abc", |
| ... | ... | @@ -47,7 +61,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 47 | 61 | await queryClient.fetchQuery(options); |
| 48 | 62 | |
| 49 | 63 | expect(getTurnDiff).toHaveBeenCalledWith({ |
| 50 | threadId: "thread-id", | |
| 64 | threadId, | |
| 51 | 65 | fromTurnCount: 3, |
| 52 | 66 | toTurnCount: 4, |
| 53 | 67 | }); |
| ... | ... | @@ -57,15 +71,10 @@ describe("checkpointDiffQueryOptions", () => { |
| 57 | 71 | it("uses explicit full thread diff API when range starts from zero", async () => { |
| 58 | 72 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); |
| 59 | 73 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); |
| 60 | const api = { | |
| 61 | orchestration: { | |
| 62 | getTurnDiff, | |
| 63 | getFullThreadDiff, | |
| 64 | }, | |
| 65 | } as unknown as NativeApi; | |
| 66 | ||
| 67 | const options = checkpointDiffQueryOptions(api, { | |
| 68 | threadId: "thread-id", | |
| 74 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | |
| 75 | ||
| 76 | const options = checkpointDiffQueryOptions({ | |
| 77 | threadId, | |
| 69 | 78 | fromTurnCount: 0, |
| 70 | 79 | toTurnCount: 2, |
| 71 | 80 | cacheScope: "thread:all", |
| ... | ... | @@ -75,15 +84,36 @@ describe("checkpointDiffQueryOptions", () => { |
| 75 | 84 | await queryClient.fetchQuery(options); |
| 76 | 85 | |
| 77 | 86 | expect(getFullThreadDiff).toHaveBeenCalledWith({ |
| 78 | threadId: "thread-id", | |
| 87 | threadId, | |
| 79 | 88 | toTurnCount: 2, |
| 80 | 89 | }); |
| 81 | 90 | expect(getTurnDiff).not.toHaveBeenCalled(); |
| 82 | 91 | }); |
| 83 | 92 | |
| 93 | it("fails fast on invalid range and does not call provider RPC", async () => { | |
| 94 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 95 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 96 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | |
| 97 | ||
| 98 | const options = checkpointDiffQueryOptions({ | |
| 99 | threadId, | |
| 100 | fromTurnCount: 4, | |
| 101 | toTurnCount: 3, | |
| 102 | cacheScope: "turn:invalid", | |
| 103 | }); | |
| 104 | ||
| 105 | const queryClient = new QueryClient(); | |
| 106 | ||
| 107 | await expect(queryClient.fetchQuery(options)).rejects.toThrow( | |
| 108 | "Checkpoint diff is unavailable.", | |
| 109 | ); | |
| 110 | expect(getTurnDiff).not.toHaveBeenCalled(); | |
| 111 | expect(getFullThreadDiff).not.toHaveBeenCalled(); | |
| 112 | }); | |
| 113 | ||
| 84 | 114 | it("retries checkpoint-not-ready errors longer than generic failures", () => { |
| 85 | const options = checkpointDiffQueryOptions(undefined, { | |
| 86 | threadId: "thread-id", | |
| 115 | const options = checkpointDiffQueryOptions({ | |
| 116 | threadId, | |
| 87 | 117 | fromTurnCount: 1, |
| 88 | 118 | toTurnCount: 2, |
| 89 | 119 | cacheScope: "turn:abc", |
| ... | ... | @@ -106,8 +136,8 @@ describe("checkpointDiffQueryOptions", () => { |
| 106 | 136 | }); |
| 107 | 137 | |
| 108 | 138 | it("backs off longer for checkpoint-not-ready errors", () => { |
| 109 | const options = checkpointDiffQueryOptions(undefined, { | |
| 110 | threadId: "thread-id", | |
| 139 | const options = checkpointDiffQueryOptions({ | |
| 140 | threadId, | |
| 111 | 141 | fromTurnCount: 1, |
| 112 | 142 | toTurnCount: 2, |
| 113 | 143 | cacheScope: "turn:abc", |
apps/web/src/lib/providerReactQuery.ts+32-32| ... | ... | @@ -1,9 +1,14 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | OrchestrationGetFullThreadDiffInput, | |
| 3 | OrchestrationGetTurnDiffInput, | |
| 4 | ThreadId, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | import { asThreadId } from "./orchestrationIds"; | |
| 7 | import { Option, Schema } from "effect"; | |
| 8 | import { ensureNativeApi } from "../nativeApi"; | |
| 4 | 9 | |
| 5 | export interface CheckpointDiffQueryInput { | |
| 6 | threadId: string | null; | |
| 10 | interface CheckpointDiffQueryInput { | |
| 11 | threadId: ThreadId | null; | |
| 7 | 12 | fromTurnCount: number | null; |
| 8 | 13 | toTurnCount: number | null; |
| 9 | 14 | cacheScope?: string | null; |
| ... | ... | @@ -22,6 +27,21 @@ export const providerQueryKeys = { |
| 22 | 27 | ] as const, |
| 23 | 28 | }; |
| 24 | 29 | |
| 30 | function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { | |
| 31 | if (input.fromTurnCount === 0) { | |
| 32 | return Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput)({ | |
| 33 | threadId: input.threadId, | |
| 34 | toTurnCount: input.toTurnCount, | |
| 35 | }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields }))); | |
| 36 | } | |
| 37 | ||
| 38 | return Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput)({ | |
| 39 | threadId: input.threadId, | |
| 40 | fromTurnCount: input.fromTurnCount, | |
| 41 | toTurnCount: input.toTurnCount, | |
| 42 | }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields }))); | |
| 43 | } | |
| 44 | ||
| 25 | 45 | function asCheckpointErrorMessage(error: unknown): string { |
| 26 | 46 | if (error instanceof Error) { |
| 27 | 47 | return error.message; |
| ... | ... | @@ -41,42 +61,22 @@ function isCheckpointTemporarilyUnavailable(error: unknown): boolean { |
| 41 | 61 | ); |
| 42 | 62 | } |
| 43 | 63 | |
| 44 | export function checkpointDiffQueryOptions( | |
| 45 | api: NativeApi | undefined, | |
| 46 | input: CheckpointDiffQueryInput, | |
| 47 | ) { | |
| 48 | const hasValidRange = | |
| 49 | typeof input.fromTurnCount === "number" && | |
| 50 | typeof input.toTurnCount === "number" && | |
| 51 | Number.isInteger(input.fromTurnCount) && | |
| 52 | Number.isInteger(input.toTurnCount) && | |
| 53 | input.fromTurnCount >= 0 && | |
| 54 | input.toTurnCount >= 0 && | |
| 55 | input.fromTurnCount <= input.toTurnCount; | |
| 64 | export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { | |
| 65 | const decodedRequest = decodeCheckpointDiffRequest(input); | |
| 56 | 66 | |
| 57 | 67 | return queryOptions({ |
| 58 | 68 | queryKey: providerQueryKeys.checkpointDiff(input), |
| 59 | 69 | queryFn: async () => { |
| 60 | if (!api || !input.threadId || !hasValidRange) { | |
| 70 | const api = ensureNativeApi(); | |
| 71 | if (!input.threadId || decodedRequest._tag === "None") { | |
| 61 | 72 | throw new Error("Checkpoint diff is unavailable."); |
| 62 | 73 | } |
| 63 | const { fromTurnCount, toTurnCount } = input; | |
| 64 | if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { | |
| 65 | throw new Error("Checkpoint diff range is invalid."); | |
| 66 | } | |
| 67 | if (fromTurnCount === 0) { | |
| 68 | return api.orchestration.getFullThreadDiff({ | |
| 69 | threadId: asThreadId(input.threadId), | |
| 70 | toTurnCount, | |
| 71 | }); | |
| 74 | if (decodedRequest.value.kind === "fullThreadDiff") { | |
| 75 | return api.orchestration.getFullThreadDiff(decodedRequest.value.input); | |
| 72 | 76 | } |
| 73 | return api.orchestration.getTurnDiff({ | |
| 74 | threadId: asThreadId(input.threadId), | |
| 75 | fromTurnCount, | |
| 76 | toTurnCount, | |
| 77 | }); | |
| 77 | return api.orchestration.getTurnDiff(decodedRequest.value.input); | |
| 78 | 78 | }, |
| 79 | enabled: !!api && !!input.threadId && hasValidRange, | |
| 79 | enabled: !!input.threadId && decodedRequest._tag === "Some", | |
| 80 | 80 | staleTime: Infinity, |
| 81 | 81 | retry: (failureCount, error) => { |
| 82 | 82 | if (isCheckpointTemporarilyUnavailable(error)) { |
apps/web/src/lib/serverReactQuery.ts+3-6| ... | ... | @@ -1,21 +1,18 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 2 | 1 | import { queryOptions } from "@tanstack/react-query"; |
| 2 | import { ensureNativeApi } from "~/nativeApi"; | |
| 3 | 3 | |
| 4 | 4 | export const serverQueryKeys = { |
| 5 | 5 | all: ["server"] as const, |
| 6 | 6 | config: () => ["server", "config"] as const, |
| 7 | 7 | }; |
| 8 | 8 | |
| 9 | export function serverConfigQueryOptions(api: NativeApi | undefined) { | |
| 9 | export function serverConfigQueryOptions() { | |
| 10 | 10 | return queryOptions({ |
| 11 | 11 | queryKey: serverQueryKeys.config(), |
| 12 | 12 | queryFn: async () => { |
| 13 | if (!api || !api.server || typeof api.server.getConfig !== "function") { | |
| 14 | throw new Error("Server config is unavailable."); | |
| 15 | } | |
| 13 | const api = ensureNativeApi(); | |
| 16 | 14 | return api.server.getConfig(); |
| 17 | 15 | }, |
| 18 | enabled: !!api && !!api.server && typeof api.server.getConfig === "function", | |
| 19 | 16 | staleTime: Infinity, |
| 20 | 17 | }); |
| 21 | 18 | } |
apps/web/src/model-logic.test.ts deleted-33| ... | ... | @@ -1,33 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic"; | |
| 3 | ||
| 4 | describe("normalizeModelSlug", () => { | |
| 5 | it("maps 5.3 aliases to canonical codex slug", () => { | |
| 6 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | |
| 7 | expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | |
| 8 | }); | |
| 9 | ||
| 10 | it("preserves non-aliased slugs", () => { | |
| 11 | expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | |
| 12 | expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | |
| 13 | }); | |
| 14 | ||
| 15 | it("returns null for empty input", () => { | |
| 16 | expect(normalizeModelSlug("")).toBeNull(); | |
| 17 | expect(normalizeModelSlug(" ")).toBeNull(); | |
| 18 | expect(normalizeModelSlug(null)).toBeNull(); | |
| 19 | expect(normalizeModelSlug(undefined)).toBeNull(); | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | describe("resolveModelSlug", () => { | |
| 24 | it("falls back to default for unknown models", () => { | |
| 25 | expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); | |
| 26 | }); | |
| 27 | ||
| 28 | it("resolves supported hard-coded models", () => { | |
| 29 | expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | |
| 30 | expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | |
| 31 | expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2"); | |
| 32 | }); | |
| 33 | }); |
apps/web/src/model-logic.ts deleted-10| ... | ... | @@ -1,10 +0,0 @@ |
| 1 | export { | |
| 2 | DEFAULT_MODEL, | |
| 3 | MODEL_OPTIONS, | |
| 4 | normalizeModelSlug, | |
| 5 | resolveModelSlug, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | |
| 9 | export type ReasoningEffort = (typeof REASONING_OPTIONS)[number]; | |
| 10 | export const DEFAULT_REASONING: ReasoningEffort = "high"; |
apps/web/src/nativeApi.ts+8| ... | ... | @@ -16,3 +16,11 @@ export function readNativeApi(): NativeApi | undefined { |
| 16 | 16 | cachedApi = createWsNativeApi(); |
| 17 | 17 | return cachedApi; |
| 18 | 18 | } |
| 19 | ||
| 20 | export function ensureNativeApi(): NativeApi { | |
| 21 | const api = readNativeApi(); | |
| 22 | if (!api) { | |
| 23 | throw new Error("Native API not found"); | |
| 24 | } | |
| 25 | return api; | |
| 26 | } |
apps/web/src/routes/__root.tsx+4-6| ... | ... | @@ -9,7 +9,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 9 | 9 | import { APP_DISPLAY_NAME } from "../branding"; |
| 10 | 10 | import { Button } from "../components/ui/button"; |
| 11 | 11 | import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; |
| 12 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 12 | import { readNativeApi } from "../nativeApi"; | |
| 13 | 13 | import { useStore } from "../store"; |
| 14 | 14 | import { onServerWelcome } from "../wsNativeApi"; |
| 15 | 15 | import { providerQueryKeys } from "../lib/providerReactQuery"; |
| ... | ... | @@ -22,9 +22,7 @@ export const Route = createRootRouteWithContext<{ |
| 22 | 22 | }); |
| 23 | 23 | |
| 24 | 24 | function RootRouteView() { |
| 25 | const api = useNativeApi(); | |
| 26 | ||
| 27 | if (!api) { | |
| 25 | if (!readNativeApi()) { | |
| 28 | 26 | return ( |
| 29 | 27 | <div className="flex h-screen flex-col bg-background text-foreground"> |
| 30 | 28 | <div className="flex flex-1 items-center justify-center"> |
| ... | ... | @@ -119,11 +117,11 @@ function errorDetails(error: unknown): string { |
| 119 | 117 | } |
| 120 | 118 | |
| 121 | 119 | function EventRouter() { |
| 122 | const api = useNativeApi(); | |
| 123 | 120 | const { dispatch } = useStore(); |
| 124 | 121 | const queryClient = useQueryClient(); |
| 125 | 122 | |
| 126 | 123 | useEffect(() => { |
| 124 | const api = readNativeApi(); | |
| 127 | 125 | if (!api) return; |
| 128 | 126 | let disposed = false; |
| 129 | 127 | let latestSequence = 0; |
| ... | ... | @@ -177,7 +175,7 @@ function EventRouter() { |
| 177 | 175 | unsubDomainEvent(); |
| 178 | 176 | unsubWelcome(); |
| 179 | 177 | }; |
| 180 | }, [api, dispatch, queryClient]); | |
| 178 | }, [dispatch, queryClient]); | |
| 181 | 179 | |
| 182 | 180 | return null; |
| 183 | 181 | } |
apps/web/src/routes/_chat.$threadId.tsx+6-8| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; |
| 3 | 4 | |
| ... | ... | @@ -133,9 +134,11 @@ const DiffPanelInlineSidebar = (props: { |
| 133 | 134 | function ChatThreadRouteView() { |
| 134 | 135 | const { state } = useStore(); |
| 135 | 136 | const navigate = useNavigate(); |
| 136 | const { threadId } = Route.useParams(); | |
| 137 | const threadId = Route.useParams({ | |
| 138 | select: (params) => ThreadId.makeUnsafe(params.threadId), | |
| 139 | }); | |
| 137 | 140 | const search = Route.useSearch(); |
| 138 | const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; | |
| 141 | const threadExists = state.threads.some((thread) => thread.id === threadId); | |
| 139 | 142 | const diffOpen = search.diff === "1"; |
| 140 | 143 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); |
| 141 | 144 | const closeDiff = useCallback(() => { |
| ... | ... | @@ -159,11 +162,6 @@ function ChatThreadRouteView() { |
| 159 | 162 | }, [navigate, threadId]); |
| 160 | 163 | |
| 161 | 164 | useEffect(() => { |
| 162 | if (!threadId) { | |
| 163 | void navigate({ to: "/", replace: true }); | |
| 164 | return; | |
| 165 | } | |
| 166 | ||
| 167 | 165 | if (!state.threadsHydrated) { |
| 168 | 166 | return; |
| 169 | 167 | } |
| ... | ... | @@ -174,7 +172,7 @@ function ChatThreadRouteView() { |
| 174 | 172 | } |
| 175 | 173 | }, [navigate, state.threadsHydrated, threadExists, threadId]); |
| 176 | 174 | |
| 177 | if (!threadId || !state.threadsHydrated || !threadExists) { | |
| 175 | if (!state.threadsHydrated || !threadExists) { | |
| 178 | 176 | return null; |
| 179 | 177 | } |
| 180 | 178 |
apps/web/src/routes/_chat.tsx+1-1| ... | ... | @@ -3,7 +3,7 @@ import { useEffect } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; |
| 5 | 5 | import ThreadSidebar from "../components/Sidebar"; |
| 6 | import { SidebarInset, Sidebar, SidebarProvider } from "~/components/ui/sidebar"; | |
| 6 | import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; | |
| 7 | 7 | |
| 8 | 8 | function ChatRouteLayout() { |
| 9 | 9 | const navigate = useNavigate(); |
apps/web/src/session-logic.test.ts+2-34| ... | ... | @@ -1,11 +1,7 @@ |
| 1 | 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | import { | |
| 5 | derivePendingApprovals, | |
| 6 | deriveTurnDiffFilesFromUnifiedDiff, | |
| 7 | deriveWorkLogEntries, | |
| 8 | } from "./session-logic"; | |
| 4 | import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic"; | |
| 9 | 5 | |
| 10 | 6 | function makeActivity(overrides: { |
| 11 | 7 | id?: string; |
| ... | ... | @@ -105,7 +101,7 @@ describe("deriveWorkLogEntries", () => { |
| 105 | 101 | makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }), |
| 106 | 102 | ]; |
| 107 | 103 | |
| 108 | const entries = deriveWorkLogEntries(activities, "turn-2"); | |
| 104 | const entries = deriveWorkLogEntries(activities, TurnId.makeUnsafe("turn-2")); | |
| 109 | 105 | expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]); |
| 110 | 106 | }); |
| 111 | 107 | |
| ... | ... | @@ -130,31 +126,3 @@ describe("deriveWorkLogEntries", () => { |
| 130 | 126 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 131 | 127 | }); |
| 132 | 128 | }); |
| 133 | ||
| 134 | describe("deriveTurnDiffFilesFromUnifiedDiff", () => { | |
| 135 | it("extracts per-file +/- counts from unified diff", () => { | |
| 136 | const diff = [ | |
| 137 | "diff --git a/a.txt b/a.txt", | |
| 138 | "index 111..222 100644", | |
| 139 | "--- a/a.txt", | |
| 140 | "+++ b/a.txt", | |
| 141 | "@@ -1 +1,2 @@", | |
| 142 | "-old", | |
| 143 | "+new", | |
| 144 | "+extra", | |
| 145 | "diff --git a/b.txt b/b.txt", | |
| 146 | "index 333..444 100644", | |
| 147 | "--- a/b.txt", | |
| 148 | "+++ b/b.txt", | |
| 149 | "@@ -2,2 +2 @@", | |
| 150 | "-x", | |
| 151 | "-y", | |
| 152 | "+z", | |
| 153 | ].join("\n"); | |
| 154 | ||
| 155 | expect(deriveTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | |
| 156 | { path: "a.txt", additions: 2, deletions: 1 }, | |
| 157 | { path: "b.txt", additions: 1, deletions: 2 }, | |
| 158 | ]); | |
| 159 | }); | |
| 160 | }); |
apps/web/src/session-logic.ts+16-64| ... | ... | @@ -1,12 +1,11 @@ |
| 1 | import type { OrchestrationThreadActivity, ProviderKind } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | ApprovalRequestId, | |
| 3 | type OrchestrationThreadActivity, | |
| 4 | type ProviderKind, | |
| 5 | type TurnId, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | |
| 3 | import type { | |
| 4 | ChatMessage, | |
| 5 | SessionPhase, | |
| 6 | ThreadSession, | |
| 7 | TurnDiffFileChange, | |
| 8 | TurnDiffSummary, | |
| 9 | } from "./types"; | |
| 8 | import type { ChatMessage, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; | |
| 10 | 9 | |
| 11 | 10 | export const PROVIDER_OPTIONS: Array<{ |
| 12 | 11 | value: ProviderKind; |
| ... | ... | @@ -26,7 +25,7 @@ export interface WorkLogEntry { |
| 26 | 25 | } |
| 27 | 26 | |
| 28 | 27 | export interface PendingApproval { |
| 29 | requestId: string; | |
| 28 | requestId: ApprovalRequestId; | |
| 30 | 29 | requestKind: "command" | "file-change"; |
| 31 | 30 | createdAt: string; |
| 32 | 31 | detail?: string; |
| ... | ... | @@ -79,7 +78,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str |
| 79 | 78 | export function derivePendingApprovals( |
| 80 | 79 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 81 | 80 | ): PendingApproval[] { |
| 82 | const openByRequestId = new Map<string, PendingApproval>(); | |
| 81 | const openByRequestId = new Map<ApprovalRequestId, PendingApproval>(); | |
| 83 | 82 | const ordered = [...activities].toSorted((left, right) => |
| 84 | 83 | left.createdAt.localeCompare(right.createdAt), |
| 85 | 84 | ); |
| ... | ... | @@ -89,7 +88,10 @@ export function derivePendingApprovals( |
| 89 | 88 | activity.payload && typeof activity.payload === "object" |
| 90 | 89 | ? (activity.payload as Record<string, unknown>) |
| 91 | 90 | : null; |
| 92 | const requestId = payload && typeof payload.requestId === "string" ? payload.requestId : null; | |
| 91 | const requestId = | |
| 92 | payload && typeof payload.requestId === "string" | |
| 93 | ? ApprovalRequestId.makeUnsafe(payload.requestId) | |
| 94 | : null; | |
| 93 | 95 | const requestKind = |
| 94 | 96 | payload && (payload.requestKind === "command" || payload.requestKind === "file-change") |
| 95 | 97 | ? payload.requestKind |
| ... | ... | @@ -118,7 +120,7 @@ export function derivePendingApprovals( |
| 118 | 120 | |
| 119 | 121 | export function deriveWorkLogEntries( |
| 120 | 122 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 121 | latestTurnId: string | undefined, | |
| 123 | latestTurnId: TurnId | undefined, | |
| 122 | 124 | ): WorkLogEntry[] { |
| 123 | 125 | const ordered = [...activities].toSorted((left, right) => |
| 124 | 126 | left.createdAt.localeCompare(right.createdAt), |
| ... | ... | @@ -163,55 +165,11 @@ export function deriveTimelineEntries( |
| 163 | 165 | return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); |
| 164 | 166 | } |
| 165 | 167 | |
| 166 | export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { | |
| 167 | const normalized = diff.replace(/\r\n/g, "\n"); | |
| 168 | const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; | |
| 169 | if (headerMatches.length === 0) { | |
| 170 | return []; | |
| 171 | } | |
| 172 | ||
| 173 | const files: TurnDiffFileChange[] = []; | |
| 174 | for (let index = 0; index < headerMatches.length; index += 1) { | |
| 175 | const header = headerMatches[index]; | |
| 176 | if (!header) continue; | |
| 177 | const start = header.index ?? 0; | |
| 178 | const nextStart = headerMatches[index + 1]?.index ?? normalized.length; | |
| 179 | const segment = normalized.slice(start, nextStart); | |
| 180 | const headerLine = header[0]; | |
| 181 | if (!headerLine) continue; | |
| 182 | ||
| 183 | const match = headerLine.match(/^diff --git a\/(.+?) b\/(.+)$/); | |
| 184 | const filePath = match?.[2] ?? match?.[1]; | |
| 185 | if (!filePath) continue; | |
| 186 | ||
| 187 | let additions = 0; | |
| 188 | let deletions = 0; | |
| 189 | for (const line of segment.split("\n")) { | |
| 190 | if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; | |
| 191 | if (line.startsWith("+")) { | |
| 192 | additions += 1; | |
| 193 | continue; | |
| 194 | } | |
| 195 | if (line.startsWith("-")) { | |
| 196 | deletions += 1; | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | files.push({ | |
| 201 | path: filePath, | |
| 202 | additions, | |
| 203 | deletions, | |
| 204 | }); | |
| 205 | } | |
| 206 | ||
| 207 | return files.toSorted((left, right) => left.path.localeCompare(right.path)); | |
| 208 | } | |
| 209 | ||
| 210 | 168 | export function inferCheckpointTurnCountByTurnId( |
| 211 | 169 | summaries: TurnDiffSummary[], |
| 212 | ): Record<string, number> { | |
| 170 | ): Record<TurnId, number> { | |
| 213 | 171 | const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); |
| 214 | const result: Record<string, number> = {}; | |
| 172 | const result: Record<TurnId, number> = {}; | |
| 215 | 173 | for (let index = 0; index < sorted.length; index += 1) { |
| 216 | 174 | const summary = sorted[index]; |
| 217 | 175 | if (!summary) continue; |
| ... | ... | @@ -220,12 +178,6 @@ export function inferCheckpointTurnCountByTurnId( |
| 220 | 178 | return result; |
| 221 | 179 | } |
| 222 | 180 | |
| 223 | export function deriveTurnDiffSummaries( | |
| 224 | _events: ReadonlyArray<OrchestrationThreadActivity>, | |
| 225 | ): TurnDiffSummary[] { | |
| 226 | return []; | |
| 227 | } | |
| 228 | ||
| 229 | 181 | export function derivePhase(session: ThreadSession | null): SessionPhase { |
| 230 | 182 | if (!session || session.status === "closed") return "disconnected"; |
| 231 | 183 | if (session.status === "connecting") return "connecting"; |
apps/web/src/store.ts+23-16| ... | ... | @@ -8,15 +8,20 @@ import { |
| 8 | 8 | useReducer, |
| 9 | 9 | } from "react"; |
| 10 | 10 | |
| 11 | import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts"; | |
| 12 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | |
| 11 | import { | |
| 12 | DEFAULT_MODEL, | |
| 13 | ProviderSessionId, | |
| 14 | ThreadId, | |
| 15 | type OrchestrationReadModel, | |
| 16 | type OrchestrationSessionStatus, | |
| 17 | resolveModelSlug, | |
| 18 | } from "@t3tools/contracts"; | |
| 13 | 19 | import { |
| 14 | 20 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 15 | 21 | DEFAULT_THREAD_TERMINAL_ID, |
| 16 | 22 | DEFAULT_RUNTIME_MODE, |
| 17 | 23 | MAX_THREAD_TERMINAL_COUNT, |
| 18 | 24 | type ChatMessage, |
| 19 | type ProjectScript, | |
| 20 | 25 | type Project, |
| 21 | 26 | type RuntimeMode, |
| 22 | 27 | type Thread, |
| ... | ... | @@ -27,19 +32,19 @@ import { |
| 27 | 32 | |
| 28 | 33 | type Action = |
| 29 | 34 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 30 | | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } | |
| 31 | | { type: "TOGGLE_PROJECT"; projectId: string } | |
| 32 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | |
| 33 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | |
| 34 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } | |
| 35 | | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } | |
| 36 | | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | |
| 37 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | |
| 38 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | |
| 39 | | { type: "SET_ERROR"; threadId: string; error: string | null } | |
| 35 | | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } | |
| 36 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } | |
| 37 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } | |
| 38 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } | |
| 39 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } | |
| 40 | | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } | |
| 41 | | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } | |
| 42 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string } | |
| 43 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } | |
| 44 | | { type: "SET_ERROR"; threadId: ThreadId; error: string | null } | |
| 40 | 45 | | { |
| 41 | 46 | type: "SET_THREAD_BRANCH"; |
| 42 | threadId: string; | |
| 47 | threadId: ThreadId; | |
| 43 | 48 | branch: string | null; |
| 44 | 49 | worktreePath: string | null; |
| 45 | 50 | } |
| ... | ... | @@ -127,7 +132,7 @@ function persistState(state: AppState): void { |
| 127 | 132 | |
| 128 | 133 | function updateThread( |
| 129 | 134 | threads: Thread[], |
| 130 | threadId: string, | |
| 135 | threadId: ThreadId, | |
| 131 | 136 | updater: (t: Thread) => Thread, |
| 132 | 137 | ): Thread[] { |
| 133 | 138 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| ... | ... | @@ -415,7 +420,9 @@ export function reducer(state: AppState, action: Action): AppState { |
| 415 | 420 | existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 416 | 421 | session: thread.session |
| 417 | 422 | ? { |
| 418 | sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`, | |
| 423 | sessionId: | |
| 424 | thread.session.providerSessionId ?? | |
| 425 | ProviderSessionId.makeUnsafe(`thread:${thread.id}`), | |
| 419 | 426 | provider: toLegacyProvider(thread.session.providerName), |
| 420 | 427 | status: toLegacySessionStatus(thread.session.status), |
| 421 | 428 | orchestrationStatus: thread.session.status, |
apps/web/src/threadFactory.ts deleted-42| ... | ... | @@ -1,42 +0,0 @@ |
| 1 | import { DEFAULT_MODEL } from "./model-logic"; | |
| 2 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; | |
| 3 | ||
| 4 | interface CreateThreadOptions { | |
| 5 | branch?: string | null; | |
| 6 | createdAt?: string; | |
| 7 | model?: string; | |
| 8 | title?: string; | |
| 9 | worktreePath?: string | null; | |
| 10 | } | |
| 11 | ||
| 12 | export function createThread(projectId: string, options: CreateThreadOptions = {}): Thread { | |
| 13 | const createdAt = options.createdAt ?? new Date().toISOString(); | |
| 14 | ||
| 15 | return { | |
| 16 | id: crypto.randomUUID(), | |
| 17 | codexThreadId: null, | |
| 18 | projectId, | |
| 19 | title: options.title ?? "New thread", | |
| 20 | model: options.model ?? DEFAULT_MODEL, | |
| 21 | terminalOpen: false, | |
| 22 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 23 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 24 | runningTerminalIds: [], | |
| 25 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 26 | terminalGroups: [ | |
| 27 | { | |
| 28 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 29 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 30 | }, | |
| 31 | ], | |
| 32 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 33 | session: null, | |
| 34 | messages: [], | |
| 35 | turnDiffSummaries: [], | |
| 36 | activities: [], | |
| 37 | error: null, | |
| 38 | createdAt, | |
| 39 | branch: options.branch ?? null, | |
| 40 | worktreePath: options.worktreePath ?? null, | |
| 41 | }; | |
| 42 | } |
apps/web/src/types.ts+21-13| ... | ... | @@ -2,6 +2,14 @@ import type { |
| 2 | 2 | OrchestrationSessionStatus, |
| 3 | 3 | OrchestrationThreadActivity, |
| 4 | 4 | ProjectScript as ContractProjectScript, |
| 5 | ThreadId, | |
| 6 | ProjectId, | |
| 7 | TurnId, | |
| 8 | MessageId, | |
| 9 | CheckpointRef, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderSessionId, | |
| 12 | ProviderKind, | |
| 5 | 13 | } from "@t3tools/contracts"; |
| 6 | 14 | |
| 7 | 15 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| ... | ... | @@ -29,7 +37,7 @@ export interface ChatImageAttachment { |
| 29 | 37 | export type ChatAttachment = ChatImageAttachment; |
| 30 | 38 | |
| 31 | 39 | export interface ChatMessage { |
| 32 | id: string; | |
| 40 | id: MessageId; | |
| 33 | 41 | role: "user" | "assistant" | "system"; |
| 34 | 42 | text: string; |
| 35 | 43 | attachments?: ChatAttachment[]; |
| ... | ... | @@ -46,17 +54,17 @@ export interface TurnDiffFileChange { |
| 46 | 54 | } |
| 47 | 55 | |
| 48 | 56 | export interface TurnDiffSummary { |
| 49 | turnId: string; | |
| 57 | turnId: TurnId; | |
| 50 | 58 | completedAt: string; |
| 51 | 59 | status?: string | undefined; |
| 52 | 60 | files: TurnDiffFileChange[]; |
| 53 | checkpointRef?: string | undefined; | |
| 54 | assistantMessageId?: string | undefined; | |
| 61 | checkpointRef?: CheckpointRef | undefined; | |
| 62 | assistantMessageId?: MessageId | undefined; | |
| 55 | 63 | checkpointTurnCount?: number | undefined; |
| 56 | 64 | } |
| 57 | 65 | |
| 58 | 66 | export interface Project { |
| 59 | id: string; | |
| 67 | id: ProjectId; | |
| 60 | 68 | name: string; |
| 61 | 69 | cwd: string; |
| 62 | 70 | model: string; |
| ... | ... | @@ -65,9 +73,9 @@ export interface Project { |
| 65 | 73 | } |
| 66 | 74 | |
| 67 | 75 | export interface Thread { |
| 68 | id: string; | |
| 69 | codexThreadId: string | null; | |
| 70 | projectId: string; | |
| 76 | id: ThreadId; | |
| 77 | codexThreadId: ProviderThreadId | null; | |
| 78 | projectId: ProjectId; | |
| 71 | 79 | title: string; |
| 72 | 80 | model: string; |
| 73 | 81 | terminalOpen: boolean; |
| ... | ... | @@ -81,7 +89,7 @@ export interface Thread { |
| 81 | 89 | messages: ChatMessage[]; |
| 82 | 90 | error: string | null; |
| 83 | 91 | createdAt: string; |
| 84 | latestTurnId?: string | undefined; | |
| 92 | latestTurnId?: TurnId | undefined; | |
| 85 | 93 | latestTurnStartedAt?: string | undefined; |
| 86 | 94 | latestTurnCompletedAt?: string | undefined; |
| 87 | 95 | latestTurnDurationMs?: number | undefined; |
| ... | ... | @@ -93,11 +101,11 @@ export interface Thread { |
| 93 | 101 | } |
| 94 | 102 | |
| 95 | 103 | export interface ThreadSession { |
| 96 | sessionId: string; | |
| 97 | provider: "codex" | "claudeCode"; | |
| 104 | sessionId: ProviderSessionId; | |
| 105 | provider: ProviderKind; | |
| 98 | 106 | status: SessionPhase | "error" | "closed"; |
| 99 | threadId: string | null; | |
| 100 | activeTurnId?: string | undefined; | |
| 107 | threadId: ProviderThreadId | null; | |
| 108 | activeTurnId?: TurnId | undefined; | |
| 101 | 109 | createdAt: string; |
| 102 | 110 | updatedAt: string; |
| 103 | 111 | lastError?: string; |
apps/web/src/worktreeCleanup.test.ts+24-11| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | |
| 3 | 4 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; |
| ... | ... | @@ -5,9 +6,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " |
| 5 | 6 | |
| 6 | 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 7 | 8 | return { |
| 8 | id: "thread-1", | |
| 9 | id: ThreadId.makeUnsafe("thread-1"), | |
| 9 | 10 | codexThreadId: null, |
| 10 | projectId: "project-1", | |
| 11 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 11 | 12 | title: "Thread", |
| 12 | 13 | model: "gpt-5.3-codex", |
| 13 | 14 | terminalOpen: false, |
| ... | ... | @@ -36,37 +37,49 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 36 | 37 | |
| 37 | 38 | describe("getOrphanedWorktreePathForThread", () => { |
| 38 | 39 | it("returns null when the target thread does not exist", () => { |
| 39 | const result = getOrphanedWorktreePathForThread([], "missing-thread"); | |
| 40 | const result = getOrphanedWorktreePathForThread([], ThreadId.makeUnsafe("missing-thread")); | |
| 40 | 41 | expect(result).toBeNull(); |
| 41 | 42 | }); |
| 42 | 43 | |
| 43 | 44 | it("returns null when the target thread has no worktree", () => { |
| 44 | 45 | const threads = [makeThread()]; |
| 45 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | |
| 46 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); | |
| 46 | 47 | expect(result).toBeNull(); |
| 47 | 48 | }); |
| 48 | 49 | |
| 49 | 50 | it("returns the path when no other thread links to that worktree", () => { |
| 50 | 51 | const threads = [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })]; |
| 51 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | |
| 52 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); | |
| 52 | 53 | expect(result).toBe("/tmp/repo/worktrees/feature-a"); |
| 53 | 54 | }); |
| 54 | 55 | |
| 55 | 56 | it("returns null when another thread links to the same worktree", () => { |
| 56 | 57 | const threads = [ |
| 57 | makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }), | |
| 58 | makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-a" }), | |
| 58 | makeThread({ | |
| 59 | id: ThreadId.makeUnsafe("thread-1"), | |
| 60 | worktreePath: "/tmp/repo/worktrees/feature-a", | |
| 61 | }), | |
| 62 | makeThread({ | |
| 63 | id: ThreadId.makeUnsafe("thread-2"), | |
| 64 | worktreePath: "/tmp/repo/worktrees/feature-a", | |
| 65 | }), | |
| 59 | 66 | ]; |
| 60 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | |
| 67 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); | |
| 61 | 68 | expect(result).toBeNull(); |
| 62 | 69 | }); |
| 63 | 70 | |
| 64 | 71 | it("ignores threads linked to different worktrees", () => { |
| 65 | 72 | const threads = [ |
| 66 | makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }), | |
| 67 | makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-b" }), | |
| 73 | makeThread({ | |
| 74 | id: ThreadId.makeUnsafe("thread-1"), | |
| 75 | worktreePath: "/tmp/repo/worktrees/feature-a", | |
| 76 | }), | |
| 77 | makeThread({ | |
| 78 | id: ThreadId.makeUnsafe("thread-2"), | |
| 79 | worktreePath: "/tmp/repo/worktrees/feature-b", | |
| 80 | }), | |
| 68 | 81 | ]; |
| 69 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | |
| 82 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); | |
| 70 | 83 | expect(result).toBe("/tmp/repo/worktrees/feature-a"); |
| 71 | 84 | }); |
| 72 | 85 | }); |
apps/web/src/worktreeCleanup.ts+1-1| ... | ... | @@ -10,7 +10,7 @@ function normalizeWorktreePath(path: string | null): string | null { |
| 10 | 10 | |
| 11 | 11 | export function getOrphanedWorktreePathForThread( |
| 12 | 12 | threads: readonly Thread[], |
| 13 | threadId: string, | |
| 13 | threadId: Thread["id"], | |
| 14 | 14 | ): string | null { |
| 15 | 15 | const targetThread = threads.find((thread) => thread.id === threadId); |
| 16 | 16 | if (!targetThread) { |
apps/web/src/wsTransport.ts+1-6| ... | ... | @@ -13,12 +13,7 @@ const REQUEST_TIMEOUT_MS = 60_000; |
| 13 | 13 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; |
| 14 | 14 | const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse)); |
| 15 | 15 | const isWsPushEnvelope = Schema.is(WsPush); |
| 16 | ||
| 17 | function isWebSocketResponseEnvelope( | |
| 18 | message: typeof WsResponse.Type, | |
| 19 | ): message is WebSocketResponse { | |
| 20 | return message instanceof WebSocketResponse; | |
| 21 | } | |
| 16 | const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse); | |
| 22 | 17 | |
| 23 | 18 | interface WsRequestEnvelope { |
| 24 | 19 | id: string; |
packages/contracts/src/editor.ts+3-2| ... | ... | @@ -8,7 +8,8 @@ export const EDITORS = [ |
| 8 | 8 | export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); |
| 9 | 9 | export type EditorId = typeof EditorId.Type; |
| 10 | 10 | |
| 11 | export class OpenInEditorInput extends Schema.Class<OpenInEditorInput>("OpenInEditorInput")({ | |
| 11 | export const OpenInEditorInput = Schema.Struct({ | |
| 12 | 12 | cwd: Schema.String, |
| 13 | 13 | editor: EditorId, |
| 14 | }) {} | |
| 14 | }); | |
| 15 | export type OpenInEditorInput = typeof OpenInEditorInput.Type; |
packages/contracts/src/git.ts+48-48| ... | ... | @@ -18,78 +18,79 @@ export const GitPrStepStatus = Schema.Literals([ |
| 18 | 18 | "skipped_not_requested", |
| 19 | 19 | ]); |
| 20 | 20 | |
| 21 | export class GitBranch extends Schema.Class<GitBranch>("GitBranch")({ | |
| 21 | export const GitBranch = Schema.Struct({ | |
| 22 | 22 | name: TrimmedNonEmptyString, |
| 23 | 23 | current: Schema.Boolean, |
| 24 | 24 | isDefault: Schema.Boolean, |
| 25 | 25 | worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 26 | }) {} | |
| 26 | }); | |
| 27 | export type GitBranch = typeof GitBranch.Type; | |
| 27 | 28 | |
| 28 | export class GitWorktree extends Schema.Class<GitWorktree>("GitWorktree")({ | |
| 29 | export const GitWorktree = Schema.Struct({ | |
| 29 | 30 | path: TrimmedNonEmptyString, |
| 30 | 31 | branch: TrimmedNonEmptyString, |
| 31 | }) {} | |
| 32 | }); | |
| 33 | export type GitWorktree = typeof GitWorktree.Type; | |
| 32 | 34 | |
| 33 | 35 | // RPC Inputs |
| 34 | 36 | |
| 35 | export class GitStatusInput extends Schema.Class<GitStatusInput>("GitStatusInput")({ | |
| 37 | export const GitStatusInput = Schema.Struct({ | |
| 36 | 38 | cwd: TrimmedNonEmptyString, |
| 37 | }) {} | |
| 39 | }); | |
| 40 | export type GitStatusInput = typeof GitStatusInput.Type; | |
| 38 | 41 | |
| 39 | export class GitPullInput extends Schema.Class<GitPullInput>("GitPullInput")({ | |
| 42 | export const GitPullInput = Schema.Struct({ | |
| 40 | 43 | cwd: TrimmedNonEmptyString, |
| 41 | }) {} | |
| 44 | }); | |
| 45 | export type GitPullInput = typeof GitPullInput.Type; | |
| 42 | 46 | |
| 43 | export class GitRunStackedActionInput extends Schema.Class<GitRunStackedActionInput>( | |
| 44 | "GitRunStackedActionInput", | |
| 45 | )({ | |
| 47 | export const GitRunStackedActionInput = Schema.Struct({ | |
| 46 | 48 | cwd: TrimmedNonEmptyString, |
| 47 | 49 | action: GitStackedAction, |
| 48 | 50 | commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))), |
| 49 | }) {} | |
| 51 | }); | |
| 52 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; | |
| 50 | 53 | |
| 51 | export class GitListBranchesInput extends Schema.Class<GitListBranchesInput>( | |
| 52 | "GitListBranchesInput", | |
| 53 | )({ | |
| 54 | export const GitListBranchesInput = Schema.Struct({ | |
| 54 | 55 | cwd: TrimmedNonEmptyString, |
| 55 | }) {} | |
| 56 | }); | |
| 57 | export type GitListBranchesInput = typeof GitListBranchesInput.Type; | |
| 56 | 58 | |
| 57 | export class GitCreateWorktreeInput extends Schema.Class<GitCreateWorktreeInput>( | |
| 58 | "GitCreateWorktreeInput", | |
| 59 | )({ | |
| 59 | export const GitCreateWorktreeInput = Schema.Struct({ | |
| 60 | 60 | cwd: TrimmedNonEmptyString, |
| 61 | 61 | branch: TrimmedNonEmptyString, |
| 62 | 62 | newBranch: TrimmedNonEmptyString, |
| 63 | 63 | path: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 64 | }) {} | |
| 64 | }); | |
| 65 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; | |
| 65 | 66 | |
| 66 | export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>( | |
| 67 | "GitRemoveWorktreeInput", | |
| 68 | )({ | |
| 67 | export const GitRemoveWorktreeInput = Schema.Struct({ | |
| 69 | 68 | cwd: TrimmedNonEmptyString, |
| 70 | 69 | path: TrimmedNonEmptyString, |
| 71 | 70 | force: Schema.optional(Schema.Boolean), |
| 72 | }) {} | |
| 71 | }); | |
| 72 | export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; | |
| 73 | 73 | |
| 74 | export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>( | |
| 75 | "GitCreateBranchInput", | |
| 76 | )({ | |
| 74 | export const GitCreateBranchInput = Schema.Struct({ | |
| 77 | 75 | cwd: TrimmedNonEmptyString, |
| 78 | 76 | branch: TrimmedNonEmptyString, |
| 79 | }) {} | |
| 77 | }); | |
| 78 | export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; | |
| 80 | 79 | |
| 81 | export class GitCheckoutInput extends Schema.Class<GitCheckoutInput>("GitCheckoutInput")({ | |
| 80 | export const GitCheckoutInput = Schema.Struct({ | |
| 82 | 81 | cwd: TrimmedNonEmptyString, |
| 83 | 82 | branch: TrimmedNonEmptyString, |
| 84 | }) {} | |
| 83 | }); | |
| 84 | export type GitCheckoutInput = typeof GitCheckoutInput.Type; | |
| 85 | 85 | |
| 86 | export class GitInitInput extends Schema.Class<GitInitInput>("GitInitInput")({ | |
| 86 | export const GitInitInput = Schema.Struct({ | |
| 87 | 87 | cwd: TrimmedNonEmptyString, |
| 88 | }) {} | |
| 88 | }); | |
| 89 | export type GitInitInput = typeof GitInitInput.Type; | |
| 89 | 90 | |
| 90 | 91 | // RPC Results |
| 91 | 92 | |
| 92 | export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusResult")({ | |
| 93 | export const GitStatusResult = Schema.Struct({ | |
| 93 | 94 | branch: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 94 | 95 | hasWorkingTreeChanges: Schema.Boolean, |
| 95 | 96 | workingTree: Schema.Struct({ |
| ... | ... | @@ -113,24 +114,21 @@ export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusRes |
| 113 | 114 | baseBranch: TrimmedNonEmptyString, |
| 114 | 115 | headBranch: TrimmedNonEmptyString, |
| 115 | 116 | }).pipe(Schema.NullOr), |
| 116 | }) {} | |
| 117 | }); | |
| 118 | export type GitStatusResult = typeof GitStatusResult.Type; | |
| 117 | 119 | |
| 118 | export class GitListBranchesResult extends Schema.Class<GitListBranchesResult>( | |
| 119 | "GitListBranchesResult", | |
| 120 | )({ | |
| 120 | export const GitListBranchesResult = Schema.Struct({ | |
| 121 | 121 | branches: Schema.Array(GitBranch), |
| 122 | 122 | isRepo: Schema.Boolean, |
| 123 | }) {} | |
| 123 | }); | |
| 124 | export type GitListBranchesResult = typeof GitListBranchesResult.Type; | |
| 124 | 125 | |
| 125 | export class GitCreateWorktreeResult extends Schema.Class<GitCreateWorktreeResult>( | |
| 126 | "GitCreateWorktreeResult", | |
| 127 | )({ | |
| 126 | export const GitCreateWorktreeResult = Schema.Struct({ | |
| 128 | 127 | worktree: GitWorktree, |
| 129 | }) {} | |
| 128 | }); | |
| 129 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; | |
| 130 | 130 | |
| 131 | export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionResult>( | |
| 132 | "GitRunStackedActionResult", | |
| 133 | )({ | |
| 131 | export const GitRunStackedActionResult = Schema.Struct({ | |
| 134 | 132 | action: GitStackedAction, |
| 135 | 133 | commit: Schema.Struct({ |
| 136 | 134 | status: GitCommitStepStatus, |
| ... | ... | @@ -151,10 +149,12 @@ export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionR |
| 151 | 149 | headBranch: Schema.optional(TrimmedNonEmptyString), |
| 152 | 150 | title: Schema.optional(TrimmedNonEmptyString), |
| 153 | 151 | }), |
| 154 | }) {} | |
| 152 | }); | |
| 153 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; | |
| 155 | 154 | |
| 156 | export class GitPullResult extends Schema.Class<GitPullResult>("GitPullResult")({ | |
| 155 | export const GitPullResult = Schema.Struct({ | |
| 157 | 156 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), |
| 158 | 157 | branch: TrimmedNonEmptyString, |
| 159 | 158 | upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 160 | }) {} | |
| 159 | }); | |
| 160 | export type GitPullResult = typeof GitPullResult.Type; |
packages/contracts/src/keybindings.ts+12-14| ... | ... | @@ -44,25 +44,27 @@ export const KeybindingWhen = Schema.Trim.check( |
| 44 | 44 | Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), |
| 45 | 45 | ); |
| 46 | 46 | export type KeybindingWhen = typeof KeybindingWhen.Type; |
| 47 | export class KeybindingRule extends Schema.Class<KeybindingRule>("KeybindingRule")({ | |
| 47 | export const KeybindingRule = Schema.Struct({ | |
| 48 | 48 | key: KeybindingValue, |
| 49 | 49 | command: KeybindingCommand, |
| 50 | 50 | when: Schema.optional(KeybindingWhen), |
| 51 | }) {} | |
| 51 | }); | |
| 52 | export type KeybindingRule = typeof KeybindingRule.Type; | |
| 52 | 53 | |
| 53 | 54 | export const KeybindingsConfig = Schema.Array(KeybindingRule).check( |
| 54 | 55 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), |
| 55 | 56 | ); |
| 56 | 57 | export type KeybindingsConfig = typeof KeybindingsConfig.Type; |
| 57 | 58 | |
| 58 | export class KeybindingShortcut extends Schema.Class<KeybindingShortcut>("KeybindingShortcut")({ | |
| 59 | export const KeybindingShortcut = Schema.Struct({ | |
| 59 | 60 | key: KeybindingValue, |
| 60 | 61 | metaKey: Schema.Boolean, |
| 61 | 62 | ctrlKey: Schema.Boolean, |
| 62 | 63 | shiftKey: Schema.Boolean, |
| 63 | 64 | altKey: Schema.Boolean, |
| 64 | 65 | modKey: Schema.Boolean, |
| 65 | }) {} | |
| 66 | }); | |
| 67 | export type KeybindingShortcut = typeof KeybindingShortcut.Type; | |
| 66 | 68 | |
| 67 | 69 | export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([ |
| 68 | 70 | Schema.Struct({ |
| ... | ... | @@ -90,16 +92,12 @@ export type KeybindingWhenNode = |
| 90 | 92 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } |
| 91 | 93 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; |
| 92 | 94 | |
| 93 | export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>( | |
| 94 | "ResolvedKeybindingRule", | |
| 95 | )( | |
| 96 | { | |
| 97 | command: KeybindingCommand, | |
| 98 | shortcut: KeybindingShortcut, | |
| 99 | whenAst: Schema.optional(KeybindingWhenNode), | |
| 100 | }, | |
| 101 | { parseOptions: { onExcessProperty: "ignore" } }, | |
| 102 | ) {} | |
| 95 | export const ResolvedKeybindingRule = Schema.Struct({ | |
| 96 | command: KeybindingCommand, | |
| 97 | shortcut: KeybindingShortcut, | |
| 98 | whenAst: Schema.optional(KeybindingWhenNode), | |
| 99 | }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); | |
| 100 | export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; | |
| 103 | 101 | |
| 104 | 102 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( |
| 105 | 103 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), |
packages/contracts/src/model.ts+4| ... | ... | @@ -37,3 +37,7 @@ export function resolveModelSlug(model: string | null | undefined): ModelSlug { |
| 37 | 37 | |
| 38 | 38 | return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL; |
| 39 | 39 | } |
| 40 | ||
| 41 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | |
| 42 | export type ReasoningEffort = (typeof REASONING_OPTIONS)[number]; | |
| 43 | export const DEFAULT_REASONING: ReasoningEffort = "high"; |
packages/contracts/src/orchestration.test.ts created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Schema } from "effect"; | |
| 3 | ||
| 4 | import { OrchestrationGetTurnDiffInput, ThreadTurnDiff } from "./orchestration"; | |
| 5 | ||
| 6 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); | |
| 7 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); | |
| 8 | ||
| 9 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => | |
| 10 | Effect.gen(function* () { | |
| 11 | const parsed = yield* decodeTurnDiffInput({ | |
| 12 | threadId: "thread-1", | |
| 13 | fromTurnCount: 1, | |
| 14 | toTurnCount: 2, | |
| 15 | }); | |
| 16 | assert.strictEqual(parsed.fromTurnCount, 1); | |
| 17 | assert.strictEqual(parsed.toTurnCount, 2); | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | it.effect("rejects turn diff input when fromTurnCount > toTurnCount", () => | |
| 22 | Effect.gen(function* () { | |
| 23 | const result = yield* Effect.exit( | |
| 24 | decodeTurnDiffInput({ | |
| 25 | threadId: "thread-1", | |
| 26 | fromTurnCount: 3, | |
| 27 | toTurnCount: 2, | |
| 28 | }), | |
| 29 | ); | |
| 30 | assert.strictEqual(result._tag, "Failure"); | |
| 31 | }), | |
| 32 | ); | |
| 33 | ||
| 34 | it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () => | |
| 35 | Effect.gen(function* () { | |
| 36 | const result = yield* Effect.exit( | |
| 37 | decodeThreadTurnDiff({ | |
| 38 | threadId: "thread-1", | |
| 39 | fromTurnCount: 3, | |
| 40 | toTurnCount: 2, | |
| 41 | diff: "patch", | |
| 42 | }), | |
| 43 | ); | |
| 44 | assert.strictEqual(result._tag, "Failure"); | |
| 45 | }), | |
| 46 | ); |
packages/contracts/src/orchestration.ts+22-11| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import { Option, Schema, SchemaIssue, Struct } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | export const ORCHESTRATION_WS_METHODS = { |
| 4 | 4 | getSnapshot: "orchestration.getSnapshot", |
| ... | ... | @@ -777,13 +777,24 @@ export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandRecei |
| 777 | 777 | export const TurnCountRange = Schema.Struct({ |
| 778 | 778 | fromTurnCount: NonNegativeInt, |
| 779 | 779 | toTurnCount: NonNegativeInt, |
| 780 | }); | |
| 780 | }).check( | |
| 781 | Schema.makeFilter( | |
| 782 | (input) => | |
| 783 | input.fromTurnCount <= input.toTurnCount || | |
| 784 | new SchemaIssue.InvalidValue(Option.some(input.fromTurnCount), { | |
| 785 | message: "fromTurnCount must be less than or equal to toTurnCount", | |
| 786 | }), | |
| 787 | { identifier: "OrchestrationTurnDiffRange" }, | |
| 788 | ), | |
| 789 | ); | |
| 781 | 790 | |
| 782 | export const ThreadTurnDiff = Schema.Struct({ | |
| 783 | threadId: ThreadId, | |
| 784 | ...TurnCountRange.fields, | |
| 785 | diff: Schema.String, | |
| 786 | }); | |
| 791 | export const ThreadTurnDiff = TurnCountRange.mapFields( | |
| 792 | Struct.assign({ | |
| 793 | threadId: ThreadId, | |
| 794 | diff: Schema.String, | |
| 795 | }), | |
| 796 | { unsafePreserveChecks: true }, | |
| 797 | ); | |
| 787 | 798 | |
| 788 | 799 | export const ProviderSessionRuntimeStatus = Schema.Literals([ |
| 789 | 800 | "starting", |
| ... | ... | @@ -829,10 +840,10 @@ export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput |
| 829 | 840 | export const OrchestrationGetSnapshotResult = OrchestrationReadModel; |
| 830 | 841 | export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; |
| 831 | 842 | |
| 832 | export const OrchestrationGetTurnDiffInput = Schema.Struct({ | |
| 833 | threadId: ThreadId, | |
| 834 | ...TurnCountRange.fields, | |
| 835 | }); | |
| 843 | export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( | |
| 844 | Struct.assign({ threadId: ThreadId }), | |
| 845 | { unsafePreserveChecks: true }, | |
| 846 | ); | |
| 836 | 847 | export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type; |
| 837 | 848 | |
| 838 | 849 | export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; |
packages/contracts/src/project.ts+9-10| ... | ... | @@ -3,28 +3,27 @@ import { Schema } from "effect"; |
| 3 | 3 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| 4 | 4 | const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); |
| 5 | 5 | |
| 6 | export class ProjectSearchEntriesInput extends Schema.Class<ProjectSearchEntriesInput>( | |
| 7 | "ProjectSearchEntriesInput", | |
| 8 | )({ | |
| 6 | export const ProjectSearchEntriesInput = Schema.Struct({ | |
| 9 | 7 | cwd: TrimmedNonEmptyString, |
| 10 | 8 | query: Schema.NonEmptyString.check(Schema.isMaxLength(256)), |
| 11 | 9 | limit: Schema.Int.check( |
| 12 | 10 | Schema.isGreaterThanOrEqualTo(1), |
| 13 | 11 | Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT), |
| 14 | 12 | ), |
| 15 | }) {} | |
| 13 | }); | |
| 14 | export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; | |
| 16 | 15 | |
| 17 | 16 | export const ProjectEntryKind = Schema.Literals(["file", "directory"]); |
| 18 | 17 | |
| 19 | export class ProjectEntry extends Schema.Class<ProjectEntry>("ProjectEntry")({ | |
| 18 | export const ProjectEntry = Schema.Struct({ | |
| 20 | 19 | path: TrimmedNonEmptyString, |
| 21 | 20 | kind: ProjectEntryKind, |
| 22 | 21 | parentPath: Schema.optional(TrimmedNonEmptyString), |
| 23 | }) {} | |
| 22 | }); | |
| 23 | export type ProjectEntry = typeof ProjectEntry.Type; | |
| 24 | 24 | |
| 25 | export class ProjectSearchEntriesResult extends Schema.Class<ProjectSearchEntriesResult>( | |
| 26 | "ProjectSearchEntriesResult", | |
| 27 | )({ | |
| 25 | export const ProjectSearchEntriesResult = Schema.Struct({ | |
| 28 | 26 | entries: Schema.Array(ProjectEntry), |
| 29 | 27 | truncated: Schema.Boolean, |
| 30 | }) {} | |
| 28 | }); | |
| 29 | export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; |
packages/contracts/src/ws.test.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Schema } from "effect"; | |
| 3 | ||
| 4 | import { ORCHESTRATION_WS_METHODS } from "./orchestration"; | |
| 5 | import { WebSocketRequest } from "./ws"; | |
| 6 | ||
| 7 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); | |
| 8 | ||
| 9 | it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () => | |
| 10 | Effect.gen(function* () { | |
| 11 | const parsed = yield* decodeWebSocketRequest({ | |
| 12 | id: "req-1", | |
| 13 | body: { | |
| 14 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 15 | threadId: "thread-1", | |
| 16 | fromTurnCount: 1, | |
| 17 | toTurnCount: 2, | |
| 18 | }, | |
| 19 | }); | |
| 20 | assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff); | |
| 21 | }), | |
| 22 | ); | |
| 23 | ||
| 24 | it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () => | |
| 25 | Effect.gen(function* () { | |
| 26 | const result = yield* Effect.exit( | |
| 27 | decodeWebSocketRequest({ | |
| 28 | id: "req-1", | |
| 29 | body: { | |
| 30 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 31 | threadId: "thread-1", | |
| 32 | fromTurnCount: 3, | |
| 33 | toTurnCount: 2, | |
| 34 | }, | |
| 35 | }), | |
| 36 | ); | |
| 37 | assert.strictEqual(result._tag, "Failure"); | |
| 38 | }), | |
| 39 | ); |
packages/contracts/src/ws.ts+49-33| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import { Schema, Struct } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | ClientOrchestrationCommand, |
| ... | ... | @@ -76,43 +76,56 @@ export const WS_CHANNELS = { |
| 76 | 76 | |
| 77 | 77 | // -- Tagged Union of all request body schemas ───────────────────────── |
| 78 | 78 | |
| 79 | export const WebSocketRequestBody = Schema.TaggedUnion({ | |
| 79 | const tagRequestBody = <const Tag extends string, const Fields extends Schema.Struct.Fields>( | |
| 80 | tag: Tag, | |
| 81 | schema: Schema.Struct<Fields>, | |
| 82 | ) => | |
| 83 | schema.mapFields( | |
| 84 | Struct.assign({ _tag: Schema.tag(tag) }), | |
| 85 | // PreserveChecks is safe here. No existing schema should have checks depending on the tag | |
| 86 | { unsafePreserveChecks: true }, | |
| 87 | ); | |
| 88 | ||
| 89 | export const WebSocketRequestBody = Schema.Union([ | |
| 80 | 90 | // Orchestration methods |
| 81 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand }, | |
| 82 | [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields, | |
| 83 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields, | |
| 84 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: OrchestrationGetFullThreadDiffInput.fields, | |
| 85 | [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields, | |
| 91 | tagRequestBody( | |
| 92 | ORCHESTRATION_WS_METHODS.dispatchCommand, | |
| 93 | Schema.Struct({ command: ClientOrchestrationCommand }), | |
| 94 | ), | |
| 95 | tagRequestBody(ORCHESTRATION_WS_METHODS.getSnapshot, OrchestrationGetSnapshotInput), | |
| 96 | tagRequestBody(ORCHESTRATION_WS_METHODS.getTurnDiff, OrchestrationGetTurnDiffInput), | |
| 97 | tagRequestBody(ORCHESTRATION_WS_METHODS.getFullThreadDiff, OrchestrationGetFullThreadDiffInput), | |
| 98 | tagRequestBody(ORCHESTRATION_WS_METHODS.replayEvents, OrchestrationReplayEventsInput), | |
| 86 | 99 | |
| 87 | 100 | // Project Search |
| 88 | [WS_METHODS.projectsSearchEntries]: ProjectSearchEntriesInput.fields, | |
| 101 | tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), | |
| 89 | 102 | |
| 90 | 103 | // Shell methods |
| 91 | [WS_METHODS.shellOpenInEditor]: OpenInEditorInput.fields, | |
| 104 | tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), | |
| 92 | 105 | |
| 93 | 106 | // Git methods |
| 94 | [WS_METHODS.gitPull]: GitPullInput.fields, | |
| 95 | [WS_METHODS.gitStatus]: GitStatusInput.fields, | |
| 96 | [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields, | |
| 97 | [WS_METHODS.gitListBranches]: GitListBranchesInput.fields, | |
| 98 | [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields, | |
| 99 | [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields, | |
| 100 | [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields, | |
| 101 | [WS_METHODS.gitCheckout]: GitCheckoutInput.fields, | |
| 102 | [WS_METHODS.gitInit]: GitInitInput.fields, | |
| 107 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), | |
| 108 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), | |
| 109 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), | |
| 110 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), | |
| 111 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), | |
| 112 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), | |
| 113 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), | |
| 114 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), | |
| 115 | tagRequestBody(WS_METHODS.gitInit, GitInitInput), | |
| 103 | 116 | |
| 104 | 117 | // Terminal methods |
| 105 | [WS_METHODS.terminalOpen]: TerminalOpenInput.fields, | |
| 106 | [WS_METHODS.terminalWrite]: TerminalWriteInput.fields, | |
| 107 | [WS_METHODS.terminalResize]: TerminalResizeInput.fields, | |
| 108 | [WS_METHODS.terminalClear]: TerminalClearInput.fields, | |
| 109 | [WS_METHODS.terminalRestart]: TerminalRestartInput.fields, | |
| 110 | [WS_METHODS.terminalClose]: TerminalCloseInput.fields, | |
| 118 | tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), | |
| 119 | tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput), | |
| 120 | tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput), | |
| 121 | tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput), | |
| 122 | tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), | |
| 123 | tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), | |
| 111 | 124 | |
| 112 | 125 | // Server meta |
| 113 | [WS_METHODS.serverGetConfig]: {}, | |
| 114 | [WS_METHODS.serverUpsertKeybinding]: KeybindingRule.fields, | |
| 115 | }); | |
| 126 | tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), | |
| 127 | tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), | |
| 128 | ]); | |
| 116 | 129 | |
| 117 | 130 | export const WebSocketRequest = Schema.Struct({ |
| 118 | 131 | id: Schema.String, |
| ... | ... | @@ -120,7 +133,7 @@ export const WebSocketRequest = Schema.Struct({ |
| 120 | 133 | }); |
| 121 | 134 | export type WebSocketRequest = typeof WebSocketRequest.Type; |
| 122 | 135 | |
| 123 | export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocketResponse")({ | |
| 136 | export const WebSocketResponse = Schema.Struct({ | |
| 124 | 137 | id: Schema.String, |
| 125 | 138 | result: Schema.optional(Schema.Unknown), |
| 126 | 139 | error: Schema.optional( |
| ... | ... | @@ -128,13 +141,15 @@ export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocke |
| 128 | 141 | message: Schema.String, |
| 129 | 142 | }), |
| 130 | 143 | ), |
| 131 | }) {} | |
| 144 | }); | |
| 145 | export type WebSocketResponse = typeof WebSocketResponse.Type; | |
| 132 | 146 | |
| 133 | export class WsPush extends Schema.Class<WsPush>("WsPushEvent")({ | |
| 147 | export const WsPush = Schema.Struct({ | |
| 134 | 148 | type: Schema.Literal("push"), |
| 135 | 149 | channel: Schema.String, |
| 136 | 150 | data: Schema.Unknown, |
| 137 | }) {} | |
| 151 | }); | |
| 152 | export type WsPush = typeof WsPush.Type; | |
| 138 | 153 | |
| 139 | 154 | // ── Union of all server → client messages ───────────────────────────── |
| 140 | 155 | |
| ... | ... | @@ -143,7 +158,8 @@ export type WsResponse = typeof WsResponse.Type; |
| 143 | 158 | |
| 144 | 159 | // ── Server welcome payload ─────────────────────────────────────────── |
| 145 | 160 | |
| 146 | export class WsWelcomePayload extends Schema.Class<WsWelcomePayload>("WsWelcomePayload")({ | |
| 161 | export const WsWelcomePayload = Schema.Struct({ | |
| 147 | 162 | cwd: Schema.String, |
| 148 | 163 | projectName: Schema.String, |
| 149 | }) {} | |
| 164 | }); | |
| 165 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; |