authorgravatar for 145564979+justsomelegs@users.noreply.github.comlegs <145564979+justsomelegs@users.noreply.github.com> 2026-05-11 11:07:26+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-11 12:07:26+02:00
log5165b8c333c0da8566a93f98011672ebd1dff9bb
tree1ec02cff0664a446c9257d6d4d028397a46a267f
parent8fc317939f5c8bbef4afbe309ae897abbc221631
signature Signed by PGP key B5690EEEBB952194

Optimize VCS diff loading to be up to 98% faster (#2586)

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 {
73import { deriveServerPaths, ServerConfig } from "../src/config.ts";73import { deriveServerPaths, ServerConfig } from "../src/config.ts";
74import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";74import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";
75import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";75import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";
76import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts";
77import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";76import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
78import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";77import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
79import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";78import { 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}
4242
43describe("CheckpointDiffQueryLive", () => {43describe("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 );
122218
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 });
206302
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");
209373
...@@ -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 @@
1import {1import {
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 {
2021
21const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult);22const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult);
2223
24function 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
23const make = Effect.gen(function* () {40const 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 });
3154
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 }
4770
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 }
102125
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({
116139 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 }
124143
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"));
132182
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"));
140223
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 });
153237 }
154 return turnDiff;
155 },
156 );
157238
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));
167241
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 with4 * 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 CheckpointStoreLive7 * @module CheckpointStoreLive
11 */8 */
12import { randomUUID } from "node:crypto";
13
14import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
16import * as FileSystem from "effect/FileSystem";
17import * as Path from "effect/Path";
1811
19import { CheckpointInvariantError } from "../Errors.ts";
20import { VcsProcessExitError } from "@t3tools/contracts";
21import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts";
22import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";12import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
23import { CheckpointRef } from "@t3tools/contracts";13import { VcsUnsupportedOperationError } from "@t3tools/contracts";
2414import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts";
25const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;15import type { VcsCheckpointOps } from "../../vcs/VcsDriver.ts";
2616
27const makeCheckpointStore = Effect.gen(function* () {17const 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 );
6519
66 const hasHeadCommit = (cwd: string) =>20 const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* (
67 vcs21 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,
7529 detail: `${handle.kind} driver does not implement checkpoint operations.`,
76 const resolveCheckpointCommit = (cwd: string, checkpointRef: CheckpointRef) =>30 });
77 vcs31 }
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 );
9334
94 const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) =>35 const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) =>
95 vcs36 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 );
10640
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);
11145 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 });
19747
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 );
20254
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);
20759 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 });
24061
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);
24465 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 );
28568
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(
29073 "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 });
30378
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";
6import * as Random from "effect/Random";6import * as Random from "effect/Random";
77
8import { ServerConfig } from "../../config.ts";8import { ServerConfig } from "../../config.ts";
9import { layer as ProcessRunnerLive } from "../../processRunner.ts";
9import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";10import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
10import packageJson from "../../../package.json" with { type: "json" };11import packageJson from "../../../package.json" with { type: "json" };
11import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";12import { 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});
9596
96export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment());97export 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 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
4import { vi } from "vitest";5import { vi } from "vitest";
56
6vi.mock("../../processRunner.ts", () => ({7import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts";
7 runProcess: vi.fn(),
8}));
9
10import { runProcess } from "../../processRunner.ts";
11import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";8import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
9import { ChildProcessSpawner } from "effect/unstable/process";
10
11const runMock = vi.fn<ProcessRunnerShape["run"]>();
1212
13const mockedRunProcess = vi.mocked(runProcess);13const ProcessRunnerTest = Layer.succeed(
14 ProcessRunner,
15 ProcessRunner.of({
16 run: (input) => runMock(input),
17 }),
18);
14const NoopFileSystemLayer = FileSystem.layerNoop({});19const NoopFileSystemLayer = FileSystem.layerNoop({});
20const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest);
21const 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);
1531
16afterEach(() => {32afterEach(() => {
17 mockedRunProcess.mockReset();33 runMock.mockReset();
18});34});
1935
20describe("resolveServerEnvironmentLabel", () => {36describe("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));
2844
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", () => {
3248
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 );
4261
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));
4867
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 );
7586
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 );
8091
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 );
90104
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));
96110
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));
113129
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", () => {
117133
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 );
121145
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));
127151
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", () => {
131155
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 );
141168
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));
147174
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";
22
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
56
6import { runProcess } from "../../processRunner.ts";7import { ProcessRunner } from "../../processRunner.ts";
78
8interface ResolveServerEnvironmentLabelInput {9interface 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",
6263 })
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 }
6669
67 return normalizeLabel(result.stdout);70 return normalizeLabel(result.value.stdout);
68});71});
6972
70const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* (73const 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";
12import * as NodeServices from "@effect/platform-node/NodeServices";
12import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
14import * as ManagedRuntime from "effect/ManagedRuntime";15import * 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";
38import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";39import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
39import { ServerConfig } from "../../config.ts";40import { ServerConfig } from "../../config.ts";
40import * as NodeServices from "@effect/platform-node/NodeServices";
4141
42const asProjectId = (value: string): ProjectId => ProjectId.make(value);42const asProjectId = (value: string): ProjectId => ProjectId.make(value);
43const asMessageId = (value: string): MessageId => MessageId.make(value);43const 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";
10import { assert, it } from "@effect/vitest";10import { assert, it } from "@effect/vitest";
11import * as NodeServices from "@effect/platform-node/NodeServices";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
13import * as SqlClient from "effect/unstable/sql/SqlClient";14import * 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);
3436
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+76
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
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
49import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";50import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
50import {51import {
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});
127const FullThreadDiffContextLookupInput = Schema.Struct({
128 threadId: ThreadId,
129 checkpointTurnCount: NonNegativeInt,
130});
131const 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});
125139
126const REQUIRED_SNAPSHOT_PROJECTORS = [140const 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 });
884898
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 sql932 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 });
17611807
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 ProjectionSnapshotQuery7 * @module ProjectionSnapshotQuery
8 */8 */
9import type {9import 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}
4243
44export 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>;
127137
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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";
9import { TestClock } from "effect/testing";
10import { ChildProcessSpawner } from "effect/unstable/process";
211
3import { isWindowsCommandNotFound, runProcess } from "./processRunner.ts";12import {
13 isWindowsCommandNotFound,
14 ProcessOutputLimitError,
15 ProcessRunner,
16 ProcessTimeoutError,
17 layer as ProcessRunnerLive,
18 type ProcessRunInput,
19} from "./processRunner.ts";
420
5describe("runProcess", () => {21type 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
27function asChildProcessCommand(command: unknown): ChildProcessCommand {
28 return command as ChildProcessCommand;
29}
30
31function 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
59function makeSpawner(
60 f: (command: ChildProcessCommand) => Effect.Effect<ChildProcessSpawner.ChildProcessHandle>,
61) {
62 return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command)));
63}
64
65const 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
81describe("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 });
1196
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 );
17102
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});
24281
25describe("isWindowsCommandNotFound", () => {282describe("isWindowsCommandNotFound", () => {
apps/server/src/processRunner.ts+290-240
...@@ -1,43 +1,101 @@...@@ -1,43 +1,101 @@
1// @effect-diagnostics nodeBuiltinImport:off1import * as Data from "effect/Data";
2import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process";2import * as Context from "effect/Context";
3import * as NodeTimers from "node:timers";3import * as Duration from "effect/Duration";
4export interface ProcessRunOptions {4import * as Effect from "effect/Effect";
5 cwd?: string | undefined;5import * as Layer from "effect/Layer";
6 timeoutMs?: number | undefined;6import * as Option from "effect/Option";
7 env?: NodeJS.ProcessEnv | undefined;7import * as PlatformError from "effect/PlatformError";
8 stdin?: string | undefined;8import * as Scope from "effect/Scope";
9 allowNonZeroExit?: boolean | undefined;9import * as Stream from "effect/Stream";
10 maxBufferBytes?: number | undefined;10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11 outputMode?: "error" | "truncate" | undefined;11import {
12}12 collectUint8StreamText,
13 type CollectedUint8StreamText,
14} from "./stream/collectUint8StreamText.ts";
1315
14export interface ProcessRunResult {16export 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}
2334
24function commandLabel(command: string, args: readonly string[]): string {35export 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}
2743
28function normalizeSpawnError(command: string, args: readonly string[], error: unknown): Error {44export 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}> {}
3250
33 const maybeCode = (error as NodeJS.ErrnoException).code;51export 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
58export 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
66export 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
74export 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
81export type ProcessRunError =
82 | ProcessSpawnError
83 | ProcessStdinError
84 | ProcessOutputLimitError
85 | ProcessReadError
86 | ProcessTimeoutError;
3787
38 return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`);88export interface ProcessRunnerShape {
89 readonly run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput, ProcessRunError>;
39}90}
4091
92export class ProcessRunner extends Context.Service<ProcessRunner, ProcessRunnerShape>()(
93 "t3/process/ProcessRunner",
94) {}
95
96const DEFAULT_TIMEOUT = "60 seconds";
97const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
98
41const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [99const 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}
59117
60function normalizeExitError(118const 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";
68126 readonly truncatedMarker: string;
69 const reason = result.timedOut127}) {
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,
76134 cwd: input.cwd,
77function 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
84function 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}
94140
95const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024;141 if (input.outputMode === "truncate") {
96142 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 the145 truncatedMarker: input.truncatedMarker,
100 * entire process tree instead.146 });
101 */
102function 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
114function 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
142export 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";
150148
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 }
158173
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});
168190
169 // @effect-diagnostics-next-line globalTimers:off - Promise/child_process boundary; moving this runner to Effect timers is a separate refactor.191function 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);
178197
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}
188226
189 const fail = (error: Error): void => {227const 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 ?? "";
195234
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 };
229259
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 );
236274
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 );
243301
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 );
249314
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 };
260324
261 finalize(() => {325export 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 });
269327
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 });
273330
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
336export 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
2import { realpathSync } from "node:fs";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
5import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
6import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
10import * as TestClock from "effect/testing/TestClock";7import * as Path from "effect/Path";
8import { TestClock } from "effect/testing";
119
12import { runProcess } from "../../processRunner.ts";10import * as ProcessRunner from "../../processRunner.ts";
13import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";11import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";
14import {12import {
15 makeRepositoryIdentityResolver,13 makeRepositoryIdentityResolver,
...@@ -17,11 +15,17 @@ import {...@@ -17,11 +15,17 @@ import {
17} from "./RepositoryIdentityResolver.ts";15} from "./RepositoryIdentityResolver.ts";
1816
19const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");17const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
20const normalizeResolvedPath = (value: string) =>18const normalizeResolvedPath = (value: string) => normalizePathSeparators(value);
21 normalizePathSeparators(realpathSync.native(value));
2219
23const git = (cwd: string, args: ReadonlyArray<string>) =>20const 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));
2529
26const makeRepositoryIdentityResolverTestLayer = (options: {30const 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));
3741
38it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {42it.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) => {
4852
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);
5158
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");
6977
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) => {
7381
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);
7687
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 );
8295
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";
1111
12import { runProcess } from "../../processRunner.ts";12import * as ProcessRunner from "../../processRunner.ts";
13import {13import {
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}
8585
86async function resolveRepositoryIdentityCacheKey(cwd: string): Promise<string> {86const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* (
87 cwd: string,
88) {
89 const processRunner = yield* ProcessRunner.ProcessRunner;
87 let cacheKey = cwd;90 let cacheKey = cwd;
8891
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 })
9699 .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 }
104103
105 return cacheKey;104 const candidate = topLevelResult.value.stdout.trim();
106}105 if (candidate.length > 0) {
106 cacheKey = candidate;
107 }
107108
108async function resolveRepositoryIdentityFromCacheKey(109 return cacheKey;
109 cacheKey: string,110});
110): Promise<RepositoryIdentity | null> {111
111 try {112const 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 }
118128
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}
125133
126export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")(134export 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 });
148163
...@@ -155,4 +170,4 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR...@@ -155,4 +170,4 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR
155export const RepositoryIdentityResolverLive = Layer.effect(170export 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}
99
10interface CollectState {10interface 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 ?? "";
2423
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 }
4547
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;
5153
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";
9import * as Cause from "effect/Cause";9import * as Data from "effect/Data";
10import * as Duration from "effect/Duration";10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Encoding from "effect/Encoding";12import * as Encoding from "effect/Encoding";
13import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
15import * as FileSystem from "effect/FileSystem";15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
17import * as PlatformError from "effect/PlatformError";18import * as PlatformError from "effect/PlatformError";
19import * as Path from "effect/Path";
18import * as Ref from "effect/Ref";20import * as Ref from "effect/Ref";
19import * as Schedule from "effect/Schedule";21import * as Schedule from "effect/Schedule";
20import * as Scope from "effect/Scope";22import * as Scope from "effect/Scope";
21import * as TestClock from "effect/testing/TestClock";23import { TestClock } from "effect/testing";
22import { expect } from "vitest";24import { expect } from "vitest";
2325
26import * as ProcessRunner from "../../processRunner.ts";
24import type { TerminalManagerShape } from "../Services/Manager.ts";27import type { TerminalManagerShape } from "../Services/Manager.ts";
25import {28import {
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";
32import { makeTerminalManagerWithOptions } from "./Manager.ts";35import { makeTerminalManagerWithOptions } from "./Manager.ts";
3336
37class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{
38 readonly message: string;
39}> {}
40
34class FakePtyProcess implements PtyProcess {41class 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 {
127const waitFor = <E, R>(134const 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 );
136153
137function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {154function 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}
158175
159function historyLogName(threadId: string): string {176const 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`)),
162179 );
163function 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
171function historyLogPath(logsDir: string, threadId = "thread-1"): string {
172 return joinPath(logsDir, historyLogName(threadId));
173}
174180
175function multiTerminalHistoryLogPath(181const 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 }) => {
182188 const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`;
183function 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 );
186197
187interface CreateManagerOptions {198interface 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.Scope223 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();
220231
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 );
255267
256it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (it) => {268it.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 );
292309
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;
296313
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);
302319
...@@ -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;
397415
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 );
402431
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 );
425460
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 );
433474
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);
476519
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 });
523572
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", (
528577
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 );
534588
...@@ -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 );
719779
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 );
724789
...@@ -735,19 +800,36 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (...@@ -735,19 +800,36 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
735800
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 );
740816
741 yield* manager.close({ threadId: "thread-1", deleteHistory: true });817 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
742818
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 );
753835
...@@ -786,7 +868,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (...@@ -786,7 +868,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
786868
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");
815904
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";
7import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";7import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
8import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as Encoding from "effect/Encoding";10import * as Encoding from "effect/Encoding";
10import * as Equal from "effect/Equal";11import * as Equal from "effect/Equal";
11import * as Exit from "effect/Exit";12import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";13import * as Fiber from "effect/Fiber";
13import * as FileSystem from "effect/FileSystem";14import * as FileSystem from "effect/FileSystem";
14import * as DateTime from "effect/DateTime";
15import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";16import * as Option from "effect/Option";
17import * as Path from "effect/Path";17import * 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";
29import { runProcess } from "../../processRunner.ts";29import * as ProcessRunner from "../../processRunner.ts";
30import {30import {
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;
51const DEFAULT_OPEN_COLS = 120;51const DEFAULT_OPEN_COLS = 120;
52const DEFAULT_OPEN_ROWS = 30;52const DEFAULT_OPEN_ROWS = 30;
53const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);53const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
54const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
5455
55class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(56class 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) {}
7374
74interface TerminalSubprocessChecker {75interface TerminalSubprocessChecker {
75 (terminalPid: number): Effect.Effect<boolean, TerminalSubprocessCheckError>;76 (
77 terminalPid: number,
78 ): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner>;
76}79}
7780
78interface ShellCandidate {81interface 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}
213216
214function basename(command: string, platform: NodeJS.Platform): string {217function 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}
219225
220function joinWindowsPath(...segments: ReadonlyArray<string>): string {226function joinWindowsPath(...parts: ReadonlyArray<string>): string {
221 return segments227 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}
226235
227function shellCandidateFromCommand(236function 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 {
357366
358function checkWindowsSubprocessActivity(367function 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}
383399
384const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (400const 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 );
403424
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 );
420445
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
447472
448const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* (473const 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);
729753
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 });
14211451
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 });
14321462
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;
14541486
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 });
14691500
1470 yield* evictInactiveSessionsIfNeeded();1501 yield* evictInactiveSessionsIfNeeded();
14711502
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 }
15481580
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);
19351969
1936export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager());1970export 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>({
6767
68it.effect("GitVcsDriver forwards execute env to the VCS process", () => {68it.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;
7071
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 });
8284
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 @@
1import { randomUUID } from "node:crypto";
2
1import * as Context from "effect/Context";3import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";4import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
6import { ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcessSpawner } from "effect/unstable/process";
711
8import {12import {
...@@ -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}
4145
...@@ -218,6 +222,7 @@ export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShap...@@ -218,6 +222,7 @@ export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShap
218222
219const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;223const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
220const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;224const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
225const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;
221const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [226const 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 !== undefined337 ...(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 !== undefined342 ...(options?.appendTruncationMarker !== undefined
337 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }343 ? { appendTruncationMarker: options.appendTruncationMarker }
338 : {}),344 : {}),
339 });345 });
340346
341export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () {347export 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* (
351359
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"));
364372
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 !== undefined380 ...(input.appendTruncationMarker !== undefined
373 ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes }381 ? { appendTruncationMarker: input.appendTruncationMarker }
374 : {}),382 : {}),
375 });383 });
376384
...@@ -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 }
383391
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* (
402410
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* (
444452
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 );
452466
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* (
494508
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 );
509523
...@@ -530,14 +544,266 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -530,14 +544,266 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
530 });544 });
531545
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);
537551
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}
8383
...@@ -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 });
566566
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 }
580580
581 const chunkToDecode =581 const chunkToDecode =
582 truncateOutputAtMaxBytes && nextBytes > maxOutputBytes582 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;
587587
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;
623623
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 !== undefined749 ...(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 );
13851385
...@@ -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";
12import { CheckpointRef } from "@t3tools/contracts";
12import * as VcsProcess from "./VcsProcess.ts";13import * as VcsProcess from "./VcsProcess.ts";
1314
15export interface VcsCaptureCheckpointInput {
16 readonly cwd: string;
17 readonly checkpointRef: CheckpointRef;
18}
19
20export interface VcsRestoreCheckpointInput {
21 readonly cwd: string;
22 readonly checkpointRef: CheckpointRef;
23 readonly fallbackToHead?: boolean;
24}
25
26export interface VcsDiffCheckpointsInput {
27 readonly cwd: string;
28 readonly fromCheckpointRef: CheckpointRef;
29 readonly toCheckpointRef: CheckpointRef;
30 readonly fallbackFromToHead?: boolean;
31 readonly ignoreWhitespace: boolean;
32}
33
34export interface VcsDeleteCheckpointRefsInput {
35 readonly cwd: string;
36 readonly checkpointRefs: ReadonlyArray<CheckpointRef>;
37}
38
39export 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
14export interface VcsDriverShape {53export 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 @@
1import { assert, it, describe } from "@effect/vitest";1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
4import { ChildProcessSpawner } from "effect/unstable/process";5import { 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});
1718
19const normalizeGitArgs = (args: ReadonlyArray<string>): ReadonlyArray<string> =>
20 args[0] === "-C" && args.length >= 2 ? args.slice(2) : args;
21
18describe("VcsDriverRegistry", () => {22describe("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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import { TestClock } from "effect/testing";
8
9import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts";
10import * as VcsProcess from "./VcsProcess.ts";
11
12const run = (input: VcsProcess.VcsProcessInput) =>
13 Effect.gen(function* () {
14 const process = yield* VcsProcess.VcsProcess;
15 return yield* process.run(input);
16 });
17
18const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer));
19
20const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) =>
21 effect.pipe(Effect.provide(liveLayer));
22
23describe("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 @@
1import * as Duration from "effect/Duration";
2import * as Context from "effect/Context";1import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";4import { ChildProcessSpawner } from "effect/unstable/process";
6import * as PlatformError from "effect/PlatformError";
7import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";
9import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
105
11import {6import {
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";
18import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";13import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts";
14import * as Match from "effect/Match";
1915
20export interface VcsProcessInput {16export 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}
3229
33export interface VcsProcessOutput {30export 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}
4037
41export interface VcsProcessCollectedText {
42 readonly text: string;
43 readonly truncated: boolean;
44}
45
46export 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
55export interface VcsProcessShape {38export 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}
6241
...@@ -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}
7453
75function 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
89export 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
105export const make = Effect.fn("makeVcsProcess")(function* () {54export const make = Effect.fn("makeVcsProcess")(function* () {
106 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;55 const processRunner = yield* ProcessRunner;
10756
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* spawner59 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));
13295
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)));
16099
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,
165104 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));
192109
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 });
231118
232 return VcsProcess.of({ withProcess, run });119 return VcsProcess.of({ run });
233});120});
234121
235export const layer = Layer.effect(VcsProcess, make());122export 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";
11import type { VcsDriverKind } from "@t3tools/contracts";11import type { VcsDriverKind } from "@t3tools/contracts";
12import * as VcsDriver from "../VcsDriver.ts";12import * as VcsDriver from "../VcsDriver.ts";
1313
14function normalizePathForComparison(value: string): string {
15 return value.replaceAll("\\", "/");
16}
17
14export interface VcsDriverFixture<R, E> {18export 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});
59export type VcsListRemotesResult = typeof VcsListRemotesResult.Type;59export type VcsListRemotesResult = typeof VcsListRemotesResult.Type;
6060
61export interface VcsProcessErrorContext {
62 readonly operation: string;
63 readonly command: string;
64 readonly cwd: string;
65}
66
67export interface VcsProcessSpawnFailure {
68 readonly cause: unknown;
69}
70
71export interface VcsProcessStdinFailure {
72 readonly cause: unknown;
73}
74
75export interface VcsProcessReadFailure {
76 readonly stream: "stdout" | "stderr" | "exitCode";
77 readonly cause: unknown;
78}
79
80export interface VcsProcessOutputLimitFailure {
81 readonly stream: "stdout" | "stderr";
82 readonly maxBytes: number;
83}
84
85export interface VcsProcessTimeoutFailure {
86 readonly timeoutMs: number;
87}
88
61export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpawnError>()(89export 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}
74109
75export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitError>()(110export 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}
103145
104export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()(146export 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}
118196
119export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()(197export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()(