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 {
7373import { deriveServerPaths, ServerConfig } from "../src/config.ts";
7474import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";
7575import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";
76import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts";
7776import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
7877import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
7978import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
......@@ -311,8 +310,11 @@ export const makeOrchestrationIntegrationHarness = (
311310 Layer.provideMerge(serverSettingsLayer),
312311 );
313312 const gitWorkflowLayer = Layer.mock(GitWorkflowService)({
314 renameBranch: (input: Parameters<GitVcsDriver.GitVcsDriverShape["renameBranch"]>[0]) =>
315 Effect.succeed({ branch: input.newBranch }),
313 renameBranch: (input: {
314 readonly cwd: string;
315 readonly oldBranch: string;
316 readonly newBranch: string;
317 }) => Effect.succeed({ branch: input.newBranch }),
316318 });
317319 const textGenerationLayer = Layer.succeed(TextGeneration, {
318320 generateBranchName: () => Effect.succeed({ branch: "update" }),
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+172-7
......@@ -41,11 +41,110 @@ function makeThreadCheckpointContext(input: {
4141}
4242
4343describe("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
44144 it("computes diffs using canonical turn-0 checkpoint refs", async () => {
45145 const projectId = ProjectId.make("project-1");
46146 const threadId = ThreadId.make("thread-1");
47147 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
48 const hasCheckpointRefCalls: Array<CheckpointRef> = [];
49148 const diffCheckpointsCalls: Array<{
50149 readonly fromCheckpointRef: CheckpointRef;
51150 readonly toCheckpointRef: CheckpointRef;
......@@ -65,11 +164,7 @@ describe("CheckpointDiffQueryLive", () => {
65164 const checkpointStore: CheckpointStoreShape = {
66165 isGitRepository: () => Effect.succeed(true),
67166 captureCheckpoint: () => Effect.void,
68 hasCheckpointRef: ({ checkpointRef }) =>
69 Effect.sync(() => {
70 hasCheckpointRefCalls.push(checkpointRef);
71 return true;
72 }),
167 hasCheckpointRef: () => Effect.succeed(true),
73168 restoreCheckpoint: () => Effect.succeed(true),
74169 diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) =>
75170 Effect.sync(() => {
......@@ -102,6 +197,7 @@ describe("CheckpointDiffQueryLive", () => {
102197 getProjectShellById: () => Effect.succeed(Option.none()),
103198 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
104199 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
200 getFullThreadDiffContext: () => Effect.die("unused"),
105201 getThreadShellById: () => Effect.succeed(Option.none()),
106202 getThreadDetailById: () => Effect.succeed(Option.none()),
107203 }),
......@@ -121,7 +217,6 @@ describe("CheckpointDiffQueryLive", () => {
121217 );
122218
123219 const expectedFromRef = checkpointRefForThreadTurn(threadId, 0);
124 expect(hasCheckpointRefCalls).toEqual([expectedFromRef, toCheckpointRef]);
125220 expect(diffCheckpointsCalls).toEqual([
126221 {
127222 cwd: "/tmp/workspace",
......@@ -184,6 +279,7 @@ describe("CheckpointDiffQueryLive", () => {
184279 getProjectShellById: () => Effect.succeed(Option.none()),
185280 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
186281 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
282 getFullThreadDiffContext: () => Effect.die("unused"),
187283 getThreadShellById: () => Effect.succeed(Option.none()),
188284 getThreadDetailById: () => Effect.succeed(Option.none()),
189285 }),
......@@ -204,6 +300,74 @@ describe("CheckpointDiffQueryLive", () => {
204300 expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]);
205301 });
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
207371 it("fails when the thread is missing from the snapshot", async () => {
208372 const threadId = ThreadId.make("thread-missing");
209373
......@@ -234,6 +398,7 @@ describe("CheckpointDiffQueryLive", () => {
234398 getProjectShellById: () => Effect.succeed(Option.none()),
235399 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
236400 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
401 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
237402 getThreadShellById: () => Effect.succeed(Option.none()),
238403 getThreadDetailById: () => Effect.succeed(Option.none()),
239404 }),
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts+128-54
......@@ -1,6 +1,7 @@
11import {
2 type CheckpointRef,
23 OrchestrationGetTurnDiffResult,
3 type OrchestrationGetFullThreadDiffInput,
4 type ThreadId,
45 type OrchestrationGetFullThreadDiffResult,
56 type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType,
67} from "@t3tools/contracts";
......@@ -20,6 +21,22 @@ import {
2021
2122const 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
2340const make = Effect.gen(function* () {
2441 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
2542 const checkpointStore = yield* CheckpointStore;
......@@ -28,6 +45,12 @@ const make = Effect.gen(function* () {
2845 function* (input) {
2946 const operation = "CheckpointDiffQuery.getTurnDiff";
3047 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
3255 if (input.fromTurnCount === input.toTurnCount) {
3356 const emptyDiff: OrchestrationGetTurnDiffResultType = {
......@@ -45,9 +68,9 @@ const make = Effect.gen(function* () {
4568 return emptyDiff;
4669 }
4770
48 const threadContext = yield* projectionSnapshotQuery.getThreadCheckpointContext(
49 input.threadId,
50 );
71 const threadContext = yield* projectionSnapshotQuery
72 .getThreadCheckpointContext(input.threadId)
73 .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext"));
5174 if (Option.isNone(threadContext)) {
5275 return yield* new CheckpointInvariantError({
5376 operation,
......@@ -100,70 +123,121 @@ const make = Effect.gen(function* () {
100123 });
101124 }
102125
103 const [fromExists, toExists] = yield* Effect.all(
104 [
105 checkpointStore.hasCheckpointRef({
106 cwd: workspaceCwd,
107 checkpointRef: fromCheckpointRef,
108 }),
109 checkpointStore.hasCheckpointRef({
110 cwd: workspaceCwd,
111 checkpointRef: toCheckpointRef,
112 }),
113 ],
114 { concurrency: "unbounded" },
115 );
116
117 if (!fromExists) {
118 return yield* new CheckpointUnavailableError({
119 threadId: input.threadId,
120 turnCount: input.fromTurnCount,
121 detail: `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount}.`,
126 const diff = yield* checkpointStore
127 .diffCheckpoints({
128 cwd: workspaceCwd,
129 fromCheckpointRef,
130 toCheckpointRef,
131 fallbackFromToHead: false,
132 ignoreWhitespace,
133 })
134 .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints"));
135
136 const turnDiff = buildTurnDiffResult(input, diff);
137 if (!isTurnDiffResult(turnDiff)) {
138 return yield* new CheckpointInvariantError({
139 operation,
140 detail: "Computed turn diff result does not satisfy contract schema.",
122141 });
123142 }
124143
125 if (!toExists) {
126 return yield* new CheckpointUnavailableError({
144 return turnDiff;
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 {
127164 threadId: input.threadId,
128 turnCount: input.toTurnCount,
129 detail: `Filesystem checkpoint is unavailable for turn ${input.toTurnCount}.`,
165 fromTurnCount: 0,
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.",
130174 });
131175 }
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({
134216 cwd: workspaceCwd,
135 fromCheckpointRef,
136 toCheckpointRef,
217 fromCheckpointRef: checkpointRefForThreadTurn(input.threadId, 0),
218 toCheckpointRef: threadContext.value.toCheckpointRef as CheckpointRef,
137219 fallbackFromToHead: false,
138220 ignoreWhitespace,
139 });
221 })
222 .pipe(Effect.withSpan("checkpoint.fullThread.diffCheckpoints"));
140223
141 const turnDiff: OrchestrationGetTurnDiffResultType = {
224 const turnDiff = buildTurnDiffResult(
225 {
142226 threadId: input.threadId,
143 fromTurnCount: input.fromTurnCount,
227 fromTurnCount: 0,
144228 toTurnCount: input.toTurnCount,
145 diff,
146 };
147 if (!isTurnDiffResult(turnDiff)) {
148 return yield* new CheckpointInvariantError({
149 operation,
150 detail: "Computed turn diff result does not satisfy contract schema.",
151 });
152 }
153
154 return turnDiff;
155 },
156 );
229 },
230 diff,
231 );
232 if (!isTurnDiffResult(turnDiff)) {
233 return yield* new CheckpointInvariantError({
234 operation,
235 detail: "Computed full thread diff result does not satisfy contract schema.",
236 });
237 }
157238
158 const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = (
159 input: OrchestrationGetFullThreadDiffInput,
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));
239 return turnDiff satisfies OrchestrationGetFullThreadDiffResult;
240 });
167241
168242 return {
169243 getTurnDiff,
apps/server/src/checkpointing/Layers/CheckpointStore.ts+39-264
......@@ -1,304 +1,79 @@
11/**
22 * CheckpointStoreLive - Filesystem checkpoint store adapter layer.
33 *
4 * Implements hidden Git-ref checkpoint capture/restore directly with
5 * Effect-native child process execution (`effect/unstable/process`).
6 *
7 * This layer owns filesystem/Git interactions only; it does not persist
8 * checkpoint metadata and does not coordinate provider rollback semantics.
4 * Resolves the active VCS driver once per checkpoint operation and delegates
5 * checkpoint-specific behavior to the driver's optional checkpoint capability.
96 *
107 * @module CheckpointStoreLive
118 */
12import { randomUUID } from "node:crypto";
13
149import * as Effect from "effect/Effect";
1510import * 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";
2212import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
23import { CheckpointRef } from "@t3tools/contracts";
24
25const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;
13import { VcsUnsupportedOperationError } from "@t3tools/contracts";
14import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts";
15import type { VcsCheckpointOps } from "../../vcs/VcsDriver.ts";
2616
2717const makeCheckpointStore = Effect.gen(function* () {
28 const fs = yield* FileSystem.FileSystem;
29 const path = yield* Path.Path;
3018 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) =>
67 vcs
68 .execute({
69 operation: "CheckpointStore.hasHeadCommit",
70 cwd,
71 args: ["rev-parse", "--verify", "HEAD"],
72 allowNonZeroExit: true,
73 })
74 .pipe(Effect.map((result) => result.exitCode === 0));
75
76 const resolveCheckpointCommit = (cwd: string, checkpointRef: CheckpointRef) =>
77 vcs
78 .execute({
79 operation: "CheckpointStore.resolveCheckpointCommit",
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 );
20 const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* (
21 operation: string,
22 cwd: string,
23 ) {
24 const handle = yield* vcsRegistry.resolve({ cwd });
25 if (!handle.driver.checkpoints) {
26 return yield* new VcsUnsupportedOperationError({
27 operation,
28 kind: handle.kind,
29 detail: `${handle.kind} driver does not implement checkpoint operations.`,
30 });
31 }
32 return handle.driver.checkpoints satisfies VcsCheckpointOps;
33 });
9334
9435 const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) =>
95 vcs
96 .execute({
97 operation: "CheckpointStore.isGitRepository",
98 cwd,
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 );
36 vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe(
37 Effect.map(() => true),
38 Effect.catch(() => Effect.succeed(false)),
39 );
10640
10741 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn(
10842 "captureCheckpoint",
10943 )(function* (input) {
110 const operation = "CheckpointStore.captureCheckpoint";
111
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 );
44 const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd);
45 return yield* checkpoints.captureCheckpoint(input);
19646 });
19747
198 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) =>
199 resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(
200 Effect.map((commit) => commit !== null),
201 );
48 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = Effect.fn("hasCheckpointRef")(
49 function* (input) {
50 const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd);
51 return yield* checkpoints.hasCheckpointRef(input);
52 },
53 );
20254
20355 const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn(
20456 "restoreCheckpoint",
20557 )(function* (input) {
206 const operation = "CheckpointStore.restoreCheckpoint";
207
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;
58 const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd);
59 return yield* checkpoints.restoreCheckpoint(input);
23960 });
24061
24162 const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")(
24263 function* (input) {
243 const operation = "CheckpointStore.diffCheckpoints";
244
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;
64 const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd);
65 return yield* checkpoints.diffCheckpoints(input);
28366 },
28467 );
28568
28669 const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn(
28770 "deleteCheckpointRefs",
28871 )(function* (input) {
289 const operation = "CheckpointStore.deleteCheckpointRefs";
290
291 yield* Effect.forEach(
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 },
72 const checkpoints = yield* resolveCheckpoints(
73 "CheckpointStore.deleteCheckpointRefs",
74 input.cwd,
30175 );
76 return yield* checkpoints.deleteCheckpointRefs(input);
30277 });
30378
30479 return {
apps/server/src/environment/Layers/ServerEnvironment.ts+4-1
......@@ -6,6 +6,7 @@ import * as Path from "effect/Path";
66import * as Random from "effect/Random";
77
88import { ServerConfig } from "../../config.ts";
9import { layer as ProcessRunnerLive } from "../../processRunner.ts";
910import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
1011import packageJson from "../../../package.json" with { type: "json" };
1112import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
......@@ -93,4 +94,6 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function
9394 } satisfies ServerEnvironmentShape;
9495});
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 @@
11import { afterEach, describe, expect, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
45import { vi } from "vitest";
56
6vi.mock("../../processRunner.ts", () => ({
7 runProcess: vi.fn(),
8}));
9
10import { runProcess } from "../../processRunner.ts";
7import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts";
118import { 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);
1419const 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
1632afterEach(() => {
17 mockedRunProcess.mockReset();
33 runMock.mockReset();
1834});
1935
2036describe("resolveServerEnvironmentLabel", () => {
......@@ -24,7 +40,7 @@ describe("resolveServerEnvironmentLabel", () => {
2440 cwdBaseName: "t3code",
2541 platform: "win32",
2642 hostname: "macbook-pro",
27 }).pipe(Effect.provide(NoopFileSystemLayer));
43 }).pipe(Effect.provide(TestLayer));
2844
2945 expect(result).toBe("macbook-pro");
3046 }),
......@@ -32,25 +48,30 @@ describe("resolveServerEnvironmentLabel", () => {
3248
3349 it.effect("prefers the macOS ComputerName", () =>
3450 Effect.gen(function* () {
35 mockedRunProcess.mockResolvedValueOnce({
36 stdout: " Julius's MacBook Pro \n",
37 stderr: "",
38 code: 0,
39 signal: null,
40 timedOut: false,
41 });
51 runMock.mockReturnValueOnce(
52 Effect.succeed({
53 stdout: " Julius's MacBook Pro \n",
54 stderr: "",
55 code: ChildProcessSpawner.ExitCode(0),
56 timedOut: false,
57 stdoutTruncated: false,
58 stderrTruncated: false,
59 }),
60 );
4261
4362 const result = yield* resolveServerEnvironmentLabel({
4463 cwdBaseName: "t3code",
4564 platform: "darwin",
4665 hostname: "macbook-pro",
47 }).pipe(Effect.provide(NoopFileSystemLayer));
66 }).pipe(Effect.provide(TestLayer));
4867
4968 expect(result).toBe("Julius's MacBook Pro");
50 expect(mockedRunProcess).toHaveBeenCalledWith(
51 "scutil",
52 ["--get", "ComputerName"],
53 expect.objectContaining({ allowNonZeroExit: true }),
69 expect(runMock).toHaveBeenCalledWith(
70 expect.objectContaining({
71 command: "scutil",
72 args: ["--get", "ComputerName"],
73 timeoutBehavior: "timedOutResult",
74 }),
5475 );
5576 }),
5677 );
......@@ -61,44 +82,39 @@ describe("resolveServerEnvironmentLabel", () => {
6182 cwdBaseName: "t3code",
6283 platform: "linux",
6384 hostname: "buildbox",
64 }).pipe(
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 );
85 }).pipe(Effect.provide(LinuxMachineInfoLayer));
7586
7687 expect(result).toBe("Build Agent 01");
77 expect(mockedRunProcess).not.toHaveBeenCalled();
88 expect(runMock).not.toHaveBeenCalled();
7889 }),
7990 );
8091
8192 it.effect("falls back to hostnamectl pretty hostname on Linux", () =>
8293 Effect.gen(function* () {
83 mockedRunProcess.mockResolvedValueOnce({
84 stdout: "CI Runner\n",
85 stderr: "",
86 code: 0,
87 signal: null,
88 timedOut: false,
89 });
94 runMock.mockReturnValueOnce(
95 Effect.succeed({
96 stdout: "CI Runner\n",
97 stderr: "",
98 code: ChildProcessSpawner.ExitCode(0),
99 timedOut: false,
100 stdoutTruncated: false,
101 stderrTruncated: false,
102 }),
103 );
90104
91105 const result = yield* resolveServerEnvironmentLabel({
92106 cwdBaseName: "t3code",
93107 platform: "linux",
94108 hostname: "runner-01",
95 }).pipe(Effect.provide(NoopFileSystemLayer));
109 }).pipe(Effect.provide(TestLayer));
96110
97111 expect(result).toBe("CI Runner");
98 expect(mockedRunProcess).toHaveBeenCalledWith(
99 "hostnamectl",
100 ["--pretty"],
101 expect.objectContaining({ allowNonZeroExit: true }),
112 expect(runMock).toHaveBeenCalledWith(
113 expect.objectContaining({
114 command: "hostnamectl",
115 args: ["--pretty"],
116 timeoutBehavior: "timedOutResult",
117 }),
102118 );
103119 }),
104120 );
......@@ -109,7 +125,7 @@ describe("resolveServerEnvironmentLabel", () => {
109125 cwdBaseName: "t3code",
110126 platform: "win32",
111127 hostname: "JULIUS-LAPTOP",
112 }).pipe(Effect.provide(NoopFileSystemLayer));
128 }).pipe(Effect.provide(TestLayer));
113129
114130 expect(result).toBe("JULIUS-LAPTOP");
115131 }),
......@@ -117,13 +133,21 @@ describe("resolveServerEnvironmentLabel", () => {
117133
118134 it.effect("falls back to the hostname when the friendly-label command is missing", () =>
119135 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
122146 const result = yield* resolveServerEnvironmentLabel({
123147 cwdBaseName: "t3code",
124148 platform: "darwin",
125149 hostname: "macbook-pro",
126 }).pipe(Effect.provide(NoopFileSystemLayer));
150 }).pipe(Effect.provide(TestLayer));
127151
128152 expect(result).toBe("macbook-pro");
129153 }),
......@@ -131,19 +155,22 @@ describe("resolveServerEnvironmentLabel", () => {
131155
132156 it.effect("falls back to the cwd basename when the hostname is blank", () =>
133157 Effect.gen(function* () {
134 mockedRunProcess.mockResolvedValueOnce({
135 stdout: " ",
136 stderr: "",
137 code: 0,
138 signal: null,
139 timedOut: false,
140 });
158 runMock.mockReturnValueOnce(
159 Effect.succeed({
160 stdout: " ",
161 stderr: "",
162 code: ChildProcessSpawner.ExitCode(0),
163 timedOut: false,
164 stdoutTruncated: false,
165 stderrTruncated: false,
166 }),
167 );
141168
142169 const result = yield* resolveServerEnvironmentLabel({
143170 cwdBaseName: "t3code",
144171 platform: "linux",
145172 hostname: " ",
146 }).pipe(Effect.provide(NoopFileSystemLayer));
173 }).pipe(Effect.provide(TestLayer));
147174
148175 expect(result).toBe("t3code");
149176 }),
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts+14-11
......@@ -2,8 +2,9 @@ import * as OS from "node:os";
22
33import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
56
6import { runProcess } from "../../processRunner.ts";
7import { ProcessRunner } from "../../processRunner.ts";
78
89interface ResolveServerEnvironmentLabelInput {
910 readonly cwdBaseName: string;
......@@ -52,19 +53,21 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* (
5253 command: string,
5354 args: readonly string[],
5455) {
55 const result = yield* Effect.tryPromise({
56 try: () =>
57 runProcess(command, args, {
58 allowNonZeroExit: true,
59 }),
60 catch: () => null,
61 }).pipe(Effect.orElseSucceed(() => null));
62
63 if (!result || result.code !== 0) {
56 const processRunner = yield* ProcessRunner;
57 const result = yield* processRunner
58 .run({
59 command,
60 args,
61 timeoutBehavior: "timedOutResult",
62 shell: process.platform === "win32",
63 })
64 .pipe(Effect.option);
65
66 if (Option.isNone(result) || result.value.code !== 0) {
6467 return null;
6568 }
6669
67 return normalizeLabel(result.stdout);
70 return normalizeLabel(result.value.stdout);
6871});
6972
7073const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* (
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+4-1
......@@ -9,6 +9,7 @@ import {
99 type OrchestrationEvent,
1010 ProviderInstanceId,
1111} from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
1213import * as Effect from "effect/Effect";
1314import * as Layer from "effect/Layer";
1415import * as ManagedRuntime from "effect/ManagedRuntime";
......@@ -37,7 +38,6 @@ import {
3738} from "../Services/ProjectionPipeline.ts";
3839import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
3940import { ServerConfig } from "../../config.ts";
40import * as NodeServices from "@effect/platform-node/NodeServices";
4141
4242const asProjectId = (value: string): ProjectId => ProjectId.make(value);
4343const asMessageId = (value: string): MessageId => MessageId.make(value);
......@@ -197,6 +197,7 @@ describe("OrchestrationEngine", () => {
197197 getProjectShellById: () => Effect.succeed(Option.none()),
198198 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
199199 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
200 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
200201 getThreadShellById: () => Effect.succeed(Option.none()),
201202 getThreadDetailById: () => Effect.succeed(Option.none()),
202203 }),
......@@ -785,6 +786,7 @@ describe("OrchestrationEngine", () => {
785786 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
786787 Layer.provide(RepositoryIdentityResolverLive),
787788 Layer.provide(SqlitePersistenceMemory),
789 Layer.provide(NodeServices.layer),
788790 ),
789791 );
790792 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
......@@ -927,6 +929,7 @@ describe("OrchestrationEngine", () => {
927929 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
928930 Layer.provide(RepositoryIdentityResolverLive),
929931 Layer.provide(SqlitePersistenceMemory),
932 Layer.provide(NodeServices.layer),
930933 ),
931934 );
932935 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -8,6 +8,7 @@ import {
88 ProviderInstanceId,
99} from "@t3tools/contracts";
1010import { assert, it } from "@effect/vitest";
11import * as NodeServices from "@effect/platform-node/NodeServices";
1112import * as Effect from "effect/Effect";
1213import * as Layer from "effect/Layer";
1314import * as SqlClient from "effect/unstable/sql/SqlClient";
......@@ -29,6 +30,7 @@ const projectionSnapshotLayer = it.layer(
2930 OrchestrationProjectionSnapshotQueryLive.pipe(
3031 Layer.provideMerge(RepositoryIdentityResolverLive),
3132 Layer.provideMerge(SqlitePersistenceMemory),
33 Layer.provideMerge(NodeServices.layer),
3234 ),
3335);
3436
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+76
......@@ -1,5 +1,6 @@
11import {
22 ChatAttachment,
3 CheckpointRef,
34 IsoDateTime,
45 MessageId,
56 NonNegativeInt,
......@@ -49,6 +50,7 @@ import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIde
4950import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
5051import {
5152 ProjectionSnapshotQuery,
53 type ProjectionFullThreadDiffContext,
5254 type ProjectionSnapshotCounts,
5355 type ProjectionThreadCheckpointContext,
5456 type ProjectionSnapshotQueryShape,
......@@ -122,6 +124,18 @@ const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({
122124 workspaceRoot: Schema.String,
123125 worktreePath: Schema.NullOr(Schema.String),
124126});
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
126140const REQUIRED_SNAPSHOT_PROJECTORS = [
127141 ORCHESTRATION_PROJECTOR_NAMES.projects,
......@@ -882,6 +896,38 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
882896 `,
883897 });
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
885931 const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () =>
886932 sql
887933 .withTransaction(
......@@ -1759,6 +1805,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
17591805 });
17601806 });
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
17621837 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>
17631838 Effect.gen(function* () {
17641839 const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([
......@@ -1963,6 +2038,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
19632038 getProjectShellById,
19642039 getFirstActiveThreadIdByProjectId,
19652040 getThreadCheckpointContext,
2041 getFullThreadDiffContext,
19662042 getThreadShellById,
19672043 getThreadDetailById,
19682044 } satisfies ProjectionSnapshotQueryShape;
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+19
......@@ -7,6 +7,7 @@
77 * @module ProjectionSnapshotQuery
88 */
99import type {
10 CheckpointRef,
1011 OrchestrationCheckpointSummary,
1112 OrchestrationProject,
1213 OrchestrationProjectShell,
......@@ -40,6 +41,15 @@ export interface ProjectionThreadCheckpointContext {
4041 readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>;
4142}
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
4353/**
4454 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.
4555 */
......@@ -125,6 +135,15 @@ export interface ProjectionSnapshotQueryShape {
125135 threadId: ThreadId,
126136 ) => 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
128147 /**
129148 * Read a single active thread shell row by id.
130149 */
apps/server/src/processRunner.test.ts+273-16
......@@ -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", () => {
6 it("fails when output exceeds max buffer in default mode", async () => {
7 await expect(
8 runProcess("node", ["-e", "process.stdout.write('x'.repeat(2048))"], { maxBufferBytes: 128 }),
9 ).rejects.toThrow("exceeded stdout buffer limit");
21type ChildProcessCommand = {
22 readonly command: string;
23 readonly args: ReadonlyArray<string>;
24};
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,
1056 });
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 () => {
13 const result = await runProcess("node", ["-e", "process.stdout.write('x'.repeat(2048))"], {
14 maxBufferBytes: 128,
15 outputMode: "truncate",
16 });
97 expect(result.code).toBe(0);
98 expect(result.stdout).toBe("x".repeat(32));
99 expect(result.timedOut).toBe(false);
100 }),
101 );
17102
18 expect(result.code).toBe(0);
19 expect(result.stdout.length).toBeLessThanOrEqual(128);
20 expect(result.stdoutTruncated).toBe(true);
21 expect(result.stderrTruncated).toBe(false);
103 it.effect("runs through the ProcessRunner service", () => {
104 const spawner = makeSpawner((command) =>
105 Effect.sync(() => {
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));
22124 });
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 );
23280});
24281
25282describe("isWindowsCommandNotFound", () => {
apps/server/src/processRunner.ts+290-240
......@@ -1,43 +1,101 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process";
3import * as NodeTimers from "node:timers";
4export interface ProcessRunOptions {
5 cwd?: string | undefined;
6 timeoutMs?: number | undefined;
7 env?: NodeJS.ProcessEnv | undefined;
8 stdin?: string | undefined;
9 allowNonZeroExit?: boolean | undefined;
10 maxBufferBytes?: number | undefined;
11 outputMode?: "error" | "truncate" | undefined;
12}
1import * as Data from "effect/Data";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as PlatformError from "effect/PlatformError";
8import * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import {
12 collectUint8StreamText,
13 type CollectedUint8StreamText,
14} from "./stream/collectUint8StreamText.ts";
1315
14export interface ProcessRunResult {
15 stdout: string;
16 stderr: string;
17 code: number | null;
18 signal: NodeJS.Signals | null;
19 timedOut: boolean;
20 stdoutTruncated?: boolean | undefined;
21 stderrTruncated?: boolean | undefined;
16export interface ProcessRunInput {
17 readonly command: string;
18 readonly args: ReadonlyArray<string>;
19 readonly cwd?: string | undefined;
20 readonly spawnCwd?: string | undefined;
21 readonly timeout?: Duration.Input | undefined;
22 readonly env?: NodeJS.ProcessEnv | 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;
2233}
2334
24function commandLabel(command: string, args: readonly string[]): string {
25 return [command, ...args].join(" ");
35export interface ProcessRunOutput {
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;
2642}
2743
28function normalizeSpawnError(command: string, args: readonly string[], error: unknown): Error {
29 if (!(error instanceof Error)) {
30 return new Error(`Failed to run ${commandLabel(command, args)}.`);
31 }
44export class ProcessSpawnError extends Data.TaggedError("ProcessSpawnError")<{
45 readonly command: string;
46 readonly args: ReadonlyArray<string>;
47 readonly cwd?: string | undefined;
48 readonly cause: unknown;
49}> {}
3250
33 const maybeCode = (error as NodeJS.ErrnoException).code;
34 if (maybeCode === "ENOENT") {
35 return new Error(`Command not found: ${command}`);
36 }
51export class ProcessStdinError extends Data.TaggedError("ProcessStdinError")<{
52 readonly command: string;
53 readonly args: ReadonlyArray<string>;
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>;
3990}
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
4199const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [
42100 /is not recognized as an internal or external command/i,
43101 /n.o . reconhecido como um comando interno/i,
......@@ -57,230 +115,222 @@ export function isWindowsCommandNotFound(code: number | null, stderr: string): b
57115 return hasWindowsCommandNotFoundMessage(stderr);
58116}
59117
60function normalizeExitError(
61 command: string,
62 args: readonly string[],
63 result: ProcessRunResult,
64): Error {
65 if (isWindowsCommandNotFound(result.code, result.stderr)) {
66 return new Error(`Command not found: ${command}`);
67 }
68
69 const reason = result.timedOut
70 ? "timed out"
71 : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`;
72 const stderr = result.stderr.trim();
73 const detail = stderr.length > 0 ? ` ${stderr}` : "";
74 return new Error(`${commandLabel(command, args)} ${reason}.${detail}`);
75}
76
77function normalizeStdinError(command: string, args: readonly string[], error: unknown): Error {
78 if (!(error instanceof Error)) {
79 return new Error(`Failed to write stdin for ${commandLabel(command, args)}.`);
80 }
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).`,
118const collectText = Effect.fn("processRunner.collectText")(function* (input: {
119 readonly command: string;
120 readonly args: ReadonlyArray<string>;
121 readonly cwd?: string | undefined;
122 readonly streamName: "stdout" | "stderr";
123 readonly stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
124 readonly maxOutputBytes: number;
125 readonly outputMode: "error" | "truncate";
126 readonly truncatedMarker: string;
127}) {
128 const stream = input.stream.pipe(
129 Stream.mapError(
130 (cause) =>
131 new ProcessReadError({
132 command: input.command,
133 args: input.args,
134 cwd: input.cwd,
135 stream: input.streamName,
136 cause,
137 }),
138 ),
92139 );
93}
94140
95const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
96
97/**
98 * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`
99 * wrapper, leaving the actual command running. Use `taskkill /T` to kill the
100 * entire process tree instead.
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 };
141 if (input.outputMode === "truncate") {
142 return yield* collectUint8StreamText({
143 stream,
144 maxBytes: input.maxOutputBytes,
145 truncatedMarker: input.truncatedMarker,
146 });
134147 }
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) => {
152 const child = spawn(command, args, {
153 cwd: options.cwd,
154 env: options.env,
155 stdio: "pipe",
156 shell: process.platform === "win32",
157 });
149 return yield* stream.pipe(
150 Stream.runFoldEffect<
151 {
152 readonly chunks: Uint8Array<ArrayBufferLike>[];
153 readonly bytes: number;
154 },
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 = "";
160 let stderr = "";
161 let stdoutBytes = 0;
162 let stderrBytes = 0;
163 let stdoutTruncated = false;
164 let stderrTruncated = false;
165 let timedOut = false;
166 let settled = false;
167 let forceKillTimer: ReturnType<typeof NodeTimers.setTimeout> | null = null;
174 state.chunks.push(chunk);
175 return Effect.succeed({
176 chunks: state.chunks,
177 bytes: state.bytes + chunk.byteLength,
178 });
179 },
180 ),
181 Effect.map(
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.
170 const timeoutTimer = NodeTimers.setTimeout(() => {
171 timedOut = true;
172 killChild(child, "SIGTERM");
173 // @effect-diagnostics-next-line globalTimers:off - Promise/child_process boundary; see timeout timer above.
174 forceKillTimer = NodeTimers.setTimeout(() => {
175 killChild(child, "SIGKILL");
176 }, 1_000);
177 }, timeoutMs);
191function finalizeRunProcess<R>(
192 effect: Effect.Effect<ProcessRunOutput, ProcessRunError, R | Scope.Scope>,
193 input: ProcessRunInput,
194): Effect.Effect<ProcessRunOutput, ProcessRunError, Exclude<R, Scope.Scope>> {
195 const timeout = Duration.fromInputUnsafe(input.timeout ?? DEFAULT_TIMEOUT);
196 const timeoutBehavior = input.timeoutBehavior ?? "error";
178197
179 const finalize = (callback: () => void): void => {
180 if (settled) return;
181 settled = true;
182 NodeTimers.clearTimeout(timeoutTimer);
183 if (forceKillTimer) {
184 NodeTimers.clearTimeout(forceKillTimer);
198 return effect.pipe(
199 Effect.scoped,
200 Effect.timeoutOption(timeout),
201 Effect.flatMap((result) => {
202 if (Option.isSome(result)) {
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);
185214 }
186 callback();
187 };
215 return Effect.fail(
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 => {
190 killChild(child, "SIGTERM");
191 finalize(() => {
192 reject(error);
193 });
194 };
227const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
228 spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
229 input: ProcessRunInput,
230): Effect.fn.Return<ProcessRunOutput, ProcessRunError, Scope.Scope> {
231 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
232 const outputMode = input.outputMode ?? "error";
233 const truncatedMarker = input.truncatedMarker ?? "";
195234
196 const appendOutput = (stream: "stdout" | "stderr", chunk: Buffer | string): Error | null => {
197 const chunkBuffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
198 const text = chunkBuffer.toString();
199 const byteLength = chunkBuffer.length;
200 if (stream === "stdout") {
201 if (outputMode === "truncate") {
202 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunkBuffer, maxBufferBytes);
203 stdout = appended.next;
204 stdoutBytes = appended.nextBytes;
205 stdoutTruncated = stdoutTruncated || appended.truncated;
206 return null;
207 }
208 stdout += text;
209 stdoutBytes += byteLength;
210 if (stdoutBytes > maxBufferBytes) {
211 return normalizeBufferError(command, args, "stdout", maxBufferBytes);
212 }
213 } else {
214 if (outputMode === "truncate") {
215 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunkBuffer, maxBufferBytes);
216 stderr = appended.next;
217 stderrBytes = appended.nextBytes;
218 stderrTruncated = stderrTruncated || appended.truncated;
219 return null;
220 }
221 stderr += text;
222 stderrBytes += byteLength;
223 if (stderrBytes > maxBufferBytes) {
224 return normalizeBufferError(command, args, "stderr", maxBufferBytes);
225 }
226 }
227 return null;
228 };
235 const child = yield* spawner
236 .spawn(
237 ChildProcess.make(input.command, [...input.args], {
238 ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}),
239 ...(input.env !== undefined
240 ? {
241 env: input.env,
242 extendEnv: true,
243 }
244 : {}),
245 ...(input.shell !== undefined ? { shell: input.shell } : {}),
246 }),
247 )
248 .pipe(
249 Effect.mapError(
250 (cause) =>
251 new ProcessSpawnError({
252 command: input.command,
253 args: input.args,
254 cwd: input.cwd,
255 cause,
256 }),
257 ),
258 );
229259
230 child.stdout.on("data", (chunk: Buffer | string) => {
231 const error = appendOutput("stdout", chunk);
232 if (error) {
233 fail(error);
234 }
235 });
260 const writeStdin =
261 input.stdin === undefined
262 ? Effect.void
263 : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe(
264 Effect.mapError(
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) => {
238 const error = appendOutput("stderr", chunk);
239 if (error) {
240 fail(error);
241 }
242 });
275 const [stdout, stderr] = yield* Effect.all(
276 [
277 collectText({
278 command: input.command,
279 args: input.args,
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) => {
245 finalize(() => {
246 reject(normalizeSpawnError(command, args, error));
247 });
248 });
302 const exitCode = yield* child.exitCode.pipe(
303 Effect.mapError(
304 (cause) =>
305 new ProcessReadError({
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) => {
251 const result: ProcessRunResult = {
252 stdout,
253 stderr,
254 code,
255 signal,
256 timedOut,
257 stdoutTruncated,
258 stderrTruncated,
259 };
315 return {
316 stdout: stdout.text,
317 stderr: stderr.text,
318 code: exitCode,
319 timedOut: false,
320 stdoutTruncated: stdout.truncated,
321 stderrTruncated: stderr.truncated,
322 } satisfies ProcessRunOutput;
323});
260324
261 finalize(() => {
262 if (!options.allowNonZeroExit && (timedOut || (code !== null && code !== 0))) {
263 reject(normalizeExitError(command, args, result));
264 return;
265 }
266 resolve(result);
267 });
268 });
325export const make = Effect.fn("makeProcessRunner")(function* () {
326 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
269327
270 child.stdin.once("error", (error) => {
271 fail(normalizeStdinError(command, args, error));
272 });
328 const run: ProcessRunnerShape["run"] = (input) =>
329 finalizeRunProcess(runProcessCore(spawner, input), input);
273330
274 if (options.stdin !== undefined) {
275 child.stdin.write(options.stdin, (error) => {
276 if (error) {
277 fail(normalizeStdinError(command, args, error));
278 return;
279 }
280 child.stdin.end();
281 });
282 return;
283 }
284 child.stdin.end();
331 return ProcessRunner.of({
332 run,
285333 });
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) =>
3636 Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
3737 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
3838 getThreadCheckpointContext: () => Effect.die("unused"),
39 getFullThreadDiffContext: () => Effect.die("unused"),
3940 getThreadShellById: () => Effect.die("unused"),
4041 getThreadDetailById: () => Effect.die("unused"),
4142 });
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+25-12
......@@ -1,15 +1,13 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import { realpathSync } from "node:fs";
3
41import * as NodeServices from "@effect/platform-node/NodeServices";
52import { expect, it } from "@effect/vitest";
63import * as Duration from "effect/Duration";
74import * as Effect from "effect/Effect";
85import * as FileSystem from "effect/FileSystem";
96import * 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";
1311import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";
1412import {
1513 makeRepositoryIdentityResolver,
......@@ -17,11 +15,17 @@ import {
1715} from "./RepositoryIdentityResolver.ts";
1816
1917const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
20const normalizeResolvedPath = (value: string) =>
21 normalizePathSeparators(realpathSync.native(value));
18const normalizeResolvedPath = (value: string) => normalizePathSeparators(value);
2219
2320const 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
2630const makeRepositoryIdentityResolverTestLayer = (options: {
2731 readonly positiveCacheTtl?: Duration.Input;
......@@ -33,7 +37,7 @@ const makeRepositoryIdentityResolverTestLayer = (options: {
3337 cacheCapacity: 16,
3438 ...options,
3539 }),
36 );
40 ).pipe(Layer.provide(ProcessRunner.layer));
3741
3842it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
3943 it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
......@@ -48,10 +52,13 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
4852
4953 const resolver = yield* RepositoryIdentityResolver;
5054 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
5259 expect(identity).not.toBeNull();
5360 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
54 expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd));
61 expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd));
5562 expect(identity?.displayName).toBe("t3tools/t3code");
5663 expect(identity?.provider).toBe("github");
5764 expect(identity?.owner).toBe("t3tools");
......@@ -62,10 +69,11 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
6269 it.effect("returns the git top-level root path when resolving from a nested workspace", () =>
6370 Effect.gen(function* () {
6471 const fileSystem = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
6573 const repoRoot = yield* fileSystem.makeTempDirectoryScoped({
6674 prefix: "t3-repository-identity-nested-root-test-",
6775 });
68 const nestedWorkspace = `${repoRoot}/packages/web`;
76 const nestedWorkspace = path.join(repoRoot, "packages", "web");
6977
7078 yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true });
7179 yield* git(repoRoot, ["init"]);
......@@ -73,10 +81,15 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
7381
7482 const resolver = yield* RepositoryIdentityResolver;
7583 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
7788 expect(identity).not.toBeNull();
7889 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 );
8093 }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
8194 );
8295
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+48-33
......@@ -9,7 +9,7 @@ import {
99 normalizeGitRemoteUrl,
1010} from "@t3tools/shared/git";
1111
12import { runProcess } from "../../processRunner.ts";
12import * as ProcessRunner from "../../processRunner.ts";
1313import {
1414 RepositoryIdentityResolver,
1515 type RepositoryIdentityResolverShape,
......@@ -83,50 +83,63 @@ interface RepositoryIdentityResolverOptions {
8383 readonly negativeCacheTtl?: Duration.Input;
8484}
8585
86async function resolveRepositoryIdentityCacheKey(cwd: string): Promise<string> {
86const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* (
87 cwd: string,
88) {
89 const processRunner = yield* ProcessRunner.ProcessRunner;
8790 let cacheKey = cwd;
8891
89 try {
90 const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
91 allowNonZeroExit: true,
92 });
93 if (topLevelResult.code !== 0) {
94 return cacheKey;
95 }
96
97 const candidate = topLevelResult.stdout.trim();
98 if (candidate.length > 0) {
99 cacheKey = candidate;
100 }
101 } catch {
92 const topLevelResult = yield* processRunner
93 .run({
94 command: "git",
95 args: ["-C", cwd, "rev-parse", "--show-toplevel"],
96 timeoutBehavior: "timedOutResult",
97 shell: process.platform === "win32",
98 })
99 .pipe(Effect.option);
100 if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) {
102101 return cacheKey;
103102 }
104103
105 return cacheKey;
106}
104 const candidate = topLevelResult.value.stdout.trim();
105 if (candidate.length > 0) {
106 cacheKey = candidate;
107 }
107108
108async function resolveRepositoryIdentityFromCacheKey(
109 cacheKey: string,
110): Promise<RepositoryIdentity | null> {
111 try {
112 const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], {
113 allowNonZeroExit: true,
114 });
115 if (remoteResult.code !== 0) {
109 return cacheKey;
110});
111
112const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")(
113 function* (
114 cacheKey: string,
115 ): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
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) {
116126 return null;
117127 }
118128
119 const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout));
129 const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
120130 return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
121 } catch {
122 return null;
123 }
124}
131 },
132);
125133
126134export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")(
127135 function* (options: RepositoryIdentityResolverOptions = {}) {
136 const processRunner = yield* ProcessRunner.ProcessRunner;
137
128138 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 ),
130143 {
131144 capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY,
132145 timeToLive: Exit.match({
......@@ -142,7 +155,9 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR
142155 const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn(
143156 "RepositoryIdentityResolver.resolve",
144157 )(function* (cwd) {
145 const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd));
158 const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe(
159 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
160 );
146161 return yield* Cache.get(repositoryIdentityCache, cacheKey);
147162 });
148163
......@@ -155,4 +170,4 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR
155170export const RepositoryIdentityResolverLive = Layer.effect(
156171 RepositoryIdentityResolver,
157172 makeRepositoryIdentityResolver(),
158);
173).pipe(Layer.provide(ProcessRunner.layer));
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -202,6 +202,7 @@ describe("ProviderSessionReaper", () => {
202202 getProjectShellById: () => Effect.die("unused"),
203203 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
204204 getThreadCheckpointContext: () => Effect.die("unused"),
205 getFullThreadDiffContext: () => Effect.die("unused"),
205206 getThreadShellById: (threadId) =>
206207 Effect.succeed(
207208 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
9898 getProjectShellById: () => Effect.succeed(Option.none()),
9999 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
100100 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
101 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
101102 getThreadShellById: () => Effect.succeed(Option.none()),
102103 getThreadDetailById: () => Effect.succeed(Option.none()),
103104 }),
......@@ -159,6 +160,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
159160 getProjectShellById: () => Effect.die("unused"),
160161 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)),
161162 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
163 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
162164 getThreadShellById: () => Effect.die("unused"),
163165 getThreadDetailById: () => Effect.die("unused"),
164166 }),
......@@ -200,6 +202,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
200202 getProjectShellById: () => Effect.die("unused"),
201203 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
202204 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
205 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
203206 getThreadShellById: () => Effect.die("unused"),
204207 getThreadDetailById: () => Effect.die("unused"),
205208 }),
apps/server/src/sourceControl/SourceControlDiscovery.ts+1-1
......@@ -100,7 +100,7 @@ export const layer = Layer.effect(
100100 cwd: config.cwd,
101101 timeoutMs: 5_000,
102102 maxOutputBytes: 8_000,
103 truncateOutputAtMaxBytes: true,
103 appendTruncationMarker: true,
104104 })
105105 .pipe(
106106 Effect.map(
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+2-2
......@@ -139,7 +139,7 @@ function probeCli(input: {
139139 cwd: input.cwd,
140140 timeoutMs: 5_000,
141141 maxOutputBytes: 8_000,
142 truncateOutputAtMaxBytes: true,
142 appendTruncationMarker: true,
143143 })
144144 .pipe(
145145 Effect.map(
......@@ -216,7 +216,7 @@ export function probeSourceControlProvider(input: {
216216 allowNonZeroExit: true,
217217 timeoutMs: 5_000,
218218 maxOutputBytes: 8_000,
219 truncateOutputAtMaxBytes: true,
219 appendTruncationMarker: true,
220220 })
221221 .pipe(
222222 Effect.map(
apps/server/src/stream/collectUint8StreamText.ts+14-11
......@@ -8,7 +8,7 @@ export interface CollectedUint8StreamText {
88}
99
1010interface CollectState {
11 readonly text: string;
11 chunks: Uint8Array[];
1212 readonly bytes: number;
1313 readonly truncated: boolean;
1414}
......@@ -18,18 +18,21 @@ export const collectUint8StreamText = <E>(input: {
1818 readonly maxBytes?: number | undefined;
1919 readonly truncatedMarker?: string | null | undefined;
2020}): Effect.Effect<CollectedUint8StreamText, E> => {
21 const decoder = new TextDecoder();
2221 const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY;
2322 const truncatedMarker = input.truncatedMarker ?? "";
2423
2524 return input.stream.pipe(
2625 Stream.runFold(
2726 (): CollectState => ({
28 text: "",
27 chunks: [],
2928 bytes: 0,
3029 truncated: false,
3130 }),
3231 (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 */
3336 if (state.truncated) {
3437 return state;
3538 }
......@@ -38,30 +41,30 @@ export const collectUint8StreamText = <E>(input: {
3841 if (remainingBytes <= 0) {
3942 return {
4043 ...state,
41 text: `${state.text}${truncatedMarker}`,
4244 truncated: true,
4345 };
4446 }
4547
4648 const nextChunk =
4749 chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk;
48 const text = `${state.text}${decoder.decode(nextChunk, { stream: true })}`;
50 state.chunks.push(nextChunk);
4951 const bytes = state.bytes + nextChunk.byteLength;
5052 const truncated = chunk.byteLength > remainingBytes;
5153
5254 return {
53 text: truncated ? `${text}${truncatedMarker}` : text,
55 chunks: state.chunks,
5456 bytes,
5557 truncated,
5658 };
5759 },
5860 ),
59 Effect.map(
60 (state): CollectedUint8StreamText => ({
61 text: state.truncated ? state.text : `${state.text}${decoder.decode()}`,
61 Effect.map((state): CollectedUint8StreamText => {
62 const text = Buffer.concat(state.chunks, state.bytes).toString("utf8");
63 return {
64 text: state.truncated && truncatedMarker.length > 0 ? `${text}${truncatedMarker}` : text,
6265 bytes: state.bytes,
6366 truncated: state.truncated,
64 }),
65 ),
67 };
68 }),
6669 );
6770};
apps/server/src/terminal/Layers/Manager.test.ts+149-60
......@@ -6,21 +6,24 @@ import {
66 type TerminalOpenInput,
77 type TerminalRestartInput,
88} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
9import * as Data from "effect/Data";
1010import * as Duration from "effect/Duration";
1111import * as Effect from "effect/Effect";
1212import * as Encoding from "effect/Encoding";
1313import * as Exit from "effect/Exit";
1414import * as Fiber from "effect/Fiber";
1515import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
1718import * as PlatformError from "effect/PlatformError";
19import * as Path from "effect/Path";
1820import * as Ref from "effect/Ref";
1921import * as Schedule from "effect/Schedule";
2022import * as Scope from "effect/Scope";
21import * as TestClock from "effect/testing/TestClock";
23import { TestClock } from "effect/testing";
2224import { expect } from "vitest";
2325
26import * as ProcessRunner from "../../processRunner.ts";
2427import type { TerminalManagerShape } from "../Services/Manager.ts";
2528import {
2629 type PtyAdapterShape,
......@@ -31,6 +34,10 @@ import {
3134} from "../Services/PTY.ts";
3235import { makeTerminalManagerWithOptions } from "./Manager.ts";
3336
37class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{
38 readonly message: string;
39}> {}
40
3441class FakePtyProcess implements PtyProcess {
3542 readonly writes: string[] = [];
3643 readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
......@@ -127,11 +134,21 @@ class FakePtyAdapter implements PtyAdapterShape {
127134const waitFor = <E, R>(
128135 predicate: Effect.Effect<boolean, E, R>,
129136 timeout: Duration.Input = 800,
130): Effect.Effect<void, E | Cause.TimeoutError, R> =>
137): Effect.Effect<void, WaitForConditionError | E, R> =>
131138 predicate.pipe(
132 Effect.filter((done) => done),
139 Effect.filterOrFail(
140 (done) => done,
141 () => new WaitForConditionError({ message: "Condition not met" }),
142 ),
133143 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 ),
135152 );
136153
137154function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {
......@@ -156,33 +173,27 @@ function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRe
156173 };
157174}
158175
159function historyLogName(threadId: string): string {
160 return `terminal_${Encoding.encodeBase64Url(threadId)}.log`;
161}
162
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}
176const historyLogPath = (logsDir: string, threadId = "thread-1") =>
177 Effect.service(Path.Path).pipe(
178 Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)),
179 );
174180
175function multiTerminalHistoryLogPath(
181const multiTerminalHistoryLogPath = (
176182 logsDir: string,
177183 threadId = "thread-1",
178 terminalId = "default",
179): string {
180 return joinPath(logsDir, multiTerminalHistoryLogName(threadId, terminalId));
181}
182
183function joinPath(...segments: ReadonlyArray<string>): string {
184 return segments.join("/");
185}
184 terminalId = DEFAULT_TERMINAL_ID,
185) =>
186 Effect.service(Path.Path).pipe(
187 Effect.map(({ join }) => {
188 const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`;
189 return join(
190 logsDir,
191 terminalId === DEFAULT_TERMINAL_ID
192 ? `${threadPart}.log`
193 : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`,
194 );
195 }),
196 );
186197
187198interface CreateManagerOptions {
188199 shellResolver?: () => string;
......@@ -209,13 +220,13 @@ const createManager = (
209220): Effect.Effect<
210221 ManagerFixture,
211222 PlatformError.PlatformError,
212 FileSystem.FileSystem | Path.Path | Scope.Scope
223 FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner
213224> =>
214225 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
215226 Effect.gen(function* () {
216 const pathService = yield* Path.Path;
227 const { join } = yield* Path.Path;
217228 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");
219230 const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter();
220231
221232 const manager = yield* makeTerminalManagerWithOptions({
......@@ -246,6 +257,7 @@ const createManager = (
246257 return {
247258 baseDir,
248259 logsDir,
260 join,
249261 ptyAdapter,
250262 manager,
251263 getEvents: Ref.get(eventsRef),
......@@ -253,7 +265,12 @@ const createManager = (
253265 }),
254266 );
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
257274 it.effect("spawns lazily and reuses running terminal per thread", () =>
258275 Effect.gen(function* () {
259276 const { manager, ptyAdapter } = yield* createManager();
......@@ -290,13 +307,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
290307 fs.writeFileString(filePath, contents),
291308 );
292309
293 it.effect("preserves non-notFound cwd stat failures", () =>
310 itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () =>
294311 Effect.gen(function* () {
295 if (process.platform === "win32") return;
312 const path = yield* Path.Path;
296313
297314 const { manager, baseDir } = yield* createManager();
298 const blockedRoot = joinPath(baseDir, "blocked-root");
299 const blockedCwd = joinPath(blockedRoot, "cwd");
315 const blockedRoot = path.join(baseDir, "blocked-root");
316 const blockedCwd = path.join(blockedRoot, "cwd");
300317 yield* makeDirectory(blockedCwd);
301318 yield* chmod(blockedRoot, 0o000);
302319
......@@ -390,15 +407,27 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
390407 it.effect("clears transcript and emits cleared event", () =>
391408 Effect.gen(function* () {
392409 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
410 const path = yield* Path.Path;
393411 yield* manager.open(openInput());
394412 const process = ptyAdapter.processes[0];
395413 expect(process).toBeDefined();
396414 if (!process) return;
397415
398416 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 );
400423 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
403432 const events = yield* getEvents;
404433 expect(events.some((event) => event.type === "cleared")).toBe(true);
......@@ -421,21 +450,34 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
421450 expect(firstProcess).toBeDefined();
422451 if (!firstProcess) return;
423452 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
426461 const snapshot = yield* manager.restart(restartInput());
427462 assert.equal(snapshot.history, "");
428463 assert.equal(snapshot.status, "running");
429464 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 );
431472 }),
432473 );
433474
434475 it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () =>
435476 Effect.gen(function* () {
436477 const { manager, getEvents, baseDir } = yield* createManager();
437 const firstWorktreePath = joinPath(baseDir, "worktrees", "feature-a");
438 const secondWorktreePath = joinPath(baseDir, "worktrees", "feature-b");
478 const path = yield* Path.Path;
479 const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a");
480 const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b");
439481 yield* makeDirectory(firstWorktreePath);
440482 yield* makeDirectory(secondWorktreePath);
441483 const startedSnapshot = yield* manager.open(
......@@ -471,7 +513,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
471513 it.effect("preserves worktree metadata when reopening an exited session", () =>
472514 Effect.gen(function* () {
473515 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");
475518 yield* makeDirectory(worktreePath);
476519
477520 yield* manager.open(
......@@ -513,12 +556,18 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
513556 it.effect("emits exited event and reopens with clean transcript after exit", () =>
514557 Effect.gen(function* () {
515558 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
559 const path = yield* Path.Path;
516560 yield* manager.open(openInput());
517561 const process = ptyAdapter.processes[0];
518562 expect(process).toBeDefined();
519563 if (!process) return;
520564 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 );
522571 process.emitExit({ exitCode: 0, signal: 0 });
523572
524573 yield* waitFor(
......@@ -528,7 +577,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
528577
529578 assert.equal(reopened.history, "");
530579 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("");
532586 }),
533587 );
534588
......@@ -715,10 +769,21 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
715769 expect(process).toBeDefined();
716770 if (!process) return;
717771 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
720780 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);
722787 }),
723788 );
724789
......@@ -735,19 +800,36 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
735800
736801 defaultProcess.emitData("default\n");
737802 sidecarProcess.emitData("sidecar\n");
738 yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default")));
739 yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar")));
803 const path = yield* Path.Path;
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
741817 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
742818
743819 assert.equal(defaultProcess.killed, true);
744820 assert.equal(sidecarProcess.killed, true);
745 expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(
746 false,
747 );
748 expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(
749 false,
750 );
821 expect(
822 yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe(
823 Effect.provideService(Path.Path, path),
824 Effect.flatMap(pathExists),
825 ),
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);
751833 }),
752834 );
753835
......@@ -786,7 +868,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
786868
787869 first.emitData("first-history\n");
788870 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 );
790878 first.emitExit({ exitCode: 0, signal: 0 });
791879 yield* Effect.sleep(Duration.millis(5));
792880 second.emitExit({ exitCode: 0, signal: 0 });
......@@ -809,8 +897,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
809897 it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () =>
810898 Effect.gen(function* () {
811899 const { manager, logsDir } = yield* createManager();
812 const legacyPath = joinPath(logsDir, "thread-1.log");
813 const nextPath = historyLogPath(logsDir);
900 const path = yield* Path.Path;
901 const legacyPath = path.join(logsDir, "thread-1.log");
902 const nextPath = yield* historyLogPath(logsDir);
814903 yield* writeFileString(legacyPath, "legacy-line\n");
815904
816905 const snapshot = yield* manager.open(openInput());
apps/server/src/terminal/Layers/Manager.ts+113-77
......@@ -5,13 +5,13 @@ import {
55 type TerminalSessionStatus,
66} from "@t3tools/contracts";
77import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
8import * as DateTime from "effect/DateTime";
89import * as Effect from "effect/Effect";
910import * as Encoding from "effect/Encoding";
1011import * as Equal from "effect/Equal";
1112import * as Exit from "effect/Exit";
1213import * as Fiber from "effect/Fiber";
1314import * as FileSystem from "effect/FileSystem";
14import * as DateTime from "effect/DateTime";
1515import * as Layer from "effect/Layer";
1616import * as Option from "effect/Option";
1717import * as Path from "effect/Path";
......@@ -26,7 +26,7 @@ import {
2626 terminalRestartsTotal,
2727 terminalSessionsTotal,
2828} from "../../observability/Metrics.ts";
29import { runProcess } from "../../processRunner.ts";
29import * as ProcessRunner from "../../processRunner.ts";
3030import {
3131 TerminalCwdError,
3232 TerminalHistoryError,
......@@ -51,6 +51,7 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;
5151const DEFAULT_OPEN_COLS = 120;
5252const DEFAULT_OPEN_ROWS = 30;
5353const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
54const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
5455
5556class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
5657 "TerminalSubprocessCheckError",
......@@ -72,7 +73,9 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess
7273) {}
7374
7475interface TerminalSubprocessChecker {
75 (terminalPid: number): Effect.Effect<boolean, TerminalSubprocessCheckError>;
76 (
77 terminalPid: number,
78 ): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner>;
7679}
7780
7881interface ShellCandidate {
......@@ -211,17 +214,23 @@ function normalizeShellCommand(
211214 return firstToken.replace(/^['"]|['"]$/g, "");
212215}
213216
214function basename(command: string, platform: NodeJS.Platform): string {
215 const normalized = platform === "win32" ? command.replaceAll("/", "\\") : command;
216 const separator = platform === "win32" ? "\\" : "/";
217 return normalized.slice(normalized.lastIndexOf(separator) + 1);
217function basenameForPlatform(command: string, platform: NodeJS.Platform): string {
218 const normalized =
219 platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/");
220 const parts = normalized
221 .split(platform === "win32" ? /\\+/ : /\/+/)
222 .filter((part) => part.length > 0);
223 return parts.at(-1) ?? normalized;
218224}
219225
220function joinWindowsPath(...segments: ReadonlyArray<string>): string {
221 return segments
222 .filter((segment) => segment.length > 0)
223 .join("\\")
224 .replace(/\\+/g, "\\");
226function joinWindowsPath(...parts: ReadonlyArray<string>): string {
227 return parts
228 .map((part, index) => {
229 if (index === 0) return part.replace(/[\\/]+$/g, "");
230 return part.replace(/^[\\/]+|[\\/]+$/g, "");
231 })
232 .filter((part) => part.length > 0)
233 .join("\\");
225234}
226235
227236function shellCandidateFromCommand(
......@@ -229,7 +238,7 @@ function shellCandidateFromCommand(
229238 platform: NodeJS.Platform = process.platform,
230239): ShellCandidate | null {
231240 if (!command || command.length === 0) return null;
232 const shellName = basename(command, platform).toLowerCase();
241 const shellName = basenameForPlatform(command, platform).toLowerCase();
233242 if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) {
234243 return { shell: command, args: ["-NoLogo"] };
235244 }
......@@ -357,66 +366,82 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean {
357366
358367function checkWindowsSubprocessActivity(
359368 terminalPid: number,
360): Effect.Effect<boolean, TerminalSubprocessCheckError> {
369): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {
361370 const command = [
362371 `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,
363372 "if ($children) { exit 0 }",
364373 "exit 1",
365374 ].join("; ");
366 return Effect.tryPromise({
367 try: () =>
368 runProcess("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
369 timeoutMs: 1_500,
370 allowNonZeroExit: true,
371 maxBufferBytes: 32_768,
372 outputMode: "truncate",
373 }),
374 catch: (cause) =>
375 new TerminalSubprocessCheckError({
376 message: "Failed to check Windows terminal subprocess activity.",
377 cause,
378 terminalPid,
379 command: "powershell",
380 }),
381 }).pipe(Effect.map((result) => result.code === 0));
375 return Effect.gen(function* () {
376 const processRunner = yield* ProcessRunner.ProcessRunner;
377 return yield* processRunner.run({
378 command: "powershell.exe",
379 args: ["-NoProfile", "-NonInteractive", "-Command", command],
380 timeout: "1500 millis",
381 maxOutputBytes: 32_768,
382 outputMode: "truncate",
383 shell: process.platform === "win32",
384 timeoutBehavior: "timedOutResult",
385 });
386 }).pipe(
387 Effect.map((result) => result.code === 0),
388 Effect.mapError(
389 (cause) =>
390 new TerminalSubprocessCheckError({
391 message: "Failed to check Windows terminal subprocess activity.",
392 cause,
393 terminalPid,
394 command: "powershell",
395 }),
396 ),
397 );
382398}
383399
384400const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (
385401 terminalPid: number,
386): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {
387 const runPgrep = Effect.tryPromise({
388 try: () =>
389 runProcess("pgrep", ["-P", String(terminalPid)], {
390 timeoutMs: 1_000,
391 allowNonZeroExit: true,
392 maxBufferBytes: 32_768,
393 outputMode: "truncate",
394 }),
395 catch: (cause) =>
396 new TerminalSubprocessCheckError({
397 message: "Failed to inspect terminal subprocesses with pgrep.",
398 cause,
399 terminalPid,
400 command: "pgrep",
401 }),
402 });
402): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {
403 const processRunner = yield* ProcessRunner.ProcessRunner;
404 const runPgrep = processRunner
405 .run({
406 command: "pgrep",
407 args: ["-P", String(terminalPid)],
408 timeout: "1 second",
409 maxOutputBytes: 32_768,
410 outputMode: "truncate",
411 timeoutBehavior: "timedOutResult",
412 })
413 .pipe(
414 Effect.mapError(
415 (cause) =>
416 new TerminalSubprocessCheckError({
417 message: "Failed to inspect terminal subprocesses with pgrep.",
418 cause,
419 terminalPid,
420 command: "pgrep",
421 }),
422 ),
423 );
403424
404 const runPs = Effect.tryPromise({
405 try: () =>
406 runProcess("ps", ["-eo", "pid=,ppid="], {
407 timeoutMs: 1_000,
408 allowNonZeroExit: true,
409 maxBufferBytes: 262_144,
410 outputMode: "truncate",
411 }),
412 catch: (cause) =>
413 new TerminalSubprocessCheckError({
414 message: "Failed to inspect terminal subprocesses with ps.",
415 cause,
416 terminalPid,
417 command: "ps",
418 }),
419 });
425 const runPs = processRunner
426 .run({
427 command: "ps",
428 args: ["-eo", "pid=,ppid="],
429 timeout: "1 second",
430 maxOutputBytes: 262_144,
431 outputMode: "truncate",
432 timeoutBehavior: "timedOutResult",
433 })
434 .pipe(
435 Effect.mapError(
436 (cause) =>
437 new TerminalSubprocessCheckError({
438 message: "Failed to inspect terminal subprocesses with ps.",
439 cause,
440 terminalPid,
441 command: "ps",
442 }),
443 ),
444 );
420445
421446 const pgrepResult = yield* Effect.exit(runPgrep);
422447 if (pgrepResult._tag === "Success") {
......@@ -447,7 +472,7 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct
447472
448473const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* (
449474 terminalPid: number,
450): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {
475): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {
451476 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
452477 return false;
453478 }
......@@ -725,14 +750,19 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
725750 const path = yield* Path.Path;
726751 const context = yield* Effect.context<never>();
727752 const runFork = Effect.runForkWith(context);
728 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
729753
730754 const logsDir = options.logsDir;
731755 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
732756 const platform = options.platform ?? process.platform;
733757 const baseEnv = options.env ?? process.env;
734758 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 ));
736766 const subprocessPollIntervalMs =
737767 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
738768 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
......@@ -1419,22 +1449,23 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
14191449 runFork(drainProcessEvents(session, processPid));
14201450 });
14211451
1422 const startedAt = yield* nowIso;
1452 const runningAt = yield* nowIso;
14231453 yield* modifyManagerState((state) => {
14241454 session.process = ptyProcess;
14251455 session.pid = processPid;
14261456 session.status = "running";
1427 session.updatedAt = startedAt;
1457 session.updatedAt = runningAt;
14281458 session.unsubscribeData = unsubscribeData;
14291459 session.unsubscribeExit = unsubscribeExit;
14301460 return [undefined, state] as const;
14311461 });
14321462
1463 const createdAt = yield* nowIso;
14331464 yield* publishEvent({
14341465 type: eventType,
14351466 threadId: session.threadId,
14361467 terminalId: session.terminalId,
1437 createdAt: startedAt,
1468 createdAt,
14381469 snapshot: snapshot(session),
14391470 });
14401471 }),
......@@ -1451,8 +1482,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
14511482 if (ptyProcess) {
14521483 yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId);
14531484 }
1485 const erroredAt = yield* nowIso;
14541486
1455 const failedAt = yield* nowIso;
14561487 yield* modifyManagerState((state) => {
14571488 session.status = "error";
14581489 session.pid = null;
......@@ -1463,18 +1494,19 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
14631494 session.pendingProcessEvents = [];
14641495 session.pendingProcessEventIndex = 0;
14651496 session.processEventDrainRunning = false;
1466 session.updatedAt = failedAt;
1497 session.updatedAt = erroredAt;
14671498 return [undefined, state] as const;
14681499 });
14691500
14701501 yield* evictInactiveSessionsIfNeeded();
14711502
14721503 const message = error.message;
1504 const createdAt = yield* nowIso;
14731505 yield* publishEvent({
14741506 type: "error",
14751507 threadId: session.threadId,
14761508 terminalId: session.terminalId,
1477 createdAt: failedAt,
1509 createdAt,
14781510 message,
14791511 });
14801512 yield* Effect.logError("failed to start terminal", {
......@@ -1547,6 +1579,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
15471579 }
15481580
15491581 const updatedAt = yield* nowIso;
1582 const createdAt = yield* nowIso;
15501583 const event = yield* modifyManagerState((state) => {
15511584 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
15521585 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),
......@@ -1568,7 +1601,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
15681601 type: "activity" as const,
15691602 threadId: liveSession.value.threadId,
15701603 terminalId: liveSession.value.terminalId,
1571 createdAt: updatedAt,
1604 createdAt,
15721605 hasRunningSubprocess: hasRunningSubprocess.value,
15731606 }),
15741607 state,
......@@ -1797,19 +1830,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
17971830 Effect.gen(function* () {
17981831 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
17991832 const session = yield* requireSession(input.threadId, terminalId);
1833 const updatedAt = yield* nowIso;
18001834 session.history = "";
18011835 session.pendingHistoryControlSequence = "";
18021836 session.pendingProcessEvents = [];
18031837 session.pendingProcessEventIndex = 0;
18041838 session.processEventDrainRunning = false;
1805 const clearedAt = yield* nowIso;
1806 session.updatedAt = clearedAt;
1839 session.updatedAt = updatedAt;
18071840 yield* persistHistory(input.threadId, terminalId, session.history);
1841 const createdAt = yield* nowIso;
18081842 yield* publishEvent({
18091843 type: "cleared",
18101844 threadId: input.threadId,
18111845 terminalId,
1812 createdAt: clearedAt,
1846 createdAt,
18131847 });
18141848 }),
18151849 );
......@@ -1933,4 +1967,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
19331967 },
19341968);
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>({
6767
6868it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
6969 let observedEnv: NodeJS.ProcessEnv | undefined;
70 let observedAppendTruncationMarker: boolean | undefined;
7071
7172 return Effect.gen(function* () {
7273 const driver = yield* GitVcsDriver.makeVcsDriverShape();
......@@ -78,26 +79,32 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
7879 env: {
7980 GIT_INDEX_FILE: "/tmp/t3-index",
8081 },
82 appendTruncationMarker: true,
8183 });
8284
8385 assert.deepStrictEqual(observedEnv, {
8486 GIT_INDEX_FILE: "/tmp/t3-index",
8587 });
88 assert.strictEqual(observedAppendTruncationMarker, true);
8689 }).pipe(
8790 Effect.provide(
88 Layer.mock(VcsProcess.VcsProcess)({
89 run: (input) =>
90 Effect.sync(() => {
91 observedEnv = input.env;
92 return {
93 exitCode: ChildProcessSpawner.ExitCode(0),
94 stdout: "",
95 stderr: "",
96 stdoutTruncated: false,
97 stderrTruncated: false,
98 };
99 }),
100 }),
91 Layer.mergeAll(
92 NodeServices.layer,
93 Layer.mock(VcsProcess.VcsProcess)({
94 run: (input) =>
95 Effect.sync(() => {
96 observedEnv = input.env;
97 observedAppendTruncationMarker = input.appendTruncationMarker;
98 return {
99 exitCode: ChildProcessSpawner.ExitCode(0),
100 stdout: "",
101 stderr: "",
102 stdoutTruncated: false,
103 stderrTruncated: false,
104 };
105 }),
106 }),
107 ),
101108 ),
102109 );
103110});
apps/server/src/vcs/GitVcsDriver.ts+288-22
......@@ -1,8 +1,12 @@
1import { randomUUID } from "node:crypto";
2
13import * as Context from "effect/Context";
24import * as DateTime from "effect/DateTime";
35import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
47import * as Layer from "effect/Layer";
58import * as Option from "effect/Option";
9import * as Path from "effect/Path";
610import { ChildProcessSpawner } from "effect/unstable/process";
711
812import {
......@@ -35,7 +39,7 @@ export interface ExecuteGitInput {
3539 readonly allowNonZeroExit?: boolean;
3640 readonly timeoutMs?: number;
3741 readonly maxOutputBytes?: number;
38 readonly truncateOutputAtMaxBytes?: boolean;
42 readonly appendTruncationMarker?: boolean;
3943 readonly progress?: ExecuteGitProgress;
4044}
4145
......@@ -218,6 +222,7 @@ export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShap
218222
219223const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
220224const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
225const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;
221226const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [
222227 "-c",
223228 "core.fsmonitor=false",
......@@ -318,14 +323,15 @@ const gitCommand = (
318323 readonly allowNonZeroExit?: boolean;
319324 readonly timeoutMs?: number;
320325 readonly maxOutputBytes?: number;
321 readonly truncateOutputAtMaxBytes?: boolean;
326 readonly appendTruncationMarker?: boolean;
322327 },
323328) =>
324329 process.run({
325330 operation,
326331 command: "git",
327 args,
332 args: ["-C", cwd, ...args],
328333 cwd,
334 spawnCwd: globalThis.process.cwd(),
329335 ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}),
330336 ...(options?.env !== undefined ? { env: options.env } : {}),
331337 ...(options?.allowNonZeroExit !== undefined
......@@ -333,13 +339,15 @@ const gitCommand = (
333339 : {}),
334340 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
335341 ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
336 ...(options?.truncateOutputAtMaxBytes !== undefined
337 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
342 ...(options?.appendTruncationMarker !== undefined
343 ? { appendTruncationMarker: options.appendTruncationMarker }
338344 : {}),
339345 });
340346
341347export 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;
343351 const capabilities = {
344352 kind: "git" as const,
345353 supportsWorktrees: true,
......@@ -351,7 +359,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
351359
352360 const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) =>
353361 gitCommand(
354 process,
362 vcsProcess,
355363 "GitVcsDriver.isInsideWorkTree",
356364 cwd,
357365 ["rev-parse", "--is-inside-work-tree"],
......@@ -363,14 +371,14 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
363371 ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"));
364372
365373 const execute: VcsDriver.VcsDriverShape["execute"] = (input) =>
366 gitCommand(process, input.operation, input.cwd, input.args, {
374 gitCommand(vcsProcess, input.operation, input.cwd, input.args, {
367375 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
368376 ...(input.env !== undefined ? { env: input.env } : {}),
369377 ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}),
370378 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
371379 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
372 ...(input.truncateOutputAtMaxBytes !== undefined
373 ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes }
380 ...(input.appendTruncationMarker !== undefined
381 ? { appendTruncationMarker: input.appendTruncationMarker }
374382 : {}),
375383 });
376384
......@@ -381,12 +389,12 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
381389 return null;
382390 }
383391
384 const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [
392 const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [
385393 "rev-parse",
386394 "--show-toplevel",
387395 ]);
388396 const gitCommonDir = yield* gitCommand(
389 process,
397 vcsProcess,
390398 "GitVcsDriver.detectRepository.commonDir",
391399 cwd,
392400 ["rev-parse", "--git-common-dir"],
......@@ -402,7 +410,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
402410
403411 const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) =>
404412 gitCommand(
405 process,
413 vcsProcess,
406414 "GitVcsDriver.listWorkspaceFiles",
407415 cwd,
408416 [
......@@ -417,7 +425,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
417425 allowNonZeroExit: true,
418426 timeoutMs: 20_000,
419427 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
420 truncateOutputAtMaxBytes: true,
428 appendTruncationMarker: true,
421429 },
422430 ).pipe(
423431 Effect.flatMap((result) =>
......@@ -444,11 +452,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
444452
445453 const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(
446454 function* (cwd) {
447 const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], {
448 allowNonZeroExit: true,
449 timeoutMs: 5_000,
450 maxOutputBytes: 64 * 1024,
451 });
455 const result = yield* gitCommand(
456 vcsProcess,
457 "GitVcsDriver.listRemotes",
458 cwd,
459 ["remote", "-v"],
460 {
461 allowNonZeroExit: true,
462 timeoutMs: 5_000,
463 maxOutputBytes: 64 * 1024,
464 },
465 );
452466
453467 if (result.exitCode !== 0) {
454468 return yield* new VcsProcessExitError({
......@@ -494,7 +508,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
494508
495509 for (const chunk of chunks) {
496510 const result = yield* gitCommand(
497 process,
511 vcsProcess,
498512 "GitVcsDriver.filterIgnoredPaths",
499513 cwd,
500514 [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
......@@ -503,7 +517,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
503517 allowNonZeroExit: true,
504518 timeoutMs: 20_000,
505519 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
506 truncateOutputAtMaxBytes: true,
520 appendTruncationMarker: true,
507521 },
508522 );
509523
......@@ -530,14 +544,266 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
530544 });
531545
532546 const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) =>
533 gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], {
547 gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], {
534548 timeoutMs: 10_000,
535549 maxOutputBytes: 64 * 1024,
536550 }).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
538803 return VcsDriver.VcsDriver.of({
539804 capabilities,
540805 execute,
806 checkpoints,
541807 detectRepository,
542808 isInsideWorkTree,
543809 listWorkspaceFiles,
apps/server/src/vcs/GitVcsDriverCore.ts+15-15
......@@ -77,7 +77,7 @@ interface ExecuteGitOptions {
7777 fallbackErrorMessage?: string | undefined;
7878 env?: NodeJS.ProcessEnv | undefined;
7979 maxOutputBytes?: number | undefined;
80 truncateOutputAtMaxBytes?: boolean | undefined;
80 appendTruncationMarker?: boolean | undefined;
8181 progress?: GitVcsDriver.ExecuteGitProgress | undefined;
8282}
8383
......@@ -535,7 +535,7 @@ const collectOutput = Effect.fnUntraced(function* <E>(
535535 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,
536536 stream: Stream.Stream<Uint8Array, E>,
537537 maxOutputBytes: number,
538 truncateOutputAtMaxBytes: boolean,
538 appendTruncationMarker: boolean,
539539 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
540540): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> {
541541 const decoder = new TextDecoder();
......@@ -565,11 +565,11 @@ const collectOutput = Effect.fnUntraced(function* <E>(
565565 });
566566
567567 const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
568 if (truncateOutputAtMaxBytes && truncated) {
568 if (appendTruncationMarker && truncated) {
569569 return;
570570 }
571571 const nextBytes = bytes + chunk.byteLength;
572 if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) {
572 if (!appendTruncationMarker && nextBytes > maxOutputBytes) {
573573 return yield* new GitCommandError({
574574 operation: input.operation,
575575 command: quoteGitCommand(input.args),
......@@ -579,11 +579,11 @@ const collectOutput = Effect.fnUntraced(function* <E>(
579579 }
580580
581581 const chunkToDecode =
582 truncateOutputAtMaxBytes && nextBytes > maxOutputBytes
582 appendTruncationMarker && nextBytes > maxOutputBytes
583583 ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes))
584584 : chunk;
585585 bytes += chunkToDecode.byteLength;
586 truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes;
586 truncated = appendTruncationMarker && nextBytes > maxOutputBytes;
587587
588588 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });
589589 text += decoded;
......@@ -619,7 +619,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
619619 } as const;
620620 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
621621 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
622 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
622 const appendTruncationMarker = input.appendTruncationMarker ?? false;
623623
624624 const runGitCommand = Effect.fn("runGitCommand")(function* () {
625625 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(
......@@ -646,14 +646,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
646646 commandInput,
647647 child.stdout,
648648 maxOutputBytes,
649 truncateOutputAtMaxBytes,
649 appendTruncationMarker,
650650 input.progress?.onStdoutLine,
651651 ),
652652 collectOutput(
653653 commandInput,
654654 child.stderr,
655655 maxOutputBytes,
656 truncateOutputAtMaxBytes,
656 appendTruncationMarker,
657657 input.progress?.onStderrLine,
658658 ),
659659 child.exitCode.pipe(
......@@ -746,8 +746,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
746746 allowNonZeroExit: true,
747747 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
748748 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
749 ...(options.truncateOutputAtMaxBytes !== undefined
750 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
749 ...(options.appendTruncationMarker !== undefined
750 ? { appendTruncationMarker: options.appendTruncationMarker }
751751 : {}),
752752 ...(options.progress ? { progress: options.progress } : {}),
753753 }).pipe(
......@@ -1379,7 +1379,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
13791379 ["diff", "--cached", "--patch", "--minimal"],
13801380 {
13811381 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1382 truncateOutputAtMaxBytes: true,
1382 appendTruncationMarker: true,
13831383 },
13841384 );
13851385
......@@ -1598,7 +1598,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
15981598 ["log", "--oneline", range],
15991599 {
16001600 maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES,
1601 truncateOutputAtMaxBytes: true,
1601 appendTruncationMarker: true,
16021602 },
16031603 ),
16041604 runGitStdoutWithOptions(
......@@ -1607,7 +1607,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
16071607 ["diff", "--stat", range],
16081608 {
16091609 maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES,
1610 truncateOutputAtMaxBytes: true,
1610 appendTruncationMarker: true,
16111611 },
16121612 ),
16131613 runGitStdoutWithOptions(
......@@ -1616,7 +1616,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
16161616 ["diff", "--patch", "--minimal", range],
16171617 {
16181618 maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES,
1619 truncateOutputAtMaxBytes: true,
1619 appendTruncationMarker: true,
16201620 },
16211621 ),
16221622 ],
apps/server/src/vcs/VcsDriver.ts+40
......@@ -9,13 +9,53 @@ import type {
99 VcsListWorkspaceFilesResult,
1010 VcsRepositoryIdentity,
1111} from "@t3tools/contracts";
12import { CheckpointRef } from "@t3tools/contracts";
1213import * 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
1453export interface VcsDriverShape {
1554 readonly capabilities: VcsDriverCapabilities;
1655 readonly execute: (
1756 input: Omit<VcsProcess.VcsProcessInput, "command">,
1857 ) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>;
58 readonly checkpoints?: VcsCheckpointOps;
1959 readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>;
2060 readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>;
2161 readonly listWorkspaceFiles: (
apps/server/src/vcs/VcsDriverRegistry.test.ts+10-2
......@@ -1,4 +1,5 @@
11import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
45import { ChildProcessSpawner } from "effect/unstable/process";
......@@ -15,9 +16,13 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
1516 stderrTruncated: false,
1617});
1718
19const normalizeGitArgs = (args: ReadonlyArray<string>): ReadonlyArray<string> =>
20 args[0] === "-C" && args.length >= 2 ? args.slice(2) : args;
21
1822describe("VcsDriverRegistry", () => {
1923 it.effect("routes directly by VCS driver kind for non-repository workflows", () => {
2024 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe(
25 Layer.provide(NodeServices.layer),
2126 Layer.provide(
2227 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
2328 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
......@@ -41,6 +46,7 @@ describe("VcsDriverRegistry", () => {
4146 it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => {
4247 const calls: VcsProcess.VcsProcessInput[] = [];
4348 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe(
49 Layer.provide(NodeServices.layer),
4450 Layer.provide(
4551 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
4652 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
......@@ -51,7 +57,9 @@ describe("VcsDriverRegistry", () => {
5157 run: (input) =>
5258 Effect.sync(() => {
5359 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(" ");
5563 if (command === "rev-parse --is-inside-work-tree") {
5664 return processOutput("true\n");
5765 }
......@@ -75,7 +83,7 @@ describe("VcsDriverRegistry", () => {
7583 assert.equal(first.repository.rootPath, "/repo");
7684 assert.equal(second.repository.rootPath, "/repo");
7785 assert.deepStrictEqual(
78 calls.map((call) => call.args.join(" ")),
86 calls.map((call) => normalizeGitArgs(call.args).join(" ")),
7987 [
8088 "rev-parse --is-inside-work-tree",
8189 "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 @@
1import * as Duration from "effect/Duration";
21import * as Context from "effect/Context";
32import * as Effect from "effect/Effect";
43import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
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";
4import { ChildProcessSpawner } from "effect/unstable/process";
105
116import {
127 VcsOutputDecodeError,
......@@ -15,19 +10,21 @@ import {
1510 VcsProcessSpawnError,
1611 VcsProcessTimeoutError,
1712} from "@t3tools/contracts";
18import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
13import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts";
14import * as Match from "effect/Match";
1915
2016export interface VcsProcessInput {
2117 readonly operation: string;
2218 readonly command: string;
2319 readonly args: ReadonlyArray<string>;
2420 readonly cwd: string;
21 readonly spawnCwd?: string;
2522 readonly stdin?: string;
2623 readonly env?: NodeJS.ProcessEnv;
2724 readonly allowNonZeroExit?: boolean;
2825 readonly timeoutMs?: number;
2926 readonly maxOutputBytes?: number;
30 readonly truncateOutputAtMaxBytes?: boolean;
27 readonly appendTruncationMarker?: boolean;
3128}
3229
3330export interface VcsProcessOutput {
......@@ -38,25 +35,7 @@ export interface VcsProcessOutput {
3835 readonly stderrTruncated: boolean;
3936}
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
5538export 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>;
6039 readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>;
6140}
6241
......@@ -72,164 +51,72 @@ function commandLabel(command: string, args: ReadonlyArray<string>): string {
7251 return [command, ...args].join(" ");
7352}
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
10554export 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) {
10958 const label = commandLabel(input.command, input.args);
110 const child = yield* spawner
111 .spawn(
112 ChildProcess.make(input.command, [...input.args], {
113 cwd: input.cwd,
114 env: {
115 ...process.env,
116 ...input.env,
117 },
118 }),
119 )
59 const baseError = {
60 operation: input.operation,
61 command: label,
62 cwd: input.cwd,
63 };
64
65 const result = yield* processRunner
66 .run({
67 command: input.command,
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 })
12079 .pipe(
12180 Effect.mapError(
122 (cause) =>
123 new VcsProcessSpawnError({
124 operation: input.operation,
125 command: label,
126 cwd: input.cwd,
127 cause,
128 }),
81 Match.valueTags({
82 ProcessSpawnError: (error) =>
83 VcsProcessSpawnError.fromProcessSpawnError(baseError, error),
84 ProcessOutputLimitError: (error) =>
85 VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error),
86 ProcessTimeoutError: (error) =>
87 VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error),
88 ProcessStdinError: (error) =>
89 VcsOutputDecodeError.fromProcessStdinError(baseError, error),
90 ProcessReadError: (error) =>
91 VcsOutputDecodeError.fromProcessReadError(baseError, error),
92 }),
12993 ),
13094 );
131 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
13295
133 const mapStreamError = (streamName: "stdout" | "stderr") =>
134 Stream.mapError((cause: PlatformError.PlatformError) =>
135 outputDecodeError(input, `failed to read process ${streamName}`, cause),
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)));
96 if (result.code === null) {
97 return yield* VcsOutputDecodeError.missingExitCode(baseError);
98 }
16099
161 const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {
162 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
163 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
164 const label = commandLabel(input.command, input.args);
165
166 const runProcess = Effect.gen(function* () {
167 const [stdout, stderr, exitCode] = yield* withProcess(input, (child) =>
168 Effect.all(
169 [
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));
100 if (!input.allowNonZeroExit && result.code !== 0) {
101 return yield* new VcsProcessExitError({
102 operation: input.operation,
103 command: label,
104 cwd: input.cwd,
105 exitCode: result.code,
106 detail: result.stderr.trim() || `${label} exited with code ${result.code}.`,
107 });
108 }
192109
193 if (!input.allowNonZeroExit && exitCode !== 0) {
194 return yield* new VcsProcessExitError({
195 operation: input.operation,
196 command: label,
197 cwd: input.cwd,
198 exitCode,
199 detail: stderr.text.trim() || `${label} exited with code ${exitCode}.`,
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 );
110 return {
111 exitCode: result.code,
112 stdout: result.stdout,
113 stderr: result.stderr,
114 stdoutTruncated: result.stdoutTruncated,
115 stderrTruncated: result.stderrTruncated,
116 } satisfies VcsProcessOutput;
230117 });
231118
232 return VcsProcess.of({ withProcess, run });
119 return VcsProcess.of({ run });
233120});
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";
1111import type { VcsDriverKind } from "@t3tools/contracts";
1212import * as VcsDriver from "../VcsDriver.ts";
1313
14function normalizePathForComparison(value: string): string {
15 return value.replaceAll("\\", "/");
16}
17
1418export interface VcsDriverFixture<R, E> {
1519 readonly createRepo: (cwd: string) => Effect.Effect<void, E, R>;
1620 readonly writeFile: (
......@@ -67,7 +71,11 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp
6771 yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n");
6872 const identity = yield* driver.detectRepository(cwd);
6973 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 );
7179 assert.equal(identity?.freshness.source, "live-local");
7280 assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt));
7381 assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none()));
packages/contracts/src/vcs.ts+78
......@@ -58,6 +58,34 @@ export const VcsListRemotesResult = Schema.Struct({
5858});
5959export 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
6189export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpawnError>()(
6290 "VcsProcessSpawnError",
6391 {
......@@ -70,6 +98,13 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpaw
7098 override get message(): string {
7199 return `VCS process failed to spawn in ${this.operation}: ${this.command} (${this.cwd})`;
72100 }
101
102 static fromProcessSpawnError(context: VcsProcessErrorContext, error: VcsProcessSpawnFailure) {
103 return new VcsProcessSpawnError({
104 ...context,
105 cause: error.cause,
106 });
107 }
73108}
74109
75110export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitError>()(
......@@ -99,6 +134,13 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTi
99134 override get message(): string {
100135 return `VCS process timed out in ${this.operation}: ${this.command} (${this.cwd}) after ${this.timeoutMs}ms`;
101136 }
137
138 static fromProcessTimeoutError(context: VcsProcessErrorContext, error: VcsProcessTimeoutFailure) {
139 return new VcsProcessTimeoutError({
140 ...context,
141 timeoutMs: error.timeoutMs,
142 });
143 }
102144}
103145
104146export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()(
......@@ -114,6 +156,42 @@ export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecod
114156 override get message(): string {
115157 return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
116158 }
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 }
117195}
118196
119197export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()(