Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { describe, expect, it } from "@effect/vitest";
import { ProjectId, type TerminalEvent, type TerminalSessionSnapshot } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Queue from "effect/Queue";
import { TestClock } from "effect/testing";

import * as ServerSettings from "../serverSettings.ts";
import * as TerminalManager from "../terminal/Manager.ts";
import * as ProjectService from "./ProjectService.ts";
import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";

const threadId = "thread-1";
const terminalId = "setup-setup";
const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" };

const openSnapshot = (history: string): TerminalSessionSnapshot => ({
threadId,
terminalId,
cwd: "/repo/.t3/worktrees/feature",
worktreePath: null,
status: "running",
pid: 123,
history,
exitCode: null,
exitSignal: null,
label: "zsh",
updatedAt: "2026-01-01T00:00:00.000Z",
});

const makeHarness = Effect.fn(function* (options: {
readonly history?: string;
readonly opening?: Deferred.Deferred<void>;
}) {
const listeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
const writes = yield* Queue.unbounded<string>();
const outputLines = yield* Queue.unbounded<string>();
const terminals = Layer.mock(TerminalManager.TerminalManager)({
open: () =>
(options.opening
? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never))
: Effect.void
).pipe(Effect.as(openSnapshot(options.history ?? ""))),
subscribe: (listener) =>
Effect.sync(() => {
listeners.add(listener);
return () => listeners.delete(listener);
}),
write: (input) => Queue.offer(writes, input.data).pipe(Effect.asVoid),
closeIdle: () => Effect.void,
});
const runner = yield* ProjectSetupScriptRunner.make.pipe(
Effect.provide(
Layer.mergeAll(
terminals,
Layer.mock(ProjectService.ProjectService)({}),
ServerSettings.layerTest(),
NodeCrypto.layer,
),
),
Effect.provideService(HostProcess.Platform, "darwin"),
Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }),
);
const start = runner.runForThread({
threadId,
worktreePath: "/repo/.t3/worktrees/feature",
observeCompletion: { onOutputLine: (line) => Queue.offer(outputLines, line) },
project: {
id: ProjectId.make("project-1"),
workspaceRoot: "/repo",
scripts: [
{
id: "setup",
name: "Setup",
command: "vp i",
icon: "configure",
runOnWorktreeCreate: true,
},
],
},
});
const emit = (terminalEvent: TerminalEvent) =>
Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true });
return { start, listeners, writes, outputLines, emit };
});

const setup = Effect.fn(function* (options: { readonly history?: string } = {}) {
const harness = yield* makeHarness(options);
const result = yield* harness.start;
const completion = result.status === "started" ? result.completion : undefined;
if (!completion) return yield* Effect.die("setup did not start an observed run");
return { ...harness, result, completion };
});

describe("ProjectSetupScriptRunner", () => {
it.effect("shows a startup prompt and types only once the line editor starts", () =>
Effect.gen(function* () {
const { result, writes, outputLines, emit } = yield* setup();
expect(result).toMatchObject({ status: "started", terminalId });

yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " });
yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" });
yield* TestClock.adjust("1 minute");
expect(yield* Queue.size(writes)).toBe(0);
expect(yield* Queue.takeAll(outputLines)).toEqual(["Missing Node.", "Install it? [y/N]:"]);

yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" });
yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" });
expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
}),
);

it.effect("types into a quiet shell that has no line editor", () =>
Effect.gen(function* () {
const { writes } = yield* setup();
yield* TestClock.adjust("3 seconds");
expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
}),
);

it.effect("ignores line editor state restored from an earlier shell", () =>
Effect.gen(function* () {
const { writes, emit } = yield* setup({ history: "old run\r\n% \x1b[?2004h" });
yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" });
yield* TestClock.adjust("1 minute");
expect(yield* Queue.size(writes)).toBe(0);
}),
);

it.effect("forgets a busy startup child when the shell restarts", () =>
Effect.gen(function* () {
const { writes, emit } = yield* setup();
yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" });
yield* TestClock.adjust("1 minute");
expect(yield* Queue.size(writes)).toBe(0);
yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") });
// The restart lands inside a quiet window, so the next full window decides.
yield* TestClock.adjust("6 seconds");
expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
}),
);

it.effect("drops its terminal listeners when cancelled while the terminal opens", () =>
Effect.gen(function* () {
const opening = yield* Deferred.make<void>();
const { start, listeners } = yield* makeHarness({ opening });
const fiber = yield* Effect.forkChild(start);
yield* Deferred.await(opening);
expect(listeners.size).toBe(2);
yield* Fiber.interrupt(fiber);
expect(listeners.size).toBe(0);
}),
);

it.effect("does not type into a shell that exits during startup", () =>
Effect.gen(function* () {
const { completion, writes, emit } = yield* setup();
yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null });
expect(yield* completion).toMatchObject({ exitCode: null });
yield* TestClock.adjust("1 minute");
expect(yield* Queue.size(writes)).toBe(0);
}),
);
});
143 changes: 118 additions & 25 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedError<Project
projectId: Schema.optional(Schema.String),
projectCwd: Schema.optional(Schema.String),
worktreePath: Schema.String,
operation: Schema.Literals(["resolveProject", "readSettings", "openTerminal", "writeCommand"]),
operation: Schema.Literals(["resolveProject", "readSettings", "openTerminal"]),
cause: Schema.Defect(),
},
) {
Expand Down Expand Up @@ -139,6 +139,18 @@ function stripTerminalControl(text: string): string {
);
}

// Line editors (zsh, fish, bash 5.1+) enable bracketed paste when they start reading a command.
const BRACKETED_PASTE_ON = "\x1b[?2004h";
const BRACKETED_PASTE_OFF = "\x1b[?2004l";
// Longer than one subprocess poll plus its ps timeout, so a startup prompt is seen as busy first.
const SHELL_QUIET_FALLBACK = "3 seconds";

function lineEditorReading(text: string, previous: boolean): boolean {
const on = text.lastIndexOf(BRACKETED_PASTE_ON);
const off = text.lastIndexOf(BRACKETED_PASTE_OFF);
return on === off ? previous : on > off;
}

type CompletionShell = "posix" | "fish" | "powershell";

function resolveCompletionShell(
Expand Down Expand Up @@ -176,6 +188,7 @@ export const make = Effect.gen(function* () {
const terminalManager = yield* TerminalManager.TerminalManager;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const crypto = yield* Crypto.Crypto;
const serviceScope = yield* Effect.scope;
const completionShell = resolveCompletionShell(
yield* HostProcess.Platform,
yield* HostProcess.Environment,
Expand Down Expand Up @@ -263,7 +276,67 @@ export const make = Effect.gen(function* () {
Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))),
Effect.ensuring(Effect.sync(() => unsubscribe())),
);
return { completion, unsubscribe };
// A startup prompt waits for input on a line that has no newline yet.
const flushPartialLine = Effect.suspend(() => {
const partial = lineBuffer;
lineBuffer = "";
return handleLine(partial);
});
const abandon = settle(null).pipe(
Effect.andThen(Deferred.succeed(promptReturned, undefined)),
);
return { completion, unsubscribe, flushPartialLine, abandon };
},
);

// Input typed during shell startup can be read by an rc-file prompt (fnm, nvm) instead of the
// shell, which loses the command. Subscribe before the terminal opens so no activity is missed.
const watchShellReadiness = Effect.fn("ProjectSetupScriptRunner.watchShellReadiness")(
function* (input: {
readonly threadId: string;
readonly terminalId: string;
readonly onStartupPrompt: Effect.Effect<void>;
}) {
const outcome = yield* Deferred.make<"ready" | "exited">();
let reading = false;
let carry = "";
let busy = false;
let activity = 0;
const unsubscribe = yield* terminalManager.subscribe((event) => {
if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
return Effect.void;
}
activity += 1;
// A new shell process starts idle; the manager resets its subprocess state without an event.
if (event.type === "started" || event.type === "restarted") {
busy = false;
reading = false;
carry = "";
}
if (event.type === "activity") busy = event.hasRunningSubprocess;
if (event.type === "output") {
const text = carry + event.data;
carry = text.slice(-(BRACKETED_PASTE_ON.length - 1));
reading = lineEditorReading(text, reading);
}
if (event.type === "exited" || event.type === "closed") {
return Deferred.succeed(outcome, "exited").pipe(Effect.asVoid);
}
return reading ? Deferred.succeed(outcome, "ready").pipe(Effect.asVoid) : Effect.void;
});
// A startup prompt runs as a child process, so a busy shell keeps waiting for the user.
const quiet = Effect.gen(function* () {
while (true) {
const seen = activity;
yield* Effect.sleep(SHELL_QUIET_FALLBACK);
if (activity !== seen) continue;
if (!busy) return "ready" as const;
yield* input.onStartupPrompt;
}
});
// Restored history can hold a previous shell's line editor state, so only live output counts.
const wait = Deferred.await(outcome).pipe(Effect.raceFirst(quiet));
return { wait, unsubscribe };
},
);

Expand Down Expand Up @@ -361,25 +434,7 @@ export const make = Effect.gen(function* () {
)
: script.command;

yield* terminalManager
.open({
threadId: input.threadId,
terminalId,
cwd,
worktreePath: input.worktreePath,
env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" },
})
.pipe(
Effect.mapError(
(cause) =>
new ProjectSetupScriptOperationError({
...errorContext,
operation: "openTerminal",
cause,
}),
),
);
// Subscribe before writing so the sentinel cannot race past the listener.
// Subscribe before opening so neither startup activity nor the sentinel can race the listeners.
const observed =
observe && completionToken
? yield* observeTerminalCompletion({
Expand All @@ -391,25 +446,63 @@ export const make = Effect.gen(function* () {
onOutputLine: observe.onOutputLine,
})
: undefined;
// PowerShell has no line-editor signal; keep its immediate write.
const readiness =
completionShell === "powershell"
? undefined
: yield* watchShellReadiness({
threadId: input.threadId,
terminalId,
onStartupPrompt: observed?.flushPartialLine ?? Effect.void,
});

yield* terminalManager
.write({
.open({
threadId: input.threadId,
terminalId,
data: projectScriptTerminalInput(commandLine),
cwd,
worktreePath: input.worktreePath,
env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" },
})
.pipe(
Effect.mapError(
(cause) =>
new ProjectSetupScriptOperationError({
...errorContext,
operation: "writeCommand",
operation: "openTerminal",
cause,
}),
),
Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())),
Effect.onError(() =>
Effect.sync(() => {
observed?.unsubscribe();
readiness?.unsubscribe();
}),
),
);

// Return once the terminal exists so callers can show it while a startup prompt waits.
yield* (readiness?.wait ?? Effect.succeed("ready" as const)).pipe(
Effect.flatMap((shell) =>
shell === "exited"
? Effect.void
: terminalManager.write({
threadId: input.threadId,
terminalId,
data: projectScriptTerminalInput(commandLine),
}),
),
Effect.catchCause((cause) =>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Effect.logWarning("project script command was not written", {
...errorContext,
terminalId,
cause,
}).pipe(Effect.andThen(observed?.abandon ?? Effect.void)),
),
Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())),
Effect.forkIn(serviceScope),
);

const completion = observed?.completion.pipe(
Effect.tap(({ exitCode }) =>
exitCode === 0
Expand Down
Loading