| author | |
| committer | |
| log | 5165b8c333c0da8566a93f98011672ebd1dff9bb |
| tree | 1ec02cff0664a446c9257d6d4d028397a46a267f |
| parent | 8fc317939f5c8bbef4afbe309ae897abbc221631 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>32 files changed, 2123 insertions(+), 1074 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+5-3| ... | @@ -73,7 +73,6 @@ import { | ... | @@ -73,7 +73,6 @@ import { |
| 73 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; | 73 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; |
| 74 | import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; | 74 | import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; |
| 75 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; | 75 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; |
| 76 | import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; | ||
| 77 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; | 76 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; |
| 78 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; | 77 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; |
| 79 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; | 78 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; |
| ... | @@ -311,8 +310,11 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -311,8 +310,11 @@ export const makeOrchestrationIntegrationHarness = ( |
| 311 | Layer.provideMerge(serverSettingsLayer), | 310 | Layer.provideMerge(serverSettingsLayer), |
| 312 | ); | 311 | ); |
| 313 | const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ | 312 | const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ |
| 314 | renameBranch: (input: Parameters<GitVcsDriver.GitVcsDriverShape["renameBranch"]>[0]) => | 313 | renameBranch: (input: { |
| 315 | Effect.succeed({ branch: input.newBranch }), | 314 | readonly cwd: string; |
| 315 | readonly oldBranch: string; | ||
| 316 | readonly newBranch: string; | ||
| 317 | }) => Effect.succeed({ branch: input.newBranch }), | ||
| 316 | }); | 318 | }); |
| 317 | const textGenerationLayer = Layer.succeed(TextGeneration, { | 319 | const textGenerationLayer = Layer.succeed(TextGeneration, { |
| 318 | generateBranchName: () => Effect.succeed({ branch: "update" }), | 320 | generateBranchName: () => Effect.succeed({ branch: "update" }), |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+172-7| ... | @@ -41,11 +41,110 @@ function makeThreadCheckpointContext(input: { | ... | @@ -41,11 +41,110 @@ function makeThreadCheckpointContext(input: { |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | describe("CheckpointDiffQueryLive", () => { | 43 | describe("CheckpointDiffQueryLive", () => { |
| 44 | it("uses the narrow full-thread context lookup for all-turns diffs", async () => { | ||
| 45 | const projectId = ProjectId.make("project-full-thread"); | ||
| 46 | const threadId = ThreadId.make("thread-full-thread"); | ||
| 47 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 4); | ||
| 48 | let getThreadCheckpointContextCalls = 0; | ||
| 49 | let getFullThreadDiffContextCalls = 0; | ||
| 50 | const diffCheckpointsCalls: Array<{ | ||
| 51 | readonly fromCheckpointRef: CheckpointRef; | ||
| 52 | readonly toCheckpointRef: CheckpointRef; | ||
| 53 | readonly cwd: string; | ||
| 54 | readonly ignoreWhitespace: boolean; | ||
| 55 | }> = []; | ||
| 56 | |||
| 57 | const checkpointStore: CheckpointStoreShape = { | ||
| 58 | isGitRepository: () => Effect.succeed(true), | ||
| 59 | captureCheckpoint: () => Effect.void, | ||
| 60 | hasCheckpointRef: () => Effect.succeed(true), | ||
| 61 | restoreCheckpoint: () => Effect.succeed(true), | ||
| 62 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => | ||
| 63 | Effect.sync(() => { | ||
| 64 | diffCheckpointsCalls.push({ | ||
| 65 | fromCheckpointRef, | ||
| 66 | toCheckpointRef, | ||
| 67 | cwd, | ||
| 68 | ignoreWhitespace, | ||
| 69 | }); | ||
| 70 | return "full thread diff patch"; | ||
| 71 | }), | ||
| 72 | deleteCheckpointRefs: () => Effect.void, | ||
| 73 | }; | ||
| 74 | |||
| 75 | const layer = CheckpointDiffQueryLive.pipe( | ||
| 76 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | ||
| 77 | Layer.provideMerge( | ||
| 78 | Layer.succeed(ProjectionSnapshotQuery, { | ||
| 79 | getCommandReadModel: () => | ||
| 80 | Effect.die("CheckpointDiffQuery should not request the command read model"), | ||
| 81 | getSnapshot: () => | ||
| 82 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | ||
| 83 | getShellSnapshot: () => | ||
| 84 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | ||
| 85 | getArchivedShellSnapshot: () => | ||
| 86 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | ||
| 87 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | ||
| 88 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | ||
| 89 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | ||
| 90 | getProjectShellById: () => Effect.succeed(Option.none()), | ||
| 91 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | ||
| 92 | getThreadCheckpointContext: () => | ||
| 93 | Effect.sync(() => { | ||
| 94 | getThreadCheckpointContextCalls += 1; | ||
| 95 | return Option.none(); | ||
| 96 | }), | ||
| 97 | getFullThreadDiffContext: () => | ||
| 98 | Effect.sync(() => { | ||
| 99 | getFullThreadDiffContextCalls += 1; | ||
| 100 | return Option.some({ | ||
| 101 | threadId, | ||
| 102 | projectId, | ||
| 103 | workspaceRoot: "/tmp/workspace", | ||
| 104 | worktreePath: "/tmp/worktree", | ||
| 105 | latestCheckpointTurnCount: 4, | ||
| 106 | toCheckpointRef, | ||
| 107 | }); | ||
| 108 | }), | ||
| 109 | getThreadShellById: () => Effect.succeed(Option.none()), | ||
| 110 | getThreadDetailById: () => Effect.succeed(Option.none()), | ||
| 111 | }), | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | |||
| 115 | const result = await Effect.runPromise( | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const query = yield* CheckpointDiffQuery; | ||
| 118 | return yield* query.getFullThreadDiff({ | ||
| 119 | threadId, | ||
| 120 | toTurnCount: 4, | ||
| 121 | ignoreWhitespace: true, | ||
| 122 | }); | ||
| 123 | }).pipe(Effect.provide(layer)), | ||
| 124 | ); | ||
| 125 | |||
| 126 | expect(getThreadCheckpointContextCalls).toBe(0); | ||
| 127 | expect(getFullThreadDiffContextCalls).toBe(1); | ||
| 128 | expect(diffCheckpointsCalls).toEqual([ | ||
| 129 | { | ||
| 130 | cwd: "/tmp/worktree", | ||
| 131 | fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), | ||
| 132 | toCheckpointRef, | ||
| 133 | ignoreWhitespace: true, | ||
| 134 | }, | ||
| 135 | ]); | ||
| 136 | expect(result).toEqual({ | ||
| 137 | threadId, | ||
| 138 | fromTurnCount: 0, | ||
| 139 | toTurnCount: 4, | ||
| 140 | diff: "full thread diff patch", | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | |||
| 44 | it("computes diffs using canonical turn-0 checkpoint refs", async () => { | 144 | it("computes diffs using canonical turn-0 checkpoint refs", async () => { |
| 45 | const projectId = ProjectId.make("project-1"); | 145 | const projectId = ProjectId.make("project-1"); |
| 46 | const threadId = ThreadId.make("thread-1"); | 146 | const threadId = ThreadId.make("thread-1"); |
| 47 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | 147 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); |
| 48 | const hasCheckpointRefCalls: Array<CheckpointRef> = []; | ||
| 49 | const diffCheckpointsCalls: Array<{ | 148 | const diffCheckpointsCalls: Array<{ |
| 50 | readonly fromCheckpointRef: CheckpointRef; | 149 | readonly fromCheckpointRef: CheckpointRef; |
| 51 | readonly toCheckpointRef: CheckpointRef; | 150 | readonly toCheckpointRef: CheckpointRef; |
| ... | @@ -65,11 +164,7 @@ describe("CheckpointDiffQueryLive", () => { | ... | @@ -65,11 +164,7 @@ describe("CheckpointDiffQueryLive", () => { |
| 65 | const checkpointStore: CheckpointStoreShape = { | 164 | const checkpointStore: CheckpointStoreShape = { |
| 66 | isGitRepository: () => Effect.succeed(true), | 165 | isGitRepository: () => Effect.succeed(true), |
| 67 | captureCheckpoint: () => Effect.void, | 166 | captureCheckpoint: () => Effect.void, |
| 68 | hasCheckpointRef: ({ checkpointRef }) => | 167 | hasCheckpointRef: () => Effect.succeed(true), |
| 69 | Effect.sync(() => { | ||
| 70 | hasCheckpointRefCalls.push(checkpointRef); | ||
| 71 | return true; | ||
| 72 | }), | ||
| 73 | restoreCheckpoint: () => Effect.succeed(true), | 168 | restoreCheckpoint: () => Effect.succeed(true), |
| 74 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => | 169 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => |
| 75 | Effect.sync(() => { | 170 | Effect.sync(() => { |
| ... | @@ -102,6 +197,7 @@ describe("CheckpointDiffQueryLive", () => { | ... | @@ -102,6 +197,7 @@ describe("CheckpointDiffQueryLive", () => { |
| 102 | getProjectShellById: () => Effect.succeed(Option.none()), | 197 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 103 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 198 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 104 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 199 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 200 | getFullThreadDiffContext: () => Effect.die("unused"), | ||
| 105 | getThreadShellById: () => Effect.succeed(Option.none()), | 201 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 106 | getThreadDetailById: () => Effect.succeed(Option.none()), | 202 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 107 | }), | 203 | }), |
| ... | @@ -121,7 +217,6 @@ describe("CheckpointDiffQueryLive", () => { | ... | @@ -121,7 +217,6 @@ describe("CheckpointDiffQueryLive", () => { |
| 121 | ); | 217 | ); |
| 122 | 218 | ||
| 123 | const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); | 219 | const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); |
| 124 | expect(hasCheckpointRefCalls).toEqual([expectedFromRef, toCheckpointRef]); | ||
| 125 | expect(diffCheckpointsCalls).toEqual([ | 220 | expect(diffCheckpointsCalls).toEqual([ |
| 126 | { | 221 | { |
| 127 | cwd: "/tmp/workspace", | 222 | cwd: "/tmp/workspace", |
| ... | @@ -184,6 +279,7 @@ describe("CheckpointDiffQueryLive", () => { | ... | @@ -184,6 +279,7 @@ describe("CheckpointDiffQueryLive", () => { |
| 184 | getProjectShellById: () => Effect.succeed(Option.none()), | 279 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 185 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 280 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 186 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 281 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 282 | getFullThreadDiffContext: () => Effect.die("unused"), | ||
| 187 | getThreadShellById: () => Effect.succeed(Option.none()), | 283 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 188 | getThreadDetailById: () => Effect.succeed(Option.none()), | 284 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 189 | }), | 285 | }), |
| ... | @@ -204,6 +300,74 @@ describe("CheckpointDiffQueryLive", () => { | ... | @@ -204,6 +300,74 @@ describe("CheckpointDiffQueryLive", () => { |
| 204 | expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); | 300 | expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); |
| 205 | }); | 301 | }); |
| 206 | 302 | ||
| 303 | it("does not preflight checkpoint refs before diffing", async () => { | ||
| 304 | const projectId = ProjectId.make("project-no-preflight"); | ||
| 305 | const threadId = ThreadId.make("thread-no-preflight"); | ||
| 306 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | ||
| 307 | let hasCheckpointRefCallCount = 0; | ||
| 308 | |||
| 309 | const threadCheckpointContext = makeThreadCheckpointContext({ | ||
| 310 | projectId, | ||
| 311 | threadId, | ||
| 312 | workspaceRoot: "/tmp/workspace", | ||
| 313 | worktreePath: null, | ||
| 314 | checkpointTurnCount: 1, | ||
| 315 | checkpointRef: toCheckpointRef, | ||
| 316 | }); | ||
| 317 | |||
| 318 | const checkpointStore: CheckpointStoreShape = { | ||
| 319 | isGitRepository: () => Effect.succeed(true), | ||
| 320 | captureCheckpoint: () => Effect.void, | ||
| 321 | hasCheckpointRef: () => | ||
| 322 | Effect.sync(() => { | ||
| 323 | hasCheckpointRefCallCount += 1; | ||
| 324 | return true; | ||
| 325 | }), | ||
| 326 | restoreCheckpoint: () => Effect.succeed(true), | ||
| 327 | diffCheckpoints: () => Effect.succeed("diff patch"), | ||
| 328 | deleteCheckpointRefs: () => Effect.void, | ||
| 329 | }; | ||
| 330 | |||
| 331 | const layer = CheckpointDiffQueryLive.pipe( | ||
| 332 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | ||
| 333 | Layer.provideMerge( | ||
| 334 | Layer.succeed(ProjectionSnapshotQuery, { | ||
| 335 | getCommandReadModel: () => | ||
| 336 | Effect.die("CheckpointDiffQuery should not request the command read model"), | ||
| 337 | getSnapshot: () => | ||
| 338 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | ||
| 339 | getShellSnapshot: () => | ||
| 340 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | ||
| 341 | getArchivedShellSnapshot: () => | ||
| 342 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | ||
| 343 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | ||
| 344 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | ||
| 345 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | ||
| 346 | getProjectShellById: () => Effect.succeed(Option.none()), | ||
| 347 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | ||
| 348 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | ||
| 349 | getFullThreadDiffContext: () => Effect.die("unused"), | ||
| 350 | getThreadShellById: () => Effect.succeed(Option.none()), | ||
| 351 | getThreadDetailById: () => Effect.succeed(Option.none()), | ||
| 352 | }), | ||
| 353 | ), | ||
| 354 | ); | ||
| 355 | |||
| 356 | await Effect.runPromise( | ||
| 357 | Effect.gen(function* () { | ||
| 358 | const query = yield* CheckpointDiffQuery; | ||
| 359 | return yield* query.getTurnDiff({ | ||
| 360 | threadId, | ||
| 361 | fromTurnCount: 0, | ||
| 362 | toTurnCount: 1, | ||
| 363 | ignoreWhitespace: true, | ||
| 364 | }); | ||
| 365 | }).pipe(Effect.provide(layer)), | ||
| 366 | ); | ||
| 367 | |||
| 368 | expect(hasCheckpointRefCallCount).toBe(0); | ||
| 369 | }); | ||
| 370 | |||
| 207 | it("fails when the thread is missing from the snapshot", async () => { | 371 | it("fails when the thread is missing from the snapshot", async () => { |
| 208 | const threadId = ThreadId.make("thread-missing"); | 372 | const threadId = ThreadId.make("thread-missing"); |
| 209 | 373 | ||
| ... | @@ -234,6 +398,7 @@ describe("CheckpointDiffQueryLive", () => { | ... | @@ -234,6 +398,7 @@ describe("CheckpointDiffQueryLive", () => { |
| 234 | getProjectShellById: () => Effect.succeed(Option.none()), | 398 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 235 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 399 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 236 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 400 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 401 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | ||
| 237 | getThreadShellById: () => Effect.succeed(Option.none()), | 402 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 238 | getThreadDetailById: () => Effect.succeed(Option.none()), | 403 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 239 | }), | 404 | }), |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts+128-54| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | type CheckpointRef, | ||
| 2 | OrchestrationGetTurnDiffResult, | 3 | OrchestrationGetTurnDiffResult, |
| 3 | type OrchestrationGetFullThreadDiffInput, | 4 | type ThreadId, |
| 4 | type OrchestrationGetFullThreadDiffResult, | 5 | type OrchestrationGetFullThreadDiffResult, |
| 5 | type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, | 6 | type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| ... | @@ -20,6 +21,22 @@ import { | ... | @@ -20,6 +21,22 @@ import { |
| 20 | 21 | ||
| 21 | const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); | 22 | const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); |
| 22 | 23 | ||
| 24 | function buildTurnDiffResult( | ||
| 25 | input: { | ||
| 26 | readonly threadId: ThreadId; | ||
| 27 | readonly fromTurnCount: number; | ||
| 28 | readonly toTurnCount: number; | ||
| 29 | }, | ||
| 30 | diff: string, | ||
| 31 | ): OrchestrationGetTurnDiffResultType { | ||
| 32 | return { | ||
| 33 | threadId: input.threadId, | ||
| 34 | fromTurnCount: input.fromTurnCount, | ||
| 35 | toTurnCount: input.toTurnCount, | ||
| 36 | diff, | ||
| 37 | }; | ||
| 38 | } | ||
| 39 | |||
| 23 | const make = Effect.gen(function* () { | 40 | const make = Effect.gen(function* () { |
| 24 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 41 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 25 | const checkpointStore = yield* CheckpointStore; | 42 | const checkpointStore = yield* CheckpointStore; |
| ... | @@ -28,6 +45,12 @@ const make = Effect.gen(function* () { | ... | @@ -28,6 +45,12 @@ const make = Effect.gen(function* () { |
| 28 | function* (input) { | 45 | function* (input) { |
| 29 | const operation = "CheckpointDiffQuery.getTurnDiff"; | 46 | const operation = "CheckpointDiffQuery.getTurnDiff"; |
| 30 | const ignoreWhitespace = input.ignoreWhitespace ?? true; | 47 | const ignoreWhitespace = input.ignoreWhitespace ?? true; |
| 48 | yield* Effect.annotateCurrentSpan({ | ||
| 49 | "checkpoint.thread_id": input.threadId, | ||
| 50 | "checkpoint.from_turn_count": input.fromTurnCount, | ||
| 51 | "checkpoint.to_turn_count": input.toTurnCount, | ||
| 52 | "checkpoint.ignore_whitespace": ignoreWhitespace, | ||
| 53 | }); | ||
| 31 | 54 | ||
| 32 | if (input.fromTurnCount === input.toTurnCount) { | 55 | if (input.fromTurnCount === input.toTurnCount) { |
| 33 | const emptyDiff: OrchestrationGetTurnDiffResultType = { | 56 | const emptyDiff: OrchestrationGetTurnDiffResultType = { |
| ... | @@ -45,9 +68,9 @@ const make = Effect.gen(function* () { | ... | @@ -45,9 +68,9 @@ const make = Effect.gen(function* () { |
| 45 | return emptyDiff; | 68 | return emptyDiff; |
| 46 | } | 69 | } |
| 47 | 70 | ||
| 48 | const threadContext = yield* projectionSnapshotQuery.getThreadCheckpointContext( | 71 | const threadContext = yield* projectionSnapshotQuery |
| 49 | input.threadId, | 72 | .getThreadCheckpointContext(input.threadId) |
| 50 | ); | 73 | .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); |
| 51 | if (Option.isNone(threadContext)) { | 74 | if (Option.isNone(threadContext)) { |
| 52 | return yield* new CheckpointInvariantError({ | 75 | return yield* new CheckpointInvariantError({ |
| 53 | operation, | 76 | operation, |
| ... | @@ -100,70 +123,121 @@ const make = Effect.gen(function* () { | ... | @@ -100,70 +123,121 @@ const make = Effect.gen(function* () { |
| 100 | }); | 123 | }); |
| 101 | } | 124 | } |
| 102 | 125 | ||
| 103 | const [fromExists, toExists] = yield* Effect.all( | 126 | const diff = yield* checkpointStore |
| 104 | [ | 127 | .diffCheckpoints({ |
| 105 | checkpointStore.hasCheckpointRef({ | 128 | cwd: workspaceCwd, |
| 106 | cwd: workspaceCwd, | 129 | fromCheckpointRef, |
| 107 | checkpointRef: fromCheckpointRef, | 130 | toCheckpointRef, |
| 108 | }), | 131 | fallbackFromToHead: false, |
| 109 | checkpointStore.hasCheckpointRef({ | 132 | ignoreWhitespace, |
| 110 | cwd: workspaceCwd, | 133 | }) |
| 111 | checkpointRef: toCheckpointRef, | 134 | .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); |
| 112 | }), | 135 | |
| 113 | ], | 136 | const turnDiff = buildTurnDiffResult(input, diff); |
| 114 | { concurrency: "unbounded" }, | 137 | if (!isTurnDiffResult(turnDiff)) { |
| 115 | ); | 138 | return yield* new CheckpointInvariantError({ |
| 116 | 139 | operation, | |
| 117 | if (!fromExists) { | 140 | detail: "Computed turn diff result does not satisfy contract schema.", |
| 118 | return yield* new CheckpointUnavailableError({ | ||
| 119 | threadId: input.threadId, | ||
| 120 | turnCount: input.fromTurnCount, | ||
| 121 | detail: `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount}.`, | ||
| 122 | }); | 141 | }); |
| 123 | } | 142 | } |
| 124 | 143 | ||
| 125 | if (!toExists) { | 144 | return turnDiff; |
| 126 | return yield* new CheckpointUnavailableError({ | 145 | }, |
| 146 | ); | ||
| 147 | |||
| 148 | const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = Effect.fn( | ||
| 149 | "CheckpointDiffQuery.getFullThreadDiff", | ||
| 150 | )(function* (input) { | ||
| 151 | const operation = "CheckpointDiffQuery.getFullThreadDiff"; | ||
| 152 | const ignoreWhitespace = input.ignoreWhitespace ?? true; | ||
| 153 | yield* Effect.annotateCurrentSpan({ | ||
| 154 | "checkpoint.thread_id": input.threadId, | ||
| 155 | "checkpoint.from_turn_count": 0, | ||
| 156 | "checkpoint.to_turn_count": input.toTurnCount, | ||
| 157 | "checkpoint.ignore_whitespace": ignoreWhitespace, | ||
| 158 | "checkpoint.diff_kind": "full-thread", | ||
| 159 | }); | ||
| 160 | |||
| 161 | if (input.toTurnCount === 0) { | ||
| 162 | const emptyDiff = buildTurnDiffResult( | ||
| 163 | { | ||
| 127 | threadId: input.threadId, | 164 | threadId: input.threadId, |
| 128 | turnCount: input.toTurnCount, | 165 | fromTurnCount: 0, |
| 129 | detail: `Filesystem checkpoint is unavailable for turn ${input.toTurnCount}.`, | 166 | toTurnCount: 0, |
| 167 | }, | ||
| 168 | "", | ||
| 169 | ); | ||
| 170 | if (!isTurnDiffResult(emptyDiff)) { | ||
| 171 | return yield* new CheckpointInvariantError({ | ||
| 172 | operation, | ||
| 173 | detail: "Computed full thread diff result does not satisfy contract schema.", | ||
| 130 | }); | 174 | }); |
| 131 | } | 175 | } |
| 176 | return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; | ||
| 177 | } | ||
| 178 | |||
| 179 | const threadContext = yield* projectionSnapshotQuery | ||
| 180 | .getFullThreadDiffContext(input.threadId, input.toTurnCount) | ||
| 181 | .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); | ||
| 132 | 182 | ||
| 133 | const diff = yield* checkpointStore.diffCheckpoints({ | 183 | if (Option.isNone(threadContext)) { |
| 184 | return yield* new CheckpointInvariantError({ | ||
| 185 | operation, | ||
| 186 | detail: `Thread '${input.threadId}' not found.`, | ||
| 187 | }); | ||
| 188 | } | ||
| 189 | |||
| 190 | if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { | ||
| 191 | return yield* new CheckpointUnavailableError({ | ||
| 192 | threadId: input.threadId, | ||
| 193 | turnCount: input.toTurnCount, | ||
| 194 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, | ||
| 195 | }); | ||
| 196 | } | ||
| 197 | |||
| 198 | const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; | ||
| 199 | if (!workspaceCwd) { | ||
| 200 | return yield* new CheckpointInvariantError({ | ||
| 201 | operation, | ||
| 202 | detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 206 | if (!threadContext.value.toCheckpointRef) { | ||
| 207 | return yield* new CheckpointUnavailableError({ | ||
| 208 | threadId: input.threadId, | ||
| 209 | turnCount: input.toTurnCount, | ||
| 210 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | ||
| 211 | }); | ||
| 212 | } | ||
| 213 | |||
| 214 | const diff = yield* checkpointStore | ||
| 215 | .diffCheckpoints({ | ||
| 134 | cwd: workspaceCwd, | 216 | cwd: workspaceCwd, |
| 135 | fromCheckpointRef, | 217 | fromCheckpointRef: checkpointRefForThreadTurn(input.threadId, 0), |
| 136 | toCheckpointRef, | 218 | toCheckpointRef: threadContext.value.toCheckpointRef as CheckpointRef, |
| 137 | fallbackFromToHead: false, | 219 | fallbackFromToHead: false, |
| 138 | ignoreWhitespace, | 220 | ignoreWhitespace, |
| 139 | }); | 221 | }) |
| 222 | .pipe(Effect.withSpan("checkpoint.fullThread.diffCheckpoints")); | ||
| 140 | 223 | ||
| 141 | const turnDiff: OrchestrationGetTurnDiffResultType = { | 224 | const turnDiff = buildTurnDiffResult( |
| 225 | { | ||
| 142 | threadId: input.threadId, | 226 | threadId: input.threadId, |
| 143 | fromTurnCount: input.fromTurnCount, | 227 | fromTurnCount: 0, |
| 144 | toTurnCount: input.toTurnCount, | 228 | toTurnCount: input.toTurnCount, |
| 145 | diff, | 229 | }, |
| 146 | }; | 230 | diff, |
| 147 | if (!isTurnDiffResult(turnDiff)) { | 231 | ); |
| 148 | return yield* new CheckpointInvariantError({ | 232 | if (!isTurnDiffResult(turnDiff)) { |
| 149 | operation, | 233 | return yield* new CheckpointInvariantError({ |
| 150 | detail: "Computed turn diff result does not satisfy contract schema.", | 234 | operation, |
| 151 | }); | 235 | detail: "Computed full thread diff result does not satisfy contract schema.", |
| 152 | } | 236 | }); |
| 153 | 237 | } | |
| 154 | return turnDiff; | ||
| 155 | }, | ||
| 156 | ); | ||
| 157 | 238 | ||
| 158 | const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = ( | 239 | return turnDiff satisfies OrchestrationGetFullThreadDiffResult; |
| 159 | input: OrchestrationGetFullThreadDiffInput, | 240 | }); |
| 160 | ) => | ||
| 161 | getTurnDiff({ | ||
| 162 | threadId: input.threadId, | ||
| 163 | fromTurnCount: 0, | ||
| 164 | toTurnCount: input.toTurnCount, | ||
| 165 | ignoreWhitespace: input.ignoreWhitespace ?? true, | ||
| 166 | }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); | ||
| 167 | 241 | ||
| 168 | return { | 242 | return { |
| 169 | getTurnDiff, | 243 | getTurnDiff, |
apps/server/src/checkpointing/Layers/CheckpointStore.ts+39-264| ... | @@ -1,304 +1,79 @@ | ... | @@ -1,304 +1,79 @@ |
| 1 | /** | 1 | /** |
| 2 | * CheckpointStoreLive - Filesystem checkpoint store adapter layer. | 2 | * CheckpointStoreLive - Filesystem checkpoint store adapter layer. |
| 3 | * | 3 | * |
| 4 | * Implements hidden Git-ref checkpoint capture/restore directly with | 4 | * Resolves the active VCS driver once per checkpoint operation and delegates |
| 5 | * Effect-native child process execution (`effect/unstable/process`). | 5 | * checkpoint-specific behavior to the driver's optional checkpoint capability. |
| 6 | * | ||
| 7 | * This layer owns filesystem/Git interactions only; it does not persist | ||
| 8 | * checkpoint metadata and does not coordinate provider rollback semantics. | ||
| 9 | * | 6 | * |
| 10 | * @module CheckpointStoreLive | 7 | * @module CheckpointStoreLive |
| 11 | */ | 8 | */ |
| 12 | import { randomUUID } from "node:crypto"; | ||
| 13 | |||
| 14 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 15 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | ||
| 17 | import * as Path from "effect/Path"; | ||
| 18 | 11 | ||
| 19 | import { CheckpointInvariantError } from "../Errors.ts"; | ||
| 20 | import { VcsProcessExitError } from "@t3tools/contracts"; | ||
| 21 | import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; | ||
| 22 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | 12 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; |
| 23 | import { CheckpointRef } from "@t3tools/contracts"; | 13 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; |
| 24 | 14 | import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; | |
| 25 | const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; | 15 | import type { VcsCheckpointOps } from "../../vcs/VcsDriver.ts"; |
| 26 | 16 | ||
| 27 | const makeCheckpointStore = Effect.gen(function* () { | 17 | const makeCheckpointStore = Effect.gen(function* () { |
| 28 | const fs = yield* FileSystem.FileSystem; | ||
| 29 | const path = yield* Path.Path; | ||
| 30 | const vcsRegistry = yield* VcsDriverRegistry; | 18 | const vcsRegistry = yield* VcsDriverRegistry; |
| 31 | const vcs = { | ||
| 32 | execute: (input: { | ||
| 33 | readonly operation: string; | ||
| 34 | readonly cwd: string; | ||
| 35 | readonly args: ReadonlyArray<string>; | ||
| 36 | readonly stdin?: string; | ||
| 37 | readonly env?: NodeJS.ProcessEnv; | ||
| 38 | readonly allowNonZeroExit?: boolean; | ||
| 39 | readonly timeoutMs?: number; | ||
| 40 | readonly maxOutputBytes?: number; | ||
| 41 | readonly truncateOutputAtMaxBytes?: boolean; | ||
| 42 | }) => | ||
| 43 | vcsRegistry | ||
| 44 | .resolve({ cwd: input.cwd, requestedKind: "git" }) | ||
| 45 | .pipe(Effect.flatMap((handle) => handle.driver.execute(input))), | ||
| 46 | }; | ||
| 47 | |||
| 48 | const resolveHeadCommit = (cwd: string) => | ||
| 49 | vcs | ||
| 50 | .execute({ | ||
| 51 | operation: "CheckpointStore.resolveHeadCommit", | ||
| 52 | cwd, | ||
| 53 | args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], | ||
| 54 | allowNonZeroExit: true, | ||
| 55 | }) | ||
| 56 | .pipe( | ||
| 57 | Effect.map((result) => { | ||
| 58 | if (result.exitCode !== 0) { | ||
| 59 | return null; | ||
| 60 | } | ||
| 61 | const commit = result.stdout.trim(); | ||
| 62 | return commit.length > 0 ? commit : null; | ||
| 63 | }), | ||
| 64 | ); | ||
| 65 | 19 | ||
| 66 | const hasHeadCommit = (cwd: string) => | 20 | const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( |
| 67 | vcs | 21 | operation: string, |
| 68 | .execute({ | 22 | cwd: string, |
| 69 | operation: "CheckpointStore.hasHeadCommit", | 23 | ) { |
| 70 | cwd, | 24 | const handle = yield* vcsRegistry.resolve({ cwd }); |
| 71 | args: ["rev-parse", "--verify", "HEAD"], | 25 | if (!handle.driver.checkpoints) { |
| 72 | allowNonZeroExit: true, | 26 | return yield* new VcsUnsupportedOperationError({ |
| 73 | }) | 27 | operation, |
| 74 | .pipe(Effect.map((result) => result.exitCode === 0)); | 28 | kind: handle.kind, |
| 75 | 29 | detail: `${handle.kind} driver does not implement checkpoint operations.`, | |
| 76 | const resolveCheckpointCommit = (cwd: string, checkpointRef: CheckpointRef) => | 30 | }); |
| 77 | vcs | 31 | } |
| 78 | .execute({ | 32 | return handle.driver.checkpoints satisfies VcsCheckpointOps; |
| 79 | operation: "CheckpointStore.resolveCheckpointCommit", | 33 | }); |
| 80 | cwd, | ||
| 81 | args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], | ||
| 82 | allowNonZeroExit: true, | ||
| 83 | }) | ||
| 84 | .pipe( | ||
| 85 | Effect.map((result) => { | ||
| 86 | if (result.exitCode !== 0) { | ||
| 87 | return null; | ||
| 88 | } | ||
| 89 | const commit = result.stdout.trim(); | ||
| 90 | return commit.length > 0 ? commit : null; | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | 34 | ||
| 94 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => | 35 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => |
| 95 | vcs | 36 | vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe( |
| 96 | .execute({ | 37 | Effect.map(() => true), |
| 97 | operation: "CheckpointStore.isGitRepository", | 38 | Effect.catch(() => Effect.succeed(false)), |
| 98 | cwd, | 39 | ); |
| 99 | args: ["rev-parse", "--is-inside-work-tree"], | ||
| 100 | allowNonZeroExit: true, | ||
| 101 | }) | ||
| 102 | .pipe( | ||
| 103 | Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"), | ||
| 104 | Effect.catch(() => Effect.succeed(false)), | ||
| 105 | ); | ||
| 106 | 40 | ||
| 107 | const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn( | 41 | const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn( |
| 108 | "captureCheckpoint", | 42 | "captureCheckpoint", |
| 109 | )(function* (input) { | 43 | )(function* (input) { |
| 110 | const operation = "CheckpointStore.captureCheckpoint"; | 44 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); |
| 111 | 45 | return yield* checkpoints.captureCheckpoint(input); | |
| 112 | yield* Effect.acquireUseRelease( | ||
| 113 | fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }), | ||
| 114 | Effect.fn("captureCheckpoint.withTempDirectory")(function* (tempDir) { | ||
| 115 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); | ||
| 116 | const commitEnv: NodeJS.ProcessEnv = { | ||
| 117 | ...process.env, | ||
| 118 | GIT_INDEX_FILE: tempIndexPath, | ||
| 119 | GIT_AUTHOR_NAME: "T3 Code", | ||
| 120 | GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", | ||
| 121 | GIT_COMMITTER_NAME: "T3 Code", | ||
| 122 | GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", | ||
| 123 | }; | ||
| 124 | |||
| 125 | const headExists = yield* hasHeadCommit(input.cwd); | ||
| 126 | if (headExists) { | ||
| 127 | yield* vcs.execute({ | ||
| 128 | operation, | ||
| 129 | cwd: input.cwd, | ||
| 130 | args: ["read-tree", "HEAD"], | ||
| 131 | env: commitEnv, | ||
| 132 | }); | ||
| 133 | } | ||
| 134 | |||
| 135 | yield* vcs.execute({ | ||
| 136 | operation, | ||
| 137 | cwd: input.cwd, | ||
| 138 | args: ["add", "-A", "--", "."], | ||
| 139 | env: commitEnv, | ||
| 140 | }); | ||
| 141 | |||
| 142 | const writeTreeResult = yield* vcs.execute({ | ||
| 143 | operation, | ||
| 144 | cwd: input.cwd, | ||
| 145 | args: ["write-tree"], | ||
| 146 | env: commitEnv, | ||
| 147 | }); | ||
| 148 | const treeOid = writeTreeResult.stdout.trim(); | ||
| 149 | if (treeOid.length === 0) { | ||
| 150 | return yield* new VcsProcessExitError({ | ||
| 151 | operation, | ||
| 152 | command: "git write-tree", | ||
| 153 | cwd: input.cwd, | ||
| 154 | exitCode: 0, | ||
| 155 | detail: "git write-tree returned an empty tree oid.", | ||
| 156 | }); | ||
| 157 | } | ||
| 158 | |||
| 159 | const message = `t3 checkpoint ref=${input.checkpointRef}`; | ||
| 160 | const commitTreeResult = yield* vcs.execute({ | ||
| 161 | operation, | ||
| 162 | cwd: input.cwd, | ||
| 163 | args: ["commit-tree", treeOid, "-m", message], | ||
| 164 | env: commitEnv, | ||
| 165 | }); | ||
| 166 | const commitOid = commitTreeResult.stdout.trim(); | ||
| 167 | if (commitOid.length === 0) { | ||
| 168 | return yield* new VcsProcessExitError({ | ||
| 169 | operation, | ||
| 170 | command: "git commit-tree", | ||
| 171 | cwd: input.cwd, | ||
| 172 | exitCode: 0, | ||
| 173 | detail: "git commit-tree returned an empty commit oid.", | ||
| 174 | }); | ||
| 175 | } | ||
| 176 | |||
| 177 | yield* vcs.execute({ | ||
| 178 | operation, | ||
| 179 | cwd: input.cwd, | ||
| 180 | args: ["update-ref", input.checkpointRef, commitOid], | ||
| 181 | }); | ||
| 182 | }), | ||
| 183 | (tempDir) => fs.remove(tempDir, { recursive: true }), | ||
| 184 | ).pipe( | ||
| 185 | Effect.catchTags({ | ||
| 186 | PlatformError: (error) => | ||
| 187 | Effect.fail( | ||
| 188 | new CheckpointInvariantError({ | ||
| 189 | operation: "CheckpointStore.captureCheckpoint", | ||
| 190 | detail: "Failed to capture checkpoint.", | ||
| 191 | cause: error, | ||
| 192 | }), | ||
| 193 | ), | ||
| 194 | }), | ||
| 195 | ); | ||
| 196 | }); | 46 | }); |
| 197 | 47 | ||
| 198 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => | 48 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = Effect.fn("hasCheckpointRef")( |
| 199 | resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( | 49 | function* (input) { |
| 200 | Effect.map((commit) => commit !== null), | 50 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); |
| 201 | ); | 51 | return yield* checkpoints.hasCheckpointRef(input); |
| 52 | }, | ||
| 53 | ); | ||
| 202 | 54 | ||
| 203 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn( | 55 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn( |
| 204 | "restoreCheckpoint", | 56 | "restoreCheckpoint", |
| 205 | )(function* (input) { | 57 | )(function* (input) { |
| 206 | const operation = "CheckpointStore.restoreCheckpoint"; | 58 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); |
| 207 | 59 | return yield* checkpoints.restoreCheckpoint(input); | |
| 208 | let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); | ||
| 209 | |||
| 210 | if (!commitOid && input.fallbackToHead === true) { | ||
| 211 | commitOid = yield* resolveHeadCommit(input.cwd); | ||
| 212 | } | ||
| 213 | |||
| 214 | if (!commitOid) { | ||
| 215 | return false; | ||
| 216 | } | ||
| 217 | |||
| 218 | yield* vcs.execute({ | ||
| 219 | operation, | ||
| 220 | cwd: input.cwd, | ||
| 221 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], | ||
| 222 | }); | ||
| 223 | yield* vcs.execute({ | ||
| 224 | operation, | ||
| 225 | cwd: input.cwd, | ||
| 226 | args: ["clean", "-fd", "--", "."], | ||
| 227 | }); | ||
| 228 | |||
| 229 | const headExists = yield* hasHeadCommit(input.cwd); | ||
| 230 | if (headExists) { | ||
| 231 | yield* vcs.execute({ | ||
| 232 | operation, | ||
| 233 | cwd: input.cwd, | ||
| 234 | args: ["reset", "--quiet", "--", "."], | ||
| 235 | }); | ||
| 236 | } | ||
| 237 | |||
| 238 | return true; | ||
| 239 | }); | 60 | }); |
| 240 | 61 | ||
| 241 | const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")( | 62 | const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")( |
| 242 | function* (input) { | 63 | function* (input) { |
| 243 | const operation = "CheckpointStore.diffCheckpoints"; | 64 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); |
| 244 | 65 | return yield* checkpoints.diffCheckpoints(input); | |
| 245 | let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); | ||
| 246 | const toCommitOid = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef); | ||
| 247 | |||
| 248 | if (!fromCommitOid && input.fallbackFromToHead === true) { | ||
| 249 | const headCommit = yield* resolveHeadCommit(input.cwd); | ||
| 250 | if (headCommit) { | ||
| 251 | fromCommitOid = headCommit; | ||
| 252 | } | ||
| 253 | } | ||
| 254 | |||
| 255 | if (!fromCommitOid || !toCommitOid) { | ||
| 256 | return yield* new VcsProcessExitError({ | ||
| 257 | operation, | ||
| 258 | command: "git diff", | ||
| 259 | cwd: input.cwd, | ||
| 260 | exitCode: 1, | ||
| 261 | detail: "Checkpoint ref is unavailable for diff operation.", | ||
| 262 | }); | ||
| 263 | } | ||
| 264 | |||
| 265 | const diffArgs = [ | ||
| 266 | "diff", | ||
| 267 | "--patch", | ||
| 268 | "--minimal", | ||
| 269 | "--no-color", | ||
| 270 | ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), | ||
| 271 | fromCommitOid, | ||
| 272 | toCommitOid, | ||
| 273 | ]; | ||
| 274 | |||
| 275 | const result = yield* vcs.execute({ | ||
| 276 | operation, | ||
| 277 | cwd: input.cwd, | ||
| 278 | args: diffArgs, | ||
| 279 | maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, | ||
| 280 | }); | ||
| 281 | |||
| 282 | return result.stdout; | ||
| 283 | }, | 66 | }, |
| 284 | ); | 67 | ); |
| 285 | 68 | ||
| 286 | const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn( | 69 | const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn( |
| 287 | "deleteCheckpointRefs", | 70 | "deleteCheckpointRefs", |
| 288 | )(function* (input) { | 71 | )(function* (input) { |
| 289 | const operation = "CheckpointStore.deleteCheckpointRefs"; | 72 | const checkpoints = yield* resolveCheckpoints( |
| 290 | 73 | "CheckpointStore.deleteCheckpointRefs", | |
| 291 | yield* Effect.forEach( | 74 | input.cwd, |
| 292 | input.checkpointRefs, | ||
| 293 | (checkpointRef) => | ||
| 294 | vcs.execute({ | ||
| 295 | operation, | ||
| 296 | cwd: input.cwd, | ||
| 297 | args: ["update-ref", "-d", checkpointRef], | ||
| 298 | allowNonZeroExit: true, | ||
| 299 | }), | ||
| 300 | { discard: true }, | ||
| 301 | ); | 75 | ); |
| 76 | return yield* checkpoints.deleteCheckpointRefs(input); | ||
| 302 | }); | 77 | }); |
| 303 | 78 | ||
| 304 | return { | 79 | return { |
apps/server/src/environment/Layers/ServerEnvironment.ts+4-1| ... | @@ -6,6 +6,7 @@ import * as Path from "effect/Path"; | ... | @@ -6,6 +6,7 @@ import * as Path from "effect/Path"; |
| 6 | import * as Random from "effect/Random"; | 6 | import * as Random from "effect/Random"; |
| 7 | 7 | ||
| 8 | import { ServerConfig } from "../../config.ts"; | 8 | import { ServerConfig } from "../../config.ts"; |
| 9 | import { layer as ProcessRunnerLive } from "../../processRunner.ts"; | ||
| 9 | import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; | 10 | import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; |
| 10 | import packageJson from "../../../package.json" with { type: "json" }; | 11 | import packageJson from "../../../package.json" with { type: "json" }; |
| 11 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | 12 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; |
| ... | @@ -93,4 +94,6 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function | ... | @@ -93,4 +94,6 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function |
| 93 | } satisfies ServerEnvironmentShape; | 94 | } satisfies ServerEnvironmentShape; |
| 94 | }); | 95 | }); |
| 95 | 96 | ||
| 96 | export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); | 97 | export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe( |
| 98 | Layer.provide(ProcessRunnerLive), | ||
| 99 | ); |
apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts+82-55| ... | @@ -1,20 +1,36 @@ | ... | @@ -1,20 +1,36 @@ |
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; | ||
| 4 | import { vi } from "vitest"; | 5 | import { vi } from "vitest"; |
| 5 | 6 | ||
| 6 | vi.mock("../../processRunner.ts", () => ({ | 7 | import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; |
| 7 | runProcess: vi.fn(), | ||
| 8 | })); | ||
| 9 | |||
| 10 | import { runProcess } from "../../processRunner.ts"; | ||
| 11 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | 8 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; |
| 9 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 10 | |||
| 11 | const runMock = vi.fn<ProcessRunnerShape["run"]>(); | ||
| 12 | 12 | ||
| 13 | const mockedRunProcess = vi.mocked(runProcess); | 13 | const ProcessRunnerTest = Layer.succeed( |
| 14 | ProcessRunner, | ||
| 15 | ProcessRunner.of({ | ||
| 16 | run: (input) => runMock(input), | ||
| 17 | }), | ||
| 18 | ); | ||
| 14 | const NoopFileSystemLayer = FileSystem.layerNoop({}); | 19 | const NoopFileSystemLayer = FileSystem.layerNoop({}); |
| 20 | const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest); | ||
| 21 | const LinuxMachineInfoLayer = Layer.merge( | ||
| 22 | ProcessRunnerTest, | ||
| 23 | FileSystem.layerNoop({ | ||
| 24 | exists: (path) => Effect.succeed(path === "/etc/machine-info"), | ||
| 25 | readFileString: (path) => | ||
| 26 | path === "/etc/machine-info" | ||
| 27 | ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') | ||
| 28 | : Effect.succeed(""), | ||
| 29 | }), | ||
| 30 | ); | ||
| 15 | 31 | ||
| 16 | afterEach(() => { | 32 | afterEach(() => { |
| 17 | mockedRunProcess.mockReset(); | 33 | runMock.mockReset(); |
| 18 | }); | 34 | }); |
| 19 | 35 | ||
| 20 | describe("resolveServerEnvironmentLabel", () => { | 36 | describe("resolveServerEnvironmentLabel", () => { |
| ... | @@ -24,7 +40,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -24,7 +40,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 24 | cwdBaseName: "t3code", | 40 | cwdBaseName: "t3code", |
| 25 | platform: "win32", | 41 | platform: "win32", |
| 26 | hostname: "macbook-pro", | 42 | hostname: "macbook-pro", |
| 27 | }).pipe(Effect.provide(NoopFileSystemLayer)); | 43 | }).pipe(Effect.provide(TestLayer)); |
| 28 | 44 | ||
| 29 | expect(result).toBe("macbook-pro"); | 45 | expect(result).toBe("macbook-pro"); |
| 30 | }), | 46 | }), |
| ... | @@ -32,25 +48,30 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -32,25 +48,30 @@ describe("resolveServerEnvironmentLabel", () => { |
| 32 | 48 | ||
| 33 | it.effect("prefers the macOS ComputerName", () => | 49 | it.effect("prefers the macOS ComputerName", () => |
| 34 | Effect.gen(function* () { | 50 | Effect.gen(function* () { |
| 35 | mockedRunProcess.mockResolvedValueOnce({ | 51 | runMock.mockReturnValueOnce( |
| 36 | stdout: " Julius's MacBook Pro \n", | 52 | Effect.succeed({ |
| 37 | stderr: "", | 53 | stdout: " Julius's MacBook Pro \n", |
| 38 | code: 0, | 54 | stderr: "", |
| 39 | signal: null, | 55 | code: ChildProcessSpawner.ExitCode(0), |
| 40 | timedOut: false, | 56 | timedOut: false, |
| 41 | }); | 57 | stdoutTruncated: false, |
| 58 | stderrTruncated: false, | ||
| 59 | }), | ||
| 60 | ); | ||
| 42 | 61 | ||
| 43 | const result = yield* resolveServerEnvironmentLabel({ | 62 | const result = yield* resolveServerEnvironmentLabel({ |
| 44 | cwdBaseName: "t3code", | 63 | cwdBaseName: "t3code", |
| 45 | platform: "darwin", | 64 | platform: "darwin", |
| 46 | hostname: "macbook-pro", | 65 | hostname: "macbook-pro", |
| 47 | }).pipe(Effect.provide(NoopFileSystemLayer)); | 66 | }).pipe(Effect.provide(TestLayer)); |
| 48 | 67 | ||
| 49 | expect(result).toBe("Julius's MacBook Pro"); | 68 | expect(result).toBe("Julius's MacBook Pro"); |
| 50 | expect(mockedRunProcess).toHaveBeenCalledWith( | 69 | expect(runMock).toHaveBeenCalledWith( |
| 51 | "scutil", | 70 | expect.objectContaining({ |
| 52 | ["--get", "ComputerName"], | 71 | command: "scutil", |
| 53 | expect.objectContaining({ allowNonZeroExit: true }), | 72 | args: ["--get", "ComputerName"], |
| 73 | timeoutBehavior: "timedOutResult", | ||
| 74 | }), | ||
| 54 | ); | 75 | ); |
| 55 | }), | 76 | }), |
| 56 | ); | 77 | ); |
| ... | @@ -61,44 +82,39 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -61,44 +82,39 @@ describe("resolveServerEnvironmentLabel", () => { |
| 61 | cwdBaseName: "t3code", | 82 | cwdBaseName: "t3code", |
| 62 | platform: "linux", | 83 | platform: "linux", |
| 63 | hostname: "buildbox", | 84 | hostname: "buildbox", |
| 64 | }).pipe( | 85 | }).pipe(Effect.provide(LinuxMachineInfoLayer)); |
| 65 | Effect.provide( | ||
| 66 | FileSystem.layerNoop({ | ||
| 67 | exists: (path) => Effect.succeed(path === "/etc/machine-info"), | ||
| 68 | readFileString: (path) => | ||
| 69 | path === "/etc/machine-info" | ||
| 70 | ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') | ||
| 71 | : Effect.succeed(""), | ||
| 72 | }), | ||
| 73 | ), | ||
| 74 | ); | ||
| 75 | 86 | ||
| 76 | expect(result).toBe("Build Agent 01"); | 87 | expect(result).toBe("Build Agent 01"); |
| 77 | expect(mockedRunProcess).not.toHaveBeenCalled(); | 88 | expect(runMock).not.toHaveBeenCalled(); |
| 78 | }), | 89 | }), |
| 79 | ); | 90 | ); |
| 80 | 91 | ||
| 81 | it.effect("falls back to hostnamectl pretty hostname on Linux", () => | 92 | it.effect("falls back to hostnamectl pretty hostname on Linux", () => |
| 82 | Effect.gen(function* () { | 93 | Effect.gen(function* () { |
| 83 | mockedRunProcess.mockResolvedValueOnce({ | 94 | runMock.mockReturnValueOnce( |
| 84 | stdout: "CI Runner\n", | 95 | Effect.succeed({ |
| 85 | stderr: "", | 96 | stdout: "CI Runner\n", |
| 86 | code: 0, | 97 | stderr: "", |
| 87 | signal: null, | 98 | code: ChildProcessSpawner.ExitCode(0), |
| 88 | timedOut: false, | 99 | timedOut: false, |
| 89 | }); | 100 | stdoutTruncated: false, |
| 101 | stderrTruncated: false, | ||
| 102 | }), | ||
| 103 | ); | ||
| 90 | 104 | ||
| 91 | const result = yield* resolveServerEnvironmentLabel({ | 105 | const result = yield* resolveServerEnvironmentLabel({ |
| 92 | cwdBaseName: "t3code", | 106 | cwdBaseName: "t3code", |
| 93 | platform: "linux", | 107 | platform: "linux", |
| 94 | hostname: "runner-01", | 108 | hostname: "runner-01", |
| 95 | }).pipe(Effect.provide(NoopFileSystemLayer)); | 109 | }).pipe(Effect.provide(TestLayer)); |
| 96 | 110 | ||
| 97 | expect(result).toBe("CI Runner"); | 111 | expect(result).toBe("CI Runner"); |
| 98 | expect(mockedRunProcess).toHaveBeenCalledWith( | 112 | expect(runMock).toHaveBeenCalledWith( |
| 99 | "hostnamectl", | 113 | expect.objectContaining({ |
| 100 | ["--pretty"], | 114 | command: "hostnamectl", |
| 101 | expect.objectContaining({ allowNonZeroExit: true }), | 115 | args: ["--pretty"], |
| 116 | timeoutBehavior: "timedOutResult", | ||
| 117 | }), | ||
| 102 | ); | 118 | ); |
| 103 | }), | 119 | }), |
| 104 | ); | 120 | ); |
| ... | @@ -109,7 +125,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -109,7 +125,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 109 | cwdBaseName: "t3code", | 125 | cwdBaseName: "t3code", |
| 110 | platform: "win32", | 126 | platform: "win32", |
| 111 | hostname: "JULIUS-LAPTOP", | 127 | hostname: "JULIUS-LAPTOP", |
| 112 | }).pipe(Effect.provide(NoopFileSystemLayer)); | 128 | }).pipe(Effect.provide(TestLayer)); |
| 113 | 129 | ||
| 114 | expect(result).toBe("JULIUS-LAPTOP"); | 130 | expect(result).toBe("JULIUS-LAPTOP"); |
| 115 | }), | 131 | }), |
| ... | @@ -117,13 +133,21 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -117,13 +133,21 @@ describe("resolveServerEnvironmentLabel", () => { |
| 117 | 133 | ||
| 118 | it.effect("falls back to the hostname when the friendly-label command is missing", () => | 134 | it.effect("falls back to the hostname when the friendly-label command is missing", () => |
| 119 | Effect.gen(function* () { | 135 | Effect.gen(function* () { |
| 120 | mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT")); | 136 | runMock.mockReturnValueOnce( |
| 137 | Effect.fail( | ||
| 138 | new ProcessSpawnError({ | ||
| 139 | command: "scutil", | ||
| 140 | args: ["--get", "ComputerName"], | ||
| 141 | cause: new Error("spawn scutil ENOENT"), | ||
| 142 | }), | ||
| 143 | ), | ||
| 144 | ); | ||
| 121 | 145 | ||
| 122 | const result = yield* resolveServerEnvironmentLabel({ | 146 | const result = yield* resolveServerEnvironmentLabel({ |
| 123 | cwdBaseName: "t3code", | 147 | cwdBaseName: "t3code", |
| 124 | platform: "darwin", | 148 | platform: "darwin", |
| 125 | hostname: "macbook-pro", | 149 | hostname: "macbook-pro", |
| 126 | }).pipe(Effect.provide(NoopFileSystemLayer)); | 150 | }).pipe(Effect.provide(TestLayer)); |
| 127 | 151 | ||
| 128 | expect(result).toBe("macbook-pro"); | 152 | expect(result).toBe("macbook-pro"); |
| 129 | }), | 153 | }), |
| ... | @@ -131,19 +155,22 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -131,19 +155,22 @@ describe("resolveServerEnvironmentLabel", () => { |
| 131 | 155 | ||
| 132 | it.effect("falls back to the cwd basename when the hostname is blank", () => | 156 | it.effect("falls back to the cwd basename when the hostname is blank", () => |
| 133 | Effect.gen(function* () { | 157 | Effect.gen(function* () { |
| 134 | mockedRunProcess.mockResolvedValueOnce({ | 158 | runMock.mockReturnValueOnce( |
| 135 | stdout: " ", | 159 | Effect.succeed({ |
| 136 | stderr: "", | 160 | stdout: " ", |
| 137 | code: 0, | 161 | stderr: "", |
| 138 | signal: null, | 162 | code: ChildProcessSpawner.ExitCode(0), |
| 139 | timedOut: false, | 163 | timedOut: false, |
| 140 | }); | 164 | stdoutTruncated: false, |
| 165 | stderrTruncated: false, | ||
| 166 | }), | ||
| 167 | ); | ||
| 141 | 168 | ||
| 142 | const result = yield* resolveServerEnvironmentLabel({ | 169 | const result = yield* resolveServerEnvironmentLabel({ |
| 143 | cwdBaseName: "t3code", | 170 | cwdBaseName: "t3code", |
| 144 | platform: "linux", | 171 | platform: "linux", |
| 145 | hostname: " ", | 172 | hostname: " ", |
| 146 | }).pipe(Effect.provide(NoopFileSystemLayer)); | 173 | }).pipe(Effect.provide(TestLayer)); |
| 147 | 174 | ||
| 148 | expect(result).toBe("t3code"); | 175 | expect(result).toBe("t3code"); |
| 149 | }), | 176 | }), |
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts+14-11| ... | @@ -2,8 +2,9 @@ import * as OS from "node:os"; | ... | @@ -2,8 +2,9 @@ import * as OS from "node:os"; |
| 2 | 2 | ||
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Option from "effect/Option"; | ||
| 5 | 6 | ||
| 6 | import { runProcess } from "../../processRunner.ts"; | 7 | import { ProcessRunner } from "../../processRunner.ts"; |
| 7 | 8 | ||
| 8 | interface ResolveServerEnvironmentLabelInput { | 9 | interface ResolveServerEnvironmentLabelInput { |
| 9 | readonly cwdBaseName: string; | 10 | readonly cwdBaseName: string; |
| ... | @@ -52,19 +53,21 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( | ... | @@ -52,19 +53,21 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( |
| 52 | command: string, | 53 | command: string, |
| 53 | args: readonly string[], | 54 | args: readonly string[], |
| 54 | ) { | 55 | ) { |
| 55 | const result = yield* Effect.tryPromise({ | 56 | const processRunner = yield* ProcessRunner; |
| 56 | try: () => | 57 | const result = yield* processRunner |
| 57 | runProcess(command, args, { | 58 | .run({ |
| 58 | allowNonZeroExit: true, | 59 | command, |
| 59 | }), | 60 | args, |
| 60 | catch: () => null, | 61 | timeoutBehavior: "timedOutResult", |
| 61 | }).pipe(Effect.orElseSucceed(() => null)); | 62 | shell: process.platform === "win32", |
| 62 | 63 | }) | |
| 63 | if (!result || result.code !== 0) { | 64 | .pipe(Effect.option); |
| 65 | |||
| 66 | if (Option.isNone(result) || result.value.code !== 0) { | ||
| 64 | return null; | 67 | return null; |
| 65 | } | 68 | } |
| 66 | 69 | ||
| 67 | return normalizeLabel(result.stdout); | 70 | return normalizeLabel(result.value.stdout); |
| 68 | }); | 71 | }); |
| 69 | 72 | ||
| 70 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( | 73 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+4-1| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | type OrchestrationEvent, | 9 | type OrchestrationEvent, |
| 10 | ProviderInstanceId, | 10 | ProviderInstanceId, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 12 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 13 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 14 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 15 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| ... | @@ -37,7 +38,6 @@ import { | ... | @@ -37,7 +38,6 @@ import { |
| 37 | } from "../Services/ProjectionPipeline.ts"; | 38 | } from "../Services/ProjectionPipeline.ts"; |
| 38 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | 39 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 39 | import { ServerConfig } from "../../config.ts"; | 40 | import { ServerConfig } from "../../config.ts"; |
| 40 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 41 | 41 | ||
| 42 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); | 42 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); |
| 43 | const asMessageId = (value: string): MessageId => MessageId.make(value); | 43 | const asMessageId = (value: string): MessageId => MessageId.make(value); |
| ... | @@ -197,6 +197,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -197,6 +197,7 @@ describe("OrchestrationEngine", () => { |
| 197 | getProjectShellById: () => Effect.succeed(Option.none()), | 197 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 198 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 198 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 199 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 199 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 200 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | ||
| 200 | getThreadShellById: () => Effect.succeed(Option.none()), | 201 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 201 | getThreadDetailById: () => Effect.succeed(Option.none()), | 202 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 202 | }), | 203 | }), |
| ... | @@ -785,6 +786,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -785,6 +786,7 @@ describe("OrchestrationEngine", () => { |
| 785 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 786 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 786 | Layer.provide(RepositoryIdentityResolverLive), | 787 | Layer.provide(RepositoryIdentityResolverLive), |
| 787 | Layer.provide(SqlitePersistenceMemory), | 788 | Layer.provide(SqlitePersistenceMemory), |
| 789 | Layer.provide(NodeServices.layer), | ||
| 788 | ), | 790 | ), |
| 789 | ); | 791 | ); |
| 790 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | 792 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| ... | @@ -927,6 +929,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -927,6 +929,7 @@ describe("OrchestrationEngine", () => { |
| 927 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 929 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 928 | Layer.provide(RepositoryIdentityResolverLive), | 930 | Layer.provide(RepositoryIdentityResolverLive), |
| 929 | Layer.provide(SqlitePersistenceMemory), | 931 | Layer.provide(SqlitePersistenceMemory), |
| 932 | Layer.provide(NodeServices.layer), | ||
| 930 | ), | 933 | ), |
| 931 | ); | 934 | ); |
| 932 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | 935 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | ProviderInstanceId, | 8 | ProviderInstanceId, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { assert, it } from "@effect/vitest"; | 10 | import { assert, it } from "@effect/vitest"; |
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 13 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | @@ -29,6 +30,7 @@ const projectionSnapshotLayer = it.layer( | ... | @@ -29,6 +30,7 @@ const projectionSnapshotLayer = it.layer( |
| 29 | OrchestrationProjectionSnapshotQueryLive.pipe( | 30 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| 30 | Layer.provideMerge(RepositoryIdentityResolverLive), | 31 | Layer.provideMerge(RepositoryIdentityResolverLive), |
| 31 | Layer.provideMerge(SqlitePersistenceMemory), | 32 | Layer.provideMerge(SqlitePersistenceMemory), |
| 33 | Layer.provideMerge(NodeServices.layer), | ||
| 32 | ), | 34 | ), |
| 33 | ); | 35 | ); |
| 34 | 36 |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+76| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | ChatAttachment, | 2 | ChatAttachment, |
| 3 | CheckpointRef, | ||
| 3 | IsoDateTime, | 4 | IsoDateTime, |
| 4 | MessageId, | 5 | MessageId, |
| 5 | NonNegativeInt, | 6 | NonNegativeInt, |
| ... | @@ -49,6 +50,7 @@ import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIde | ... | @@ -49,6 +50,7 @@ import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIde |
| 49 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | 50 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 50 | import { | 51 | import { |
| 51 | ProjectionSnapshotQuery, | 52 | ProjectionSnapshotQuery, |
| 53 | type ProjectionFullThreadDiffContext, | ||
| 52 | type ProjectionSnapshotCounts, | 54 | type ProjectionSnapshotCounts, |
| 53 | type ProjectionThreadCheckpointContext, | 55 | type ProjectionThreadCheckpointContext, |
| 54 | type ProjectionSnapshotQueryShape, | 56 | type ProjectionSnapshotQueryShape, |
| ... | @@ -122,6 +124,18 @@ const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ | ... | @@ -122,6 +124,18 @@ const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ |
| 122 | workspaceRoot: Schema.String, | 124 | workspaceRoot: Schema.String, |
| 123 | worktreePath: Schema.NullOr(Schema.String), | 125 | worktreePath: Schema.NullOr(Schema.String), |
| 124 | }); | 126 | }); |
| 127 | const FullThreadDiffContextLookupInput = Schema.Struct({ | ||
| 128 | threadId: ThreadId, | ||
| 129 | checkpointTurnCount: NonNegativeInt, | ||
| 130 | }); | ||
| 131 | const ProjectionFullThreadDiffContextRowSchema = Schema.Struct({ | ||
| 132 | threadId: ThreadId, | ||
| 133 | projectId: ProjectId, | ||
| 134 | workspaceRoot: Schema.String, | ||
| 135 | worktreePath: Schema.NullOr(Schema.String), | ||
| 136 | latestCheckpointTurnCount: Schema.NullOr(NonNegativeInt), | ||
| 137 | toCheckpointRef: Schema.NullOr(CheckpointRef), | ||
| 138 | }); | ||
| 125 | 139 | ||
| 126 | const REQUIRED_SNAPSHOT_PROJECTORS = [ | 140 | const REQUIRED_SNAPSHOT_PROJECTORS = [ |
| 127 | ORCHESTRATION_PROJECTOR_NAMES.projects, | 141 | ORCHESTRATION_PROJECTOR_NAMES.projects, |
| ... | @@ -882,6 +896,38 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -882,6 +896,38 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 882 | `, | 896 | `, |
| 883 | }); | 897 | }); |
| 884 | 898 | ||
| 899 | const getFullThreadDiffContextRow = SqlSchema.findOneOption({ | ||
| 900 | Request: FullThreadDiffContextLookupInput, | ||
| 901 | Result: ProjectionFullThreadDiffContextRowSchema, | ||
| 902 | execute: ({ threadId, checkpointTurnCount }) => | ||
| 903 | sql` | ||
| 904 | SELECT | ||
| 905 | threads.thread_id AS "threadId", | ||
| 906 | threads.project_id AS "projectId", | ||
| 907 | projects.workspace_root AS "workspaceRoot", | ||
| 908 | threads.worktree_path AS "worktreePath", | ||
| 909 | ( | ||
| 910 | SELECT MAX(turns.checkpoint_turn_count) | ||
| 911 | FROM projection_turns AS turns | ||
| 912 | WHERE turns.thread_id = threads.thread_id | ||
| 913 | AND turns.checkpoint_turn_count IS NOT NULL | ||
| 914 | ) AS "latestCheckpointTurnCount", | ||
| 915 | ( | ||
| 916 | SELECT turns.checkpoint_ref | ||
| 917 | FROM projection_turns AS turns | ||
| 918 | WHERE turns.thread_id = threads.thread_id | ||
| 919 | AND turns.checkpoint_turn_count = ${checkpointTurnCount} | ||
| 920 | LIMIT 1 | ||
| 921 | ) AS "toCheckpointRef" | ||
| 922 | FROM projection_threads AS threads | ||
| 923 | INNER JOIN projection_projects AS projects | ||
| 924 | ON projects.project_id = threads.project_id | ||
| 925 | WHERE threads.thread_id = ${threadId} | ||
| 926 | AND threads.deleted_at IS NULL | ||
| 927 | LIMIT 1 | ||
| 928 | `, | ||
| 929 | }); | ||
| 930 | |||
| 885 | const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => | 931 | const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => |
| 886 | sql | 932 | sql |
| 887 | .withTransaction( | 933 | .withTransaction( |
| ... | @@ -1759,6 +1805,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1759,6 +1805,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1759 | }); | 1805 | }); |
| 1760 | }); | 1806 | }); |
| 1761 | 1807 | ||
| 1808 | const getFullThreadDiffContext: NonNullable< | ||
| 1809 | ProjectionSnapshotQueryShape["getFullThreadDiffContext"] | ||
| 1810 | > = (threadId, toTurnCount) => | ||
| 1811 | Effect.gen(function* () { | ||
| 1812 | const row = yield* getFullThreadDiffContextRow({ | ||
| 1813 | threadId, | ||
| 1814 | checkpointTurnCount: toTurnCount, | ||
| 1815 | }).pipe( | ||
| 1816 | Effect.mapError( | ||
| 1817 | toPersistenceSqlOrDecodeError( | ||
| 1818 | "ProjectionSnapshotQuery.getFullThreadDiffContext:query", | ||
| 1819 | "ProjectionSnapshotQuery.getFullThreadDiffContext:decodeRow", | ||
| 1820 | ), | ||
| 1821 | ), | ||
| 1822 | ); | ||
| 1823 | if (Option.isNone(row)) { | ||
| 1824 | return Option.none<ProjectionFullThreadDiffContext>(); | ||
| 1825 | } | ||
| 1826 | |||
| 1827 | return Option.some({ | ||
| 1828 | threadId: row.value.threadId, | ||
| 1829 | projectId: row.value.projectId, | ||
| 1830 | workspaceRoot: row.value.workspaceRoot, | ||
| 1831 | worktreePath: row.value.worktreePath, | ||
| 1832 | latestCheckpointTurnCount: row.value.latestCheckpointTurnCount ?? 0, | ||
| 1833 | toCheckpointRef: row.value.toCheckpointRef, | ||
| 1834 | }); | ||
| 1835 | }); | ||
| 1836 | |||
| 1762 | const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => | 1837 | const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => |
| 1763 | Effect.gen(function* () { | 1838 | Effect.gen(function* () { |
| 1764 | const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ | 1839 | const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ |
| ... | @@ -1963,6 +2038,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1963,6 +2038,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1963 | getProjectShellById, | 2038 | getProjectShellById, |
| 1964 | getFirstActiveThreadIdByProjectId, | 2039 | getFirstActiveThreadIdByProjectId, |
| 1965 | getThreadCheckpointContext, | 2040 | getThreadCheckpointContext, |
| 2041 | getFullThreadDiffContext, | ||
| 1966 | getThreadShellById, | 2042 | getThreadShellById, |
| 1967 | getThreadDetailById, | 2043 | getThreadDetailById, |
| 1968 | } satisfies ProjectionSnapshotQueryShape; | 2044 | } satisfies ProjectionSnapshotQueryShape; |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+19| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | * @module ProjectionSnapshotQuery | 7 | * @module ProjectionSnapshotQuery |
| 8 | */ | 8 | */ |
| 9 | import type { | 9 | import type { |
| 10 | CheckpointRef, | ||
| 10 | OrchestrationCheckpointSummary, | 11 | OrchestrationCheckpointSummary, |
| 11 | OrchestrationProject, | 12 | OrchestrationProject, |
| 12 | OrchestrationProjectShell, | 13 | OrchestrationProjectShell, |
| ... | @@ -40,6 +41,15 @@ export interface ProjectionThreadCheckpointContext { | ... | @@ -40,6 +41,15 @@ export interface ProjectionThreadCheckpointContext { |
| 40 | readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>; | 41 | readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>; |
| 41 | } | 42 | } |
| 42 | 43 | ||
| 44 | export interface ProjectionFullThreadDiffContext { | ||
| 45 | readonly threadId: ThreadId; | ||
| 46 | readonly projectId: ProjectId; | ||
| 47 | readonly workspaceRoot: string; | ||
| 48 | readonly worktreePath: string | null; | ||
| 49 | readonly latestCheckpointTurnCount: number; | ||
| 50 | readonly toCheckpointRef: CheckpointRef | null; | ||
| 51 | } | ||
| 52 | |||
| 43 | /** | 53 | /** |
| 44 | * ProjectionSnapshotQueryShape - Service API for read-model snapshots. | 54 | * ProjectionSnapshotQueryShape - Service API for read-model snapshots. |
| 45 | */ | 55 | */ |
| ... | @@ -125,6 +135,15 @@ export interface ProjectionSnapshotQueryShape { | ... | @@ -125,6 +135,15 @@ export interface ProjectionSnapshotQueryShape { |
| 125 | threadId: ThreadId, | 135 | threadId: ThreadId, |
| 126 | ) => Effect.Effect<Option.Option<ProjectionThreadCheckpointContext>, ProjectionRepositoryError>; | 136 | ) => Effect.Effect<Option.Option<ProjectionThreadCheckpointContext>, ProjectionRepositoryError>; |
| 127 | 137 | ||
| 138 | /** | ||
| 139 | * Read only the narrow context needed to compute a full-thread diff from | ||
| 140 | * checkpoint 0 to a specific turn count. | ||
| 141 | */ | ||
| 142 | readonly getFullThreadDiffContext: ( | ||
| 143 | threadId: ThreadId, | ||
| 144 | toTurnCount: number, | ||
| 145 | ) => Effect.Effect<Option.Option<ProjectionFullThreadDiffContext>, ProjectionRepositoryError>; | ||
| 146 | |||
| 128 | /** | 147 | /** |
| 129 | * Read a single active thread shell row by id. | 148 | * Read a single active thread shell row by id. |
| 130 | */ | 149 | */ |
apps/server/src/processRunner.test.ts+273-16| ... | @@ -1,25 +1,282 @@ | ... | @@ -1,25 +1,282 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Sink from "effect/Sink"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import { TestClock } from "effect/testing"; | ||
| 10 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 2 | 11 | ||
| 3 | import { isWindowsCommandNotFound, runProcess } from "./processRunner.ts"; | 12 | import { |
| 13 | isWindowsCommandNotFound, | ||
| 14 | ProcessOutputLimitError, | ||
| 15 | ProcessRunner, | ||
| 16 | ProcessTimeoutError, | ||
| 17 | layer as ProcessRunnerLive, | ||
| 18 | type ProcessRunInput, | ||
| 19 | } from "./processRunner.ts"; | ||
| 4 | 20 | ||
| 5 | describe("runProcess", () => { | 21 | type ChildProcessCommand = { |
| 6 | it("fails when output exceeds max buffer in default mode", async () => { | 22 | readonly command: string; |
| 7 | await expect( | 23 | readonly args: ReadonlyArray<string>; |
| 8 | runProcess("node", ["-e", "process.stdout.write('x'.repeat(2048))"], { maxBufferBytes: 128 }), | 24 | }; |
| 9 | ).rejects.toThrow("exceeded stdout buffer limit"); | 25 | |
| 26 | // Accesses private properties of ChildProcessCommand for testing purposes | ||
| 27 | function asChildProcessCommand(command: unknown): ChildProcessCommand { | ||
| 28 | return command as ChildProcessCommand; | ||
| 29 | } | ||
| 30 | |||
| 31 | function makeHandle(input: { | ||
| 32 | readonly stdout?: string | Stream.Stream<Uint8Array>; | ||
| 33 | readonly stderr?: string | Stream.Stream<Uint8Array>; | ||
| 34 | readonly code?: number; | ||
| 35 | readonly stdin?: ChildProcessSpawner.ChildProcessHandle["stdin"]; | ||
| 36 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; | ||
| 37 | }) { | ||
| 38 | return ChildProcessSpawner.makeHandle({ | ||
| 39 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 40 | exitCode: input.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(input.code ?? 0)), | ||
| 41 | isRunning: Effect.succeed(false), | ||
| 42 | kill: () => Effect.void, | ||
| 43 | unref: Effect.succeed(Effect.void), | ||
| 44 | stdin: input.stdin ?? Sink.drain, | ||
| 45 | stdout: | ||
| 46 | typeof input.stdout === "string" | ||
| 47 | ? Stream.encodeText(Stream.make(input.stdout)) | ||
| 48 | : (input.stdout ?? Stream.empty), | ||
| 49 | stderr: | ||
| 50 | typeof input.stderr === "string" | ||
| 51 | ? Stream.encodeText(Stream.make(input.stderr)) | ||
| 52 | : (input.stderr ?? Stream.empty), | ||
| 53 | all: Stream.empty, | ||
| 54 | getInputFd: () => Sink.drain, | ||
| 55 | getOutputFd: () => Stream.empty, | ||
| 10 | }); | 56 | }); |
| 57 | } | ||
| 58 | |||
| 59 | function makeSpawner( | ||
| 60 | f: (command: ChildProcessCommand) => Effect.Effect<ChildProcessSpawner.ChildProcessHandle>, | ||
| 61 | ) { | ||
| 62 | return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command))); | ||
| 63 | } | ||
| 64 | |||
| 65 | const runWith = | ||
| 66 | (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => (input: ProcessRunInput) => | ||
| 67 | Effect.service(ProcessRunner).pipe( | ||
| 68 | Effect.flatMap((runner) => | ||
| 69 | runner.run({ | ||
| 70 | ...input, | ||
| 71 | shell: input.shell ?? false, | ||
| 72 | }), | ||
| 73 | ), | ||
| 74 | Effect.provide( | ||
| 75 | ProcessRunnerLive.pipe( | ||
| 76 | Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), | ||
| 77 | ), | ||
| 78 | ), | ||
| 79 | ); | ||
| 80 | |||
| 81 | describe("runProcess", () => { | ||
| 82 | it.effect("collects stdout through an injected ChildProcessSpawner", () => | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const spawner = makeSpawner((command) => | ||
| 85 | Effect.sync(() => { | ||
| 86 | expect(command.command).toBe("fake"); | ||
| 87 | expect(command.args).toEqual(["stdout-bytes", "32"]); | ||
| 88 | return makeHandle({ stdout: "x".repeat(32) }); | ||
| 89 | }), | ||
| 90 | ); | ||
| 91 | |||
| 92 | const result = yield* runWith(spawner)({ | ||
| 93 | command: "fake", | ||
| 94 | args: ["stdout-bytes", "32"], | ||
| 95 | }); | ||
| 11 | 96 | ||
| 12 | it("truncates output when outputMode is truncate", async () => { | 97 | expect(result.code).toBe(0); |
| 13 | const result = await runProcess("node", ["-e", "process.stdout.write('x'.repeat(2048))"], { | 98 | expect(result.stdout).toBe("x".repeat(32)); |
| 14 | maxBufferBytes: 128, | 99 | expect(result.timedOut).toBe(false); |
| 15 | outputMode: "truncate", | 100 | }), |
| 16 | }); | 101 | ); |
| 17 | 102 | ||
| 18 | expect(result.code).toBe(0); | 103 | it.effect("runs through the ProcessRunner service", () => { |
| 19 | expect(result.stdout.length).toBeLessThanOrEqual(128); | 104 | const spawner = makeSpawner((command) => |
| 20 | expect(result.stdoutTruncated).toBe(true); | 105 | Effect.sync(() => { |
| 21 | expect(result.stderrTruncated).toBe(false); | 106 | expect(command.command).toBe("fake"); |
| 107 | expect(command.args).toEqual(["--service"]); | ||
| 108 | return makeHandle({ stdout: "service ok" }); | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | const layer = ProcessRunnerLive.pipe( | ||
| 112 | Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), | ||
| 113 | ); | ||
| 114 | |||
| 115 | return Effect.gen(function* () { | ||
| 116 | const runner = yield* ProcessRunner; | ||
| 117 | const result = yield* runner.run({ | ||
| 118 | command: "fake", | ||
| 119 | args: ["--service"], | ||
| 120 | }); | ||
| 121 | |||
| 122 | expect(result.stdout).toBe("service ok"); | ||
| 123 | }).pipe(Effect.provide(layer)); | ||
| 22 | }); | 124 | }); |
| 125 | |||
| 126 | it.effect("fails when output exceeds max buffer in default mode", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); | ||
| 129 | |||
| 130 | const error = yield* runWith(spawner)({ | ||
| 131 | command: "fake", | ||
| 132 | args: ["stdout-bytes", "2048"], | ||
| 133 | maxOutputBytes: 128, | ||
| 134 | }).pipe(Effect.flip); | ||
| 135 | |||
| 136 | expect(error).toBeInstanceOf(ProcessOutputLimitError); | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | |||
| 140 | it.effect("fails fast on output limit before timeout for long-running output", () => | ||
| 141 | Effect.gen(function* () { | ||
| 142 | const textChunk = "x".repeat(64); | ||
| 143 | const spawner = makeSpawner(() => | ||
| 144 | Effect.succeed( | ||
| 145 | makeHandle({ | ||
| 146 | stdout: Stream.fromIterable(Array.from({ length: 10 }, () => textChunk)).pipe( | ||
| 147 | Stream.encodeText, | ||
| 148 | ), | ||
| 149 | exitCode: Effect.never, | ||
| 150 | }), | ||
| 151 | ), | ||
| 152 | ); | ||
| 153 | |||
| 154 | const error = yield* runWith(spawner)({ | ||
| 155 | command: "fake", | ||
| 156 | args: ["spam-stdout"], | ||
| 157 | maxOutputBytes: 128, | ||
| 158 | timeout: "2 seconds", | ||
| 159 | }).pipe(Effect.flip); | ||
| 160 | |||
| 161 | expect(error).toBeInstanceOf(ProcessOutputLimitError); | ||
| 162 | }), | ||
| 163 | ); | ||
| 164 | |||
| 165 | it.effect("truncates output when outputMode is truncate", () => | ||
| 166 | Effect.gen(function* () { | ||
| 167 | const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); | ||
| 168 | |||
| 169 | const result = yield* runWith(spawner)({ | ||
| 170 | command: "fake", | ||
| 171 | args: ["stdout-bytes", "2048"], | ||
| 172 | maxOutputBytes: 128, | ||
| 173 | outputMode: "truncate", | ||
| 174 | }); | ||
| 175 | |||
| 176 | expect(result.code).toBe(0); | ||
| 177 | expect(result.stdout.length).toBeLessThanOrEqual(128); | ||
| 178 | expect(result.stdoutTruncated).toBe(true); | ||
| 179 | expect(result.stderrTruncated).toBe(false); | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | |||
| 183 | it.effect("writes stdin before waiting for exit", () => | ||
| 184 | Effect.gen(function* () { | ||
| 185 | const stdinWritten = yield* Deferred.make<void>(); | ||
| 186 | const decoder = new TextDecoder(); | ||
| 187 | const spawner = makeSpawner(() => | ||
| 188 | Effect.succeed( | ||
| 189 | makeHandle({ | ||
| 190 | stdout: "stdin payload", | ||
| 191 | stdin: Sink.forEach((chunk: Uint8Array) => { | ||
| 192 | const text = decoder.decode(chunk, { stream: true }); | ||
| 193 | return text.includes("stdin payload") | ||
| 194 | ? Deferred.succeed(stdinWritten, undefined) | ||
| 195 | : Effect.void; | ||
| 196 | }), | ||
| 197 | exitCode: Deferred.await(stdinWritten).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | ||
| 198 | }), | ||
| 199 | ), | ||
| 200 | ); | ||
| 201 | |||
| 202 | const result = yield* runWith(spawner)({ | ||
| 203 | command: "fake", | ||
| 204 | args: ["stdin-echo"], | ||
| 205 | stdin: "stdin payload", | ||
| 206 | }); | ||
| 207 | |||
| 208 | expect(result.stdout).toBe("stdin payload"); | ||
| 209 | expect(result.code).toBe(0); | ||
| 210 | }), | ||
| 211 | ); | ||
| 212 | |||
| 213 | it.effect("returns output for non-zero exit codes", () => | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stderr: "boom", code: 2 }))); | ||
| 216 | |||
| 217 | const result = yield* runWith(spawner)({ | ||
| 218 | command: "fake", | ||
| 219 | args: ["stderr-exit", "boom", "2"], | ||
| 220 | }); | ||
| 221 | |||
| 222 | expect(result.code).toBe(2); | ||
| 223 | expect(result.stderr).toBe("boom"); | ||
| 224 | }), | ||
| 225 | ); | ||
| 226 | |||
| 227 | it.effect("fails on timeout", () => | ||
| 228 | Effect.gen(function* () { | ||
| 229 | const spawner = makeSpawner(() => | ||
| 230 | Effect.succeed( | ||
| 231 | makeHandle({ | ||
| 232 | exitCode: Effect.never, | ||
| 233 | }), | ||
| 234 | ), | ||
| 235 | ); | ||
| 236 | const errorFiber = yield* runWith(spawner)({ | ||
| 237 | command: "fake", | ||
| 238 | args: ["sleep"], | ||
| 239 | timeout: "50 millis", | ||
| 240 | }).pipe(Effect.flip, Effect.forkScoped); | ||
| 241 | |||
| 242 | yield* Effect.yieldNow; | ||
| 243 | yield* TestClock.adjust(Duration.millis(50)); | ||
| 244 | const error = yield* Fiber.join(errorFiber); | ||
| 245 | |||
| 246 | expect(error).toBeInstanceOf(ProcessTimeoutError); | ||
| 247 | }), | ||
| 248 | ); | ||
| 249 | |||
| 250 | it.effect("returns a synthetic timed out result when timeoutBehavior is timedOutResult", () => | ||
| 251 | Effect.gen(function* () { | ||
| 252 | const spawner = makeSpawner(() => | ||
| 253 | Effect.succeed( | ||
| 254 | makeHandle({ | ||
| 255 | exitCode: Effect.never, | ||
| 256 | }), | ||
| 257 | ), | ||
| 258 | ); | ||
| 259 | const resultFiber = yield* runWith(spawner)({ | ||
| 260 | command: "fake", | ||
| 261 | args: ["sleep"], | ||
| 262 | timeout: "50 millis", | ||
| 263 | timeoutBehavior: "timedOutResult", | ||
| 264 | }).pipe(Effect.forkScoped); | ||
| 265 | |||
| 266 | yield* Effect.yieldNow; | ||
| 267 | yield* TestClock.adjust(Duration.millis(50)); | ||
| 268 | const result = yield* Fiber.join(resultFiber); | ||
| 269 | |||
| 270 | expect(result).toMatchObject({ | ||
| 271 | stdout: "", | ||
| 272 | stderr: "", | ||
| 273 | code: null, | ||
| 274 | timedOut: true, | ||
| 275 | stdoutTruncated: false, | ||
| 276 | stderrTruncated: false, | ||
| 277 | }); | ||
| 278 | }), | ||
| 279 | ); | ||
| 23 | }); | 280 | }); |
| 24 | 281 | ||
| 25 | describe("isWindowsCommandNotFound", () => { | 282 | describe("isWindowsCommandNotFound", () => { |
apps/server/src/processRunner.ts+290-240| ... | @@ -1,43 +1,101 @@ | ... | @@ -1,43 +1,101 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | import * as Data from "effect/Data"; |
| 2 | import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process"; | 2 | import * as Context from "effect/Context"; |
| 3 | import * as NodeTimers from "node:timers"; | 3 | import * as Duration from "effect/Duration"; |
| 4 | export interface ProcessRunOptions { | 4 | import * as Effect from "effect/Effect"; |
| 5 | cwd?: string | undefined; | 5 | import * as Layer from "effect/Layer"; |
| 6 | timeoutMs?: number | undefined; | 6 | import * as Option from "effect/Option"; |
| 7 | env?: NodeJS.ProcessEnv | undefined; | 7 | import * as PlatformError from "effect/PlatformError"; |
| 8 | stdin?: string | undefined; | 8 | import * as Scope from "effect/Scope"; |
| 9 | allowNonZeroExit?: boolean | undefined; | 9 | import * as Stream from "effect/Stream"; |
| 10 | maxBufferBytes?: number | undefined; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | outputMode?: "error" | "truncate" | undefined; | 11 | import { |
| 12 | } | 12 | collectUint8StreamText, |
| 13 | type CollectedUint8StreamText, | ||
| 14 | } from "./stream/collectUint8StreamText.ts"; | ||
| 13 | 15 | ||
| 14 | export interface ProcessRunResult { | 16 | export interface ProcessRunInput { |
| 15 | stdout: string; | 17 | readonly command: string; |
| 16 | stderr: string; | 18 | readonly args: ReadonlyArray<string>; |
| 17 | code: number | null; | 19 | readonly cwd?: string | undefined; |
| 18 | signal: NodeJS.Signals | null; | 20 | readonly spawnCwd?: string | undefined; |
| 19 | timedOut: boolean; | 21 | readonly timeout?: Duration.Input | undefined; |
| 20 | stdoutTruncated?: boolean | undefined; | 22 | readonly env?: NodeJS.ProcessEnv | undefined; |
| 21 | stderrTruncated?: boolean | undefined; | 23 | readonly stdin?: string | undefined; |
| 24 | readonly maxOutputBytes?: number | undefined; | ||
| 25 | readonly outputMode?: "error" | "truncate" | undefined; | ||
| 26 | readonly truncatedMarker?: string | undefined; | ||
| 27 | readonly shell?: boolean | string | undefined; | ||
| 28 | /** | ||
| 29 | * On timeout, return a synthetic timedOut result. | ||
| 30 | * Partial stdout/stderr are not preserved. | ||
| 31 | */ | ||
| 32 | readonly timeoutBehavior?: "error" | "timedOutResult" | undefined; | ||
| 22 | } | 33 | } |
| 23 | 34 | ||
| 24 | function commandLabel(command: string, args: readonly string[]): string { | 35 | export interface ProcessRunOutput { |
| 25 | return [command, ...args].join(" "); | 36 | readonly stdout: string; |
| 37 | readonly stderr: string; | ||
| 38 | readonly code: ChildProcessSpawner.ExitCode | null; | ||
| 39 | readonly timedOut: boolean; | ||
| 40 | readonly stdoutTruncated: boolean; | ||
| 41 | readonly stderrTruncated: boolean; | ||
| 26 | } | 42 | } |
| 27 | 43 | ||
| 28 | function normalizeSpawnError(command: string, args: readonly string[], error: unknown): Error { | 44 | export class ProcessSpawnError extends Data.TaggedError("ProcessSpawnError")<{ |
| 29 | if (!(error instanceof Error)) { | 45 | readonly command: string; |
| 30 | return new Error(`Failed to run ${commandLabel(command, args)}.`); | 46 | readonly args: ReadonlyArray<string>; |
| 31 | } | 47 | readonly cwd?: string | undefined; |
| 48 | readonly cause: unknown; | ||
| 49 | }> {} | ||
| 32 | 50 | ||
| 33 | const maybeCode = (error as NodeJS.ErrnoException).code; | 51 | export class ProcessStdinError extends Data.TaggedError("ProcessStdinError")<{ |
| 34 | if (maybeCode === "ENOENT") { | 52 | readonly command: string; |
| 35 | return new Error(`Command not found: ${command}`); | 53 | readonly args: ReadonlyArray<string>; |
| 36 | } | 54 | readonly cwd?: string | undefined; |
| 55 | readonly cause: unknown; | ||
| 56 | }> {} | ||
| 57 | |||
| 58 | export class ProcessOutputLimitError extends Data.TaggedError("ProcessOutputLimitError")<{ | ||
| 59 | readonly command: string; | ||
| 60 | readonly args: ReadonlyArray<string>; | ||
| 61 | readonly cwd?: string | undefined; | ||
| 62 | readonly stream: "stdout" | "stderr"; | ||
| 63 | readonly maxBytes: number; | ||
| 64 | }> {} | ||
| 65 | |||
| 66 | export class ProcessReadError extends Data.TaggedError("ProcessReadError")<{ | ||
| 67 | readonly command: string; | ||
| 68 | readonly args: ReadonlyArray<string>; | ||
| 69 | readonly cwd?: string | undefined; | ||
| 70 | readonly stream: "stdout" | "stderr" | "exitCode"; | ||
| 71 | readonly cause: unknown; | ||
| 72 | }> {} | ||
| 73 | |||
| 74 | export class ProcessTimeoutError extends Data.TaggedError("ProcessTimeoutError")<{ | ||
| 75 | readonly command: string; | ||
| 76 | readonly args: ReadonlyArray<string>; | ||
| 77 | readonly cwd?: string | undefined; | ||
| 78 | readonly timeoutMs: number; | ||
| 79 | }> {} | ||
| 80 | |||
| 81 | export type ProcessRunError = | ||
| 82 | | ProcessSpawnError | ||
| 83 | | ProcessStdinError | ||
| 84 | | ProcessOutputLimitError | ||
| 85 | | ProcessReadError | ||
| 86 | | ProcessTimeoutError; | ||
| 37 | 87 | ||
| 38 | return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); | 88 | export interface ProcessRunnerShape { |
| 89 | readonly run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput, ProcessRunError>; | ||
| 39 | } | 90 | } |
| 40 | 91 | ||
| 92 | export class ProcessRunner extends Context.Service<ProcessRunner, ProcessRunnerShape>()( | ||
| 93 | "t3/process/ProcessRunner", | ||
| 94 | ) {} | ||
| 95 | |||
| 96 | const DEFAULT_TIMEOUT = "60 seconds"; | ||
| 97 | const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | ||
| 98 | |||
| 41 | const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ | 99 | const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ |
| 42 | /is not recognized as an internal or external command/i, | 100 | /is not recognized as an internal or external command/i, |
| 43 | /n.o . reconhecido como um comando interno/i, | 101 | /n.o . reconhecido como um comando interno/i, |
| ... | @@ -57,230 +115,222 @@ export function isWindowsCommandNotFound(code: number | null, stderr: string): b | ... | @@ -57,230 +115,222 @@ export function isWindowsCommandNotFound(code: number | null, stderr: string): b |
| 57 | return hasWindowsCommandNotFoundMessage(stderr); | 115 | return hasWindowsCommandNotFoundMessage(stderr); |
| 58 | } | 116 | } |
| 59 | 117 | ||
| 60 | function normalizeExitError( | 118 | const collectText = Effect.fn("processRunner.collectText")(function* (input: { |
| 61 | command: string, | 119 | readonly command: string; |
| 62 | args: readonly string[], | 120 | readonly args: ReadonlyArray<string>; |
| 63 | result: ProcessRunResult, | 121 | readonly cwd?: string | undefined; |
| 64 | ): Error { | 122 | readonly streamName: "stdout" | "stderr"; |
| 65 | if (isWindowsCommandNotFound(result.code, result.stderr)) { | 123 | readonly stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>; |
| 66 | return new Error(`Command not found: ${command}`); | 124 | readonly maxOutputBytes: number; |
| 67 | } | 125 | readonly outputMode: "error" | "truncate"; |
| 68 | 126 | readonly truncatedMarker: string; | |
| 69 | const reason = result.timedOut | 127 | }) { |
| 70 | ? "timed out" | 128 | const stream = input.stream.pipe( |
| 71 | : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`; | 129 | Stream.mapError( |
| 72 | const stderr = result.stderr.trim(); | 130 | (cause) => |
| 73 | const detail = stderr.length > 0 ? ` ${stderr}` : ""; | 131 | new ProcessReadError({ |
| 74 | return new Error(`${commandLabel(command, args)} ${reason}.${detail}`); | 132 | command: input.command, |
| 75 | } | 133 | args: input.args, |
| 76 | 134 | cwd: input.cwd, | |
| 77 | function normalizeStdinError(command: string, args: readonly string[], error: unknown): Error { | 135 | stream: input.streamName, |
| 78 | if (!(error instanceof Error)) { | 136 | cause, |
| 79 | return new Error(`Failed to write stdin for ${commandLabel(command, args)}.`); | 137 | }), |
| 80 | } | 138 | ), |
| 81 | return new Error(`Failed to write stdin for ${commandLabel(command, args)}: ${error.message}`); | ||
| 82 | } | ||
| 83 | |||
| 84 | function normalizeBufferError( | ||
| 85 | command: string, | ||
| 86 | args: readonly string[], | ||
| 87 | stream: "stdout" | "stderr", | ||
| 88 | maxBufferBytes: number, | ||
| 89 | ): Error { | ||
| 90 | return new Error( | ||
| 91 | `${commandLabel(command, args)} exceeded ${stream} buffer limit (${maxBufferBytes} bytes).`, | ||
| 92 | ); | 139 | ); |
| 93 | } | ||
| 94 | 140 | ||
| 95 | const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; | 141 | if (input.outputMode === "truncate") { |
| 96 | 142 | return yield* collectUint8StreamText({ | |
| 97 | /** | 143 | stream, |
| 98 | * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` | 144 | maxBytes: input.maxOutputBytes, |
| 99 | * wrapper, leaving the actual command running. Use `taskkill /T` to kill the | 145 | truncatedMarker: input.truncatedMarker, |
| 100 | * entire process tree instead. | 146 | }); |
| 101 | */ | ||
| 102 | function killChild(child: ChildProcessHandle, signal: NodeJS.Signals = "SIGTERM"): void { | ||
| 103 | if (process.platform === "win32" && child.pid !== undefined) { | ||
| 104 | try { | ||
| 105 | spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); | ||
| 106 | return; | ||
| 107 | } catch { | ||
| 108 | // fallback to direct kill | ||
| 109 | } | ||
| 110 | } | ||
| 111 | child.kill(signal); | ||
| 112 | } | ||
| 113 | |||
| 114 | function appendChunkWithinLimit( | ||
| 115 | target: string, | ||
| 116 | currentBytes: number, | ||
| 117 | chunk: Buffer, | ||
| 118 | maxBytes: number, | ||
| 119 | ): { | ||
| 120 | next: string; | ||
| 121 | nextBytes: number; | ||
| 122 | truncated: boolean; | ||
| 123 | } { | ||
| 124 | const remaining = maxBytes - currentBytes; | ||
| 125 | if (remaining <= 0) { | ||
| 126 | return { next: target, nextBytes: currentBytes, truncated: true }; | ||
| 127 | } | ||
| 128 | if (chunk.length <= remaining) { | ||
| 129 | return { | ||
| 130 | next: `${target}${chunk.toString()}`, | ||
| 131 | nextBytes: currentBytes + chunk.length, | ||
| 132 | truncated: false, | ||
| 133 | }; | ||
| 134 | } | 147 | } |
| 135 | return { | ||
| 136 | next: `${target}${chunk.subarray(0, remaining).toString()}`, | ||
| 137 | nextBytes: currentBytes + remaining, | ||
| 138 | truncated: true, | ||
| 139 | }; | ||
| 140 | } | ||
| 141 | |||
| 142 | export async function runProcess( | ||
| 143 | command: string, | ||
| 144 | args: readonly string[], | ||
| 145 | options: ProcessRunOptions = {}, | ||
| 146 | ): Promise<ProcessRunResult> { | ||
| 147 | const timeoutMs = options.timeoutMs ?? 60_000; | ||
| 148 | const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; | ||
| 149 | const outputMode = options.outputMode ?? "error"; | ||
| 150 | 148 | ||
| 151 | return new Promise<ProcessRunResult>((resolve, reject) => { | 149 | return yield* stream.pipe( |
| 152 | const child = spawn(command, args, { | 150 | Stream.runFoldEffect< |
| 153 | cwd: options.cwd, | 151 | { |
| 154 | env: options.env, | 152 | readonly chunks: Uint8Array<ArrayBufferLike>[]; |
| 155 | stdio: "pipe", | 153 | readonly bytes: number; |
| 156 | shell: process.platform === "win32", | 154 | }, |
| 157 | }); | 155 | Uint8Array<ArrayBufferLike>, |
| 156 | ProcessOutputLimitError | ProcessReadError, | ||
| 157 | never | ||
| 158 | >( | ||
| 159 | () => ({ chunks: [], bytes: 0 }), | ||
| 160 | (state, chunk) => { | ||
| 161 | const remainingBytes = input.maxOutputBytes - state.bytes; | ||
| 162 | if (remainingBytes <= 0 || chunk.byteLength > remainingBytes) { | ||
| 163 | return Effect.fail( | ||
| 164 | new ProcessOutputLimitError({ | ||
| 165 | command: input.command, | ||
| 166 | args: input.args, | ||
| 167 | cwd: input.cwd, | ||
| 168 | stream: input.streamName, | ||
| 169 | maxBytes: input.maxOutputBytes, | ||
| 170 | }), | ||
| 171 | ); | ||
| 172 | } | ||
| 158 | 173 | ||
| 159 | let stdout = ""; | 174 | state.chunks.push(chunk); |
| 160 | let stderr = ""; | 175 | return Effect.succeed({ |
| 161 | let stdoutBytes = 0; | 176 | chunks: state.chunks, |
| 162 | let stderrBytes = 0; | 177 | bytes: state.bytes + chunk.byteLength, |
| 163 | let stdoutTruncated = false; | 178 | }); |
| 164 | let stderrTruncated = false; | 179 | }, |
| 165 | let timedOut = false; | 180 | ), |
| 166 | let settled = false; | 181 | Effect.map( |
| 167 | let forceKillTimer: ReturnType<typeof NodeTimers.setTimeout> | null = null; | 182 | (state): CollectedUint8StreamText => ({ |
| 183 | text: Buffer.concat(state.chunks, state.bytes).toString("utf8"), | ||
| 184 | bytes: state.bytes, | ||
| 185 | truncated: false, | ||
| 186 | }), | ||
| 187 | ), | ||
| 188 | ); | ||
| 189 | }); | ||
| 168 | 190 | ||
| 169 | // @effect-diagnostics-next-line globalTimers:off - Promise/child_process boundary; moving this runner to Effect timers is a separate refactor. | 191 | function finalizeRunProcess<R>( |
| 170 | const timeoutTimer = NodeTimers.setTimeout(() => { | 192 | effect: Effect.Effect<ProcessRunOutput, ProcessRunError, R | Scope.Scope>, |
| 171 | timedOut = true; | 193 | input: ProcessRunInput, |
| 172 | killChild(child, "SIGTERM"); | 194 | ): Effect.Effect<ProcessRunOutput, ProcessRunError, Exclude<R, Scope.Scope>> { |
| 173 | // @effect-diagnostics-next-line globalTimers:off - Promise/child_process boundary; see timeout timer above. | 195 | const timeout = Duration.fromInputUnsafe(input.timeout ?? DEFAULT_TIMEOUT); |
| 174 | forceKillTimer = NodeTimers.setTimeout(() => { | 196 | const timeoutBehavior = input.timeoutBehavior ?? "error"; |
| 175 | killChild(child, "SIGKILL"); | ||
| 176 | }, 1_000); | ||
| 177 | }, timeoutMs); | ||
| 178 | 197 | ||
| 179 | const finalize = (callback: () => void): void => { | 198 | return effect.pipe( |
| 180 | if (settled) return; | 199 | Effect.scoped, |
| 181 | settled = true; | 200 | Effect.timeoutOption(timeout), |
| 182 | NodeTimers.clearTimeout(timeoutTimer); | 201 | Effect.flatMap((result) => { |
| 183 | if (forceKillTimer) { | 202 | if (Option.isSome(result)) { |
| 184 | NodeTimers.clearTimeout(forceKillTimer); | 203 | return Effect.succeed(result.value); |
| 204 | } | ||
| 205 | if (timeoutBehavior === "timedOutResult") { | ||
| 206 | return Effect.succeed({ | ||
| 207 | stdout: "", | ||
| 208 | stderr: "", | ||
| 209 | code: null, | ||
| 210 | timedOut: true, | ||
| 211 | stdoutTruncated: false, | ||
| 212 | stderrTruncated: false, | ||
| 213 | } satisfies ProcessRunOutput); | ||
| 185 | } | 214 | } |
| 186 | callback(); | 215 | return Effect.fail( |
| 187 | }; | 216 | new ProcessTimeoutError({ |
| 217 | command: input.command, | ||
| 218 | args: input.args, | ||
| 219 | cwd: input.cwd, | ||
| 220 | timeoutMs: Duration.toMillis(timeout), | ||
| 221 | }), | ||
| 222 | ); | ||
| 223 | }), | ||
| 224 | ); | ||
| 225 | } | ||
| 188 | 226 | ||
| 189 | const fail = (error: Error): void => { | 227 | const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( |
| 190 | killChild(child, "SIGTERM"); | 228 | spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], |
| 191 | finalize(() => { | 229 | input: ProcessRunInput, |
| 192 | reject(error); | 230 | ): Effect.fn.Return<ProcessRunOutput, ProcessRunError, Scope.Scope> { |
| 193 | }); | 231 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 194 | }; | 232 | const outputMode = input.outputMode ?? "error"; |
| 233 | const truncatedMarker = input.truncatedMarker ?? ""; | ||
| 195 | 234 | ||
| 196 | const appendOutput = (stream: "stdout" | "stderr", chunk: Buffer | string): Error | null => { | 235 | const child = yield* spawner |
| 197 | const chunkBuffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; | 236 | .spawn( |
| 198 | const text = chunkBuffer.toString(); | 237 | ChildProcess.make(input.command, [...input.args], { |
| 199 | const byteLength = chunkBuffer.length; | 238 | ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), |
| 200 | if (stream === "stdout") { | 239 | ...(input.env !== undefined |
| 201 | if (outputMode === "truncate") { | 240 | ? { |
| 202 | const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunkBuffer, maxBufferBytes); | 241 | env: input.env, |
| 203 | stdout = appended.next; | 242 | extendEnv: true, |
| 204 | stdoutBytes = appended.nextBytes; | 243 | } |
| 205 | stdoutTruncated = stdoutTruncated || appended.truncated; | 244 | : {}), |
| 206 | return null; | 245 | ...(input.shell !== undefined ? { shell: input.shell } : {}), |
| 207 | } | 246 | }), |
| 208 | stdout += text; | 247 | ) |
| 209 | stdoutBytes += byteLength; | 248 | .pipe( |
| 210 | if (stdoutBytes > maxBufferBytes) { | 249 | Effect.mapError( |
| 211 | return normalizeBufferError(command, args, "stdout", maxBufferBytes); | 250 | (cause) => |
| 212 | } | 251 | new ProcessSpawnError({ |
| 213 | } else { | 252 | command: input.command, |
| 214 | if (outputMode === "truncate") { | 253 | args: input.args, |
| 215 | const appended = appendChunkWithinLimit(stderr, stderrBytes, chunkBuffer, maxBufferBytes); | 254 | cwd: input.cwd, |
| 216 | stderr = appended.next; | 255 | cause, |
| 217 | stderrBytes = appended.nextBytes; | 256 | }), |
| 218 | stderrTruncated = stderrTruncated || appended.truncated; | 257 | ), |
| 219 | return null; | 258 | ); |
| 220 | } | ||
| 221 | stderr += text; | ||
| 222 | stderrBytes += byteLength; | ||
| 223 | if (stderrBytes > maxBufferBytes) { | ||
| 224 | return normalizeBufferError(command, args, "stderr", maxBufferBytes); | ||
| 225 | } | ||
| 226 | } | ||
| 227 | return null; | ||
| 228 | }; | ||
| 229 | 259 | ||
| 230 | child.stdout.on("data", (chunk: Buffer | string) => { | 260 | const writeStdin = |
| 231 | const error = appendOutput("stdout", chunk); | 261 | input.stdin === undefined |
| 232 | if (error) { | 262 | ? Effect.void |
| 233 | fail(error); | 263 | : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( |
| 234 | } | 264 | Effect.mapError( |
| 235 | }); | 265 | (cause) => |
| 266 | new ProcessStdinError({ | ||
| 267 | command: input.command, | ||
| 268 | args: input.args, | ||
| 269 | cwd: input.cwd, | ||
| 270 | cause, | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ); | ||
| 236 | 274 | ||
| 237 | child.stderr.on("data", (chunk: Buffer | string) => { | 275 | const [stdout, stderr] = yield* Effect.all( |
| 238 | const error = appendOutput("stderr", chunk); | 276 | [ |
| 239 | if (error) { | 277 | collectText({ |
| 240 | fail(error); | 278 | command: input.command, |
| 241 | } | 279 | args: input.args, |
| 242 | }); | 280 | cwd: input.cwd, |
| 281 | streamName: "stdout", | ||
| 282 | stream: child.stdout, | ||
| 283 | maxOutputBytes, | ||
| 284 | outputMode, | ||
| 285 | truncatedMarker, | ||
| 286 | }), | ||
| 287 | collectText({ | ||
| 288 | command: input.command, | ||
| 289 | args: input.args, | ||
| 290 | cwd: input.cwd, | ||
| 291 | streamName: "stderr", | ||
| 292 | stream: child.stderr, | ||
| 293 | maxOutputBytes, | ||
| 294 | outputMode, | ||
| 295 | truncatedMarker, | ||
| 296 | }), | ||
| 297 | writeStdin, | ||
| 298 | ], | ||
| 299 | { concurrency: "unbounded" }, | ||
| 300 | ); | ||
| 243 | 301 | ||
| 244 | child.once("error", (error) => { | 302 | const exitCode = yield* child.exitCode.pipe( |
| 245 | finalize(() => { | 303 | Effect.mapError( |
| 246 | reject(normalizeSpawnError(command, args, error)); | 304 | (cause) => |
| 247 | }); | 305 | new ProcessReadError({ |
| 248 | }); | 306 | command: input.command, |
| 307 | args: input.args, | ||
| 308 | cwd: input.cwd, | ||
| 309 | stream: "exitCode", | ||
| 310 | cause, | ||
| 311 | }), | ||
| 312 | ), | ||
| 313 | ); | ||
| 249 | 314 | ||
| 250 | child.once("close", (code, signal) => { | 315 | return { |
| 251 | const result: ProcessRunResult = { | 316 | stdout: stdout.text, |
| 252 | stdout, | 317 | stderr: stderr.text, |
| 253 | stderr, | 318 | code: exitCode, |
| 254 | code, | 319 | timedOut: false, |
| 255 | signal, | 320 | stdoutTruncated: stdout.truncated, |
| 256 | timedOut, | 321 | stderrTruncated: stderr.truncated, |
| 257 | stdoutTruncated, | 322 | } satisfies ProcessRunOutput; |
| 258 | stderrTruncated, | 323 | }); |
| 259 | }; | ||
| 260 | 324 | ||
| 261 | finalize(() => { | 325 | export const make = Effect.fn("makeProcessRunner")(function* () { |
| 262 | if (!options.allowNonZeroExit && (timedOut || (code !== null && code !== 0))) { | 326 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 263 | reject(normalizeExitError(command, args, result)); | ||
| 264 | return; | ||
| 265 | } | ||
| 266 | resolve(result); | ||
| 267 | }); | ||
| 268 | }); | ||
| 269 | 327 | ||
| 270 | child.stdin.once("error", (error) => { | 328 | const run: ProcessRunnerShape["run"] = (input) => |
| 271 | fail(normalizeStdinError(command, args, error)); | 329 | finalizeRunProcess(runProcessCore(spawner, input), input); |
| 272 | }); | ||
| 273 | 330 | ||
| 274 | if (options.stdin !== undefined) { | 331 | return ProcessRunner.of({ |
| 275 | child.stdin.write(options.stdin, (error) => { | 332 | run, |
| 276 | if (error) { | ||
| 277 | fail(normalizeStdinError(command, args, error)); | ||
| 278 | return; | ||
| 279 | } | ||
| 280 | child.stdin.end(); | ||
| 281 | }); | ||
| 282 | return; | ||
| 283 | } | ||
| 284 | child.stdin.end(); | ||
| 285 | }); | 333 | }); |
| 286 | } | 334 | }); |
| 335 | |||
| 336 | export const layer = Layer.effect(ProcessRunner, make()); |
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+1| ... | @@ -36,6 +36,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => | ... | @@ -36,6 +36,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => |
| 36 | Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), | 36 | Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), |
| 37 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), | 37 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), |
| 38 | getThreadCheckpointContext: () => Effect.die("unused"), | 38 | getThreadCheckpointContext: () => Effect.die("unused"), |
| 39 | getFullThreadDiffContext: () => Effect.die("unused"), | ||
| 39 | getThreadShellById: () => Effect.die("unused"), | 40 | getThreadShellById: () => Effect.die("unused"), |
| 40 | getThreadDetailById: () => Effect.die("unused"), | 41 | getThreadDetailById: () => Effect.die("unused"), |
| 41 | }); | 42 | }); |
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+25-12| ... | @@ -1,15 +1,13 @@ | ... | @@ -1,15 +1,13 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import { realpathSync } from "node:fs"; | ||
| 3 | |||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 6 | import * as Duration from "effect/Duration"; | 3 | import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 10 | import * as TestClock from "effect/testing/TestClock"; | 7 | import * as Path from "effect/Path"; |
| 8 | import { TestClock } from "effect/testing"; | ||
| 11 | 9 | ||
| 12 | import { runProcess } from "../../processRunner.ts"; | 10 | import * as ProcessRunner from "../../processRunner.ts"; |
| 13 | import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; | 11 | import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; |
| 14 | import { | 12 | import { |
| 15 | makeRepositoryIdentityResolver, | 13 | makeRepositoryIdentityResolver, |
| ... | @@ -17,11 +15,17 @@ import { | ... | @@ -17,11 +15,17 @@ import { |
| 17 | } from "./RepositoryIdentityResolver.ts"; | 15 | } from "./RepositoryIdentityResolver.ts"; |
| 18 | 16 | ||
| 19 | const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); | 17 | const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); |
| 20 | const normalizeResolvedPath = (value: string) => | 18 | const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); |
| 21 | normalizePathSeparators(realpathSync.native(value)); | ||
| 22 | 19 | ||
| 23 | const git = (cwd: string, args: ReadonlyArray<string>) => | 20 | const git = (cwd: string, args: ReadonlyArray<string>) => |
| 24 | Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); | 21 | Effect.gen(function* () { |
| 22 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 23 | return yield* processRunner.run({ | ||
| 24 | command: "git", | ||
| 25 | args: ["-C", cwd, ...args], | ||
| 26 | shell: process.platform === "win32", | ||
| 27 | }); | ||
| 28 | }).pipe(Effect.provide(ProcessRunner.layer)); | ||
| 25 | 29 | ||
| 26 | const makeRepositoryIdentityResolverTestLayer = (options: { | 30 | const makeRepositoryIdentityResolverTestLayer = (options: { |
| 27 | readonly positiveCacheTtl?: Duration.Input; | 31 | readonly positiveCacheTtl?: Duration.Input; |
| ... | @@ -33,7 +37,7 @@ const makeRepositoryIdentityResolverTestLayer = (options: { | ... | @@ -33,7 +37,7 @@ const makeRepositoryIdentityResolverTestLayer = (options: { |
| 33 | cacheCapacity: 16, | 37 | cacheCapacity: 16, |
| 34 | ...options, | 38 | ...options, |
| 35 | }), | 39 | }), |
| 36 | ); | 40 | ).pipe(Layer.provide(ProcessRunner.layer)); |
| 37 | 41 | ||
| 38 | it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | 42 | it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 39 | it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => | 43 | it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => |
| ... | @@ -48,10 +52,13 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | ... | @@ -48,10 +52,13 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 48 | 52 | ||
| 49 | const resolver = yield* RepositoryIdentityResolver; | 53 | const resolver = yield* RepositoryIdentityResolver; |
| 50 | const identity = yield* resolver.resolve(cwd); | 54 | const identity = yield* resolver.resolve(cwd); |
| 55 | const resolvedIdentityRoot = | ||
| 56 | identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); | ||
| 57 | const resolvedCwd = yield* fileSystem.realPath(cwd); | ||
| 51 | 58 | ||
| 52 | expect(identity).not.toBeNull(); | 59 | expect(identity).not.toBeNull(); |
| 53 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | 60 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); |
| 54 | expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd)); | 61 | expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd)); |
| 55 | expect(identity?.displayName).toBe("t3tools/t3code"); | 62 | expect(identity?.displayName).toBe("t3tools/t3code"); |
| 56 | expect(identity?.provider).toBe("github"); | 63 | expect(identity?.provider).toBe("github"); |
| 57 | expect(identity?.owner).toBe("t3tools"); | 64 | expect(identity?.owner).toBe("t3tools"); |
| ... | @@ -62,10 +69,11 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | ... | @@ -62,10 +69,11 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 62 | it.effect("returns the git top-level root path when resolving from a nested workspace", () => | 69 | it.effect("returns the git top-level root path when resolving from a nested workspace", () => |
| 63 | Effect.gen(function* () { | 70 | Effect.gen(function* () { |
| 64 | const fileSystem = yield* FileSystem.FileSystem; | 71 | const fileSystem = yield* FileSystem.FileSystem; |
| 72 | const path = yield* Path.Path; | ||
| 65 | const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ | 73 | const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ |
| 66 | prefix: "t3-repository-identity-nested-root-test-", | 74 | prefix: "t3-repository-identity-nested-root-test-", |
| 67 | }); | 75 | }); |
| 68 | const nestedWorkspace = `${repoRoot}/packages/web`; | 76 | const nestedWorkspace = path.join(repoRoot, "packages", "web"); |
| 69 | 77 | ||
| 70 | yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); | 78 | yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); |
| 71 | yield* git(repoRoot, ["init"]); | 79 | yield* git(repoRoot, ["init"]); |
| ... | @@ -73,10 +81,15 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | ... | @@ -73,10 +81,15 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 73 | 81 | ||
| 74 | const resolver = yield* RepositoryIdentityResolver; | 82 | const resolver = yield* RepositoryIdentityResolver; |
| 75 | const identity = yield* resolver.resolve(nestedWorkspace); | 83 | const identity = yield* resolver.resolve(nestedWorkspace); |
| 84 | const resolvedIdentityRoot = | ||
| 85 | identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); | ||
| 86 | const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); | ||
| 76 | 87 | ||
| 77 | expect(identity).not.toBeNull(); | 88 | expect(identity).not.toBeNull(); |
| 78 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | 89 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); |
| 79 | expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(repoRoot)); | 90 | expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( |
| 91 | normalizeResolvedPath(resolvedRepoRoot), | ||
| 92 | ); | ||
| 80 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | 93 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), |
| 81 | ); | 94 | ); |
| 82 | 95 |
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+48-33| ... | @@ -9,7 +9,7 @@ import { | ... | @@ -9,7 +9,7 @@ import { |
| 9 | normalizeGitRemoteUrl, | 9 | normalizeGitRemoteUrl, |
| 10 | } from "@t3tools/shared/git"; | 10 | } from "@t3tools/shared/git"; |
| 11 | 11 | ||
| 12 | import { runProcess } from "../../processRunner.ts"; | 12 | import * as ProcessRunner from "../../processRunner.ts"; |
| 13 | import { | 13 | import { |
| 14 | RepositoryIdentityResolver, | 14 | RepositoryIdentityResolver, |
| 15 | type RepositoryIdentityResolverShape, | 15 | type RepositoryIdentityResolverShape, |
| ... | @@ -83,50 +83,63 @@ interface RepositoryIdentityResolverOptions { | ... | @@ -83,50 +83,63 @@ interface RepositoryIdentityResolverOptions { |
| 83 | readonly negativeCacheTtl?: Duration.Input; | 83 | readonly negativeCacheTtl?: Duration.Input; |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | async function resolveRepositoryIdentityCacheKey(cwd: string): Promise<string> { | 86 | const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* ( |
| 87 | cwd: string, | ||
| 88 | ) { | ||
| 89 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 87 | let cacheKey = cwd; | 90 | let cacheKey = cwd; |
| 88 | 91 | ||
| 89 | try { | 92 | const topLevelResult = yield* processRunner |
| 90 | const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { | 93 | .run({ |
| 91 | allowNonZeroExit: true, | 94 | command: "git", |
| 92 | }); | 95 | args: ["-C", cwd, "rev-parse", "--show-toplevel"], |
| 93 | if (topLevelResult.code !== 0) { | 96 | timeoutBehavior: "timedOutResult", |
| 94 | return cacheKey; | 97 | shell: process.platform === "win32", |
| 95 | } | 98 | }) |
| 96 | 99 | .pipe(Effect.option); | |
| 97 | const candidate = topLevelResult.stdout.trim(); | 100 | if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { |
| 98 | if (candidate.length > 0) { | ||
| 99 | cacheKey = candidate; | ||
| 100 | } | ||
| 101 | } catch { | ||
| 102 | return cacheKey; | 101 | return cacheKey; |
| 103 | } | 102 | } |
| 104 | 103 | ||
| 105 | return cacheKey; | 104 | const candidate = topLevelResult.value.stdout.trim(); |
| 106 | } | 105 | if (candidate.length > 0) { |
| 106 | cacheKey = candidate; | ||
| 107 | } | ||
| 107 | 108 | ||
| 108 | async function resolveRepositoryIdentityFromCacheKey( | 109 | return cacheKey; |
| 109 | cacheKey: string, | 110 | }); |
| 110 | ): Promise<RepositoryIdentity | null> { | 111 | |
| 111 | try { | 112 | const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")( |
| 112 | const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { | 113 | function* ( |
| 113 | allowNonZeroExit: true, | 114 | cacheKey: string, |
| 114 | }); | 115 | ): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> { |
| 115 | if (remoteResult.code !== 0) { | 116 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 117 | const remoteResult = yield* processRunner | ||
| 118 | .run({ | ||
| 119 | command: "git", | ||
| 120 | args: ["-C", cacheKey, "remote", "-v"], | ||
| 121 | timeoutBehavior: "timedOutResult", | ||
| 122 | shell: process.platform === "win32", | ||
| 123 | }) | ||
| 124 | .pipe(Effect.option); | ||
| 125 | if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { | ||
| 116 | return null; | 126 | return null; |
| 117 | } | 127 | } |
| 118 | 128 | ||
| 119 | const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); | 129 | const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); |
| 120 | return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; | 130 | return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; |
| 121 | } catch { | 131 | }, |
| 122 | return null; | 132 | ); |
| 123 | } | ||
| 124 | } | ||
| 125 | 133 | ||
| 126 | export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( | 134 | export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( |
| 127 | function* (options: RepositoryIdentityResolverOptions = {}) { | 135 | function* (options: RepositoryIdentityResolverOptions = {}) { |
| 136 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 137 | |||
| 128 | const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>( | 138 | const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>( |
| 129 | (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), | 139 | (cacheKey) => |
| 140 | resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( | ||
| 141 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 142 | ), | ||
| 130 | { | 143 | { |
| 131 | capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, | 144 | capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, |
| 132 | timeToLive: Exit.match({ | 145 | timeToLive: Exit.match({ |
| ... | @@ -142,7 +155,9 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR | ... | @@ -142,7 +155,9 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR |
| 142 | const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( | 155 | const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( |
| 143 | "RepositoryIdentityResolver.resolve", | 156 | "RepositoryIdentityResolver.resolve", |
| 144 | )(function* (cwd) { | 157 | )(function* (cwd) { |
| 145 | const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); | 158 | const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( |
| 159 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 160 | ); | ||
| 146 | return yield* Cache.get(repositoryIdentityCache, cacheKey); | 161 | return yield* Cache.get(repositoryIdentityCache, cacheKey); |
| 147 | }); | 162 | }); |
| 148 | 163 | ||
| ... | @@ -155,4 +170,4 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR | ... | @@ -155,4 +170,4 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR |
| 155 | export const RepositoryIdentityResolverLive = Layer.effect( | 170 | export const RepositoryIdentityResolverLive = Layer.effect( |
| 156 | RepositoryIdentityResolver, | 171 | RepositoryIdentityResolver, |
| 157 | makeRepositoryIdentityResolver(), | 172 | makeRepositoryIdentityResolver(), |
| 158 | ); | 173 | ).pipe(Layer.provide(ProcessRunner.layer)); |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | @@ -202,6 +202,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -202,6 +202,7 @@ describe("ProviderSessionReaper", () => { |
| 202 | getProjectShellById: () => Effect.die("unused"), | 202 | getProjectShellById: () => Effect.die("unused"), |
| 203 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), | 203 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), |
| 204 | getThreadCheckpointContext: () => Effect.die("unused"), | 204 | getThreadCheckpointContext: () => Effect.die("unused"), |
| 205 | getFullThreadDiffContext: () => Effect.die("unused"), | ||
| 205 | getThreadShellById: (threadId) => | 206 | getThreadShellById: (threadId) => |
| 206 | Effect.succeed( | 207 | Effect.succeed( |
| 207 | input.readModel.threads.find((thread) => thread.id === threadId) | 208 | input.readModel.threads.find((thread) => thread.id === threadId) |
apps/server/src/serverRuntimeStartup.test.ts+3| ... | @@ -98,6 +98,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa | ... | @@ -98,6 +98,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa |
| 98 | getProjectShellById: () => Effect.succeed(Option.none()), | 98 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 99 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 99 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 100 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 100 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 101 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | ||
| 101 | getThreadShellById: () => Effect.succeed(Option.none()), | 102 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 102 | getThreadDetailById: () => Effect.succeed(Option.none()), | 103 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 103 | }), | 104 | }), |
| ... | @@ -159,6 +160,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa | ... | @@ -159,6 +160,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 159 | getProjectShellById: () => Effect.die("unused"), | 160 | getProjectShellById: () => Effect.die("unused"), |
| 160 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), | 161 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), |
| 161 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 162 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 163 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | ||
| 162 | getThreadShellById: () => Effect.die("unused"), | 164 | getThreadShellById: () => Effect.die("unused"), |
| 163 | getThreadDetailById: () => Effect.die("unused"), | 165 | getThreadDetailById: () => Effect.die("unused"), |
| 164 | }), | 166 | }), |
| ... | @@ -200,6 +202,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when | ... | @@ -200,6 +202,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 200 | getProjectShellById: () => Effect.die("unused"), | 202 | getProjectShellById: () => Effect.die("unused"), |
| 201 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 203 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 202 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 204 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 205 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | ||
| 203 | getThreadShellById: () => Effect.die("unused"), | 206 | getThreadShellById: () => Effect.die("unused"), |
| 204 | getThreadDetailById: () => Effect.die("unused"), | 207 | getThreadDetailById: () => Effect.die("unused"), |
| 205 | }), | 208 | }), |
apps/server/src/sourceControl/SourceControlDiscovery.ts+1-1| ... | @@ -100,7 +100,7 @@ export const layer = Layer.effect( | ... | @@ -100,7 +100,7 @@ export const layer = Layer.effect( |
| 100 | cwd: config.cwd, | 100 | cwd: config.cwd, |
| 101 | timeoutMs: 5_000, | 101 | timeoutMs: 5_000, |
| 102 | maxOutputBytes: 8_000, | 102 | maxOutputBytes: 8_000, |
| 103 | truncateOutputAtMaxBytes: true, | 103 | appendTruncationMarker: true, |
| 104 | }) | 104 | }) |
| 105 | .pipe( | 105 | .pipe( |
| 106 | Effect.map( | 106 | Effect.map( |
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+2-2| ... | @@ -139,7 +139,7 @@ function probeCli(input: { | ... | @@ -139,7 +139,7 @@ function probeCli(input: { |
| 139 | cwd: input.cwd, | 139 | cwd: input.cwd, |
| 140 | timeoutMs: 5_000, | 140 | timeoutMs: 5_000, |
| 141 | maxOutputBytes: 8_000, | 141 | maxOutputBytes: 8_000, |
| 142 | truncateOutputAtMaxBytes: true, | 142 | appendTruncationMarker: true, |
| 143 | }) | 143 | }) |
| 144 | .pipe( | 144 | .pipe( |
| 145 | Effect.map( | 145 | Effect.map( |
| ... | @@ -216,7 +216,7 @@ export function probeSourceControlProvider(input: { | ... | @@ -216,7 +216,7 @@ export function probeSourceControlProvider(input: { |
| 216 | allowNonZeroExit: true, | 216 | allowNonZeroExit: true, |
| 217 | timeoutMs: 5_000, | 217 | timeoutMs: 5_000, |
| 218 | maxOutputBytes: 8_000, | 218 | maxOutputBytes: 8_000, |
| 219 | truncateOutputAtMaxBytes: true, | 219 | appendTruncationMarker: true, |
| 220 | }) | 220 | }) |
| 221 | .pipe( | 221 | .pipe( |
| 222 | Effect.map( | 222 | Effect.map( |
apps/server/src/stream/collectUint8StreamText.ts+14-11| ... | @@ -8,7 +8,7 @@ export interface CollectedUint8StreamText { | ... | @@ -8,7 +8,7 @@ export interface CollectedUint8StreamText { |
| 8 | } | 8 | } |
| 9 | 9 | ||
| 10 | interface CollectState { | 10 | interface CollectState { |
| 11 | readonly text: string; | 11 | chunks: Uint8Array[]; |
| 12 | readonly bytes: number; | 12 | readonly bytes: number; |
| 13 | readonly truncated: boolean; | 13 | readonly truncated: boolean; |
| 14 | } | 14 | } |
| ... | @@ -18,18 +18,21 @@ export const collectUint8StreamText = <E>(input: { | ... | @@ -18,18 +18,21 @@ export const collectUint8StreamText = <E>(input: { |
| 18 | readonly maxBytes?: number | undefined; | 18 | readonly maxBytes?: number | undefined; |
| 19 | readonly truncatedMarker?: string | null | undefined; | 19 | readonly truncatedMarker?: string | null | undefined; |
| 20 | }): Effect.Effect<CollectedUint8StreamText, E> => { | 20 | }): Effect.Effect<CollectedUint8StreamText, E> => { |
| 21 | const decoder = new TextDecoder(); | ||
| 22 | const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY; | 21 | const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY; |
| 23 | const truncatedMarker = input.truncatedMarker ?? ""; | 22 | const truncatedMarker = input.truncatedMarker ?? ""; |
| 24 | 23 | ||
| 25 | return input.stream.pipe( | 24 | return input.stream.pipe( |
| 26 | Stream.runFold( | 25 | Stream.runFold( |
| 27 | (): CollectState => ({ | 26 | (): CollectState => ({ |
| 28 | text: "", | 27 | chunks: [], |
| 29 | bytes: 0, | 28 | bytes: 0, |
| 30 | truncated: false, | 29 | truncated: false, |
| 31 | }), | 30 | }), |
| 32 | (state, chunk): CollectState => { | 31 | (state, chunk): CollectState => { |
| 32 | /* | ||
| 33 | * keep draining after truncation so the child process can exit normally. | ||
| 34 | * its a know issue that on windows killing after the output cap can force an expensive taskkill operation and hurt performance | ||
| 35 | */ | ||
| 33 | if (state.truncated) { | 36 | if (state.truncated) { |
| 34 | return state; | 37 | return state; |
| 35 | } | 38 | } |
| ... | @@ -38,30 +41,30 @@ export const collectUint8StreamText = <E>(input: { | ... | @@ -38,30 +41,30 @@ export const collectUint8StreamText = <E>(input: { |
| 38 | if (remainingBytes <= 0) { | 41 | if (remainingBytes <= 0) { |
| 39 | return { | 42 | return { |
| 40 | ...state, | 43 | ...state, |
| 41 | text: `${state.text}${truncatedMarker}`, | ||
| 42 | truncated: true, | 44 | truncated: true, |
| 43 | }; | 45 | }; |
| 44 | } | 46 | } |
| 45 | 47 | ||
| 46 | const nextChunk = | 48 | const nextChunk = |
| 47 | chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; | 49 | chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; |
| 48 | const text = `${state.text}${decoder.decode(nextChunk, { stream: true })}`; | 50 | state.chunks.push(nextChunk); |
| 49 | const bytes = state.bytes + nextChunk.byteLength; | 51 | const bytes = state.bytes + nextChunk.byteLength; |
| 50 | const truncated = chunk.byteLength > remainingBytes; | 52 | const truncated = chunk.byteLength > remainingBytes; |
| 51 | 53 | ||
| 52 | return { | 54 | return { |
| 53 | text: truncated ? `${text}${truncatedMarker}` : text, | 55 | chunks: state.chunks, |
| 54 | bytes, | 56 | bytes, |
| 55 | truncated, | 57 | truncated, |
| 56 | }; | 58 | }; |
| 57 | }, | 59 | }, |
| 58 | ), | 60 | ), |
| 59 | Effect.map( | 61 | Effect.map((state): CollectedUint8StreamText => { |
| 60 | (state): CollectedUint8StreamText => ({ | 62 | const text = Buffer.concat(state.chunks, state.bytes).toString("utf8"); |
| 61 | text: state.truncated ? state.text : `${state.text}${decoder.decode()}`, | 63 | return { |
| 64 | text: state.truncated && truncatedMarker.length > 0 ? `${text}${truncatedMarker}` : text, | ||
| 62 | bytes: state.bytes, | 65 | bytes: state.bytes, |
| 63 | truncated: state.truncated, | 66 | truncated: state.truncated, |
| 64 | }), | 67 | }; |
| 65 | ), | 68 | }), |
| 66 | ); | 69 | ); |
| 67 | }; | 70 | }; |
apps/server/src/terminal/Layers/Manager.test.ts+149-60| ... | @@ -6,21 +6,24 @@ import { | ... | @@ -6,21 +6,24 @@ import { |
| 6 | type TerminalOpenInput, | 6 | type TerminalOpenInput, |
| 7 | type TerminalRestartInput, | 7 | type TerminalRestartInput, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; | 9 | import * as Data from "effect/Data"; |
| 10 | import * as Duration from "effect/Duration"; | 10 | import * as Duration from "effect/Duration"; |
| 11 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Encoding from "effect/Encoding"; | 12 | import * as Encoding from "effect/Encoding"; |
| 13 | import * as Exit from "effect/Exit"; | 13 | import * as Exit from "effect/Exit"; |
| 14 | import * as Fiber from "effect/Fiber"; | 14 | import * as Fiber from "effect/Fiber"; |
| 15 | import * as FileSystem from "effect/FileSystem"; | 15 | import * as FileSystem from "effect/FileSystem"; |
| 16 | import * as Path from "effect/Path"; | 16 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; | ||
| 17 | import * as PlatformError from "effect/PlatformError"; | 18 | import * as PlatformError from "effect/PlatformError"; |
| 19 | import * as Path from "effect/Path"; | ||
| 18 | import * as Ref from "effect/Ref"; | 20 | import * as Ref from "effect/Ref"; |
| 19 | import * as Schedule from "effect/Schedule"; | 21 | import * as Schedule from "effect/Schedule"; |
| 20 | import * as Scope from "effect/Scope"; | 22 | import * as Scope from "effect/Scope"; |
| 21 | import * as TestClock from "effect/testing/TestClock"; | 23 | import { TestClock } from "effect/testing"; |
| 22 | import { expect } from "vitest"; | 24 | import { expect } from "vitest"; |
| 23 | 25 | ||
| 26 | import * as ProcessRunner from "../../processRunner.ts"; | ||
| 24 | import type { TerminalManagerShape } from "../Services/Manager.ts"; | 27 | import type { TerminalManagerShape } from "../Services/Manager.ts"; |
| 25 | import { | 28 | import { |
| 26 | type PtyAdapterShape, | 29 | type PtyAdapterShape, |
| ... | @@ -31,6 +34,10 @@ import { | ... | @@ -31,6 +34,10 @@ import { |
| 31 | } from "../Services/PTY.ts"; | 34 | } from "../Services/PTY.ts"; |
| 32 | import { makeTerminalManagerWithOptions } from "./Manager.ts"; | 35 | import { makeTerminalManagerWithOptions } from "./Manager.ts"; |
| 33 | 36 | ||
| 37 | class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ | ||
| 38 | readonly message: string; | ||
| 39 | }> {} | ||
| 40 | |||
| 34 | class FakePtyProcess implements PtyProcess { | 41 | class FakePtyProcess implements PtyProcess { |
| 35 | readonly writes: string[] = []; | 42 | readonly writes: string[] = []; |
| 36 | readonly resizeCalls: Array<{ cols: number; rows: number }> = []; | 43 | readonly resizeCalls: Array<{ cols: number; rows: number }> = []; |
| ... | @@ -127,11 +134,21 @@ class FakePtyAdapter implements PtyAdapterShape { | ... | @@ -127,11 +134,21 @@ class FakePtyAdapter implements PtyAdapterShape { |
| 127 | const waitFor = <E, R>( | 134 | const waitFor = <E, R>( |
| 128 | predicate: Effect.Effect<boolean, E, R>, | 135 | predicate: Effect.Effect<boolean, E, R>, |
| 129 | timeout: Duration.Input = 800, | 136 | timeout: Duration.Input = 800, |
| 130 | ): Effect.Effect<void, E | Cause.TimeoutError, R> => | 137 | ): Effect.Effect<void, WaitForConditionError | E, R> => |
| 131 | predicate.pipe( | 138 | predicate.pipe( |
| 132 | Effect.filter((done) => done), | 139 | Effect.filterOrFail( |
| 140 | (done) => done, | ||
| 141 | () => new WaitForConditionError({ message: "Condition not met" }), | ||
| 142 | ), | ||
| 133 | Effect.retry(Schedule.spaced("15 millis")), | 143 | Effect.retry(Schedule.spaced("15 millis")), |
| 134 | Effect.timeout(timeout), | 144 | Effect.timeoutOption(timeout), |
| 145 | Effect.flatMap((result) => | ||
| 146 | Option.match(result, { | ||
| 147 | onNone: () => | ||
| 148 | Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), | ||
| 149 | onSome: () => Effect.void, | ||
| 150 | }), | ||
| 151 | ), | ||
| 135 | ); | 152 | ); |
| 136 | 153 | ||
| 137 | function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { | 154 | function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { |
| ... | @@ -156,33 +173,27 @@ function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRe | ... | @@ -156,33 +173,27 @@ function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRe |
| 156 | }; | 173 | }; |
| 157 | } | 174 | } |
| 158 | 175 | ||
| 159 | function historyLogName(threadId: string): string { | 176 | const historyLogPath = (logsDir: string, threadId = "thread-1") => |
| 160 | return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; | 177 | Effect.service(Path.Path).pipe( |
| 161 | } | 178 | Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), |
| 162 | 179 | ); | |
| 163 | function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { | ||
| 164 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; | ||
| 165 | if (terminalId === DEFAULT_TERMINAL_ID) { | ||
| 166 | return `${threadPart}.log`; | ||
| 167 | } | ||
| 168 | return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; | ||
| 169 | } | ||
| 170 | |||
| 171 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { | ||
| 172 | return joinPath(logsDir, historyLogName(threadId)); | ||
| 173 | } | ||
| 174 | 180 | ||
| 175 | function multiTerminalHistoryLogPath( | 181 | const multiTerminalHistoryLogPath = ( |
| 176 | logsDir: string, | 182 | logsDir: string, |
| 177 | threadId = "thread-1", | 183 | threadId = "thread-1", |
| 178 | terminalId = "default", | 184 | terminalId = DEFAULT_TERMINAL_ID, |
| 179 | ): string { | 185 | ) => |
| 180 | return joinPath(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); | 186 | Effect.service(Path.Path).pipe( |
| 181 | } | 187 | Effect.map(({ join }) => { |
| 182 | 188 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 183 | function joinPath(...segments: ReadonlyArray<string>): string { | 189 | return join( |
| 184 | return segments.join("/"); | 190 | logsDir, |
| 185 | } | 191 | terminalId === DEFAULT_TERMINAL_ID |
| 192 | ? `${threadPart}.log` | ||
| 193 | : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, | ||
| 194 | ); | ||
| 195 | }), | ||
| 196 | ); | ||
| 186 | 197 | ||
| 187 | interface CreateManagerOptions { | 198 | interface CreateManagerOptions { |
| 188 | shellResolver?: () => string; | 199 | shellResolver?: () => string; |
| ... | @@ -209,13 +220,13 @@ const createManager = ( | ... | @@ -209,13 +220,13 @@ const createManager = ( |
| 209 | ): Effect.Effect< | 220 | ): Effect.Effect< |
| 210 | ManagerFixture, | 221 | ManagerFixture, |
| 211 | PlatformError.PlatformError, | 222 | PlatformError.PlatformError, |
| 212 | FileSystem.FileSystem | Path.Path | Scope.Scope | 223 | FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner |
| 213 | > => | 224 | > => |
| 214 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => | 225 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => |
| 215 | Effect.gen(function* () { | 226 | Effect.gen(function* () { |
| 216 | const pathService = yield* Path.Path; | 227 | const { join } = yield* Path.Path; |
| 217 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); | 228 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); |
| 218 | const logsDir = pathService.join(baseDir, "userdata", "logs", "terminals"); | 229 | const logsDir = join(baseDir, "userdata", "logs", "terminals"); |
| 219 | const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); | 230 | const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); |
| 220 | 231 | ||
| 221 | const manager = yield* makeTerminalManagerWithOptions({ | 232 | const manager = yield* makeTerminalManagerWithOptions({ |
| ... | @@ -246,6 +257,7 @@ const createManager = ( | ... | @@ -246,6 +257,7 @@ const createManager = ( |
| 246 | return { | 257 | return { |
| 247 | baseDir, | 258 | baseDir, |
| 248 | logsDir, | 259 | logsDir, |
| 260 | join, | ||
| 249 | ptyAdapter, | 261 | ptyAdapter, |
| 250 | manager, | 262 | manager, |
| 251 | getEvents: Ref.get(eventsRef), | 263 | getEvents: Ref.get(eventsRef), |
| ... | @@ -253,7 +265,12 @@ const createManager = ( | ... | @@ -253,7 +265,12 @@ const createManager = ( |
| 253 | }), | 265 | }), |
| 254 | ); | 266 | ); |
| 255 | 267 | ||
| 256 | it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (it) => { | 268 | it.layer( |
| 269 | Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), | ||
| 270 | { excludeTestServices: true }, | ||
| 271 | )("TerminalManager", (it) => { | ||
| 272 | const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect; | ||
| 273 | |||
| 257 | it.effect("spawns lazily and reuses running terminal per thread", () => | 274 | it.effect("spawns lazily and reuses running terminal per thread", () => |
| 258 | Effect.gen(function* () { | 275 | Effect.gen(function* () { |
| 259 | const { manager, ptyAdapter } = yield* createManager(); | 276 | const { manager, ptyAdapter } = yield* createManager(); |
| ... | @@ -290,13 +307,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -290,13 +307,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 290 | fs.writeFileString(filePath, contents), | 307 | fs.writeFileString(filePath, contents), |
| 291 | ); | 308 | ); |
| 292 | 309 | ||
| 293 | it.effect("preserves non-notFound cwd stat failures", () => | 310 | itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () => |
| 294 | Effect.gen(function* () { | 311 | Effect.gen(function* () { |
| 295 | if (process.platform === "win32") return; | 312 | const path = yield* Path.Path; |
| 296 | 313 | ||
| 297 | const { manager, baseDir } = yield* createManager(); | 314 | const { manager, baseDir } = yield* createManager(); |
| 298 | const blockedRoot = joinPath(baseDir, "blocked-root"); | 315 | const blockedRoot = path.join(baseDir, "blocked-root"); |
| 299 | const blockedCwd = joinPath(blockedRoot, "cwd"); | 316 | const blockedCwd = path.join(blockedRoot, "cwd"); |
| 300 | yield* makeDirectory(blockedCwd); | 317 | yield* makeDirectory(blockedCwd); |
| 301 | yield* chmod(blockedRoot, 0o000); | 318 | yield* chmod(blockedRoot, 0o000); |
| 302 | 319 | ||
| ... | @@ -390,15 +407,27 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -390,15 +407,27 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 390 | it.effect("clears transcript and emits cleared event", () => | 407 | it.effect("clears transcript and emits cleared event", () => |
| 391 | Effect.gen(function* () { | 408 | Effect.gen(function* () { |
| 392 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); | 409 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); |
| 410 | const path = yield* Path.Path; | ||
| 393 | yield* manager.open(openInput()); | 411 | yield* manager.open(openInput()); |
| 394 | const process = ptyAdapter.processes[0]; | 412 | const process = ptyAdapter.processes[0]; |
| 395 | expect(process).toBeDefined(); | 413 | expect(process).toBeDefined(); |
| 396 | if (!process) return; | 414 | if (!process) return; |
| 397 | 415 | ||
| 398 | process.emitData("hello\n"); | 416 | process.emitData("hello\n"); |
| 399 | yield* waitFor(pathExists(historyLogPath(logsDir))); | 417 | yield* waitFor( |
| 418 | historyLogPath(logsDir).pipe( | ||
| 419 | Effect.provideService(Path.Path, path), | ||
| 420 | Effect.flatMap(pathExists), | ||
| 421 | ), | ||
| 422 | ); | ||
| 400 | yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); | 423 | yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); |
| 401 | yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); | 424 | yield* waitFor( |
| 425 | historyLogPath(logsDir).pipe( | ||
| 426 | Effect.provideService(Path.Path, path), | ||
| 427 | Effect.flatMap(readFileString), | ||
| 428 | Effect.map((text) => text === ""), | ||
| 429 | ), | ||
| 430 | ); | ||
| 402 | 431 | ||
| 403 | const events = yield* getEvents; | 432 | const events = yield* getEvents; |
| 404 | expect(events.some((event) => event.type === "cleared")).toBe(true); | 433 | expect(events.some((event) => event.type === "cleared")).toBe(true); |
| ... | @@ -421,21 +450,34 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -421,21 +450,34 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 421 | expect(firstProcess).toBeDefined(); | 450 | expect(firstProcess).toBeDefined(); |
| 422 | if (!firstProcess) return; | 451 | if (!firstProcess) return; |
| 423 | firstProcess.emitData("before restart\n"); | 452 | firstProcess.emitData("before restart\n"); |
| 424 | yield* waitFor(pathExists(historyLogPath(logsDir))); | 453 | const path = yield* Path.Path; |
| 454 | yield* waitFor( | ||
| 455 | historyLogPath(logsDir).pipe( | ||
| 456 | Effect.provideService(Path.Path, path), | ||
| 457 | Effect.flatMap(pathExists), | ||
| 458 | ), | ||
| 459 | ); | ||
| 425 | 460 | ||
| 426 | const snapshot = yield* manager.restart(restartInput()); | 461 | const snapshot = yield* manager.restart(restartInput()); |
| 427 | assert.equal(snapshot.history, ""); | 462 | assert.equal(snapshot.history, ""); |
| 428 | assert.equal(snapshot.status, "running"); | 463 | assert.equal(snapshot.status, "running"); |
| 429 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | 464 | expect(ptyAdapter.spawnInputs).toHaveLength(2); |
| 430 | yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); | 465 | yield* waitFor( |
| 466 | historyLogPath(logsDir).pipe( | ||
| 467 | Effect.provideService(Path.Path, path), | ||
| 468 | Effect.flatMap(readFileString), | ||
| 469 | Effect.map((text) => text === ""), | ||
| 470 | ), | ||
| 471 | ); | ||
| 431 | }), | 472 | }), |
| 432 | ); | 473 | ); |
| 433 | 474 | ||
| 434 | it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => | 475 | it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => |
| 435 | Effect.gen(function* () { | 476 | Effect.gen(function* () { |
| 436 | const { manager, getEvents, baseDir } = yield* createManager(); | 477 | const { manager, getEvents, baseDir } = yield* createManager(); |
| 437 | const firstWorktreePath = joinPath(baseDir, "worktrees", "feature-a"); | 478 | const path = yield* Path.Path; |
| 438 | const secondWorktreePath = joinPath(baseDir, "worktrees", "feature-b"); | 479 | const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); |
| 480 | const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); | ||
| 439 | yield* makeDirectory(firstWorktreePath); | 481 | yield* makeDirectory(firstWorktreePath); |
| 440 | yield* makeDirectory(secondWorktreePath); | 482 | yield* makeDirectory(secondWorktreePath); |
| 441 | const startedSnapshot = yield* manager.open( | 483 | const startedSnapshot = yield* manager.open( |
| ... | @@ -471,7 +513,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -471,7 +513,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 471 | it.effect("preserves worktree metadata when reopening an exited session", () => | 513 | it.effect("preserves worktree metadata when reopening an exited session", () => |
| 472 | Effect.gen(function* () { | 514 | Effect.gen(function* () { |
| 473 | const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); | 515 | const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); |
| 474 | const worktreePath = joinPath(baseDir, "worktrees", "feature-a"); | 516 | const path = yield* Path.Path; |
| 517 | const worktreePath = path.join(baseDir, "worktrees", "feature-a"); | ||
| 475 | yield* makeDirectory(worktreePath); | 518 | yield* makeDirectory(worktreePath); |
| 476 | 519 | ||
| 477 | yield* manager.open( | 520 | yield* manager.open( |
| ... | @@ -513,12 +556,18 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -513,12 +556,18 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 513 | it.effect("emits exited event and reopens with clean transcript after exit", () => | 556 | it.effect("emits exited event and reopens with clean transcript after exit", () => |
| 514 | Effect.gen(function* () { | 557 | Effect.gen(function* () { |
| 515 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); | 558 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); |
| 559 | const path = yield* Path.Path; | ||
| 516 | yield* manager.open(openInput()); | 560 | yield* manager.open(openInput()); |
| 517 | const process = ptyAdapter.processes[0]; | 561 | const process = ptyAdapter.processes[0]; |
| 518 | expect(process).toBeDefined(); | 562 | expect(process).toBeDefined(); |
| 519 | if (!process) return; | 563 | if (!process) return; |
| 520 | process.emitData("old data\n"); | 564 | process.emitData("old data\n"); |
| 521 | yield* waitFor(pathExists(historyLogPath(logsDir))); | 565 | yield* waitFor( |
| 566 | historyLogPath(logsDir).pipe( | ||
| 567 | Effect.provideService(Path.Path, path), | ||
| 568 | Effect.flatMap(pathExists), | ||
| 569 | ), | ||
| 570 | ); | ||
| 522 | process.emitExit({ exitCode: 0, signal: 0 }); | 571 | process.emitExit({ exitCode: 0, signal: 0 }); |
| 523 | 572 | ||
| 524 | yield* waitFor( | 573 | yield* waitFor( |
| ... | @@ -528,7 +577,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -528,7 +577,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 528 | 577 | ||
| 529 | assert.equal(reopened.history, ""); | 578 | assert.equal(reopened.history, ""); |
| 530 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | 579 | expect(ptyAdapter.spawnInputs).toHaveLength(2); |
| 531 | expect(yield* readFileString(historyLogPath(logsDir))).toBe(""); | 580 | expect( |
| 581 | yield* historyLogPath(logsDir).pipe( | ||
| 582 | Effect.provideService(Path.Path, path), | ||
| 583 | Effect.flatMap(readFileString), | ||
| 584 | ), | ||
| 585 | ).toBe(""); | ||
| 532 | }), | 586 | }), |
| 533 | ); | 587 | ); |
| 534 | 588 | ||
| ... | @@ -715,10 +769,21 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -715,10 +769,21 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 715 | expect(process).toBeDefined(); | 769 | expect(process).toBeDefined(); |
| 716 | if (!process) return; | 770 | if (!process) return; |
| 717 | process.emitData("bye\n"); | 771 | process.emitData("bye\n"); |
| 718 | yield* waitFor(pathExists(historyLogPath(logsDir))); | 772 | const path = yield* Path.Path; |
| 773 | yield* waitFor( | ||
| 774 | historyLogPath(logsDir).pipe( | ||
| 775 | Effect.provideService(Path.Path, path), | ||
| 776 | Effect.flatMap(pathExists), | ||
| 777 | ), | ||
| 778 | ); | ||
| 719 | 779 | ||
| 720 | yield* manager.close({ threadId: "thread-1", deleteHistory: true }); | 780 | yield* manager.close({ threadId: "thread-1", deleteHistory: true }); |
| 721 | expect(yield* pathExists(historyLogPath(logsDir))).toBe(false); | 781 | expect( |
| 782 | yield* historyLogPath(logsDir).pipe( | ||
| 783 | Effect.provideService(Path.Path, path), | ||
| 784 | Effect.flatMap(pathExists), | ||
| 785 | ), | ||
| 786 | ).toBe(false); | ||
| 722 | }), | 787 | }), |
| 723 | ); | 788 | ); |
| 724 | 789 | ||
| ... | @@ -735,19 +800,36 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -735,19 +800,36 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 735 | 800 | ||
| 736 | defaultProcess.emitData("default\n"); | 801 | defaultProcess.emitData("default\n"); |
| 737 | sidecarProcess.emitData("sidecar\n"); | 802 | sidecarProcess.emitData("sidecar\n"); |
| 738 | yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); | 803 | const path = yield* Path.Path; |
| 739 | yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); | 804 | yield* waitFor( |
| 805 | multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( | ||
| 806 | Effect.provideService(Path.Path, path), | ||
| 807 | Effect.flatMap(pathExists), | ||
| 808 | ), | ||
| 809 | ); | ||
| 810 | yield* waitFor( | ||
| 811 | multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( | ||
| 812 | Effect.provideService(Path.Path, path), | ||
| 813 | Effect.flatMap(pathExists), | ||
| 814 | ), | ||
| 815 | ); | ||
| 740 | 816 | ||
| 741 | yield* manager.close({ threadId: "thread-1", deleteHistory: true }); | 817 | yield* manager.close({ threadId: "thread-1", deleteHistory: true }); |
| 742 | 818 | ||
| 743 | assert.equal(defaultProcess.killed, true); | 819 | assert.equal(defaultProcess.killed, true); |
| 744 | assert.equal(sidecarProcess.killed, true); | 820 | assert.equal(sidecarProcess.killed, true); |
| 745 | expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe( | 821 | expect( |
| 746 | false, | 822 | yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( |
| 747 | ); | 823 | Effect.provideService(Path.Path, path), |
| 748 | expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe( | 824 | Effect.flatMap(pathExists), |
| 749 | false, | 825 | ), |
| 750 | ); | 826 | ).toBe(false); |
| 827 | expect( | ||
| 828 | yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( | ||
| 829 | Effect.provideService(Path.Path, path), | ||
| 830 | Effect.flatMap(pathExists), | ||
| 831 | ), | ||
| 832 | ).toBe(false); | ||
| 751 | }), | 833 | }), |
| 752 | ); | 834 | ); |
| 753 | 835 | ||
| ... | @@ -786,7 +868,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -786,7 +868,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 786 | 868 | ||
| 787 | first.emitData("first-history\n"); | 869 | first.emitData("first-history\n"); |
| 788 | second.emitData("second-history\n"); | 870 | second.emitData("second-history\n"); |
| 789 | yield* waitFor(pathExists(historyLogPath(logsDir, "thread-1"))); | 871 | const path = yield* Path.Path; |
| 872 | yield* waitFor( | ||
| 873 | historyLogPath(logsDir, "thread-1").pipe( | ||
| 874 | Effect.provideService(Path.Path, path), | ||
| 875 | Effect.flatMap(pathExists), | ||
| 876 | ), | ||
| 877 | ); | ||
| 790 | first.emitExit({ exitCode: 0, signal: 0 }); | 878 | first.emitExit({ exitCode: 0, signal: 0 }); |
| 791 | yield* Effect.sleep(Duration.millis(5)); | 879 | yield* Effect.sleep(Duration.millis(5)); |
| 792 | second.emitExit({ exitCode: 0, signal: 0 }); | 880 | second.emitExit({ exitCode: 0, signal: 0 }); |
| ... | @@ -809,8 +897,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -809,8 +897,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 809 | it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => | 897 | it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => |
| 810 | Effect.gen(function* () { | 898 | Effect.gen(function* () { |
| 811 | const { manager, logsDir } = yield* createManager(); | 899 | const { manager, logsDir } = yield* createManager(); |
| 812 | const legacyPath = joinPath(logsDir, "thread-1.log"); | 900 | const path = yield* Path.Path; |
| 813 | const nextPath = historyLogPath(logsDir); | 901 | const legacyPath = path.join(logsDir, "thread-1.log"); |
| 902 | const nextPath = yield* historyLogPath(logsDir); | ||
| 814 | yield* writeFileString(legacyPath, "legacy-line\n"); | 903 | yield* writeFileString(legacyPath, "legacy-line\n"); |
| 815 | 904 | ||
| 816 | const snapshot = yield* manager.open(openInput()); | 905 | const snapshot = yield* manager.open(openInput()); |
apps/server/src/terminal/Layers/Manager.ts+113-77| ... | @@ -5,13 +5,13 @@ import { | ... | @@ -5,13 +5,13 @@ import { |
| 5 | type TerminalSessionStatus, | 5 | type TerminalSessionStatus, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; | 7 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 9 | import * as Encoding from "effect/Encoding"; | 10 | import * as Encoding from "effect/Encoding"; |
| 10 | import * as Equal from "effect/Equal"; | 11 | import * as Equal from "effect/Equal"; |
| 11 | import * as Exit from "effect/Exit"; | 12 | import * as Exit from "effect/Exit"; |
| 12 | import * as Fiber from "effect/Fiber"; | 13 | import * as Fiber from "effect/Fiber"; |
| 13 | import * as FileSystem from "effect/FileSystem"; | 14 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as DateTime from "effect/DateTime"; | ||
| 15 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 17 | import * as Path from "effect/Path"; | 17 | import * as Path from "effect/Path"; |
| ... | @@ -26,7 +26,7 @@ import { | ... | @@ -26,7 +26,7 @@ import { |
| 26 | terminalRestartsTotal, | 26 | terminalRestartsTotal, |
| 27 | terminalSessionsTotal, | 27 | terminalSessionsTotal, |
| 28 | } from "../../observability/Metrics.ts"; | 28 | } from "../../observability/Metrics.ts"; |
| 29 | import { runProcess } from "../../processRunner.ts"; | 29 | import * as ProcessRunner from "../../processRunner.ts"; |
| 30 | import { | 30 | import { |
| 31 | TerminalCwdError, | 31 | TerminalCwdError, |
| 32 | TerminalHistoryError, | 32 | TerminalHistoryError, |
| ... | @@ -51,6 +51,7 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; | ... | @@ -51,6 +51,7 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; |
| 51 | const DEFAULT_OPEN_COLS = 120; | 51 | const DEFAULT_OPEN_COLS = 120; |
| 52 | const DEFAULT_OPEN_ROWS = 30; | 52 | const DEFAULT_OPEN_ROWS = 30; |
| 53 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | 53 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); |
| 54 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | ||
| 54 | 55 | ||
| 55 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( | 56 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( |
| 56 | "TerminalSubprocessCheckError", | 57 | "TerminalSubprocessCheckError", |
| ... | @@ -72,7 +73,9 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess | ... | @@ -72,7 +73,9 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess |
| 72 | ) {} | 73 | ) {} |
| 73 | 74 | ||
| 74 | interface TerminalSubprocessChecker { | 75 | interface TerminalSubprocessChecker { |
| 75 | (terminalPid: number): Effect.Effect<boolean, TerminalSubprocessCheckError>; | 76 | ( |
| 77 | terminalPid: number, | ||
| 78 | ): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner>; | ||
| 76 | } | 79 | } |
| 77 | 80 | ||
| 78 | interface ShellCandidate { | 81 | interface ShellCandidate { |
| ... | @@ -211,17 +214,23 @@ function normalizeShellCommand( | ... | @@ -211,17 +214,23 @@ function normalizeShellCommand( |
| 211 | return firstToken.replace(/^['"]|['"]$/g, ""); | 214 | return firstToken.replace(/^['"]|['"]$/g, ""); |
| 212 | } | 215 | } |
| 213 | 216 | ||
| 214 | function basename(command: string, platform: NodeJS.Platform): string { | 217 | function basenameForPlatform(command: string, platform: NodeJS.Platform): string { |
| 215 | const normalized = platform === "win32" ? command.replaceAll("/", "\\") : command; | 218 | const normalized = |
| 216 | const separator = platform === "win32" ? "\\" : "/"; | 219 | platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); |
| 217 | return normalized.slice(normalized.lastIndexOf(separator) + 1); | 220 | const parts = normalized |
| 221 | .split(platform === "win32" ? /\\+/ : /\/+/) | ||
| 222 | .filter((part) => part.length > 0); | ||
| 223 | return parts.at(-1) ?? normalized; | ||
| 218 | } | 224 | } |
| 219 | 225 | ||
| 220 | function joinWindowsPath(...segments: ReadonlyArray<string>): string { | 226 | function joinWindowsPath(...parts: ReadonlyArray<string>): string { |
| 221 | return segments | 227 | return parts |
| 222 | .filter((segment) => segment.length > 0) | 228 | .map((part, index) => { |
| 223 | .join("\\") | 229 | if (index === 0) return part.replace(/[\\/]+$/g, ""); |
| 224 | .replace(/\\+/g, "\\"); | 230 | return part.replace(/^[\\/]+|[\\/]+$/g, ""); |
| 231 | }) | ||
| 232 | .filter((part) => part.length > 0) | ||
| 233 | .join("\\"); | ||
| 225 | } | 234 | } |
| 226 | 235 | ||
| 227 | function shellCandidateFromCommand( | 236 | function shellCandidateFromCommand( |
| ... | @@ -229,7 +238,7 @@ function shellCandidateFromCommand( | ... | @@ -229,7 +238,7 @@ function shellCandidateFromCommand( |
| 229 | platform: NodeJS.Platform = process.platform, | 238 | platform: NodeJS.Platform = process.platform, |
| 230 | ): ShellCandidate | null { | 239 | ): ShellCandidate | null { |
| 231 | if (!command || command.length === 0) return null; | 240 | if (!command || command.length === 0) return null; |
| 232 | const shellName = basename(command, platform).toLowerCase(); | 241 | const shellName = basenameForPlatform(command, platform).toLowerCase(); |
| 233 | if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { | 242 | if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { |
| 234 | return { shell: command, args: ["-NoLogo"] }; | 243 | return { shell: command, args: ["-NoLogo"] }; |
| 235 | } | 244 | } |
| ... | @@ -357,66 +366,82 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { | ... | @@ -357,66 +366,82 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { |
| 357 | 366 | ||
| 358 | function checkWindowsSubprocessActivity( | 367 | function checkWindowsSubprocessActivity( |
| 359 | terminalPid: number, | 368 | terminalPid: number, |
| 360 | ): Effect.Effect<boolean, TerminalSubprocessCheckError> { | 369 | ): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> { |
| 361 | const command = [ | 370 | const command = [ |
| 362 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, | 371 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, |
| 363 | "if ($children) { exit 0 }", | 372 | "if ($children) { exit 0 }", |
| 364 | "exit 1", | 373 | "exit 1", |
| 365 | ].join("; "); | 374 | ].join("; "); |
| 366 | return Effect.tryPromise({ | 375 | return Effect.gen(function* () { |
| 367 | try: () => | 376 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 368 | runProcess("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { | 377 | return yield* processRunner.run({ |
| 369 | timeoutMs: 1_500, | 378 | command: "powershell.exe", |
| 370 | allowNonZeroExit: true, | 379 | args: ["-NoProfile", "-NonInteractive", "-Command", command], |
| 371 | maxBufferBytes: 32_768, | 380 | timeout: "1500 millis", |
| 372 | outputMode: "truncate", | 381 | maxOutputBytes: 32_768, |
| 373 | }), | 382 | outputMode: "truncate", |
| 374 | catch: (cause) => | 383 | shell: process.platform === "win32", |
| 375 | new TerminalSubprocessCheckError({ | 384 | timeoutBehavior: "timedOutResult", |
| 376 | message: "Failed to check Windows terminal subprocess activity.", | 385 | }); |
| 377 | cause, | 386 | }).pipe( |
| 378 | terminalPid, | 387 | Effect.map((result) => result.code === 0), |
| 379 | command: "powershell", | 388 | Effect.mapError( |
| 380 | }), | 389 | (cause) => |
| 381 | }).pipe(Effect.map((result) => result.code === 0)); | 390 | new TerminalSubprocessCheckError({ |
| 391 | message: "Failed to check Windows terminal subprocess activity.", | ||
| 392 | cause, | ||
| 393 | terminalPid, | ||
| 394 | command: "powershell", | ||
| 395 | }), | ||
| 396 | ), | ||
| 397 | ); | ||
| 382 | } | 398 | } |
| 383 | 399 | ||
| 384 | const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* ( | 400 | const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* ( |
| 385 | terminalPid: number, | 401 | terminalPid: number, |
| 386 | ): Effect.fn.Return<boolean, TerminalSubprocessCheckError> { | 402 | ): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> { |
| 387 | const runPgrep = Effect.tryPromise({ | 403 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 388 | try: () => | 404 | const runPgrep = processRunner |
| 389 | runProcess("pgrep", ["-P", String(terminalPid)], { | 405 | .run({ |
| 390 | timeoutMs: 1_000, | 406 | command: "pgrep", |
| 391 | allowNonZeroExit: true, | 407 | args: ["-P", String(terminalPid)], |
| 392 | maxBufferBytes: 32_768, | 408 | timeout: "1 second", |
| 393 | outputMode: "truncate", | 409 | maxOutputBytes: 32_768, |
| 394 | }), | 410 | outputMode: "truncate", |
| 395 | catch: (cause) => | 411 | timeoutBehavior: "timedOutResult", |
| 396 | new TerminalSubprocessCheckError({ | 412 | }) |
| 397 | message: "Failed to inspect terminal subprocesses with pgrep.", | 413 | .pipe( |
| 398 | cause, | 414 | Effect.mapError( |
| 399 | terminalPid, | 415 | (cause) => |
| 400 | command: "pgrep", | 416 | new TerminalSubprocessCheckError({ |
| 401 | }), | 417 | message: "Failed to inspect terminal subprocesses with pgrep.", |
| 402 | }); | 418 | cause, |
| 419 | terminalPid, | ||
| 420 | command: "pgrep", | ||
| 421 | }), | ||
| 422 | ), | ||
| 423 | ); | ||
| 403 | 424 | ||
| 404 | const runPs = Effect.tryPromise({ | 425 | const runPs = processRunner |
| 405 | try: () => | 426 | .run({ |
| 406 | runProcess("ps", ["-eo", "pid=,ppid="], { | 427 | command: "ps", |
| 407 | timeoutMs: 1_000, | 428 | args: ["-eo", "pid=,ppid="], |
| 408 | allowNonZeroExit: true, | 429 | timeout: "1 second", |
| 409 | maxBufferBytes: 262_144, | 430 | maxOutputBytes: 262_144, |
| 410 | outputMode: "truncate", | 431 | outputMode: "truncate", |
| 411 | }), | 432 | timeoutBehavior: "timedOutResult", |
| 412 | catch: (cause) => | 433 | }) |
| 413 | new TerminalSubprocessCheckError({ | 434 | .pipe( |
| 414 | message: "Failed to inspect terminal subprocesses with ps.", | 435 | Effect.mapError( |
| 415 | cause, | 436 | (cause) => |
| 416 | terminalPid, | 437 | new TerminalSubprocessCheckError({ |
| 417 | command: "ps", | 438 | message: "Failed to inspect terminal subprocesses with ps.", |
| 418 | }), | 439 | cause, |
| 419 | }); | 440 | terminalPid, |
| 441 | command: "ps", | ||
| 442 | }), | ||
| 443 | ), | ||
| 444 | ); | ||
| 420 | 445 | ||
| 421 | const pgrepResult = yield* Effect.exit(runPgrep); | 446 | const pgrepResult = yield* Effect.exit(runPgrep); |
| 422 | if (pgrepResult._tag === "Success") { | 447 | if (pgrepResult._tag === "Success") { |
| ... | @@ -447,7 +472,7 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct | ... | @@ -447,7 +472,7 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct |
| 447 | 472 | ||
| 448 | const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* ( | 473 | const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* ( |
| 449 | terminalPid: number, | 474 | terminalPid: number, |
| 450 | ): Effect.fn.Return<boolean, TerminalSubprocessCheckError> { | 475 | ): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> { |
| 451 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | 476 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { |
| 452 | return false; | 477 | return false; |
| 453 | } | 478 | } |
| ... | @@ -725,14 +750,19 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -725,14 +750,19 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 725 | const path = yield* Path.Path; | 750 | const path = yield* Path.Path; |
| 726 | const context = yield* Effect.context<never>(); | 751 | const context = yield* Effect.context<never>(); |
| 727 | const runFork = Effect.runForkWith(context); | 752 | const runFork = Effect.runForkWith(context); |
| 728 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | ||
| 729 | 753 | ||
| 730 | const logsDir = options.logsDir; | 754 | const logsDir = options.logsDir; |
| 731 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | 755 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; |
| 732 | const platform = options.platform ?? process.platform; | 756 | const platform = options.platform ?? process.platform; |
| 733 | const baseEnv = options.env ?? process.env; | 757 | const baseEnv = options.env ?? process.env; |
| 734 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); | 758 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); |
| 735 | const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | 759 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 760 | const subprocessChecker = | ||
| 761 | options.subprocessChecker ?? | ||
| 762 | ((terminalPid) => | ||
| 763 | defaultSubprocessChecker(terminalPid).pipe( | ||
| 764 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 765 | )); | ||
| 736 | const subprocessPollIntervalMs = | 766 | const subprocessPollIntervalMs = |
| 737 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | 767 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; |
| 738 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; | 768 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; |
| ... | @@ -1419,22 +1449,23 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1419,22 +1449,23 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1419 | runFork(drainProcessEvents(session, processPid)); | 1449 | runFork(drainProcessEvents(session, processPid)); |
| 1420 | }); | 1450 | }); |
| 1421 | 1451 | ||
| 1422 | const startedAt = yield* nowIso; | 1452 | const runningAt = yield* nowIso; |
| 1423 | yield* modifyManagerState((state) => { | 1453 | yield* modifyManagerState((state) => { |
| 1424 | session.process = ptyProcess; | 1454 | session.process = ptyProcess; |
| 1425 | session.pid = processPid; | 1455 | session.pid = processPid; |
| 1426 | session.status = "running"; | 1456 | session.status = "running"; |
| 1427 | session.updatedAt = startedAt; | 1457 | session.updatedAt = runningAt; |
| 1428 | session.unsubscribeData = unsubscribeData; | 1458 | session.unsubscribeData = unsubscribeData; |
| 1429 | session.unsubscribeExit = unsubscribeExit; | 1459 | session.unsubscribeExit = unsubscribeExit; |
| 1430 | return [undefined, state] as const; | 1460 | return [undefined, state] as const; |
| 1431 | }); | 1461 | }); |
| 1432 | 1462 | ||
| 1463 | const createdAt = yield* nowIso; | ||
| 1433 | yield* publishEvent({ | 1464 | yield* publishEvent({ |
| 1434 | type: eventType, | 1465 | type: eventType, |
| 1435 | threadId: session.threadId, | 1466 | threadId: session.threadId, |
| 1436 | terminalId: session.terminalId, | 1467 | terminalId: session.terminalId, |
| 1437 | createdAt: startedAt, | 1468 | createdAt, |
| 1438 | snapshot: snapshot(session), | 1469 | snapshot: snapshot(session), |
| 1439 | }); | 1470 | }); |
| 1440 | }), | 1471 | }), |
| ... | @@ -1451,8 +1482,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1451,8 +1482,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1451 | if (ptyProcess) { | 1482 | if (ptyProcess) { |
| 1452 | yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); | 1483 | yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); |
| 1453 | } | 1484 | } |
| 1485 | const erroredAt = yield* nowIso; | ||
| 1454 | 1486 | ||
| 1455 | const failedAt = yield* nowIso; | ||
| 1456 | yield* modifyManagerState((state) => { | 1487 | yield* modifyManagerState((state) => { |
| 1457 | session.status = "error"; | 1488 | session.status = "error"; |
| 1458 | session.pid = null; | 1489 | session.pid = null; |
| ... | @@ -1463,18 +1494,19 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1463,18 +1494,19 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1463 | session.pendingProcessEvents = []; | 1494 | session.pendingProcessEvents = []; |
| 1464 | session.pendingProcessEventIndex = 0; | 1495 | session.pendingProcessEventIndex = 0; |
| 1465 | session.processEventDrainRunning = false; | 1496 | session.processEventDrainRunning = false; |
| 1466 | session.updatedAt = failedAt; | 1497 | session.updatedAt = erroredAt; |
| 1467 | return [undefined, state] as const; | 1498 | return [undefined, state] as const; |
| 1468 | }); | 1499 | }); |
| 1469 | 1500 | ||
| 1470 | yield* evictInactiveSessionsIfNeeded(); | 1501 | yield* evictInactiveSessionsIfNeeded(); |
| 1471 | 1502 | ||
| 1472 | const message = error.message; | 1503 | const message = error.message; |
| 1504 | const createdAt = yield* nowIso; | ||
| 1473 | yield* publishEvent({ | 1505 | yield* publishEvent({ |
| 1474 | type: "error", | 1506 | type: "error", |
| 1475 | threadId: session.threadId, | 1507 | threadId: session.threadId, |
| 1476 | terminalId: session.terminalId, | 1508 | terminalId: session.terminalId, |
| 1477 | createdAt: failedAt, | 1509 | createdAt, |
| 1478 | message, | 1510 | message, |
| 1479 | }); | 1511 | }); |
| 1480 | yield* Effect.logError("failed to start terminal", { | 1512 | yield* Effect.logError("failed to start terminal", { |
| ... | @@ -1547,6 +1579,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1547,6 +1579,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1547 | } | 1579 | } |
| 1548 | 1580 | ||
| 1549 | const updatedAt = yield* nowIso; | 1581 | const updatedAt = yield* nowIso; |
| 1582 | const createdAt = yield* nowIso; | ||
| 1550 | const event = yield* modifyManagerState((state) => { | 1583 | const event = yield* modifyManagerState((state) => { |
| 1551 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( | 1584 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( |
| 1552 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), | 1585 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), |
| ... | @@ -1568,7 +1601,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1568,7 +1601,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1568 | type: "activity" as const, | 1601 | type: "activity" as const, |
| 1569 | threadId: liveSession.value.threadId, | 1602 | threadId: liveSession.value.threadId, |
| 1570 | terminalId: liveSession.value.terminalId, | 1603 | terminalId: liveSession.value.terminalId, |
| 1571 | createdAt: updatedAt, | 1604 | createdAt, |
| 1572 | hasRunningSubprocess: hasRunningSubprocess.value, | 1605 | hasRunningSubprocess: hasRunningSubprocess.value, |
| 1573 | }), | 1606 | }), |
| 1574 | state, | 1607 | state, |
| ... | @@ -1797,19 +1830,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1797,19 +1830,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1797 | Effect.gen(function* () { | 1830 | Effect.gen(function* () { |
| 1798 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 1831 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; |
| 1799 | const session = yield* requireSession(input.threadId, terminalId); | 1832 | const session = yield* requireSession(input.threadId, terminalId); |
| 1833 | const updatedAt = yield* nowIso; | ||
| 1800 | session.history = ""; | 1834 | session.history = ""; |
| 1801 | session.pendingHistoryControlSequence = ""; | 1835 | session.pendingHistoryControlSequence = ""; |
| 1802 | session.pendingProcessEvents = []; | 1836 | session.pendingProcessEvents = []; |
| 1803 | session.pendingProcessEventIndex = 0; | 1837 | session.pendingProcessEventIndex = 0; |
| 1804 | session.processEventDrainRunning = false; | 1838 | session.processEventDrainRunning = false; |
| 1805 | const clearedAt = yield* nowIso; | 1839 | session.updatedAt = updatedAt; |
| 1806 | session.updatedAt = clearedAt; | ||
| 1807 | yield* persistHistory(input.threadId, terminalId, session.history); | 1840 | yield* persistHistory(input.threadId, terminalId, session.history); |
| 1841 | const createdAt = yield* nowIso; | ||
| 1808 | yield* publishEvent({ | 1842 | yield* publishEvent({ |
| 1809 | type: "cleared", | 1843 | type: "cleared", |
| 1810 | threadId: input.threadId, | 1844 | threadId: input.threadId, |
| 1811 | terminalId, | 1845 | terminalId, |
| 1812 | createdAt: clearedAt, | 1846 | createdAt, |
| 1813 | }); | 1847 | }); |
| 1814 | }), | 1848 | }), |
| 1815 | ); | 1849 | ); |
| ... | @@ -1933,4 +1967,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1933,4 +1967,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1933 | }, | 1967 | }, |
| 1934 | ); | 1968 | ); |
| 1935 | 1969 | ||
| 1936 | export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()); | 1970 | export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe( |
| 1971 | Layer.provide(ProcessRunner.layer), | ||
| 1972 | ); |
apps/server/src/vcs/GitVcsDriver.test.ts+20-13| ... | @@ -67,6 +67,7 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ | ... | @@ -67,6 +67,7 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ |
| 67 | 67 | ||
| 68 | it.effect("GitVcsDriver forwards execute env to the VCS process", () => { | 68 | it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 69 | let observedEnv: NodeJS.ProcessEnv | undefined; | 69 | let observedEnv: NodeJS.ProcessEnv | undefined; |
| 70 | let observedAppendTruncationMarker: boolean | undefined; | ||
| 70 | 71 | ||
| 71 | return Effect.gen(function* () { | 72 | return Effect.gen(function* () { |
| 72 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | 73 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); |
| ... | @@ -78,26 +79,32 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { | ... | @@ -78,26 +79,32 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 78 | env: { | 79 | env: { |
| 79 | GIT_INDEX_FILE: "/tmp/t3-index", | 80 | GIT_INDEX_FILE: "/tmp/t3-index", |
| 80 | }, | 81 | }, |
| 82 | appendTruncationMarker: true, | ||
| 81 | }); | 83 | }); |
| 82 | 84 | ||
| 83 | assert.deepStrictEqual(observedEnv, { | 85 | assert.deepStrictEqual(observedEnv, { |
| 84 | GIT_INDEX_FILE: "/tmp/t3-index", | 86 | GIT_INDEX_FILE: "/tmp/t3-index", |
| 85 | }); | 87 | }); |
| 88 | assert.strictEqual(observedAppendTruncationMarker, true); | ||
| 86 | }).pipe( | 89 | }).pipe( |
| 87 | Effect.provide( | 90 | Effect.provide( |
| 88 | Layer.mock(VcsProcess.VcsProcess)({ | 91 | Layer.mergeAll( |
| 89 | run: (input) => | 92 | NodeServices.layer, |
| 90 | Effect.sync(() => { | 93 | Layer.mock(VcsProcess.VcsProcess)({ |
| 91 | observedEnv = input.env; | 94 | run: (input) => |
| 92 | return { | 95 | Effect.sync(() => { |
| 93 | exitCode: ChildProcessSpawner.ExitCode(0), | 96 | observedEnv = input.env; |
| 94 | stdout: "", | 97 | observedAppendTruncationMarker = input.appendTruncationMarker; |
| 95 | stderr: "", | 98 | return { |
| 96 | stdoutTruncated: false, | 99 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 97 | stderrTruncated: false, | 100 | stdout: "", |
| 98 | }; | 101 | stderr: "", |
| 99 | }), | 102 | stdoutTruncated: false, |
| 100 | }), | 103 | stderrTruncated: false, |
| 104 | }; | ||
| 105 | }), | ||
| 106 | }), | ||
| 107 | ), | ||
| 101 | ), | 108 | ), |
| 102 | ); | 109 | ); |
| 103 | }); | 110 | }); |
apps/server/src/vcs/GitVcsDriver.ts+288-22| ... | @@ -1,8 +1,12 @@ | ... | @@ -1,8 +1,12 @@ |
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | |||
| 1 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 2 | import * as DateTime from "effect/DateTime"; | 4 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 9 | import * as Path from "effect/Path"; | ||
| 6 | import { ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 7 | 11 | ||
| 8 | import { | 12 | import { |
| ... | @@ -35,7 +39,7 @@ export interface ExecuteGitInput { | ... | @@ -35,7 +39,7 @@ export interface ExecuteGitInput { |
| 35 | readonly allowNonZeroExit?: boolean; | 39 | readonly allowNonZeroExit?: boolean; |
| 36 | readonly timeoutMs?: number; | 40 | readonly timeoutMs?: number; |
| 37 | readonly maxOutputBytes?: number; | 41 | readonly maxOutputBytes?: number; |
| 38 | readonly truncateOutputAtMaxBytes?: boolean; | 42 | readonly appendTruncationMarker?: boolean; |
| 39 | readonly progress?: ExecuteGitProgress; | 43 | readonly progress?: ExecuteGitProgress; |
| 40 | } | 44 | } |
| 41 | 45 | ||
| ... | @@ -218,6 +222,7 @@ export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShap | ... | @@ -218,6 +222,7 @@ export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShap |
| 218 | 222 | ||
| 219 | const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; | 223 | const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; |
| 220 | const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; | 224 | const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; |
| 225 | const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; | ||
| 221 | const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ | 226 | const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ |
| 222 | "-c", | 227 | "-c", |
| 223 | "core.fsmonitor=false", | 228 | "core.fsmonitor=false", |
| ... | @@ -318,14 +323,15 @@ const gitCommand = ( | ... | @@ -318,14 +323,15 @@ const gitCommand = ( |
| 318 | readonly allowNonZeroExit?: boolean; | 323 | readonly allowNonZeroExit?: boolean; |
| 319 | readonly timeoutMs?: number; | 324 | readonly timeoutMs?: number; |
| 320 | readonly maxOutputBytes?: number; | 325 | readonly maxOutputBytes?: number; |
| 321 | readonly truncateOutputAtMaxBytes?: boolean; | 326 | readonly appendTruncationMarker?: boolean; |
| 322 | }, | 327 | }, |
| 323 | ) => | 328 | ) => |
| 324 | process.run({ | 329 | process.run({ |
| 325 | operation, | 330 | operation, |
| 326 | command: "git", | 331 | command: "git", |
| 327 | args, | 332 | args: ["-C", cwd, ...args], |
| 328 | cwd, | 333 | cwd, |
| 334 | spawnCwd: globalThis.process.cwd(), | ||
| 329 | ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), | 335 | ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), |
| 330 | ...(options?.env !== undefined ? { env: options.env } : {}), | 336 | ...(options?.env !== undefined ? { env: options.env } : {}), |
| 331 | ...(options?.allowNonZeroExit !== undefined | 337 | ...(options?.allowNonZeroExit !== undefined |
| ... | @@ -333,13 +339,15 @@ const gitCommand = ( | ... | @@ -333,13 +339,15 @@ const gitCommand = ( |
| 333 | : {}), | 339 | : {}), |
| 334 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | 340 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 335 | ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), | 341 | ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| 336 | ...(options?.truncateOutputAtMaxBytes !== undefined | 342 | ...(options?.appendTruncationMarker !== undefined |
| 337 | ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } | 343 | ? { appendTruncationMarker: options.appendTruncationMarker } |
| 338 | : {}), | 344 | : {}), |
| 339 | }); | 345 | }); |
| 340 | 346 | ||
| 341 | export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { | 347 | export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { |
| 342 | const process = yield* VcsProcess.VcsProcess; | 348 | const fileSystem = yield* FileSystem.FileSystem; |
| 349 | const path = yield* Path.Path; | ||
| 350 | const vcsProcess = yield* VcsProcess.VcsProcess; | ||
| 343 | const capabilities = { | 351 | const capabilities = { |
| 344 | kind: "git" as const, | 352 | kind: "git" as const, |
| 345 | supportsWorktrees: true, | 353 | supportsWorktrees: true, |
| ... | @@ -351,7 +359,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -351,7 +359,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 351 | 359 | ||
| 352 | const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => | 360 | const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => |
| 353 | gitCommand( | 361 | gitCommand( |
| 354 | process, | 362 | vcsProcess, |
| 355 | "GitVcsDriver.isInsideWorkTree", | 363 | "GitVcsDriver.isInsideWorkTree", |
| 356 | cwd, | 364 | cwd, |
| 357 | ["rev-parse", "--is-inside-work-tree"], | 365 | ["rev-parse", "--is-inside-work-tree"], |
| ... | @@ -363,14 +371,14 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -363,14 +371,14 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 363 | ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); | 371 | ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); |
| 364 | 372 | ||
| 365 | const execute: VcsDriver.VcsDriverShape["execute"] = (input) => | 373 | const execute: VcsDriver.VcsDriverShape["execute"] = (input) => |
| 366 | gitCommand(process, input.operation, input.cwd, input.args, { | 374 | gitCommand(vcsProcess, input.operation, input.cwd, input.args, { |
| 367 | ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), | 375 | ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), |
| 368 | ...(input.env !== undefined ? { env: input.env } : {}), | 376 | ...(input.env !== undefined ? { env: input.env } : {}), |
| 369 | ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), | 377 | ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), |
| 370 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), | 378 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), |
| 371 | ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), | 379 | ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), |
| 372 | ...(input.truncateOutputAtMaxBytes !== undefined | 380 | ...(input.appendTruncationMarker !== undefined |
| 373 | ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes } | 381 | ? { appendTruncationMarker: input.appendTruncationMarker } |
| 374 | : {}), | 382 | : {}), |
| 375 | }); | 383 | }); |
| 376 | 384 | ||
| ... | @@ -381,12 +389,12 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -381,12 +389,12 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 381 | return null; | 389 | return null; |
| 382 | } | 390 | } |
| 383 | 391 | ||
| 384 | const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ | 392 | const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ |
| 385 | "rev-parse", | 393 | "rev-parse", |
| 386 | "--show-toplevel", | 394 | "--show-toplevel", |
| 387 | ]); | 395 | ]); |
| 388 | const gitCommonDir = yield* gitCommand( | 396 | const gitCommonDir = yield* gitCommand( |
| 389 | process, | 397 | vcsProcess, |
| 390 | "GitVcsDriver.detectRepository.commonDir", | 398 | "GitVcsDriver.detectRepository.commonDir", |
| 391 | cwd, | 399 | cwd, |
| 392 | ["rev-parse", "--git-common-dir"], | 400 | ["rev-parse", "--git-common-dir"], |
| ... | @@ -402,7 +410,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -402,7 +410,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 402 | 410 | ||
| 403 | const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => | 411 | const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => |
| 404 | gitCommand( | 412 | gitCommand( |
| 405 | process, | 413 | vcsProcess, |
| 406 | "GitVcsDriver.listWorkspaceFiles", | 414 | "GitVcsDriver.listWorkspaceFiles", |
| 407 | cwd, | 415 | cwd, |
| 408 | [ | 416 | [ |
| ... | @@ -417,7 +425,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -417,7 +425,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 417 | allowNonZeroExit: true, | 425 | allowNonZeroExit: true, |
| 418 | timeoutMs: 20_000, | 426 | timeoutMs: 20_000, |
| 419 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | 427 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, |
| 420 | truncateOutputAtMaxBytes: true, | 428 | appendTruncationMarker: true, |
| 421 | }, | 429 | }, |
| 422 | ).pipe( | 430 | ).pipe( |
| 423 | Effect.flatMap((result) => | 431 | Effect.flatMap((result) => |
| ... | @@ -444,11 +452,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -444,11 +452,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 444 | 452 | ||
| 445 | const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( | 453 | const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( |
| 446 | function* (cwd) { | 454 | function* (cwd) { |
| 447 | const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { | 455 | const result = yield* gitCommand( |
| 448 | allowNonZeroExit: true, | 456 | vcsProcess, |
| 449 | timeoutMs: 5_000, | 457 | "GitVcsDriver.listRemotes", |
| 450 | maxOutputBytes: 64 * 1024, | 458 | cwd, |
| 451 | }); | 459 | ["remote", "-v"], |
| 460 | { | ||
| 461 | allowNonZeroExit: true, | ||
| 462 | timeoutMs: 5_000, | ||
| 463 | maxOutputBytes: 64 * 1024, | ||
| 464 | }, | ||
| 465 | ); | ||
| 452 | 466 | ||
| 453 | if (result.exitCode !== 0) { | 467 | if (result.exitCode !== 0) { |
| 454 | return yield* new VcsProcessExitError({ | 468 | return yield* new VcsProcessExitError({ |
| ... | @@ -494,7 +508,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -494,7 +508,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 494 | 508 | ||
| 495 | for (const chunk of chunks) { | 509 | for (const chunk of chunks) { |
| 496 | const result = yield* gitCommand( | 510 | const result = yield* gitCommand( |
| 497 | process, | 511 | vcsProcess, |
| 498 | "GitVcsDriver.filterIgnoredPaths", | 512 | "GitVcsDriver.filterIgnoredPaths", |
| 499 | cwd, | 513 | cwd, |
| 500 | [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], | 514 | [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], |
| ... | @@ -503,7 +517,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -503,7 +517,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 503 | allowNonZeroExit: true, | 517 | allowNonZeroExit: true, |
| 504 | timeoutMs: 20_000, | 518 | timeoutMs: 20_000, |
| 505 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | 519 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, |
| 506 | truncateOutputAtMaxBytes: true, | 520 | appendTruncationMarker: true, |
| 507 | }, | 521 | }, |
| 508 | ); | 522 | ); |
| 509 | 523 | ||
| ... | @@ -530,14 +544,266 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -530,14 +544,266 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 530 | }); | 544 | }); |
| 531 | 545 | ||
| 532 | const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => | 546 | const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => |
| 533 | gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { | 547 | gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { |
| 534 | timeoutMs: 10_000, | 548 | timeoutMs: 10_000, |
| 535 | maxOutputBytes: 64 * 1024, | 549 | maxOutputBytes: 64 * 1024, |
| 536 | }).pipe(Effect.asVoid); | 550 | }).pipe(Effect.asVoid); |
| 537 | 551 | ||
| 552 | const resolveHeadCommit = (cwd: string) => | ||
| 553 | execute({ | ||
| 554 | operation: "GitVcsDriver.checkpoints.resolveHeadCommit", | ||
| 555 | cwd, | ||
| 556 | args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], | ||
| 557 | allowNonZeroExit: true, | ||
| 558 | }).pipe( | ||
| 559 | Effect.map((result) => { | ||
| 560 | if (result.exitCode !== 0) { | ||
| 561 | return null; | ||
| 562 | } | ||
| 563 | const commit = result.stdout.trim(); | ||
| 564 | return commit.length > 0 ? commit : null; | ||
| 565 | }), | ||
| 566 | ); | ||
| 567 | |||
| 568 | const hasHeadCommit = (cwd: string) => | ||
| 569 | execute({ | ||
| 570 | operation: "GitVcsDriver.checkpoints.hasHeadCommit", | ||
| 571 | cwd, | ||
| 572 | args: ["rev-parse", "--verify", "HEAD"], | ||
| 573 | allowNonZeroExit: true, | ||
| 574 | }).pipe(Effect.map((result) => result.exitCode === 0)); | ||
| 575 | |||
| 576 | const resolveCheckpointCommit = (cwd: string, checkpointRef: string) => | ||
| 577 | execute({ | ||
| 578 | operation: "GitVcsDriver.checkpoints.resolveCheckpointCommit", | ||
| 579 | cwd, | ||
| 580 | args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], | ||
| 581 | allowNonZeroExit: true, | ||
| 582 | }).pipe( | ||
| 583 | Effect.map((result) => { | ||
| 584 | if (result.exitCode !== 0) { | ||
| 585 | return null; | ||
| 586 | } | ||
| 587 | const commit = result.stdout.trim(); | ||
| 588 | return commit.length > 0 ? commit : null; | ||
| 589 | }), | ||
| 590 | ); | ||
| 591 | |||
| 592 | const resolveGitCommonDir = (cwd: string) => | ||
| 593 | Effect.gen(function* () { | ||
| 594 | const result = yield* execute({ | ||
| 595 | operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", | ||
| 596 | cwd, | ||
| 597 | args: ["rev-parse", "--git-common-dir"], | ||
| 598 | }); | ||
| 599 | const gitCommonDir = result.stdout.trim(); | ||
| 600 | return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); | ||
| 601 | }); | ||
| 602 | |||
| 603 | const checkpoints: VcsDriver.VcsCheckpointOps = { | ||
| 604 | captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { | ||
| 605 | const operation = "GitVcsDriver.checkpoints.captureCheckpoint"; | ||
| 606 | const gitCommonDir = yield* resolveGitCommonDir(input.cwd); | ||
| 607 | const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${randomUUID()}`); | ||
| 608 | const commitEnv: NodeJS.ProcessEnv = { | ||
| 609 | ...process.env, | ||
| 610 | GIT_INDEX_FILE: tempIndexPath, | ||
| 611 | GIT_AUTHOR_NAME: "T3 Code", | ||
| 612 | GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", | ||
| 613 | GIT_COMMITTER_NAME: "T3 Code", | ||
| 614 | GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", | ||
| 615 | }; | ||
| 616 | |||
| 617 | const cleanupTempIndex = fileSystem | ||
| 618 | .remove(tempIndexPath, { force: true }) | ||
| 619 | .pipe(Effect.ignore); | ||
| 620 | |||
| 621 | yield* Effect.gen(function* () { | ||
| 622 | const headExists = yield* hasHeadCommit(input.cwd); | ||
| 623 | if (headExists) { | ||
| 624 | yield* execute({ | ||
| 625 | operation, | ||
| 626 | cwd: input.cwd, | ||
| 627 | args: ["read-tree", "HEAD"], | ||
| 628 | env: commitEnv, | ||
| 629 | }); | ||
| 630 | } | ||
| 631 | |||
| 632 | yield* execute({ | ||
| 633 | operation, | ||
| 634 | cwd: input.cwd, | ||
| 635 | args: ["add", "-A", "--", "."], | ||
| 636 | env: commitEnv, | ||
| 637 | }); | ||
| 638 | |||
| 639 | const writeTreeResult = yield* execute({ | ||
| 640 | operation, | ||
| 641 | cwd: input.cwd, | ||
| 642 | args: ["write-tree"], | ||
| 643 | env: commitEnv, | ||
| 644 | }); | ||
| 645 | const treeOid = writeTreeResult.stdout.trim(); | ||
| 646 | if (treeOid.length === 0) { | ||
| 647 | return yield* new VcsProcessExitError({ | ||
| 648 | operation, | ||
| 649 | command: "git write-tree", | ||
| 650 | cwd: input.cwd, | ||
| 651 | exitCode: 0, | ||
| 652 | detail: "git write-tree returned an empty tree oid.", | ||
| 653 | }); | ||
| 654 | } | ||
| 655 | |||
| 656 | const message = `t3 checkpoint ref=${input.checkpointRef}`; | ||
| 657 | const commitTreeResult = yield* execute({ | ||
| 658 | operation, | ||
| 659 | cwd: input.cwd, | ||
| 660 | args: ["commit-tree", treeOid, "-m", message], | ||
| 661 | env: commitEnv, | ||
| 662 | }); | ||
| 663 | const commitOid = commitTreeResult.stdout.trim(); | ||
| 664 | if (commitOid.length === 0) { | ||
| 665 | return yield* new VcsProcessExitError({ | ||
| 666 | operation, | ||
| 667 | command: "git commit-tree", | ||
| 668 | cwd: input.cwd, | ||
| 669 | exitCode: 0, | ||
| 670 | detail: "git commit-tree returned an empty commit oid.", | ||
| 671 | }); | ||
| 672 | } | ||
| 673 | |||
| 674 | yield* execute({ | ||
| 675 | operation, | ||
| 676 | cwd: input.cwd, | ||
| 677 | args: ["update-ref", input.checkpointRef, commitOid], | ||
| 678 | }); | ||
| 679 | }).pipe(Effect.ensuring(cleanupTempIndex)); | ||
| 680 | }), | ||
| 681 | |||
| 682 | hasCheckpointRef: (input) => | ||
| 683 | resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( | ||
| 684 | Effect.map((commit) => commit !== null), | ||
| 685 | ), | ||
| 686 | |||
| 687 | restoreCheckpoint: Effect.fn("GitVcsDriver.checkpoints.restoreCheckpoint")(function* (input) { | ||
| 688 | const operation = "GitVcsDriver.checkpoints.restoreCheckpoint"; | ||
| 689 | |||
| 690 | let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); | ||
| 691 | |||
| 692 | if (!commitOid && input.fallbackToHead === true) { | ||
| 693 | commitOid = yield* resolveHeadCommit(input.cwd); | ||
| 694 | } | ||
| 695 | |||
| 696 | if (!commitOid) { | ||
| 697 | return false; | ||
| 698 | } | ||
| 699 | |||
| 700 | yield* execute({ | ||
| 701 | operation, | ||
| 702 | cwd: input.cwd, | ||
| 703 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], | ||
| 704 | }); | ||
| 705 | yield* execute({ | ||
| 706 | operation, | ||
| 707 | cwd: input.cwd, | ||
| 708 | args: ["clean", "-fd", "--", "."], | ||
| 709 | }); | ||
| 710 | |||
| 711 | const headExists = yield* hasHeadCommit(input.cwd); | ||
| 712 | if (headExists) { | ||
| 713 | yield* execute({ | ||
| 714 | operation, | ||
| 715 | cwd: input.cwd, | ||
| 716 | args: ["reset", "--quiet", "--", "."], | ||
| 717 | }); | ||
| 718 | } | ||
| 719 | |||
| 720 | return true; | ||
| 721 | }), | ||
| 722 | |||
| 723 | diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { | ||
| 724 | const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; | ||
| 725 | yield* Effect.annotateCurrentSpan({ | ||
| 726 | "checkpoint.cwd": input.cwd, | ||
| 727 | "checkpoint.from_ref": input.fromCheckpointRef, | ||
| 728 | "checkpoint.to_ref": input.toCheckpointRef, | ||
| 729 | "checkpoint.ignore_whitespace": input.ignoreWhitespace, | ||
| 730 | "checkpoint.fallback_from_to_head": input.fallbackFromToHead, | ||
| 731 | }); | ||
| 732 | |||
| 733 | let fromRevision: string = input.fromCheckpointRef; | ||
| 734 | if (input.fallbackFromToHead === true) { | ||
| 735 | const resolvedFromCommit = yield* resolveCheckpointCommit( | ||
| 736 | input.cwd, | ||
| 737 | input.fromCheckpointRef, | ||
| 738 | ); | ||
| 739 | if (resolvedFromCommit) { | ||
| 740 | fromRevision = resolvedFromCommit; | ||
| 741 | } else { | ||
| 742 | const headCommit = yield* resolveHeadCommit(input.cwd); | ||
| 743 | if (!headCommit) { | ||
| 744 | return yield* new VcsProcessExitError({ | ||
| 745 | operation, | ||
| 746 | command: "git diff", | ||
| 747 | cwd: input.cwd, | ||
| 748 | exitCode: 1, | ||
| 749 | detail: "Checkpoint ref is unavailable for diff operation.", | ||
| 750 | }); | ||
| 751 | } | ||
| 752 | fromRevision = headCommit; | ||
| 753 | } | ||
| 754 | } | ||
| 755 | |||
| 756 | const result = yield* execute({ | ||
| 757 | operation, | ||
| 758 | cwd: input.cwd, | ||
| 759 | args: [ | ||
| 760 | "diff", | ||
| 761 | "--patch", | ||
| 762 | "--no-color", | ||
| 763 | "--no-ext-diff", | ||
| 764 | "--no-textconv", | ||
| 765 | ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), | ||
| 766 | `${fromRevision}^{commit}`, | ||
| 767 | `${input.toCheckpointRef}^{commit}`, | ||
| 768 | ], | ||
| 769 | allowNonZeroExit: true, | ||
| 770 | maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, | ||
| 771 | }); | ||
| 772 | |||
| 773 | if (result.exitCode !== 0) { | ||
| 774 | return yield* new VcsProcessExitError({ | ||
| 775 | operation, | ||
| 776 | command: "git diff", | ||
| 777 | cwd: input.cwd, | ||
| 778 | exitCode: result.exitCode, | ||
| 779 | detail: result.stderr.trim() || "Checkpoint ref is unavailable for diff operation.", | ||
| 780 | }); | ||
| 781 | } | ||
| 782 | |||
| 783 | return result.stdout; | ||
| 784 | }), | ||
| 785 | |||
| 786 | deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( | ||
| 787 | function* (input) { | ||
| 788 | yield* Effect.forEach( | ||
| 789 | input.checkpointRefs, | ||
| 790 | (checkpointRef) => | ||
| 791 | execute({ | ||
| 792 | operation: "GitVcsDriver.checkpoints.deleteCheckpointRefs", | ||
| 793 | cwd: input.cwd, | ||
| 794 | args: ["update-ref", "-d", checkpointRef], | ||
| 795 | allowNonZeroExit: true, | ||
| 796 | }), | ||
| 797 | { discard: true }, | ||
| 798 | ); | ||
| 799 | }, | ||
| 800 | ), | ||
| 801 | }; | ||
| 802 | |||
| 538 | return VcsDriver.VcsDriver.of({ | 803 | return VcsDriver.VcsDriver.of({ |
| 539 | capabilities, | 804 | capabilities, |
| 540 | execute, | 805 | execute, |
| 806 | checkpoints, | ||
| 541 | detectRepository, | 807 | detectRepository, |
| 542 | isInsideWorkTree, | 808 | isInsideWorkTree, |
| 543 | listWorkspaceFiles, | 809 | listWorkspaceFiles, |
apps/server/src/vcs/GitVcsDriverCore.ts+15-15| ... | @@ -77,7 +77,7 @@ interface ExecuteGitOptions { | ... | @@ -77,7 +77,7 @@ interface ExecuteGitOptions { |
| 77 | fallbackErrorMessage?: string | undefined; | 77 | fallbackErrorMessage?: string | undefined; |
| 78 | env?: NodeJS.ProcessEnv | undefined; | 78 | env?: NodeJS.ProcessEnv | undefined; |
| 79 | maxOutputBytes?: number | undefined; | 79 | maxOutputBytes?: number | undefined; |
| 80 | truncateOutputAtMaxBytes?: boolean | undefined; | 80 | appendTruncationMarker?: boolean | undefined; |
| 81 | progress?: GitVcsDriver.ExecuteGitProgress | undefined; | 81 | progress?: GitVcsDriver.ExecuteGitProgress | undefined; |
| 82 | } | 82 | } |
| 83 | 83 | ||
| ... | @@ -535,7 +535,7 @@ const collectOutput = Effect.fnUntraced(function* <E>( | ... | @@ -535,7 +535,7 @@ const collectOutput = Effect.fnUntraced(function* <E>( |
| 535 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, | 535 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| 536 | stream: Stream.Stream<Uint8Array, E>, | 536 | stream: Stream.Stream<Uint8Array, E>, |
| 537 | maxOutputBytes: number, | 537 | maxOutputBytes: number, |
| 538 | truncateOutputAtMaxBytes: boolean, | 538 | appendTruncationMarker: boolean, |
| 539 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, | 539 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, |
| 540 | ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { | 540 | ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { |
| 541 | const decoder = new TextDecoder(); | 541 | const decoder = new TextDecoder(); |
| ... | @@ -565,11 +565,11 @@ const collectOutput = Effect.fnUntraced(function* <E>( | ... | @@ -565,11 +565,11 @@ const collectOutput = Effect.fnUntraced(function* <E>( |
| 565 | }); | 565 | }); |
| 566 | 566 | ||
| 567 | const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { | 567 | const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { |
| 568 | if (truncateOutputAtMaxBytes && truncated) { | 568 | if (appendTruncationMarker && truncated) { |
| 569 | return; | 569 | return; |
| 570 | } | 570 | } |
| 571 | const nextBytes = bytes + chunk.byteLength; | 571 | const nextBytes = bytes + chunk.byteLength; |
| 572 | if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { | 572 | if (!appendTruncationMarker && nextBytes > maxOutputBytes) { |
| 573 | return yield* new GitCommandError({ | 573 | return yield* new GitCommandError({ |
| 574 | operation: input.operation, | 574 | operation: input.operation, |
| 575 | command: quoteGitCommand(input.args), | 575 | command: quoteGitCommand(input.args), |
| ... | @@ -579,11 +579,11 @@ const collectOutput = Effect.fnUntraced(function* <E>( | ... | @@ -579,11 +579,11 @@ const collectOutput = Effect.fnUntraced(function* <E>( |
| 579 | } | 579 | } |
| 580 | 580 | ||
| 581 | const chunkToDecode = | 581 | const chunkToDecode = |
| 582 | truncateOutputAtMaxBytes && nextBytes > maxOutputBytes | 582 | appendTruncationMarker && nextBytes > maxOutputBytes |
| 583 | ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) | 583 | ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) |
| 584 | : chunk; | 584 | : chunk; |
| 585 | bytes += chunkToDecode.byteLength; | 585 | bytes += chunkToDecode.byteLength; |
| 586 | truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; | 586 | truncated = appendTruncationMarker && nextBytes > maxOutputBytes; |
| 587 | 587 | ||
| 588 | const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); | 588 | const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); |
| 589 | text += decoded; | 589 | text += decoded; |
| ... | @@ -619,7 +619,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -619,7 +619,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 619 | } as const; | 619 | } as const; |
| 620 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | 620 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 621 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | 621 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 622 | const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; | 622 | const appendTruncationMarker = input.appendTruncationMarker ?? false; |
| 623 | 623 | ||
| 624 | const runGitCommand = Effect.fn("runGitCommand")(function* () { | 624 | const runGitCommand = Effect.fn("runGitCommand")(function* () { |
| 625 | const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( | 625 | const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( |
| ... | @@ -646,14 +646,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -646,14 +646,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 646 | commandInput, | 646 | commandInput, |
| 647 | child.stdout, | 647 | child.stdout, |
| 648 | maxOutputBytes, | 648 | maxOutputBytes, |
| 649 | truncateOutputAtMaxBytes, | 649 | appendTruncationMarker, |
| 650 | input.progress?.onStdoutLine, | 650 | input.progress?.onStdoutLine, |
| 651 | ), | 651 | ), |
| 652 | collectOutput( | 652 | collectOutput( |
| 653 | commandInput, | 653 | commandInput, |
| 654 | child.stderr, | 654 | child.stderr, |
| 655 | maxOutputBytes, | 655 | maxOutputBytes, |
| 656 | truncateOutputAtMaxBytes, | 656 | appendTruncationMarker, |
| 657 | input.progress?.onStderrLine, | 657 | input.progress?.onStderrLine, |
| 658 | ), | 658 | ), |
| 659 | child.exitCode.pipe( | 659 | child.exitCode.pipe( |
| ... | @@ -746,8 +746,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -746,8 +746,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 746 | allowNonZeroExit: true, | 746 | allowNonZeroExit: true, |
| 747 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | 747 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 748 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), | 748 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| 749 | ...(options.truncateOutputAtMaxBytes !== undefined | 749 | ...(options.appendTruncationMarker !== undefined |
| 750 | ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } | 750 | ? { appendTruncationMarker: options.appendTruncationMarker } |
| 751 | : {}), | 751 | : {}), |
| 752 | ...(options.progress ? { progress: options.progress } : {}), | 752 | ...(options.progress ? { progress: options.progress } : {}), |
| 753 | }).pipe( | 753 | }).pipe( |
| ... | @@ -1379,7 +1379,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1379,7 +1379,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1379 | ["diff", "--cached", "--patch", "--minimal"], | 1379 | ["diff", "--cached", "--patch", "--minimal"], |
| 1380 | { | 1380 | { |
| 1381 | maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, | 1381 | maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, |
| 1382 | truncateOutputAtMaxBytes: true, | 1382 | appendTruncationMarker: true, |
| 1383 | }, | 1383 | }, |
| 1384 | ); | 1384 | ); |
| 1385 | 1385 | ||
| ... | @@ -1598,7 +1598,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1598,7 +1598,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1598 | ["log", "--oneline", range], | 1598 | ["log", "--oneline", range], |
| 1599 | { | 1599 | { |
| 1600 | maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, | 1600 | maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, |
| 1601 | truncateOutputAtMaxBytes: true, | 1601 | appendTruncationMarker: true, |
| 1602 | }, | 1602 | }, |
| 1603 | ), | 1603 | ), |
| 1604 | runGitStdoutWithOptions( | 1604 | runGitStdoutWithOptions( |
| ... | @@ -1607,7 +1607,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1607,7 +1607,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1607 | ["diff", "--stat", range], | 1607 | ["diff", "--stat", range], |
| 1608 | { | 1608 | { |
| 1609 | maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, | 1609 | maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, |
| 1610 | truncateOutputAtMaxBytes: true, | 1610 | appendTruncationMarker: true, |
| 1611 | }, | 1611 | }, |
| 1612 | ), | 1612 | ), |
| 1613 | runGitStdoutWithOptions( | 1613 | runGitStdoutWithOptions( |
| ... | @@ -1616,7 +1616,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1616,7 +1616,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1616 | ["diff", "--patch", "--minimal", range], | 1616 | ["diff", "--patch", "--minimal", range], |
| 1617 | { | 1617 | { |
| 1618 | maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, | 1618 | maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, |
| 1619 | truncateOutputAtMaxBytes: true, | 1619 | appendTruncationMarker: true, |
| 1620 | }, | 1620 | }, |
| 1621 | ), | 1621 | ), |
| 1622 | ], | 1622 | ], |
apps/server/src/vcs/VcsDriver.ts+40| ... | @@ -9,13 +9,53 @@ import type { | ... | @@ -9,13 +9,53 @@ import type { |
| 9 | VcsListWorkspaceFilesResult, | 9 | VcsListWorkspaceFilesResult, |
| 10 | VcsRepositoryIdentity, | 10 | VcsRepositoryIdentity, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { CheckpointRef } from "@t3tools/contracts"; | ||
| 12 | import * as VcsProcess from "./VcsProcess.ts"; | 13 | import * as VcsProcess from "./VcsProcess.ts"; |
| 13 | 14 | ||
| 15 | export interface VcsCaptureCheckpointInput { | ||
| 16 | readonly cwd: string; | ||
| 17 | readonly checkpointRef: CheckpointRef; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface VcsRestoreCheckpointInput { | ||
| 21 | readonly cwd: string; | ||
| 22 | readonly checkpointRef: CheckpointRef; | ||
| 23 | readonly fallbackToHead?: boolean; | ||
| 24 | } | ||
| 25 | |||
| 26 | export interface VcsDiffCheckpointsInput { | ||
| 27 | readonly cwd: string; | ||
| 28 | readonly fromCheckpointRef: CheckpointRef; | ||
| 29 | readonly toCheckpointRef: CheckpointRef; | ||
| 30 | readonly fallbackFromToHead?: boolean; | ||
| 31 | readonly ignoreWhitespace: boolean; | ||
| 32 | } | ||
| 33 | |||
| 34 | export interface VcsDeleteCheckpointRefsInput { | ||
| 35 | readonly cwd: string; | ||
| 36 | readonly checkpointRefs: ReadonlyArray<CheckpointRef>; | ||
| 37 | } | ||
| 38 | |||
| 39 | export interface VcsCheckpointOps { | ||
| 40 | readonly captureCheckpoint: (input: VcsCaptureCheckpointInput) => Effect.Effect<void, VcsError>; | ||
| 41 | readonly hasCheckpointRef: ( | ||
| 42 | input: Omit<VcsRestoreCheckpointInput, "fallbackToHead">, | ||
| 43 | ) => Effect.Effect<boolean, VcsError>; | ||
| 44 | readonly restoreCheckpoint: ( | ||
| 45 | input: VcsRestoreCheckpointInput, | ||
| 46 | ) => Effect.Effect<boolean, VcsError>; | ||
| 47 | readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect<string, VcsError>; | ||
| 48 | readonly deleteCheckpointRefs: ( | ||
| 49 | input: VcsDeleteCheckpointRefsInput, | ||
| 50 | ) => Effect.Effect<void, VcsError>; | ||
| 51 | } | ||
| 52 | |||
| 14 | export interface VcsDriverShape { | 53 | export interface VcsDriverShape { |
| 15 | readonly capabilities: VcsDriverCapabilities; | 54 | readonly capabilities: VcsDriverCapabilities; |
| 16 | readonly execute: ( | 55 | readonly execute: ( |
| 17 | input: Omit<VcsProcess.VcsProcessInput, "command">, | 56 | input: Omit<VcsProcess.VcsProcessInput, "command">, |
| 18 | ) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>; | 57 | ) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>; |
| 58 | readonly checkpoints?: VcsCheckpointOps; | ||
| 19 | readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>; | 59 | readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>; |
| 20 | readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>; | 60 | readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>; |
| 21 | readonly listWorkspaceFiles: ( | 61 | readonly listWorkspaceFiles: ( |
apps/server/src/vcs/VcsDriverRegistry.test.ts+10-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { assert, it, describe } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 4 | import { ChildProcessSpawner } from "effect/unstable/process"; | 5 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| ... | @@ -15,9 +16,13 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ | ... | @@ -15,9 +16,13 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ |
| 15 | stderrTruncated: false, | 16 | stderrTruncated: false, |
| 16 | }); | 17 | }); |
| 17 | 18 | ||
| 19 | const normalizeGitArgs = (args: ReadonlyArray<string>): ReadonlyArray<string> => | ||
| 20 | args[0] === "-C" && args.length >= 2 ? args.slice(2) : args; | ||
| 21 | |||
| 18 | describe("VcsDriverRegistry", () => { | 22 | describe("VcsDriverRegistry", () => { |
| 19 | it.effect("routes directly by VCS driver kind for non-repository workflows", () => { | 23 | it.effect("routes directly by VCS driver kind for non-repository workflows", () => { |
| 20 | const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( | 24 | const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( |
| 25 | Layer.provide(NodeServices.layer), | ||
| 21 | Layer.provide( | 26 | Layer.provide( |
| 22 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ | 27 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ |
| 23 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), | 28 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), |
| ... | @@ -41,6 +46,7 @@ describe("VcsDriverRegistry", () => { | ... | @@ -41,6 +46,7 @@ describe("VcsDriverRegistry", () => { |
| 41 | it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { | 46 | it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { |
| 42 | const calls: VcsProcess.VcsProcessInput[] = []; | 47 | const calls: VcsProcess.VcsProcessInput[] = []; |
| 43 | const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( | 48 | const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( |
| 49 | Layer.provide(NodeServices.layer), | ||
| 44 | Layer.provide( | 50 | Layer.provide( |
| 45 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ | 51 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ |
| 46 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), | 52 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), |
| ... | @@ -51,7 +57,9 @@ describe("VcsDriverRegistry", () => { | ... | @@ -51,7 +57,9 @@ describe("VcsDriverRegistry", () => { |
| 51 | run: (input) => | 57 | run: (input) => |
| 52 | Effect.sync(() => { | 58 | Effect.sync(() => { |
| 53 | calls.push(input); | 59 | calls.push(input); |
| 54 | const command = input.args.join(" "); | 60 | const normalizedArgs = |
| 61 | input.args[0] === "-C" && input.args.length >= 2 ? input.args.slice(2) : input.args; | ||
| 62 | const command = normalizedArgs.join(" "); | ||
| 55 | if (command === "rev-parse --is-inside-work-tree") { | 63 | if (command === "rev-parse --is-inside-work-tree") { |
| 56 | return processOutput("true\n"); | 64 | return processOutput("true\n"); |
| 57 | } | 65 | } |
| ... | @@ -75,7 +83,7 @@ describe("VcsDriverRegistry", () => { | ... | @@ -75,7 +83,7 @@ describe("VcsDriverRegistry", () => { |
| 75 | assert.equal(first.repository.rootPath, "/repo"); | 83 | assert.equal(first.repository.rootPath, "/repo"); |
| 76 | assert.equal(second.repository.rootPath, "/repo"); | 84 | assert.equal(second.repository.rootPath, "/repo"); |
| 77 | assert.deepStrictEqual( | 85 | assert.deepStrictEqual( |
| 78 | calls.map((call) => call.args.join(" ")), | 86 | calls.map((call) => normalizeGitArgs(call.args).join(" ")), |
| 79 | [ | 87 | [ |
| 80 | "rev-parse --is-inside-work-tree", | 88 | "rev-parse --is-inside-work-tree", |
| 81 | "rev-parse --show-toplevel", | 89 | "rev-parse --show-toplevel", |
apps/server/src/vcs/VcsProcess.test.ts created+138| ... | @@ -0,0 +1,138 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import { TestClock } from "effect/testing"; | ||
| 8 | |||
| 9 | import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; | ||
| 10 | import * as VcsProcess from "./VcsProcess.ts"; | ||
| 11 | |||
| 12 | const run = (input: VcsProcess.VcsProcessInput) => | ||
| 13 | Effect.gen(function* () { | ||
| 14 | const process = yield* VcsProcess.VcsProcess; | ||
| 15 | return yield* process.run(input); | ||
| 16 | }); | ||
| 17 | |||
| 18 | const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); | ||
| 19 | |||
| 20 | const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) => | ||
| 21 | effect.pipe(Effect.provide(liveLayer)); | ||
| 22 | |||
| 23 | describe("VcsProcess.run", () => { | ||
| 24 | it.effect("collects stdout", () => | ||
| 25 | Effect.gen(function* () { | ||
| 26 | const result = yield* run({ | ||
| 27 | operation: "test.stdout", | ||
| 28 | command: "node", | ||
| 29 | args: ["-e", "process.stdout.write('hello')"], | ||
| 30 | cwd: process.cwd(), | ||
| 31 | }); | ||
| 32 | |||
| 33 | expect(result.stdout).toBe("hello"); | ||
| 34 | expect(result.stderr).toBe(""); | ||
| 35 | expect(result.stdoutTruncated).toBe(false); | ||
| 36 | expect(result.stderrTruncated).toBe(false); | ||
| 37 | }).pipe(provideLive), | ||
| 38 | ); | ||
| 39 | |||
| 40 | it.effect("writes stdin before waiting for exit", () => | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const result = yield* run({ | ||
| 43 | operation: "test.stdin", | ||
| 44 | command: "node", | ||
| 45 | args: [ | ||
| 46 | "-e", | ||
| 47 | [ | ||
| 48 | "process.stdin.setEncoding('utf8');", | ||
| 49 | "let data='';", | ||
| 50 | "process.stdin.on('data', chunk => { data += chunk; });", | ||
| 51 | "process.stdin.on('end', () => { process.stdout.write(data); });", | ||
| 52 | ].join(""), | ||
| 53 | ], | ||
| 54 | cwd: process.cwd(), | ||
| 55 | stdin: "stdin payload", | ||
| 56 | }); | ||
| 57 | |||
| 58 | expect(result.stdout).toBe("stdin payload"); | ||
| 59 | }).pipe(provideLive), | ||
| 60 | ); | ||
| 61 | |||
| 62 | it.effect("fails with VcsProcessExitError for non-zero exits by default", () => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const error = yield* run({ | ||
| 65 | operation: "test.exit", | ||
| 66 | command: "node", | ||
| 67 | args: ["-e", "process.stderr.write('boom'); process.exit(2)"], | ||
| 68 | cwd: process.cwd(), | ||
| 69 | }).pipe(Effect.flip); | ||
| 70 | |||
| 71 | expect(error).toBeInstanceOf(VcsProcessExitError); | ||
| 72 | }).pipe(provideLive), | ||
| 73 | ); | ||
| 74 | |||
| 75 | it.effect("returns output when non-zero exits are allowed", () => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const result = yield* run({ | ||
| 78 | operation: "test.allowed-exit", | ||
| 79 | command: "node", | ||
| 80 | args: ["-e", "process.stderr.write('boom'); process.exit(2)"], | ||
| 81 | cwd: process.cwd(), | ||
| 82 | allowNonZeroExit: true, | ||
| 83 | }); | ||
| 84 | |||
| 85 | expect(result.exitCode).toBe(2); | ||
| 86 | expect(result.stderr).toBe("boom"); | ||
| 87 | }).pipe(provideLive), | ||
| 88 | ); | ||
| 89 | |||
| 90 | it.effect("truncates output and appends the marker when requested", () => | ||
| 91 | Effect.gen(function* () { | ||
| 92 | const result = yield* run({ | ||
| 93 | operation: "test.truncate-marker", | ||
| 94 | command: "node", | ||
| 95 | args: ["-e", "process.stdout.write('x'.repeat(2048))"], | ||
| 96 | cwd: process.cwd(), | ||
| 97 | maxOutputBytes: 128, | ||
| 98 | appendTruncationMarker: true, | ||
| 99 | }); | ||
| 100 | |||
| 101 | expect(result.stdoutTruncated).toBe(true); | ||
| 102 | expect(result.stdout).toContain("[truncated]"); | ||
| 103 | expect(result.stderrTruncated).toBe(false); | ||
| 104 | }).pipe(provideLive), | ||
| 105 | ); | ||
| 106 | |||
| 107 | it.effect("truncates without the marker when truncation markers are disabled", () => | ||
| 108 | Effect.gen(function* () { | ||
| 109 | const result = yield* run({ | ||
| 110 | operation: "test.truncate-silent", | ||
| 111 | command: "node", | ||
| 112 | args: ["-e", "process.stdout.write('x'.repeat(2048))"], | ||
| 113 | cwd: process.cwd(), | ||
| 114 | maxOutputBytes: 128, | ||
| 115 | }); | ||
| 116 | |||
| 117 | expect(result.stdoutTruncated).toBe(true); | ||
| 118 | expect(result.stdout).not.toContain("[truncated]"); | ||
| 119 | }).pipe(provideLive), | ||
| 120 | ); | ||
| 121 | |||
| 122 | it.effect("fails with VcsProcessTimeoutError on timeout", () => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const errorFiber = yield* run({ | ||
| 125 | operation: "test.timeout", | ||
| 126 | command: "node", | ||
| 127 | args: ["-e", "setTimeout(() => {}, 5000)"], | ||
| 128 | cwd: process.cwd(), | ||
| 129 | timeoutMs: 50, | ||
| 130 | }).pipe(Effect.flip, Effect.forkScoped); | ||
| 131 | yield* Effect.yieldNow; | ||
| 132 | yield* TestClock.adjust(Duration.millis(50)); | ||
| 133 | const error = yield* Fiber.join(errorFiber); | ||
| 134 | |||
| 135 | expect(error).toBeInstanceOf(VcsProcessTimeoutError); | ||
| 136 | }).pipe(provideLive), | ||
| 137 | ); | ||
| 138 | }); | ||
apps/server/src/vcs/VcsProcess.ts+60-173| ... | @@ -1,12 +1,7 @@ | ... | @@ -1,12 +1,7 @@ |
| 1 | import * as Duration from "effect/Duration"; | ||
| 2 | import * as Context from "effect/Context"; | 1 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 4 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | import * as PlatformError from "effect/PlatformError"; | ||
| 7 | import * as Sink from "effect/Sink"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 10 | 5 | ||
| 11 | import { | 6 | import { |
| 12 | VcsOutputDecodeError, | 7 | VcsOutputDecodeError, |
| ... | @@ -15,19 +10,21 @@ import { | ... | @@ -15,19 +10,21 @@ import { |
| 15 | VcsProcessSpawnError, | 10 | VcsProcessSpawnError, |
| 16 | VcsProcessTimeoutError, | 11 | VcsProcessTimeoutError, |
| 17 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 18 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | 13 | import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts"; |
| 14 | import * as Match from "effect/Match"; | ||
| 19 | 15 | ||
| 20 | export interface VcsProcessInput { | 16 | export interface VcsProcessInput { |
| 21 | readonly operation: string; | 17 | readonly operation: string; |
| 22 | readonly command: string; | 18 | readonly command: string; |
| 23 | readonly args: ReadonlyArray<string>; | 19 | readonly args: ReadonlyArray<string>; |
| 24 | readonly cwd: string; | 20 | readonly cwd: string; |
| 21 | readonly spawnCwd?: string; | ||
| 25 | readonly stdin?: string; | 22 | readonly stdin?: string; |
| 26 | readonly env?: NodeJS.ProcessEnv; | 23 | readonly env?: NodeJS.ProcessEnv; |
| 27 | readonly allowNonZeroExit?: boolean; | 24 | readonly allowNonZeroExit?: boolean; |
| 28 | readonly timeoutMs?: number; | 25 | readonly timeoutMs?: number; |
| 29 | readonly maxOutputBytes?: number; | 26 | readonly maxOutputBytes?: number; |
| 30 | readonly truncateOutputAtMaxBytes?: boolean; | 27 | readonly appendTruncationMarker?: boolean; |
| 31 | } | 28 | } |
| 32 | 29 | ||
| 33 | export interface VcsProcessOutput { | 30 | export interface VcsProcessOutput { |
| ... | @@ -38,25 +35,7 @@ export interface VcsProcessOutput { | ... | @@ -38,25 +35,7 @@ export interface VcsProcessOutput { |
| 38 | readonly stderrTruncated: boolean; | 35 | readonly stderrTruncated: boolean; |
| 39 | } | 36 | } |
| 40 | 37 | ||
| 41 | export interface VcsProcessCollectedText { | ||
| 42 | readonly text: string; | ||
| 43 | readonly truncated: boolean; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface VcsProcessHandle { | ||
| 47 | readonly pid: ChildProcessSpawner.ProcessId; | ||
| 48 | readonly stdin: Sink.Sink<void, Uint8Array, never, VcsError>; | ||
| 49 | readonly stdout: Stream.Stream<Uint8Array, VcsError>; | ||
| 50 | readonly stderr: Stream.Stream<Uint8Array, VcsError>; | ||
| 51 | readonly exitCode: Effect.Effect<ChildProcessSpawner.ExitCode, VcsError>; | ||
| 52 | readonly writeStdin: (input: string) => Effect.Effect<void, VcsError>; | ||
| 53 | } | ||
| 54 | |||
| 55 | export interface VcsProcessShape { | 38 | export interface VcsProcessShape { |
| 56 | readonly withProcess: <A, E, R>( | ||
| 57 | input: VcsProcessInput, | ||
| 58 | use: (handle: VcsProcessHandle) => Effect.Effect<A, E, R>, | ||
| 59 | ) => Effect.Effect<A, E | VcsError, R>; | ||
| 60 | readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>; | 39 | readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>; |
| 61 | } | 40 | } |
| 62 | 41 | ||
| ... | @@ -72,164 +51,72 @@ function commandLabel(command: string, args: ReadonlyArray<string>): string { | ... | @@ -72,164 +51,72 @@ function commandLabel(command: string, args: ReadonlyArray<string>): string { |
| 72 | return [command, ...args].join(" "); | 51 | return [command, ...args].join(" "); |
| 73 | } | 52 | } |
| 74 | 53 | ||
| 75 | function outputDecodeError( | ||
| 76 | input: VcsProcessInput, | ||
| 77 | detail: string, | ||
| 78 | cause: unknown, | ||
| 79 | ): VcsOutputDecodeError { | ||
| 80 | return new VcsOutputDecodeError({ | ||
| 81 | operation: input.operation, | ||
| 82 | command: commandLabel(input.command, input.args), | ||
| 83 | cwd: input.cwd, | ||
| 84 | detail, | ||
| 85 | cause, | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | |||
| 89 | export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: { | ||
| 90 | readonly operation: string; | ||
| 91 | readonly command: string; | ||
| 92 | readonly cwd: string; | ||
| 93 | readonly stream: Stream.Stream<Uint8Array, VcsError>; | ||
| 94 | readonly maxOutputBytes?: number; | ||
| 95 | readonly truncateOutputAtMaxBytes?: boolean; | ||
| 96 | }) { | ||
| 97 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | ||
| 98 | return yield* collectUint8StreamText({ | ||
| 99 | stream: input.stream, | ||
| 100 | maxBytes: maxOutputBytes, | ||
| 101 | truncatedMarker: input.truncateOutputAtMaxBytes ? OUTPUT_TRUNCATED_MARKER : null, | ||
| 102 | }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | export const make = Effect.fn("makeVcsProcess")(function* () { | 54 | export const make = Effect.fn("makeVcsProcess")(function* () { |
| 106 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 55 | const processRunner = yield* ProcessRunner; |
| 107 | 56 | ||
| 108 | const spawn = Effect.fn("VcsProcess.spawn")(function* (input: VcsProcessInput) { | 57 | const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { |
| 109 | const label = commandLabel(input.command, input.args); | 58 | const label = commandLabel(input.command, input.args); |
| 110 | const child = yield* spawner | 59 | const baseError = { |
| 111 | .spawn( | 60 | operation: input.operation, |
| 112 | ChildProcess.make(input.command, [...input.args], { | 61 | command: label, |
| 113 | cwd: input.cwd, | 62 | cwd: input.cwd, |
| 114 | env: { | 63 | }; |
| 115 | ...process.env, | 64 | |
| 116 | ...input.env, | 65 | const result = yield* processRunner |
| 117 | }, | 66 | .run({ |
| 118 | }), | 67 | command: input.command, |
| 119 | ) | 68 | args: input.args, |
| 69 | cwd: input.cwd, | ||
| 70 | ...(input.spawnCwd !== undefined ? { spawnCwd: input.spawnCwd } : {}), | ||
| 71 | ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), | ||
| 72 | ...(input.env !== undefined ? { env: input.env } : {}), | ||
| 73 | timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 74 | maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, | ||
| 75 | outputMode: "truncate", | ||
| 76 | truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "", | ||
| 77 | timeoutBehavior: "error", | ||
| 78 | }) | ||
| 120 | .pipe( | 79 | .pipe( |
| 121 | Effect.mapError( | 80 | Effect.mapError( |
| 122 | (cause) => | 81 | Match.valueTags({ |
| 123 | new VcsProcessSpawnError({ | 82 | ProcessSpawnError: (error) => |
| 124 | operation: input.operation, | 83 | VcsProcessSpawnError.fromProcessSpawnError(baseError, error), |
| 125 | command: label, | 84 | ProcessOutputLimitError: (error) => |
| 126 | cwd: input.cwd, | 85 | VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), |
| 127 | cause, | 86 | ProcessTimeoutError: (error) => |
| 128 | }), | 87 | VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), |
| 88 | ProcessStdinError: (error) => | ||
| 89 | VcsOutputDecodeError.fromProcessStdinError(baseError, error), | ||
| 90 | ProcessReadError: (error) => | ||
| 91 | VcsOutputDecodeError.fromProcessReadError(baseError, error), | ||
| 92 | }), | ||
| 129 | ), | 93 | ), |
| 130 | ); | 94 | ); |
| 131 | yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); | ||
| 132 | 95 | ||
| 133 | const mapStreamError = (streamName: "stdout" | "stderr") => | 96 | if (result.code === null) { |
| 134 | Stream.mapError((cause: PlatformError.PlatformError) => | 97 | return yield* VcsOutputDecodeError.missingExitCode(baseError); |
| 135 | outputDecodeError(input, `failed to read process ${streamName}`, cause), | 98 | } |
| 136 | ); | ||
| 137 | const mapEffectError = (detail: string) => | ||
| 138 | Effect.mapError((cause: PlatformError.PlatformError) => | ||
| 139 | outputDecodeError(input, detail, cause), | ||
| 140 | ); | ||
| 141 | const writeStdin = (stdin: string) => | ||
| 142 | Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe( | ||
| 143 | mapEffectError("failed to write process stdin"), | ||
| 144 | ); | ||
| 145 | |||
| 146 | return { | ||
| 147 | pid: child.pid, | ||
| 148 | stdin: child.stdin.pipe( | ||
| 149 | Sink.mapError((cause) => outputDecodeError(input, "failed to write process stdin", cause)), | ||
| 150 | ), | ||
| 151 | stdout: child.stdout.pipe(mapStreamError("stdout")), | ||
| 152 | stderr: child.stderr.pipe(mapStreamError("stderr")), | ||
| 153 | exitCode: child.exitCode.pipe(mapEffectError("failed to read process exit code")), | ||
| 154 | writeStdin, | ||
| 155 | } satisfies VcsProcessHandle; | ||
| 156 | }); | ||
| 157 | |||
| 158 | const withProcess: VcsProcessShape["withProcess"] = (input, use) => | ||
| 159 | Effect.scoped(spawn(input).pipe(Effect.flatMap(use))); | ||
| 160 | 99 | ||
| 161 | const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { | 100 | if (!input.allowNonZeroExit && result.code !== 0) { |
| 162 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | 101 | return yield* new VcsProcessExitError({ |
| 163 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | 102 | operation: input.operation, |
| 164 | const label = commandLabel(input.command, input.args); | 103 | command: label, |
| 165 | 104 | cwd: input.cwd, | |
| 166 | const runProcess = Effect.gen(function* () { | 105 | exitCode: result.code, |
| 167 | const [stdout, stderr, exitCode] = yield* withProcess(input, (child) => | 106 | detail: result.stderr.trim() || `${label} exited with code ${result.code}.`, |
| 168 | Effect.all( | 107 | }); |
| 169 | [ | 108 | } |
| 170 | collectText({ | ||
| 171 | operation: input.operation, | ||
| 172 | command: label, | ||
| 173 | cwd: input.cwd, | ||
| 174 | stream: child.stdout, | ||
| 175 | maxOutputBytes, | ||
| 176 | truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, | ||
| 177 | }), | ||
| 178 | collectText({ | ||
| 179 | operation: input.operation, | ||
| 180 | command: label, | ||
| 181 | cwd: input.cwd, | ||
| 182 | stream: child.stderr, | ||
| 183 | maxOutputBytes, | ||
| 184 | truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, | ||
| 185 | }), | ||
| 186 | child.exitCode, | ||
| 187 | input.stdin === undefined ? Effect.void : child.writeStdin(input.stdin), | ||
| 188 | ], | ||
| 189 | { concurrency: "unbounded" }, | ||
| 190 | ), | ||
| 191 | ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); | ||
| 192 | 109 | ||
| 193 | if (!input.allowNonZeroExit && exitCode !== 0) { | 110 | return { |
| 194 | return yield* new VcsProcessExitError({ | 111 | exitCode: result.code, |
| 195 | operation: input.operation, | 112 | stdout: result.stdout, |
| 196 | command: label, | 113 | stderr: result.stderr, |
| 197 | cwd: input.cwd, | 114 | stdoutTruncated: result.stdoutTruncated, |
| 198 | exitCode, | 115 | stderrTruncated: result.stderrTruncated, |
| 199 | detail: stderr.text.trim() || `${label} exited with code ${exitCode}.`, | 116 | } satisfies VcsProcessOutput; |
| 200 | }); | ||
| 201 | } | ||
| 202 | |||
| 203 | return { | ||
| 204 | exitCode, | ||
| 205 | stdout: stdout.text, | ||
| 206 | stderr: stderr.text, | ||
| 207 | stdoutTruncated: stdout.truncated, | ||
| 208 | stderrTruncated: stderr.truncated, | ||
| 209 | } satisfies VcsProcessOutput; | ||
| 210 | }); | ||
| 211 | |||
| 212 | return yield* runProcess.pipe( | ||
| 213 | Effect.scoped, | ||
| 214 | Effect.timeoutOption(Duration.millis(timeoutMs)), | ||
| 215 | Effect.flatMap((result) => | ||
| 216 | Option.match(result, { | ||
| 217 | onSome: Effect.succeed, | ||
| 218 | onNone: () => | ||
| 219 | Effect.fail( | ||
| 220 | new VcsProcessTimeoutError({ | ||
| 221 | operation: input.operation, | ||
| 222 | command: label, | ||
| 223 | cwd: input.cwd, | ||
| 224 | timeoutMs, | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | }), | ||
| 228 | ), | ||
| 229 | ); | ||
| 230 | }); | 117 | }); |
| 231 | 118 | ||
| 232 | return VcsProcess.of({ withProcess, run }); | 119 | return VcsProcess.of({ run }); |
| 233 | }); | 120 | }); |
| 234 | 121 | ||
| 235 | export const layer = Layer.effect(VcsProcess, make()); | 122 | export const layer = Layer.effect(VcsProcess, make()).pipe(Layer.provide(ProcessRunnerLive)); |
apps/server/src/vcs/testing/VcsDriverContractHarness.ts+9-1| ... | @@ -11,6 +11,10 @@ import * as Option from "effect/Option"; | ... | @@ -11,6 +11,10 @@ import * as Option from "effect/Option"; |
| 11 | import type { VcsDriverKind } from "@t3tools/contracts"; | 11 | import type { VcsDriverKind } from "@t3tools/contracts"; |
| 12 | import * as VcsDriver from "../VcsDriver.ts"; | 12 | import * as VcsDriver from "../VcsDriver.ts"; |
| 13 | 13 | ||
| 14 | function normalizePathForComparison(value: string): string { | ||
| 15 | return value.replaceAll("\\", "/"); | ||
| 16 | } | ||
| 17 | |||
| 14 | export interface VcsDriverFixture<R, E> { | 18 | export interface VcsDriverFixture<R, E> { |
| 15 | readonly createRepo: (cwd: string) => Effect.Effect<void, E, R>; | 19 | readonly createRepo: (cwd: string) => Effect.Effect<void, E, R>; |
| 16 | readonly writeFile: ( | 20 | readonly writeFile: ( |
| ... | @@ -67,7 +71,11 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp | ... | @@ -67,7 +71,11 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp |
| 67 | yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); | 71 | yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); |
| 68 | const identity = yield* driver.detectRepository(cwd); | 72 | const identity = yield* driver.detectRepository(cwd); |
| 69 | assert.equal(identity?.kind, input.kind); | 73 | assert.equal(identity?.kind, input.kind); |
| 70 | assert.isTrue(identity?.rootPath.endsWith(cwd)); | 74 | assert.isTrue( |
| 75 | normalizePathForComparison(identity?.rootPath ?? "").endsWith( | ||
| 76 | normalizePathForComparison(cwd), | ||
| 77 | ), | ||
| 78 | ); | ||
| 71 | assert.equal(identity?.freshness.source, "live-local"); | 79 | assert.equal(identity?.freshness.source, "live-local"); |
| 72 | assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); | 80 | assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); |
| 73 | assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); | 81 | assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); |
packages/contracts/src/vcs.ts+78| ... | @@ -58,6 +58,34 @@ export const VcsListRemotesResult = Schema.Struct({ | ... | @@ -58,6 +58,34 @@ export const VcsListRemotesResult = Schema.Struct({ |
| 58 | }); | 58 | }); |
| 59 | export type VcsListRemotesResult = typeof VcsListRemotesResult.Type; | 59 | export type VcsListRemotesResult = typeof VcsListRemotesResult.Type; |
| 60 | 60 | ||
| 61 | export interface VcsProcessErrorContext { | ||
| 62 | readonly operation: string; | ||
| 63 | readonly command: string; | ||
| 64 | readonly cwd: string; | ||
| 65 | } | ||
| 66 | |||
| 67 | export interface VcsProcessSpawnFailure { | ||
| 68 | readonly cause: unknown; | ||
| 69 | } | ||
| 70 | |||
| 71 | export interface VcsProcessStdinFailure { | ||
| 72 | readonly cause: unknown; | ||
| 73 | } | ||
| 74 | |||
| 75 | export interface VcsProcessReadFailure { | ||
| 76 | readonly stream: "stdout" | "stderr" | "exitCode"; | ||
| 77 | readonly cause: unknown; | ||
| 78 | } | ||
| 79 | |||
| 80 | export interface VcsProcessOutputLimitFailure { | ||
| 81 | readonly stream: "stdout" | "stderr"; | ||
| 82 | readonly maxBytes: number; | ||
| 83 | } | ||
| 84 | |||
| 85 | export interface VcsProcessTimeoutFailure { | ||
| 86 | readonly timeoutMs: number; | ||
| 87 | } | ||
| 88 | |||
| 61 | export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpawnError>()( | 89 | export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpawnError>()( |
| 62 | "VcsProcessSpawnError", | 90 | "VcsProcessSpawnError", |
| 63 | { | 91 | { |
| ... | @@ -70,6 +98,13 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpaw | ... | @@ -70,6 +98,13 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpaw |
| 70 | override get message(): string { | 98 | override get message(): string { |
| 71 | return `VCS process failed to spawn in ${this.operation}: ${this.command} (${this.cwd})`; | 99 | return `VCS process failed to spawn in ${this.operation}: ${this.command} (${this.cwd})`; |
| 72 | } | 100 | } |
| 101 | |||
| 102 | static fromProcessSpawnError(context: VcsProcessErrorContext, error: VcsProcessSpawnFailure) { | ||
| 103 | return new VcsProcessSpawnError({ | ||
| 104 | ...context, | ||
| 105 | cause: error.cause, | ||
| 106 | }); | ||
| 107 | } | ||
| 73 | } | 108 | } |
| 74 | 109 | ||
| 75 | export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitError>()( | 110 | export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitError>()( |
| ... | @@ -99,6 +134,13 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTi | ... | @@ -99,6 +134,13 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTi |
| 99 | override get message(): string { | 134 | override get message(): string { |
| 100 | return `VCS process timed out in ${this.operation}: ${this.command} (${this.cwd}) after ${this.timeoutMs}ms`; | 135 | return `VCS process timed out in ${this.operation}: ${this.command} (${this.cwd}) after ${this.timeoutMs}ms`; |
| 101 | } | 136 | } |
| 137 | |||
| 138 | static fromProcessTimeoutError(context: VcsProcessErrorContext, error: VcsProcessTimeoutFailure) { | ||
| 139 | return new VcsProcessTimeoutError({ | ||
| 140 | ...context, | ||
| 141 | timeoutMs: error.timeoutMs, | ||
| 142 | }); | ||
| 143 | } | ||
| 102 | } | 144 | } |
| 103 | 145 | ||
| 104 | export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()( | 146 | export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()( |
| ... | @@ -114,6 +156,42 @@ export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecod | ... | @@ -114,6 +156,42 @@ export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecod |
| 114 | override get message(): string { | 156 | override get message(): string { |
| 115 | return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | 157 | return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; |
| 116 | } | 158 | } |
| 159 | |||
| 160 | static fromProcessStdinError(context: VcsProcessErrorContext, error: VcsProcessStdinFailure) { | ||
| 161 | return new VcsOutputDecodeError({ | ||
| 162 | ...context, | ||
| 163 | detail: "failed to write process stdin", | ||
| 164 | cause: error.cause, | ||
| 165 | }); | ||
| 166 | } | ||
| 167 | |||
| 168 | static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { | ||
| 169 | return new VcsOutputDecodeError({ | ||
| 170 | ...context, | ||
| 171 | detail: | ||
| 172 | error.stream === "exitCode" | ||
| 173 | ? "failed to read process exit code" | ||
| 174 | : `failed to read process ${error.stream}`, | ||
| 175 | cause: error.cause, | ||
| 176 | }); | ||
| 177 | } | ||
| 178 | |||
| 179 | static fromProcessOutputLimitError( | ||
| 180 | context: VcsProcessErrorContext, | ||
| 181 | error: VcsProcessOutputLimitFailure, | ||
| 182 | ) { | ||
| 183 | return new VcsOutputDecodeError({ | ||
| 184 | ...context, | ||
| 185 | detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, | ||
| 186 | }); | ||
| 187 | } | ||
| 188 | |||
| 189 | static missingExitCode(context: VcsProcessErrorContext) { | ||
| 190 | return new VcsOutputDecodeError({ | ||
| 191 | ...context, | ||
| 192 | detail: "process completed without an exit code", | ||
| 193 | }); | ||
| 194 | } | ||
| 117 | } | 195 | } |
| 118 | 196 | ||
| 119 | export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()( | 197 | export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()( |