| author | |
| committer | |
| log | 0dd5c64bcfac7974d81bac76740ecd83540077e8 |
| tree | 5928af5f93a8cacd90fa9793ee38e89576c5844e |
| parent | f33fdc992488e36ccb70cbb71d55e630b5184cbd |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 129 insertions(+), 17 deletions(-)
apps/server/src/git/Utils.ts deleted-7| ... | ... | @@ -1,7 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodePath from "node:path"; | |
| 4 | ||
| 5 | export function isGitRepository(cwd: string): boolean { | |
| 6 | return NodeFS.existsSync(NodePath.join(cwd, ".git")); | |
| 7 | } |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+77| ... | ... | @@ -590,6 +590,83 @@ describe("CheckpointReactor", () => { |
| 590 | 590 | }), |
| 591 | 591 | ); |
| 592 | 592 | |
| 593 | effectIt.effect("captures and reverts checkpoints from a nested Git workspace", () => | |
| 594 | Effect.gen(function* () { | |
| 595 | const repositoryRoot = createGitRepository(); | |
| 596 | tempDirs.push(repositoryRoot); | |
| 597 | const workspaceRoot = NodePath.join(repositoryRoot, "apps", "server"); | |
| 598 | NodeFS.mkdirSync(workspaceRoot, { recursive: true }); | |
| 599 | const filePath = NodePath.join(workspaceRoot, "index.ts"); | |
| 600 | NodeFS.writeFileSync(filePath, "export const value = 1;\n"); | |
| 601 | runGit(repositoryRoot, ["add", "."]); | |
| 602 | runGit(repositoryRoot, ["commit", "-m", "Add nested workspace"]); | |
| 603 | const harness = yield* Effect.promise(() => | |
| 604 | createHarness({ | |
| 605 | seedFilesystemCheckpoints: false, | |
| 606 | projectWorkspaceRoot: workspaceRoot, | |
| 607 | threadWorktreePath: workspaceRoot, | |
| 608 | providerSessionCwd: workspaceRoot, | |
| 609 | }), | |
| 610 | ); | |
| 611 | const threadId = ThreadId.make("thread-1"); | |
| 612 | const turnId = asTurnId("turn-nested"); | |
| 613 | const createdAt = "2026-01-01T00:00:00.000Z"; | |
| 614 | harness.provider.emit({ | |
| 615 | type: "turn.started", | |
| 616 | eventId: EventId.make("evt-nested-start"), | |
| 617 | provider: ProviderDriverKind.make("codex"), | |
| 618 | createdAt, | |
| 619 | threadId, | |
| 620 | turnId, | |
| 621 | }); | |
| 622 | yield* Effect.promise(harness.drain); | |
| 623 | expect(gitRefExists(repositoryRoot, checkpointRefForThreadTurn(threadId, 0))).toBe(true); | |
| 624 | expect(yield* harness.nextReceipt).toMatchObject({ | |
| 625 | type: "checkpoint.baseline.captured", | |
| 626 | }); | |
| 627 | ||
| 628 | NodeFS.writeFileSync(filePath, "export const value = 2;\n"); | |
| 629 | harness.provider.emit({ | |
| 630 | type: "turn.completed", | |
| 631 | eventId: EventId.make("evt-nested-complete"), | |
| 632 | provider: ProviderDriverKind.make("codex"), | |
| 633 | createdAt, | |
| 634 | threadId, | |
| 635 | turnId, | |
| 636 | payload: { state: "completed" }, | |
| 637 | }); | |
| 638 | yield* Effect.promise(harness.drain); | |
| 639 | const thread = (yield* Effect.promise(harness.readModel)).threads.find( | |
| 640 | (entry) => entry.id === threadId, | |
| 641 | ); | |
| 642 | expect(thread?.checkpoints[0]).toMatchObject({ | |
| 643 | status: "ready", | |
| 644 | files: [{ path: "apps/server/index.ts", additions: 1, deletions: 1 }], | |
| 645 | }); | |
| 646 | expect(yield* harness.nextReceipt).toMatchObject({ | |
| 647 | type: "checkpoint.diff.finalized", | |
| 648 | turnId, | |
| 649 | }); | |
| 650 | expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" }); | |
| 651 | ||
| 652 | yield* harness.engine.dispatch({ | |
| 653 | type: "thread.checkpoint.revert", | |
| 654 | commandId: CommandId.make("cmd-nested-revert"), | |
| 655 | threadId, | |
| 656 | turnCount: 0, | |
| 657 | createdAt, | |
| 658 | }); | |
| 659 | yield* Effect.promise(harness.drain); | |
| 660 | expect(NodeFS.readFileSync(filePath, "utf8")).toBe("export const value = 1;\n"); | |
| 661 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ threadId, numTurns: 1 }); | |
| 662 | expect(gitRefExists(repositoryRoot, checkpointRefForThreadTurn(threadId, 1))).toBe(false); | |
| 663 | const reverted = (yield* Effect.promise(harness.readModel)).threads.find( | |
| 664 | (entry) => entry.id === threadId, | |
| 665 | ); | |
| 666 | expect(reverted?.checkpoints).toEqual([]); | |
| 667 | }), | |
| 668 | ); | |
| 669 | ||
| 593 | 670 | it("refreshes local git status state on turn completion using the session cwd", async () => { |
| 594 | 671 | const gitStatusRefreshCalls: string[] = []; |
| 595 | 672 | const harness = await createHarness({ |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-6| ... | ... | @@ -35,7 +35,6 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" |
| 35 | 35 | import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; |
| 36 | 36 | import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 37 | 37 | import type { OrchestrationDispatchError } from "../Errors.ts"; |
| 38 | import { isGitRepository } from "../../git/Utils.ts"; | |
| 39 | 38 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 40 | 39 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; |
| 41 | 40 | import * as PullRequestService from "../../pullRequest/PullRequestService.ts"; |
| ... | ... | @@ -181,8 +180,6 @@ const make = Effect.gen(function* () { |
| 181 | 180 | return project ? [project] : []; |
| 182 | 181 | }); |
| 183 | 182 | |
| 184 | const isGitWorkspace = (cwd: string) => isGitRepository(cwd); | |
| 185 | ||
| 186 | 183 | // Resolves the workspace CWD for checkpoint operations, preferring the |
| 187 | 184 | // active provider session CWD and falling back to the thread/project config. |
| 188 | 185 | // Returns undefined when no CWD can be determined or the workspace is not |
| ... | ... | @@ -192,7 +189,7 @@ const make = Effect.gen(function* () { |
| 192 | 189 | readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null }; |
| 193 | 190 | readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; |
| 194 | 191 | readonly preferSessionRuntime: boolean; |
| 195 | }): Effect.fn.Return<string | undefined> { | |
| 192 | }): Effect.fn.Return<string | undefined, CheckpointStoreError> { | |
| 196 | 193 | const fromSession = yield* resolveSessionRuntimeForThread(input.threadId); |
| 197 | 194 | const fromThread = resolveThreadWorkspaceCwd({ |
| 198 | 195 | thread: input.thread, |
| ... | ... | @@ -213,7 +210,7 @@ const make = Effect.gen(function* () { |
| 213 | 210 | if (!cwd) { |
| 214 | 211 | return undefined; |
| 215 | 212 | } |
| 216 | if (!isGitWorkspace(cwd)) { | |
| 213 | if (!(yield* checkpointStore.isGitRepository(cwd))) { | |
| 217 | 214 | return undefined; |
| 218 | 215 | } |
| 219 | 216 | return cwd; |
| ... | ... | @@ -751,7 +748,7 @@ const make = Effect.gen(function* () { |
| 751 | 748 | }).pipe(Effect.catch(() => Effect.void)); |
| 752 | 749 | return; |
| 753 | 750 | } |
| 754 | if (!isGitWorkspace(sessionRuntime.value.cwd)) { | |
| 751 | if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) { | |
| 755 | 752 | yield* appendRevertFailureActivity({ |
| 756 | 753 | threadId: event.payload.threadId, |
| 757 | 754 | turnCount: event.payload.turnCount, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+41-2| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | import * as NodeFS from "node:fs"; |
| 3 | 3 | import * as NodeOS from "node:os"; |
| 4 | 4 | import * as NodePath from "node:path"; |
| 5 | import * as NodeChildProcess from "node:child_process"; | |
| 5 | 6 | |
| 6 | 7 | import { |
| 7 | 8 | OrchestrationReadModel, |
| ... | ... | @@ -44,6 +45,9 @@ import { |
| 44 | 45 | type ProviderServiceShape, |
| 45 | 46 | } from "../../provider/Services/ProviderService.ts"; |
| 46 | 47 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 48 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; | |
| 49 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; | |
| 50 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; | |
| 47 | 51 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 48 | 52 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 49 | 53 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| ... | ... | @@ -257,9 +261,15 @@ describe("ProviderRuntimeIngestion", () => { |
| 257 | 261 | async function createHarness(options?: { |
| 258 | 262 | serverSettings?: Partial<ServerSettings>; |
| 259 | 263 | threadTitle?: string; |
| 264 | workspaceSubdirectory?: string; | |
| 260 | 265 | }) { |
| 261 | const workspaceRoot = makeTempDir("t3-provider-project-"); | |
| 262 | NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); | |
| 266 | const repositoryRoot = makeTempDir("t3-provider-project-"); | |
| 267 | NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main"], { | |
| 268 | cwd: repositoryRoot, | |
| 269 | stdio: "ignore", | |
| 270 | }); | |
| 271 | const workspaceRoot = NodePath.join(repositoryRoot, options?.workspaceSubdirectory ?? ""); | |
| 272 | NodeFS.mkdirSync(workspaceRoot, { recursive: true }); | |
| 263 | 273 | const provider = createProviderServiceHarness(); |
| 264 | 274 | const sqlCounter = makeSqlStatementCounter(); |
| 265 | 275 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| ... | ... | @@ -284,6 +294,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 284 | 294 | Layer.provideMerge(SqlitePersistenceMemory), |
| 285 | 295 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 286 | 296 | Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), |
| 297 | Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), | |
| 298 | Layer.provideMerge(VcsProcess.layer), | |
| 287 | 299 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 288 | 300 | Layer.provideMerge(NodeServices.layer), |
| 289 | 301 | Layer.provideMerge(Layer.succeed(Tracer.Tracer, sqlCounter.tracer)), |
| ... | ... | @@ -3177,6 +3189,33 @@ describe("ProviderRuntimeIngestion", () => { |
| 3177 | 3189 | }); |
| 3178 | 3190 | }); |
| 3179 | 3191 | |
| 3192 | effectIt.effect("tracks provider diff updates from a nested Git workspace", () => | |
| 3193 | Effect.gen(function* () { | |
| 3194 | const harness = yield* Effect.promise(() => | |
| 3195 | createHarness({ workspaceSubdirectory: "apps/server" }), | |
| 3196 | ); | |
| 3197 | yield* Effect.promise(() => | |
| 3198 | harness.emitAndDrain([ | |
| 3199 | { | |
| 3200 | type: "turn.diff.updated", | |
| 3201 | eventId: asEventId("evt-nested-diff"), | |
| 3202 | provider: ProviderDriverKind.make("codex"), | |
| 3203 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 3204 | threadId: asThreadId("thread-1"), | |
| 3205 | turnId: asTurnId("nested-turn"), | |
| 3206 | payload: { | |
| 3207 | unifiedDiff: "diff --git a/apps/server/file.ts b/apps/server/file.ts\n+new\n", | |
| 3208 | }, | |
| 3209 | }, | |
| 3210 | ]), | |
| 3211 | ); | |
| 3212 | const snapshot = yield* Effect.promise(harness.readModel); | |
| 3213 | expect(snapshot.threads[0]?.checkpoints).toEqual([ | |
| 3214 | expect.objectContaining({ turnId: "nested-turn", status: "missing" }), | |
| 3215 | ]); | |
| 3216 | }), | |
| 3217 | ); | |
| 3218 | ||
| 3180 | 3219 | it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { |
| 3181 | 3220 | const harness = await createHarness(); |
| 3182 | 3221 | const now = "2026-01-01T00:00:00.000Z"; |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+8-2| ... | ... | @@ -36,7 +36,7 @@ import { formatTokens } from "@t3tools/shared/usageFormat"; |
| 36 | 36 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 37 | 37 | import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; |
| 38 | 38 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; |
| 39 | import { isGitRepository } from "../../git/Utils.ts"; | |
| 39 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; | |
| 40 | 40 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 41 | 41 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; |
| 42 | 42 | import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; |
| ... | ... | @@ -956,6 +956,7 @@ const make = Effect.gen(function* () { |
| 956 | 956 | const providerService = yield* ProviderService; |
| 957 | 957 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| 958 | 958 | const serverSettingsService = yield* ServerSettingsService; |
| 959 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | |
| 959 | 960 | const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => |
| 960 | 961 | crypto.randomUUIDv4.pipe( |
| 961 | 962 | Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), |
| ... | ... | @@ -2013,7 +2014,12 @@ const make = Effect.gen(function* () { |
| 2013 | 2014 | : undefined; |
| 2014 | 2015 | const workspaceCwd = |
| 2015 | 2016 | checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined; |
| 2016 | if (turnId && checkpointContext && workspaceCwd && isGitRepository(workspaceCwd)) { | |
| 2017 | if ( | |
| 2018 | turnId && | |
| 2019 | checkpointContext && | |
| 2020 | workspaceCwd && | |
| 2021 | (yield* checkpointStore.isGitRepository(workspaceCwd)) | |
| 2022 | ) { | |
| 2017 | 2023 | // Skip if a checkpoint already exists for this turn. A real |
| 2018 | 2024 | // (non-placeholder) capture from CheckpointReactor should not |
| 2019 | 2025 | // be clobbered, and dispatching a duplicate placeholder for the |