diff --git a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts new file mode 100644 index 000000000000..fc193e57d1da --- /dev/null +++ b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts @@ -0,0 +1,862 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalTimers:off globalDate:off preferSchemaOverJson:off - this test owns a real temp workspace and a real server process. +import { + CommandId, + MessageId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + WsRpcGroup, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { assert, it } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { execScriptSource, writeFakeCli } from "../src/testUtils/fakeCli.ts"; + +const DESKTOP_BOOTSTRAP_TOKEN = "envchk-p1-desktop-bootstrap-token"; +const GROK_MODEL = "grok-4.6"; +const PROJECT_ID = ProjectId.make("envchk-p1-project"); +const THREAD_ID = ThreadId.make("envchk-p1-thread"); +const MISSING_THREAD_ID = ThreadId.make("envchk-p1-missing-thread"); +const GROK_INSTANCE = ProviderInstanceId.make("grok"); +const GROK_MISSING_INSTANCE = ProviderInstanceId.make("grok-missing"); + +const findFreePort = (): Promise => + new Promise((resolve, reject) => { + const server = NodeNet.createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("could not allocate a loopback port")); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); + +interface Fixture { + readonly baseDir: string; + readonly root: string; + readonly fakeDir: string; + readonly argvLogPath: string; + /** Each stub invocation appends its pid here so cleanup can reap the tree. */ + readonly pidLogPath: string; + readonly wrapperPath: string; +} + +const makeFixture = (): Effect.Effect => + Effect.gen(function* () { + const fs = yield* Effect.promise(() => import("node:fs/promises")); + const baseDir = yield* Effect.promise(() => + fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-")), + ); + const root = yield* Effect.promise(() => + fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-root-")), + ); + const fakeDir = yield* Effect.promise(() => + fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-fake-")), + ); + const argvLogPath = NodePath.join(fakeDir, "argv.log"); + const pidLogPath = NodePath.join(fakeDir, "stub-pids.log"); + const mockAgentPath = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../scripts/acp-mock-agent.ts", + ); + const wrapperPath = writeFakeCli({ + directory: fakeDir, + name: "fake-grok-envchk-p1", + // Record this invocation's pid before the mock agent takes over. The T3 + // server spawns each provider CLI in its own process group, so killing the + // server does not reach them; cleanup reads these pids and kills the + // exact fixture-owned processes instead of matching on a name. + source: + 'import { appendFileSync as recordStubPid } from "node:fs";\n' + + "recordStubPid(" + + JSON.stringify(pidLogPath) + + ', String(process.pid) + "\\n");\n' + + execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + NodeChildProcess.execFile("git", ["init", "-q"], { cwd: root }, (error) => + error ? reject(error) : resolve(), + ); + }), + ); + return { baseDir, root, fakeDir, argvLogPath, pidLogPath, wrapperPath }; + }).pipe(Effect.orDie); + +interface SpawnedServer { + readonly child: NodeChildProcess.ChildProcess; + readonly port: number; + readonly stdout: () => string; + readonly stderr: () => string; +} + +const spawnServer = async (input: { + readonly baseDir: string; + readonly root: string; + readonly wrapperPath: string; + readonly missingPath: string; + readonly port: number; +}): Promise => { + const stateDir = NodePath.join(input.baseDir, "userdata"); + NodeFS.mkdirSync(stateDir, { recursive: true }); + const settings = { + sharedSessionRoot: input.root, + providers: { + grok: { enabled: true, binaryPath: input.wrapperPath }, + }, + providerInstances: { + "grok-missing": { + driver: "grok", + enabled: true, + config: { enabled: true, binaryPath: input.missingPath }, + }, + }, + }; + NodeFS.writeFileSync(NodePath.join(stateDir, "settings.json"), JSON.stringify(settings), "utf8"); + + const bootstrapFile = NodePath.join(input.baseDir, "bootstrap.ndjson"); + NodeFS.writeFileSync( + bootstrapFile, + `${JSON.stringify({ + mode: "desktop", + noBrowser: true, + port: input.port, + host: "127.0.0.1", + desktopBootstrapToken: DESKTOP_BOOTSTRAP_TOKEN, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + })}\n`, + "utf8", + ); + + const bootstrapFd = NodeFS.openSync(bootstrapFile, "r"); + const appsServerDir = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "..", + ); + const child = NodeChildProcess.spawn( + process.execPath, + [ + "src/bin.ts", + "serve", + "--bootstrap-fd", + "3", + "--base-dir", + input.baseDir, + "--port", + String(input.port), + "--host", + "127.0.0.1", + "--log-level", + "info", + input.root, + ], + { + cwd: appsServerDir, + stdio: ["ignore", "pipe", "pipe", bootstrapFd], + env: { ...process.env }, + }, + ); + NodeFS.closeSync(bootstrapFd); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + return { child, port: input.port, stdout: () => stdout, stderr: () => stderr }; +}; + +const waitForHttp = async (port: number, attempted: () => string): Promise => { + const url = `http://127.0.0.1:${port}/api/auth/session`; + // A cold start runs the full migration + module load; on a busy native host + // that can take tens of seconds, so allow a generous bounded window. + for (let i = 0; i < 400; i += 1) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(3000) }); + if (response.status > 0) return; + } catch { + // not ready + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`server never responded; stderr=\n${attempted()}`); +}; + +const CLEANUP_TIMEOUT_MS = 10_000; +const CLEANUP_POLL_MS = 50; + +/** + * Termination/probe seam. The defaults signal and observe real OS processes by + * the exact pids the fixture recorded; a regression test injects a signal that + * never reports exit to exercise the bounded-failure path without a real kill. + */ +interface OwnedProcessSignal { + readonly terminate: (pid: number) => void; + readonly isAlive: (pid: number) => boolean; +} + +const defaultOwnedProcessSignal: OwnedProcessSignal = { + terminate: (pid) => process.kill(pid, "SIGKILL"), + isAlive: (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } + }, +}; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const readRecordedStubPids = async (pidLogPath: string): Promise> => { + const fs = await import("node:fs/promises"); + let contents = ""; + try { + contents = await fs.readFile(pidLogPath, "utf8"); + } catch { + return []; + } + return [ + ...new Set( + contents + .split("\n") + .map((line) => Number(line.trim())) + .filter((pid) => Number.isInteger(pid) && pid > 0), + ), + ]; +}; + +/** + * Sends the termination signal to exact captured fixture-owned pids and waits + * until every one is confirmed gone. Never matches a name or pattern, so + * installed T3/provider processes are untouched. Rejects with the still-live + * pids if the bound elapses. + */ +const terminateAndConfirmPidsExited = async ( + pids: ReadonlyArray, + options: { + readonly timeoutMs?: number | undefined; + readonly signal?: OwnedProcessSignal | undefined; + } = {}, +): Promise => { + const timeoutMs = options.timeoutMs ?? CLEANUP_TIMEOUT_MS; + const signal = options.signal ?? defaultOwnedProcessSignal; + for (const pid of pids) { + try { + signal.terminate(pid); + } catch { + // Already exited; nothing to reap. + } + } + const deadline = Date.now() + timeoutMs; + let remaining = pids.filter((pid) => signal.isAlive(pid)); + while (remaining.length > 0 && Date.now() < deadline) { + await delay(CLEANUP_POLL_MS); + remaining = remaining.filter((pid) => signal.isAlive(pid)); + } + if (remaining.length > 0) { + throw new Error(`owned process(es) still alive after ${timeoutMs}ms: ${remaining.join(", ")}`); + } +}; + +/** + * Terminates the captured fixture server and awaits its actual exit. Bounded so + * a wedged process fails cleanup rather than hanging it. + */ +const terminateAndConfirmChildExit = ( + child: NodeChildProcess.ChildProcess, + options: { + readonly timeoutMs?: number | undefined; + readonly terminate?: ((child: NodeChildProcess.ChildProcess) => void) | undefined; + } = {}, +): Promise => + new Promise((resolve, reject) => { + const timeoutMs = options.timeoutMs ?? CLEANUP_TIMEOUT_MS; + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + let timer: NodeJS.Timeout; + const onExit = () => { + clearTimeout(timer); + resolve(); + }; + timer = setTimeout(() => { + child.removeListener("exit", onExit); + reject( + new Error( + `captured server pid ${child.pid ?? "unknown"} did not exit within ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + child.once("exit", onExit); + try { + (options.terminate ?? ((target) => target.kill("SIGKILL")))(child); + } catch (error) { + clearTimeout(timer); + child.removeListener("exit", onExit); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + +interface FixtureCleanupInput { + readonly serverChild: NodeChildProcess.ChildProcess | undefined; + readonly fixture: Fixture | undefined; + readonly timeoutMs?: number; + readonly signal?: OwnedProcessSignal; + readonly terminateChild?: (child: NodeChildProcess.ChildProcess) => void; +} + +/** + * Ordered teardown: terminate the captured server so it can no longer spawn + * stubs, then reap the exact pids the fixture recorded, and only after every + * owned process is confirmed exited remove the fixture state. On any + * termination/confirmation failure it throws and deliberately leaves the + * fixture directories in place for diagnosis rather than reporting clean + * cleanup. + */ +const cleanupFixtureProcesses = async (input: FixtureCleanupInput): Promise => { + const errors: Array = []; + if (input.serverChild !== undefined) { + try { + await terminateAndConfirmChildExit(input.serverChild, { + timeoutMs: input.timeoutMs, + terminate: input.terminateChild, + }); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + const fixture = input.fixture; + if (fixture !== undefined) { + try { + const pids = await readRecordedStubPids(fixture.pidLogPath); + await terminateAndConfirmPidsExited(pids, { + timeoutMs: input.timeoutMs, + signal: input.signal, + }); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + if (errors.length > 0) { + throw new Error( + `ENVCHK fixture cleanup failed; preserving fixture state for diagnosis: ${errors.join("; ")}`, + ); + } + if (fixture !== undefined) { + const fs = await import("node:fs/promises"); + await Promise.all([ + fs.rm(fixture.baseDir, { recursive: true, force: true }), + fs.rm(fixture.fakeDir, { recursive: true, force: true }), + fs.rm(fixture.root, { recursive: true, force: true }), + ]); + } +}; + +const bootstrapCookie = async (port: number): Promise => { + const response = await fetch(`http://127.0.0.1:${port}/api/auth/browser-session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ credential: DESKTOP_BOOTSTRAP_TOKEN }), + }); + if (!response.ok) { + throw new Error( + `browser-session bootstrap failed: ${response.status} ${await response.text()}`, + ); + } + const setCookie = response.headers.get("set-cookie"); + if (setCookie === null) throw new Error("bootstrap returned no session cookie"); + return setCookie.split(";")[0] ?? ""; +}; + +const parseSessionCookieFromWsUrl = ( + wsUrl: string, +): { readonly cookie: string | null; readonly url: string } => { + const next = new URL(wsUrl); + const cookie = next.hash.startsWith("#cookie=") + ? decodeURIComponent(next.hash.slice("#cookie=".length)) + : null; + next.hash = ""; + return { cookie, url: next.toString() }; +}; + +const wsRpcProtocolLayer = (wsUrl: string) => { + const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); + const webSocketConstructorLayer = Layer.succeed( + Socket.WebSocketConstructor, + (socketUrl, protocols) => { + const socket = new NodeSocket.NodeWS.WebSocket( + socketUrl, + protocols as string | string[] | undefined, + cookie ? { headers: { cookie } } : undefined, + ); + return socket as unknown as globalThis.WebSocket; + }, + ); + return RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(url).pipe(Layer.provide(webSocketConstructorLayer))), + Layer.provide(RpcSerialization.layerJson), + ); +}; + +const makeWsRpcClient = RpcClient.make(WsRpcGroup); +type WsRpcClient = + typeof makeWsRpcClient extends Effect.Effect ? Client : never; + +const withWsRpcClient = ( + wsUrl: string, + f: (client: WsRpcClient) => Effect.Effect, +) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); + +const readFileLines = async (path: string): Promise> => { + try { + const raw = await import("node:fs/promises").then((fs) => fs.readFile(path, "utf8")); + return raw.split("\n").filter((line) => line.trim().length > 0); + } catch { + return []; + } +}; + +const activitiesOf = (items: ReadonlyArray) => + items.flatMap((item) => + item.kind === "event" && item.event.type === "thread.activity-appended" + ? [item.event.payload.activity] + : [], + ); + +it.live( + "ENVCHK:P1 authenticated production WebSocket smoke: preflight warning delivered over the wire", + () => { + let cleanupFixture: Fixture | undefined; + let cleanupServer: SpawnedServer | undefined; + // Single ordered teardown, memoized so the resource release and the + // `ensuring` guard drive the same run: terminate the server (so it can no + // longer spawn stubs), reap the exact recorded stub pids, and remove state + // only after every owned process is confirmed exited. A failure leaves the + // fixture directories in place for diagnosis. + let teardownPromise: Promise | undefined; + const teardown = Effect.promise(() => { + teardownPromise ??= cleanupFixtureProcesses({ + serverChild: cleanupServer?.child, + fixture: cleanupFixture, + }); + return teardownPromise; + }); + return Effect.gen(function* () { + const fixture = yield* makeFixture(); + cleanupFixture = fixture; + const port = yield* Effect.promise(findFreePort); + const missingPath = NodePath.join(fixture.fakeDir, "not-a-real-grok"); + const server = yield* Effect.acquireRelease( + Effect.promise(() => + spawnServer({ + baseDir: fixture.baseDir, + root: fixture.root, + wrapperPath: fixture.wrapperPath, + missingPath, + port, + }), + ), + () => teardown, + ); + cleanupServer = server; + + yield* Effect.promise(() => waitForHttp(port, server.stderr)); + yield* Effect.logInfo(`ENVCHK_P1_HTTP_READY port=${port}`); + const startupLog = `${server.stdout()}\n${server.stderr()}`; + const cookie = yield* Effect.promise(() => bootstrapCookie(port)); + yield* Effect.logInfo(`ENVCHK_P1_BOOTSTRAP_OK cookie_present=${cookie.length > 0}`); + + const wsUrl = `ws://127.0.0.1:${port}/ws#cookie=${encodeURIComponent(cookie)}`; + const received = yield* Ref.make>([]); + const missingReceived = yield* Ref.make>([]); + + const wsOutcome = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "project.create", + commandId: CommandId.make("envchk-p1-project-create"), + projectId: PROJECT_ID, + title: "ENVCHK:P1", + workspaceRoot: fixture.root, + createdAt: "2026-09-28T00:00:00.000Z", + }); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make("envchk-p1-thread-create"), + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "ENVCHK:P1", + modelSelection: { instanceId: GROK_INSTANCE, model: GROK_MODEL }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-09-28T00:00:00.000Z", + }); + + const stream = client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: THREAD_ID, + afterSequence: 0, + requestCompletionMarker: true, + }); + yield* stream.pipe( + Stream.tap((item) => Ref.update(received, (current) => [...current, item])), + Stream.runDrain, + Effect.forkScoped, + ); + + let synchronized = false; + for (let i = 0; i < 200 && !synchronized; i += 1) { + const items = yield* Ref.get(received); + synchronized = items.some((item) => item.kind === "synchronized"); + if (!synchronized) yield* Effect.sleep("50 millis"); + } + if (!synchronized) { + return yield* Effect.die(new Error("thread subscription never synchronized")); + } + + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("envchk-p1-turn-start"), + threadId: THREAD_ID, + message: { + messageId: MessageId.make("envchk-p1-message"), + role: "user", + text: "envchk bounded launch probe", + attachments: [], + }, + modelSelection: { instanceId: GROK_INSTANCE, model: GROK_MODEL }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-09-28T00:00:00.000Z", + }); + + let found = false; + for (let i = 0; i < 400 && !found; i += 1) { + const items = yield* Ref.get(received); + found = activitiesOf(items).some((activity) => activity.kind === "launch.preflight"); + if (!found) yield* Effect.sleep("50 millis"); + } + + // Configured-executable failure path: a second grok instance whose + // configured binary does not exist. The provider session must fail + // before any model work, and that failure must reach this same + // authenticated wire subscription. + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make("envchk-p1-missing-thread-create"), + threadId: MISSING_THREAD_ID, + projectId: PROJECT_ID, + title: "ENVCHK:P1 missing", + modelSelection: { instanceId: GROK_MISSING_INSTANCE, model: GROK_MODEL }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-09-28T00:00:00.000Z", + }); + const missingStream = client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: MISSING_THREAD_ID, + afterSequence: 0, + requestCompletionMarker: true, + }); + yield* missingStream.pipe( + Stream.tap((item) => Ref.update(missingReceived, (current) => [...current, item])), + Stream.runDrain, + Effect.forkScoped, + ); + let missingSynchronized = false; + for (let i = 0; i < 200 && !missingSynchronized; i += 1) { + const missingItems = yield* Ref.get(missingReceived); + missingSynchronized = missingItems.some((item) => item.kind === "synchronized"); + if (!missingSynchronized) yield* Effect.sleep("50 millis"); + } + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("envchk-p1-missing-turn-start"), + threadId: MISSING_THREAD_ID, + message: { + messageId: MessageId.make("envchk-p1-missing-message"), + role: "user", + text: "envchk missing executable probe", + attachments: [], + }, + modelSelection: { instanceId: GROK_MISSING_INSTANCE, model: GROK_MODEL }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-09-28T00:00:00.000Z", + }); + let failureObserved = false; + for (let i = 0; i < 600 && !failureObserved; i += 1) { + const missingItems = yield* Ref.get(missingReceived); + failureObserved = missingItems.some((item) => { + if (item.kind !== "event") return false; + if (item.event.type === "thread.activity-appended") { + return ( + (item.event.payload as { activity: { kind: string } }).activity.kind === + "provider.turn.start.failed" + ); + } + if (item.event.type === "thread.session-set") { + return ( + (item.event.payload as { session: { status: string } }).session.status === + "error" + ); + } + return false; + }); + if (!failureObserved) yield* Effect.sleep("50 millis"); + } + }), + ), + ).pipe(Effect.timeoutOption("90 seconds")); + + if (wsOutcome._tag === "None") { + return yield* Effect.die(new Error(`WS smoke timed out; stderr=\n${server.stderr()}`)); + } + yield* Effect.logInfo("ENVCHK_P1_WS_PHASE_DONE"); + + const items = yield* Ref.get(received); + const warning = activitiesOf(items).find((activity) => activity.kind === "launch.preflight"); + if (warning === undefined) { + return yield* Effect.die( + new Error( + `no launch.preflight activity reached the WS subscription; stderr=\n${server.stderr()}`, + ), + ); + } + const summary = (warning as { summary: string }).summary; + const payload = (warning as { payload: { code?: string; cwd?: string } }).payload; + const tone = (warning as { tone: string }).tone; + assert.strictEqual(tone, "error"); + assert.strictEqual(payload.code, "shared-root-git"); + assert.strictEqual(payload.cwd, fixture.root); + assert.include(summary, fixture.root); + assert.include(summary, "shared session root"); + + const invocations = yield* Effect.promise(() => readFileLines(fixture.argvLogPath)); + const sessionInvocations = invocations.filter((line) => line.startsWith("agent")); + + const eventTypes = items.flatMap((item) => (item.kind === "event" ? [item.event.type] : [])); + const activityDetails = activitiesOf(items).map((activity) => ({ + kind: (activity as { kind: string }).kind, + tone: (activity as { tone: string }).tone, + summary: (activity as { summary: string }).summary, + })); + + const missingItems = yield* Ref.get(missingReceived); + const missingEventTypes = missingItems.flatMap((item) => + item.kind === "event" ? [item.event.type] : [], + ); + const missingActivityDetails = activitiesOf(missingItems).map((activity) => ({ + kind: (activity as { kind: string }).kind, + tone: (activity as { tone: string }).tone, + summary: (activity as { summary: string }).summary, + })); + const missingSessions = missingItems.flatMap((item) => + item.kind === "event" && item.event.type === "thread.session-set" + ? [ + { + status: (item.event.payload as { session: { status: string } }).session.status, + lastError: (item.event.payload as { session: { lastError: string | null } }).session + .lastError, + }, + ] + : [], + ); + const missingFailureDetail = [ + ...missingSessions.map((session) => session.lastError ?? ""), + ...missingItems.flatMap((item) => + item.kind === "event" && item.event.type === "thread.activity-appended" + ? [JSON.stringify((item.event.payload as { activity: unknown }).activity)] + : [], + ), + ].join("\n"); + const mainFailureText = items + .filter((item) => item.kind === "event") + .map((item) => JSON.stringify((item as { event: unknown }).event)) + .join("\n"); + + const evidence = { + port, + root: fixture.root, + startupPreflightLogged: startupLog.includes("launch preflight"), + warning: { tone, code: payload.code, cwd: payload.cwd, summary }, + providerInvocations: invocations, + sessionStartCount: sessionInvocations.length, + streamItemCount: items.length, + eventTypes, + activityDetails, + missingExecutable: { + eventTypes: missingEventTypes, + activityDetails: missingActivityDetails, + sessions: missingSessions, + failureDetail: missingFailureDetail.slice(0, 4000), + mentionsGrok: missingFailureDetail.includes("grok"), + mentionsMissingPath: missingFailureDetail.includes("not-a-real-grok"), + }, + mainThreadFailure: { + sawTurnStartFailed: activityDetails.some( + (activity) => activity.kind === "provider.turn.start.failed", + ), + failureText: mainFailureText.slice(0, 4000), + }, + }; + yield* Effect.logInfo(`ENVCHK_P1_EVIDENCE=${JSON.stringify(evidence)}`); + assert.strictEqual(sessionInvocations.length, 1); + assert.strictEqual(evidence.startupPreflightLogged, true); + assert.strictEqual(evidence.mainThreadFailure.sawTurnStartFailed, false); + const missingSawFailure = + missingSessions.some((session) => session.status === "error") || + missingActivityDetails.some((activity) => activity.kind === "provider.turn.start.failed"); + assert.strictEqual(missingSawFailure, true); + assert.strictEqual( + missingFailureDetail.includes("grok") || missingFailureDetail.includes("not-a-real-grok"), + true, + ); + }).pipe( + // Bound the whole attempt (startup, auth, subscription, cleanup), not just + // the WebSocket phase, so a wedged start cannot hang the run. Generous + // because a cold native start can take tens of seconds. + Effect.timeoutOption("180 seconds"), + Effect.flatMap((outcome) => + outcome._tag === "None" + ? Effect.die(new Error("ENVCHK wire smoke timed out")) + : Effect.void, + ), + // Cleanup runs on success and failure alike. `teardown` is idempotent, so + // the guard here and the resource release both drive the same ordered + // kill-server -> reap-stubs -> remove-state sequence. + Effect.ensuring(teardown), + Effect.provide(NodeServices.layer), + ); + }, +); + +it.live( + "ENVCHK:E7 fixture cleanup proves owned-process exit before removing state (normal + forced timeout)", + () => + Effect.gen(function* () { + const fs = yield* Effect.promise(() => import("node:fs/promises")); + + const makeCleanupFixture = (): Effect.Effect => + Effect.promise(async () => { + const baseDir = await fs.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "envchk-e7-cleanup-base-"), + ); + const root = await fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-e7-cleanup-root-")); + const fakeDir = await fs.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "envchk-e7-cleanup-fake-"), + ); + return { + baseDir, + root, + fakeDir, + argvLogPath: NodePath.join(fakeDir, "argv.log"), + pidLogPath: NodePath.join(fakeDir, "stub-pids.log"), + wrapperPath: "", + }; + }); + + const spawnSleeper = (): Effect.Effect => + Effect.promise(() => + Promise.resolve( + NodeChildProcess.spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { + stdio: "ignore", + }), + ), + ); + + const awaitChildExit = (child: NodeChildProcess.ChildProcess): Effect.Effect => + Effect.promise( + () => + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once("exit", () => resolve()); + }), + ); + + const removeFixtureState = (fixture: Fixture): Effect.Effect => + Effect.promise(() => + Promise.all([ + fs.rm(fixture.baseDir, { recursive: true, force: true }), + fs.rm(fixture.fakeDir, { recursive: true, force: true }), + fs.rm(fixture.root, { recursive: true, force: true }), + ]), + ); + + // Normal completion: a captured live stub pid is terminated and confirmed + // gone, and only then is the fixture state removed. + const normalFixture = yield* makeCleanupFixture(); + const normalChild = yield* spawnSleeper(); + yield* Effect.promise(() => + fs.writeFile(normalFixture.pidLogPath, `${normalChild.pid ?? 0}\n`, "utf8"), + ); + yield* Effect.promise(() => + cleanupFixtureProcesses({ serverChild: undefined, fixture: normalFixture }), + ); + assert.strictEqual(NodeFS.existsSync(normalFixture.baseDir), false); + assert.strictEqual(NodeFS.existsSync(normalFixture.fakeDir), false); + assert.strictEqual(NodeFS.existsSync(normalFixture.root), false); + + // Forced timeout: a captured pid that never reports exit must fail cleanup + // clearly and preserve the fixture directories for diagnosis. + const stuckFixture = yield* makeCleanupFixture(); + const stuckChild = yield* spawnSleeper(); + yield* Effect.promise(() => + fs.writeFile(stuckFixture.pidLogPath, `${stuckChild.pid ?? 0}\n`, "utf8"), + ); + const neverExits: OwnedProcessSignal = { terminate: () => {}, isAlive: () => true }; + const failure = yield* Effect.promise(() => + cleanupFixtureProcesses({ + serverChild: undefined, + fixture: stuckFixture, + timeoutMs: 150, + signal: neverExits, + }).then( + () => undefined, + (error: unknown) => error, + ), + ); + assert.instanceOf(failure, Error); + assert.include((failure as Error).message, "preserving fixture state"); + assert.strictEqual(NodeFS.existsSync(stuckFixture.baseDir), true); + assert.strictEqual(NodeFS.existsSync(stuckFixture.fakeDir), true); + assert.strictEqual(NodeFS.existsSync(stuckFixture.root), true); + + // Real teardown of the deliberately-stuck fixture so this test leaks nothing. + stuckChild.kill("SIGKILL"); + yield* awaitChildExit(stuckChild); + yield* removeFixtureState(stuckFixture); + }), +); diff --git a/apps/server/integration/launchPreflightDelivery.integration.test.ts b/apps/server/integration/launchPreflightDelivery.integration.test.ts new file mode 100644 index 000000000000..9af6df47edd5 --- /dev/null +++ b/apps/server/integration/launchPreflightDelivery.integration.test.ts @@ -0,0 +1,1029 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - a real temporary workspace exercises the launch path and tests stringify provider warnings for failure detail. +import { + CommandId, + GrokSettings, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, assert } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../src/config.ts"; +import * as LaunchPreflight from "../src/environment/LaunchPreflight.ts"; +import { LaunchPreflightWarningInbox } from "../src/environment/LaunchPreflightWarningInbox.ts"; +import { makeLaunchPreflightWarningReporter } from "../src/environment/launchPreflightReporter.ts"; +import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../src/orchestration/Services/OrchestrationEngine.ts"; +import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; +import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; +import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { makeGrokAdapter } from "../src/provider/Layers/GrokAdapter.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../src/provider/Layers/ProviderEventLoggers.ts"; +import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import { ProviderAdapterProcessError } from "../src/provider/Errors.ts"; +import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderService } from "../src/provider/Services/ProviderService.ts"; +import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; +import { ServerSettingsService } from "../src/serverSettings.ts"; +import { execScriptSource, writeFakeCli } from "../src/testUtils/fakeCli.ts"; +import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; +import { makeTestProviderAdapterHarness } from "./TestProviderAdapter.integration.ts"; + +const codexInstanceId = ProviderInstanceId.make("codex"); +const grokInstanceId = ProviderInstanceId.make("grok"); +const decodeGrokSettings = Schema.decodeSync(GrokSettings); + +const findingResult = ( + findings: ReadonlyArray, +): LaunchPreflight.LaunchPreflightResult => ({ + findings, + warnings: findings.filter((finding) => finding.severity === "warning"), + blockers: findings.filter((finding) => finding.severity === "blocker"), +}); + +/** + * The real orchestration engine the production reporter dispatches into. Its + * domain-event stream is exactly what a client subscribes to. + */ +const orchestrationEngineLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, +).pipe( + Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationEventStoreLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-a3-engine-" })), + Layer.provideMerge(NodeServices.layer), +); + +const makeWorkspaceDirectory = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fs.makeTempDirectory(); + yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n"); + return cwd; +}).pipe(Effect.provide(NodeServices.layer)); + +it.live( + "A3: the production reporter delivers a real pre-thread warning through the real subscription", + () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + yield* Effect.promise(async () => { + NodeChildProcess.execFileSync("git", ["init", "-q"], { cwd }); + }); + + // A real preflight finding before any thread exists: the workspace is a + // Git repository and is explicitly configured as the shared session root, + // so the real bounded probe reports an unexpected umbrella repository. + const early = yield* Effect.gen(function* () { + const preflight = yield* LaunchPreflight.LaunchPreflight; + return yield* preflight.run(cwd, { isSharedRoot: true }); + }).pipe( + Effect.provide( + LaunchPreflight.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(NodeServices.layer), + ), + ), + ); + assert.deepStrictEqual( + early.findings.map((finding) => finding.code), + ["shared-root-git"], + ); + const earlyMessage = early.warnings[0]?.message ?? ""; + assert.isAbove(earlyMessage.length, 0); + + const engineContext = yield* Layer.build(orchestrationEngineLayer); + const engine = Context.get(engineContext, OrchestrationEngineService); + const crypto = yield* Crypto.Crypto; + const reportWarning = makeLaunchPreflightWarningReporter(engine, crypto); + + const harness = yield* makeTestProviderAdapterHarness(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const pathService = yield* Path.Path; + inbox.set( + LaunchPreflight.normalizePathKey(pathService, cwd), + early.warnings.map((warning) => ({ code: warning.code, message: warning.message })), + ); + + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(cwd, cwd).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ ...DEFAULT_SERVER_SETTINGS, sharedSessionRoot: cwd }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + + // The production reporter is the composition root's own closure; the + // per-launch runner returns no findings so the only warning delivered is + // the real pre-thread one. This is an observable, not a substituted sink. + const providerLayer = makeProviderServiceLive({ + reportLaunchPreflightWarning: reportWarning, + launchPreflightRunner: () => Effect.succeed(findingResult([])), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + const projectId = ProjectId.make("a3-project"); + const threadId = ThreadId.make("a3-thread"); + const received = yield* Ref.make>([]); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const events = yield* engine.subscribeDomainEvents; + yield* events.pipe( + Stream.tap((event) => Ref.update(received, (current) => [...current, event])), + Stream.runDrain, + Effect.forkScoped, + ); + + // The startup notice is already pending. The thread is created only + // now, so the notice was found before it existed. + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("a3-project"), + projectId, + title: "A3", + workspaceRoot: cwd, + createdAt: "2026-09-28T00:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("a3-thread"), + threadId, + projectId, + title: "A3", + modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-09-28T00:00:00.000Z", + }); + + const session = yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd, + runtimeMode: "full-access", + }); + assert.equal(session.provider, "codex"); + // Warning-only launch started the configured provider exactly once. + assert.equal(harness.getStartCount(), 1); + + // Give the forked subscription a chance to drain the appended event. + yield* Effect.sleep("50 millis"); + }).pipe(Effect.provide(providerLayer)); + + const collected = yield* Ref.get(received); + const activities = collected.filter( + (event): event is Extract => + event.type === "thread.activity-appended", + ); + const warningActivity = activities.find( + (event) => event.payload.activity.kind === "launch.preflight", + ); + assert.isDefined(warningActivity, "no launch.preflight activity reached the subscription"); + assert.equal(warningActivity?.payload.activity.tone, "error"); + assert.strictEqual(warningActivity?.payload.activity.summary, earlyMessage); + assert.equal(warningActivity?.payload.threadId, threadId); + assert.deepStrictEqual(warningActivity?.payload.activity.payload, { + code: "shared-root-git", + cwd, + }); + // A notice is not dropped before successful delivery: it was consumed. + assert.strictEqual(inbox.size, 0); + + yield* Effect.promise(() => + import("node:fs/promises").then((fs) => fs.rm(cwd, { recursive: true, force: true })), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live( + "A3: a notice is retained when the production report fails, and delivered by the next session", + () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const code = "shared-root-git" as const; + const message = "The shared session root is itself a Git repository."; + const harness = yield* makeTestProviderAdapterHarness(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + const pathService = yield* Path.Path; + const inbox = new Map>(); + inbox.set(LaunchPreflight.normalizePathKey(pathService, cwd), [{ code, message }]); + let attempt = 0; + + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(cwd, cwd).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ ...DEFAULT_SERVER_SETTINGS, sharedSessionRoot: cwd }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + const providerLayer = makeProviderServiceLive({ + // First delivery fails; the second succeeds. + reportLaunchPreflightWarning: () => + Effect.sync(() => { + attempt += 1; + return attempt > 1; + }), + launchPreflightRunner: () => Effect.succeed(findingResult([])), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("a3-retry-thread"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd, + runtimeMode: "full-access", + }); + // Failed delivery kept the notice pending for the next session. + assert.strictEqual(inbox.size, 1); + + const secondThread = ThreadId.make("a3-retry-thread-2"); + yield* provider.startSession(secondThread, { + threadId: secondThread, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd, + runtimeMode: "full-access", + }); + assert.strictEqual(attempt, 2); + assert.strictEqual(inbox.size, 0); + }).pipe(Effect.provide(providerLayer)); + + yield* Effect.promise(() => + import("node:fs/promises").then((fs) => fs.rm(cwd, { recursive: true, force: true })), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +interface DeliveryProviderOptions { + readonly cwd: string; + readonly registry: ReturnType; + readonly inbox: Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >; + readonly reportWarning: (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }) => Effect.Effect; +} + +const deliveryProviderLayer = (options: DeliveryProviderOptions) => { + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, options.registry), + Layer.succeed(LaunchPreflightWarningInbox, options.inbox), + ServerConfig.layerTest(options.cwd, options.cwd).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + sharedSessionRoot: options.cwd, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + return makeProviderServiceLive({ + reportLaunchPreflightWarning: options.reportWarning, + launchPreflightRunner: () => Effect.succeed(findingResult([])), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); +}; + +it.live( + "A3: a real configured dummy provider starts once and the production reporter delivers through the subscription", + () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-a3-grok-")), + ); + const argvLogPath = NodePath.join(dir, "argv.log"); + const mockAgentPath = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../scripts/acp-mock-agent.ts", + ); + const wrapperPath = writeFakeCli({ + directory: dir, + name: "fake-grok-a3", + source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); + + const engineContext = yield* Layer.build(orchestrationEngineLayer); + const engine = Context.get(engineContext, OrchestrationEngineService); + const crypto = yield* Crypto.Crypto; + const reportWarning = makeLaunchPreflightWarningReporter(engine, crypto); + + const grokAdapter = yield* makeGrokAdapter( + decodeGrokSettings({ binaryPath: wrapperPath }), + ).pipe( + Effect.provide(ServerConfig.layerTest(cwd, cwd)), + Effect.provide(NodeServices.layer), + Effect.orDie, + ); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("grok")]: grokAdapter, + }); + const message = "The shared session root is itself a Git repository."; + const pathService = yield* Path.Path; + const inbox = new Map< + string, + ReadonlyArray<{ code: LaunchPreflight.LaunchPreflightFindingCode; message: string }> + >(); + inbox.set(LaunchPreflight.normalizePathKey(pathService, cwd), [ + { code: "shared-root-git", message }, + ]); + + const providerLayer = deliveryProviderLayer({ + cwd, + registry, + inbox, + reportWarning, + }); + + const projectId = ProjectId.make("a3-grok-project"); + const threadId = ThreadId.make("a3-grok-thread"); + const received = yield* Ref.make>([]); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const events = yield* engine.subscribeDomainEvents; + yield* events.pipe( + Stream.tap((event) => Ref.update(received, (current) => [...current, event])), + Stream.runDrain, + Effect.forkScoped, + ); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("a3-grok-project"), + projectId, + title: "A3", + workspaceRoot: cwd, + createdAt: "2026-09-28T00:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("a3-grok-thread"), + threadId, + projectId, + title: "A3", + modelSelection: { instanceId: grokInstanceId, model: "grok-4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-09-28T00:00:00.000Z", + }); + const session = yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + cwd, + runtimeMode: "full-access", + }); + assert.equal(session.provider, "grok"); + yield* Effect.sleep("50 millis"); + }).pipe(Effect.provide(providerLayer)); + + const collected = yield* Ref.get(received); + const warningActivity = collected.find( + (event) => + event.type === "thread.activity-appended" && + event.payload.activity.kind === "launch.preflight", + ); + assert.isDefined(warningActivity); + assert.strictEqual( + warningActivity?.type === "thread.activity-appended" + ? warningActivity.payload.activity.summary + : undefined, + message, + ); + + const invocations = yield* Effect.promise(() => + NodeFSP.readFile(argvLogPath, "utf8").then( + (raw) => raw.split("\n").filter((line) => line.trim().length > 0).length, + () => 0, + ), + ); + assert.equal(invocations, 1); + assert.strictEqual(inbox.size, 0); + + yield* Effect.promise(() => NodeFSP.rm(dir, { recursive: true, force: true })); + yield* Effect.promise(() => NodeFSP.rm(cwd, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("A3: a missing configured executable fails before model work", () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const pathService = yield* Path.Path; + const missing = pathService.join(cwd, "not-a-real-grok"); + const engineContext = yield* Layer.build(orchestrationEngineLayer); + const engine = Context.get(engineContext, OrchestrationEngineService); + const crypto = yield* Crypto.Crypto; + const reportWarning = makeLaunchPreflightWarningReporter(engine, crypto); + const missingGrokAdapter = yield* makeGrokAdapter( + decodeGrokSettings({ binaryPath: missing }), + ).pipe( + Effect.provide(ServerConfig.layerTest(cwd, cwd)), + Effect.provide(NodeServices.layer), + Effect.orDie, + ); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("grok")]: missingGrokAdapter, + }); + const inbox = new Map< + string, + ReadonlyArray<{ code: LaunchPreflight.LaunchPreflightFindingCode; message: string }> + >(); + const providerLayer = deliveryProviderLayer({ cwd, registry, inbox, reportWarning }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("a3-missing-exec-thread"); + const error = yield* provider + .startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + cwd, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + assert.instanceOf(error, ProviderAdapterProcessError); + assert.include((error as ProviderAdapterProcessError).message, "grok"); + }).pipe(Effect.provide(providerLayer)); + + yield* Effect.promise(() => NodeFSP.rm(cwd, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); +// --- R3: production settings/instance construction selects the consumer ------------------------ + +interface CapturedLaunch { + consumer?: LaunchPreflight.LaunchPreflightConsumer | undefined; + gitEnvironment?: NodeJS.ProcessEnv | undefined; +} + +const captureConsumerLayer = (options: { + readonly cwd: string; + readonly registry: ReturnType; + readonly providerInstances: Readonly>; + readonly captured: CapturedLaunch; +}) => { + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, options.registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(options.cwd, options.cwd).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: options.providerInstances, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + return makeProviderServiceLive({ + reportLaunchPreflightWarning: () => Effect.succeed(true), + launchPreflightRunner: (_root, runnerOptions) => { + options.captured.consumer = runnerOptions?.consumer; + options.captured.gitEnvironment = runnerOptions?.gitEnvironment; + return Effect.succeed(findingResult([])); + }, + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); +}; + +const openCodeInstance = (overrides: { + readonly config?: unknown; + readonly environment?: ProviderInstanceConfig["environment"]; +}): ProviderInstanceConfig => ({ + driver: ProviderDriverKind.make("opencode"), + enabled: true, + ...(overrides.config !== undefined ? { config: overrides.config } : {}), + ...(overrides.environment !== undefined ? { environment: overrides.environment } : {}), +}); + +it.live( + "R3: production settings select the launch consumer (local OpenCode, snapshot:false, external, fallback)", + () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const sentinel = "envchk-e6-provider-sentinel"; + const codexHarness = yield* makeTestProviderAdapterHarness(); + const opencodeHarness = yield* makeTestProviderAdapterHarness({ + provider: ProviderDriverKind.make("opencode"), + }); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codexHarness.adapter, + [ProviderDriverKind.make("opencode")]: opencodeHarness.adapter, + }); + + const runCase = (input: { + readonly threadId: string; + readonly provider: ProviderDriverKind; + readonly providerInstanceId: ProviderInstanceId; + readonly providerInstances: Readonly>; + }) => + Effect.gen(function* () { + const captured: CapturedLaunch = {}; + const layer = captureConsumerLayer({ + cwd, + registry, + providerInstances: input.providerInstances, + captured, + }); + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make(input.threadId); + yield* provider.startSession(threadId, { + threadId, + provider: input.provider, + providerInstanceId: input.providerInstanceId, + cwd, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(layer)); + return captured; + }); + + // 1. Default local OpenCode: snapshots are on and the selected provider + // environment is threaded through unchanged. + const local = yield* runCase({ + threadId: "r3-local", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ + environment: [{ name: "ENVCHK_SENTINEL", value: sentinel, sensitive: false }], + }), + }, + }); + assert.deepStrictEqual(local.consumer, { driver: "opencode", snapshotsEnabled: true }); + assert.strictEqual(local.gitEnvironment?.ENVCHK_SENTINEL, sentinel); + + // 2. Effective `snapshot:false` in the selected instance's environment + // suppresses the provider-specific requirement. + const disabled = yield* runCase({ + threadId: "r3-disabled", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ + environment: [ + { name: "OPENCODE_CONFIG_CONTENT", value: '{"snapshot":false}', sensitive: false }, + { name: "ENVCHK_SENTINEL", value: sentinel, sensitive: false }, + ], + }), + }, + }); + assert.deepStrictEqual(disabled.consumer, { driver: "opencode", snapshotsEnabled: false }); + + // 2b. W1-D: valid inline JSONC (comment + trailing comma) with + // snapshot:false is honored, not misread as enabled. + const jsoncDisabled = yield* runCase({ + threadId: "r3-jsonc-disabled", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ + environment: [ + { + name: "OPENCODE_CONFIG_CONTENT", + value: '{ /* staging off */ "snapshot": false, }', + sensitive: false, + }, + ], + }), + }, + }); + assert.deepStrictEqual(jsoncDisabled.consumer, { + driver: "opencode", + snapshotsEnabled: false, + }); + + // 2c. W1-D: unknown configuration is not asserted enabled. + const unknownConfig = yield* runCase({ + threadId: "r3-unknown-config", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ + environment: [ + { name: "OPENCODE_CONFIG_CONTENT", value: "not valid config", sensitive: false }, + ], + }), + }, + }); + assert.deepStrictEqual(unknownConfig.consumer, { + driver: "opencode", + snapshotsEnabled: undefined, + }); + + // 3. External OpenCode server: the local Git is not that process's Git. + const external = yield* runCase({ + threadId: "r3-external", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ config: { serverUrl: "http://127.0.0.1:4096" } }), + }, + }); + assert.deepStrictEqual(external.consumer, { driver: "opencode", snapshotsEnabled: false }); + + // 4. Non-OpenCode fallback: T3's own Git path only needs `--sparse` in a + // sparse checkout, which the repository probe decides. + const fallback = yield* runCase({ + threadId: "r3-codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerInstances: { + codex: { driver: ProviderDriverKind.make("codex"), enabled: true, config: {} }, + }, + }); + assert.deepStrictEqual(fallback.consumer, { driver: "codex", snapshotsEnabled: true }); + + yield* Effect.promise(() => NodeFSP.rm(cwd, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const writeSparseLessGit = (binDir: string, realGit: string): void => { + writeFakeCli({ + directory: binDir, + name: "git", + platform: process.platform, + source: [ + 'import { spawnSync } from "node:child_process";', + "const args = process.argv.slice(2);", + 'if (args[0] === "add" && args[1] === "-h") {', + ' process.stdout.write("usage: git add [options] [--] ...\\n -n, --dry-run dry run\\n -v, --verbose be verbose\\n");', + " process.exit(0);", + "}", + `const r = spawnSync(${JSON.stringify(realGit)}, args, { stdio: "inherit" });`, + "process.exit(r.status ?? 1);", + "", + ].join("\n"), + }); +}; + +const resolveRealGitPath = (): string => + NodeChildProcess.execFileSync(process.platform === "win32" ? "where.exe" : "which", ["git"], { + encoding: "utf8", + }) + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? "git"; + +it.live( + "R3: a local OpenCode launch with default snapshots detects a controlled Git missing --sparse", + () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-r3-sparse-")), + ); + const repo = NodePath.join(base, "repo"); + const providerBin = NodePath.join(base, "provider-bin"); + yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); + yield* Effect.promise(() => NodeFSP.mkdir(providerBin, { recursive: true })); + const realGit = resolveRealGitPath(); + writeSparseLessGit(providerBin, realGit); + const git = (args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd: repo }); + }); + yield* git(["init", "-q"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "hello\n")); + yield* git(["add", "."]); + yield* git(["commit", "-q", "-m", "initial"]); + + const harness = yield* makeTestProviderAdapterHarness({ + provider: ProviderDriverKind.make("opencode"), + }); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("opencode")]: harness.adapter, + }); + const reported = yield* Ref.make< + ReadonlyArray<{ readonly code: string; readonly message: string }> + >([]); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const shared = Layer.mergeAll( + ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntime.layer)), + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(repo, repo).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + // No OPENCODE_CONFIG_CONTENT: OpenCode's default snapshots are on. The + // selected instance environment resolves the controlled sparse-less Git. + providerInstances: { + [ProviderInstanceId.make("opencode")]: { + driver: ProviderDriverKind.make("opencode"), + enabled: true, + environment: [ + { + name: "PATH", + value: `${providerBin}${NodePath.delimiter}${process.env.PATH ?? ""}`, + sensitive: false, + }, + ], + }, + }, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + // The real preflight runner runs, so the production-derived consumer and the + // selected provider environment are what the capability probe inspects. + const providerLayer = makeProviderServiceLive({ + reportLaunchPreflightWarning: ({ code, message }) => + Ref.update(reported, (current) => [...current, { code, message }]).pipe(Effect.as(true)), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("r3-sparse"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + cwd: repo, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + const warnings = yield* Ref.get(reported); + const sparseWarning = warnings.find( + (warning) => warning.code === "git-sparse-add-unsupported", + ); + assert.isDefined(sparseWarning, "no git-sparse-add-unsupported warning was reported"); + assert.include(sparseWarning?.message ?? "", "OpenCode"); + assert.include(sparseWarning?.message ?? "", "--sparse"); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// --- W1-C: configured shared root recognized through a physical alias (production settings) --- + +it.live( + "W1-C: a configured shared root is recognized through a physical alias (junction/symlink)", + () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-w1c-alias-")), + ); + const realRoot = NodePath.join(base, "real-root"); + const aliasRoot = NodePath.join(base, "alias-root"); + yield* Effect.promise(() => NodeFSP.mkdir(realRoot, { recursive: true })); + // A Windows junction (or a POSIX directory symlink) is a real alias of the + // same physical directory; the lexical spellings differ. + yield* Effect.promise(() => + NodeFSP.symlink(realRoot, aliasRoot, process.platform === "win32" ? "junction" : "dir"), + ); + yield* Effect.promise(async () => { + NodeChildProcess.execFileSync(resolveRealGitPath(), ["init", "-q"], { cwd: realRoot }); + }); + + const harness = yield* makeTestProviderAdapterHarness(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + const reported = yield* Ref.make< + ReadonlyArray<{ readonly code: string; readonly message: string }> + >([]); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const shared = Layer.mergeAll( + ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntime.layer)), + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(aliasRoot, aliasRoot).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + // The canonical root is configured; the session cwd is the alias. + sharedSessionRoot: realRoot, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + const providerLayer = makeProviderServiceLive({ + reportLaunchPreflightWarning: ({ code, message }) => + Ref.update(reported, (current) => [...current, { code, message }]).pipe(Effect.as(true)), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("w1c-alias"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: aliasRoot, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + const warnings = yield* Ref.get(reported); + assert.isTrue( + warnings.some((warning) => warning.code === "shared-root-git"), + `expected shared-root-git through the physical alias; got ${JSON.stringify(warnings)}`, + ); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// --- W1-B: non-OpenCode launch in a verified sparse checkout, incomplete probe warns --------- + +it.live( + "W1-B: a non-OpenCode launch in a verified sparse checkout warns when the capability probe fails", + () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-w1b-sparse-")), + ); + const repo = NodePath.join(base, "repo"); + const providerBin = NodePath.join(base, "provider-bin"); + yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); + yield* Effect.promise(() => NodeFSP.mkdir(providerBin, { recursive: true })); + const realGit = resolveRealGitPath(); + writeSparseLessGit(providerBin, realGit); + const git = (args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd: repo }); + }); + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.join(repo, "src"), { recursive: true })); + yield* git(["init", "-q"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(repo, "src", "file.txt"), "hi\n"), + ); + yield* git(["add", "."]); + yield* git(["commit", "-q", "-m", "initial"]); + // A real sparse checkout makes `git add --sparse` relevant for T3's own + // checkpoint path, independent of the selected (non-OpenCode) consumer. + yield* git(["sparse-checkout", "set", "src"]); + + const harness = yield* makeTestProviderAdapterHarness(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + const reported = yield* Ref.make< + ReadonlyArray<{ readonly code: string; readonly message: string }> + >([]); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const shared = Layer.mergeAll( + ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntime.layer)), + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(repo, repo).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "PATH", + value: `${providerBin}${NodePath.delimiter}${process.env.PATH ?? ""}`, + sensitive: false, + }, + ], + }, + }, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + const providerLayer = makeProviderServiceLive({ + reportLaunchPreflightWarning: ({ code, message }) => + Ref.update(reported, (current) => [...current, { code, message }]).pipe(Effect.as(true)), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("w1b-sparse"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: repo, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + const warnings = yield* Ref.get(reported); + const sparseWarning = warnings.find( + (warning) => warning.code === "git-sparse-add-unsupported", + ); + assert.isDefined( + sparseWarning, + `expected a sparse-checkout capability warning; got ${JSON.stringify(warnings)}`, + ); + // The non-OpenCode path uses the sparse-checkout message, not the + // consumer-specific one. + assert.include(sparseWarning?.message ?? "", "sparse"); + assert.notInclude(sparseWarning?.message ?? "", "OpenCode"); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 77173af1b377..4461c133ccc5 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -22,6 +22,7 @@ import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { HttpServer } from "effect/unstable/http"; import * as NetAddress from "effect/unstable/net/NetAddress"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as EnvironmentAuth from "../src/auth/EnvironmentAuth.ts"; import * as ServiceLauncherClient from "../src/cloud/serviceLauncherClient.ts"; @@ -45,6 +46,7 @@ import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; import * as ServerSettings from "../src/serverSettings.ts"; import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; const providerInstanceId = ProviderInstanceId.make("codex"); const projectId = ProjectId.make("project-startup-orphan"); @@ -111,6 +113,16 @@ const startupDependencies = Layer.mergeAll( ), AnalyticsService.layerTest, Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "git version 2.55.0\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), Layer.succeed(ProviderService.ProviderService, { startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 0d041b36ecb8..42567a3a3fb9 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -1,30 +1,46 @@ +// @effect-diagnostics nodeBuiltinImport:off - the real dummy-executable fixture writes a launcher with node fs/path. import type { ProviderRuntimeEvent } from "@t3tools/contracts"; -import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { GrokSettings, ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { it, assert } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderSessionDirectory } from "../src/provider/Services/ProviderSessionDirectory.ts"; +import { makeGrokAdapter } from "../src/provider/Layers/GrokAdapter.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers, } from "../src/provider/Layers/ProviderEventLoggers.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import { + ProviderAdapterProcessError, + ProviderLaunchPreflightBlockedError, +} from "../src/provider/Errors.ts"; import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; import * as ServerConfig from "../src/config.ts"; +import * as LaunchPreflight from "../src/environment/LaunchPreflight.ts"; +import { LaunchPreflightWarningInbox } from "../src/environment/LaunchPreflightWarningInbox.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; +import { execScriptSource, writeFakeCli } from "../src/testUtils/fakeCli.ts"; import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; @@ -41,6 +57,8 @@ import { } from "./fixtures/providerRuntime.ts"; const codexInstanceId = ProviderInstanceId.make("codex"); +const grokInstanceId = ProviderInstanceId.make("grok"); +const decodeGrokSettings = Schema.decodeSync(GrokSettings); const makeWorkspaceDirectory = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -53,7 +71,7 @@ const makeWorkspaceDirectory = Effect.gen(function* () { interface IntegrationFixture { readonly cwd: string; readonly harness: TestProviderAdapterHarness; - readonly layer: Layer.Layer; + readonly layer: Layer.Layer; } interface RecordedAnalyticsEvent { @@ -78,13 +96,67 @@ const makeRecordingAnalytics = Effect.gen(function* () { return { layer, get: Ref.get(recorded) } as const; }); -const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer }) => +const makeIntegrationFixture = (options?: { + readonly analytics?: Layer.Layer; + readonly grokBinaryPath?: string; + readonly serverConfigCwd?: string; + readonly settings?: Parameters[0]; + /** Configure the workspace directory itself as the shared session root. */ + readonly sharedSessionRootIsWorkspace?: boolean; + /** Pre-seed the pre-thread startup warning inbox for the workspace cwd. */ + readonly pendingWarnings?: ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }>; + readonly launchPreflightRunner?: ( + root: string, + options?: { + readonly isSharedRoot?: boolean; + readonly configuredRoot?: string; + readonly consumer?: LaunchPreflight.LaunchPreflightConsumer; + readonly gitEnvironment?: NodeJS.ProcessEnv; + }, + ) => Effect.Effect; + readonly reportLaunchPreflightWarning?: (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }) => Effect.Effect; +}) => Effect.gen(function* () { const cwd = yield* makeWorkspaceDirectory; const harness = yield* makeTestProviderAdapterHarness(); + const pathService = yield* Path.Path; + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + if (options?.pendingWarnings !== undefined) { + inbox.set(LaunchPreflight.normalizePathKey(pathService, cwd), options.pendingWarnings); + } + + // A real adapter whose configured executable is the caller's path, so a + // launch exercises the actual platform spawn/error path (not a mock). + const realAdapters = + options?.grokBinaryPath === undefined + ? {} + : { + [ProviderDriverKind.make("grok")]: yield* makeGrokAdapter( + decodeGrokSettings({ binaryPath: options.grokBinaryPath }), + ).pipe( + Effect.provide(ServerConfig.layerTest(cwd, cwd)), + Effect.provide(NodeServices.layer), + Effect.orDie, + ), + }; const registry = makeAdapterRegistryMock({ [ProviderDriverKind.make("codex")]: harness.adapter, + ...realAdapters, }); const directoryLayer = ProviderSessionDirectoryLive.pipe( @@ -94,16 +166,26 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); + +const blockedFinding: LaunchPreflight.LaunchPreflightFinding = { + code: "git-startup-failed", + severity: "blocker", + message: "Git could not start; the session cannot checkpoint. Fix Git and retry.", +}; + +const warningFinding: LaunchPreflight.LaunchPreflightFinding = { + code: "shared-root-git", + severity: "warning", + message: "The shared session root is itself a Git repository.", +}; + +const findingResult = ( + findings: ReadonlyArray, +): LaunchPreflight.LaunchPreflightResult => ({ + findings, + warnings: findings.filter((finding) => finding.severity === "warning"), + blockers: findings.filter((finding) => finding.severity === "blocker"), +}); + +it.live("a launch-preflight blocker prevents the new provider session from starting", () => + Effect.gen(function* () { + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: () => Effect.succeed(findingResult([blockedFinding])), + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const error = yield* provider + .startSession(ThreadId.make("thread-preflight-blocked"), { + threadId: ThreadId.make("thread-preflight-blocked"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderLaunchPreflightBlockedError); + const sessions = yield* fixture.harness.adapter.listSessions(); + assert.equal(sessions.length, 0); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("a launch-preflight warning is reported and the session still starts once", () => + Effect.gen(function* () { + const reported = yield* Ref.make>([]); + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: () => Effect.succeed(findingResult([warningFinding])), + reportLaunchPreflightWarning: ({ message }) => + Ref.update(reported, (current) => [...current, message]).pipe(Effect.as(true)), + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession(ThreadId.make("thread-preflight-warned"), { + threadId: ThreadId.make("thread-preflight-warned"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + + assert.equal((session.threadId ?? "").length > 0, true); + assert.equal(fixture.harness.getStartCount(), 1); + assert.deepStrictEqual(yield* Ref.get(reported), [warningFinding.message]); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("the same repository at the server cwd is ordinary by default", () => + Effect.gen(function* () { + const seen: Array<{ readonly isSharedRoot?: boolean; readonly configuredRoot?: string }> = []; + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: (_root, options) => { + seen.push({ + ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options?.configuredRoot !== undefined + ? { configuredRoot: options.configuredRoot } + : {}), + }); + return Effect.succeed(findingResult([])); + }, + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(ThreadId.make("thread-shared-default"), { + threadId: ThreadId.make("thread-shared-default"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + // No shared root is configured, so neither an explicit shared-root override + // nor a configured root is handed to the bounded preflight. + assert.deepStrictEqual(seen, [{}]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live( + "a configured shared session root is carried into the preflight independently of the cwd", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const otherBackendCwd = yield* fs.makeTempDirectory(); + const seen: Array<{ readonly isSharedRoot?: boolean; readonly configuredRoot?: string }> = []; + const fixture = yield* makeIntegrationFixture({ + serverConfigCwd: otherBackendCwd, + sharedSessionRootIsWorkspace: true, + launchPreflightRunner: (_root, options) => { + seen.push({ + ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options?.configuredRoot !== undefined + ? { configuredRoot: options.configuredRoot } + : {}), + }); + return Effect.succeed(findingResult([])); + }, + }); + const nested = path.join(fixture.cwd, "nested"); + yield* fs.makeDirectory(nested, { recursive: true }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + // The exact configured root is carried into the bounded preflight even + // though the backend cwd is a different directory. + yield* provider.startSession(ThreadId.make("thread-shared-root"), { + threadId: ThreadId.make("thread-shared-root"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + // A nested repository selected as the session cwd still receives the + // configured root; the preflight owns the canonical comparison and keeps + // the nested repository ordinary (covered by the real-preflight suites). + yield* provider.startSession(ThreadId.make("thread-shared-nested"), { + threadId: ThreadId.make("thread-shared-nested"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: nested, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + assert.deepStrictEqual(seen, [ + { configuredRoot: fixture.cwd }, + { configuredRoot: fixture.cwd }, + ]); + // Clean up the extra backend cwd owned by this fixture. + yield* fs.remove(otherBackendCwd, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("the launch preflight inspects the selected provider environment", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const providerBin = yield* fs.makeTempDirectory(); + const sentinel = "envchk-integration-sentinel"; + const captured: Array<{ + readonly consumer?: LaunchPreflight.LaunchPreflightConsumer | undefined; + readonly gitEnvironment?: NodeJS.ProcessEnv | undefined; + }> = []; + const fixture = yield* makeIntegrationFixture({ + settings: { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + environment: [ + { name: "PATH", value: providerBin }, + { name: "ENVCHK_SENTINEL", value: sentinel }, + ], + }, + }, + }, + launchPreflightRunner: (_root, options) => { + captured.push({ consumer: options?.consumer, gitEnvironment: options?.gitEnvironment }); + return Effect.succeed(findingResult([])); + }, + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(ThreadId.make("thread-provider-env"), { + threadId: ThreadId.make("thread-provider-env"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + assert.strictEqual(captured.length, 1); + // The selected consumer is passed to the preflight. + assert.strictEqual(captured[0]?.consumer?.driver, "codex"); + // The launch environment is the selected provider environment layered over + // the host: the sentinel and PATH come from the instance (replacement), + // while unrelated host variables are inherited. + const environment = captured[0]?.gitEnvironment; + assert.isDefined(environment); + assert.strictEqual(environment?.ENVCHK_SENTINEL, sentinel); + assert.strictEqual(environment?.PATH, providerBin); + assert.strictEqual(environment?.HOME, process.env.HOME); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("pre-thread startup warnings reach the first affected session", () => + Effect.gen(function* () { + const reported = yield* Ref.make>([]); + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: () => Effect.succeed(findingResult([])), + reportLaunchPreflightWarning: ({ message }) => + Ref.update(reported, (current) => [...current, message]).pipe(Effect.as(true)), + pendingWarnings: [{ code: "shared-root-git", message: "startup umbrella warning" }], + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(ThreadId.make("thread-startup-warning"), { + threadId: ThreadId.make("thread-startup-warning"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + assert.deepStrictEqual(yield* Ref.get(reported), ["startup umbrella warning"]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("the recovery path also invokes the launch preflight", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: () => + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.map((count) => findingResult(count === 1 ? [] : [blockedFinding])), + ), + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("thread-preflight-recovery"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + + // Drop the adapter session but keep the persisted binding so the next + // sendTurn must recover it. + yield* fixture.harness.adapter.stopSession(threadId); + + const error = yield* provider + .sendTurn({ threadId, input: "recover me", attachments: [] }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderLaunchPreflightBlockedError); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +// --- R2 / R4: the real configured executable through the real launch composition --------------- + +it.live( + "a missing configured provider executable is reported before model work (new session)", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const missing = path.join(yield* fs.makeTempDirectory(), "grok"); + const fixture = yield* makeIntegrationFixture({ grokBinaryPath: missing }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-real-exec-missing-new"); + + const error = yield* provider + .startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderAdapterProcessError); + assert.include((error as ProviderAdapterProcessError).message, "grok"); + assert.isAbove((error as ProviderAdapterProcessError).message.length, 0); + // No session was accepted and no turn/model work could have run. + const sessions = yield* fixture.harness.adapter.listSessions(); + assert.equal(sessions.length, 0); + assert.isTrue(Option.isNone(yield* directory.getBinding(threadId))); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("recovery/resume reports the same missing configured executable before model work", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const missing = path.join(yield* fs.makeTempDirectory(), "grok"); + const fixture = yield* makeIntegrationFixture({ grokBinaryPath: missing }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-real-exec-missing-recover"); + + // A persisted binding with resume state is all recovery needs; the + // configured executable is resolved and spawned by the same adapter path + // as a new session. + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + resumeCursor: { sessionId: "resume-e3" }, + runtimePayload: { cwd: fixture.cwd }, + runtimeMode: "full-access", + }); + + const error = yield* provider + .sendTurn({ threadId, input: "recover me", attachments: [] }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderAdapterProcessError); + assert.include((error as ProviderAdapterProcessError).message, "grok"); + assert.isAbove((error as ProviderAdapterProcessError).message.length, 0); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("a real configured dummy provider executable launches exactly once and warns visibly", () => + Effect.gen(function* () { + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-grok-wrapper-")), + ); + const argvLogPath = NodePath.join(dir, "argv.log"); + const mockAgentPath = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../scripts/acp-mock-agent.ts", + ); + const wrapperPath = writeFakeCli({ + directory: dir, + name: "fake-grok-e3", + source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); + + const reported = yield* Ref.make>([]); + const fixture = yield* makeIntegrationFixture({ + grokBinaryPath: wrapperPath, + launchPreflightRunner: () => Effect.succeed(findingResult([warningFinding])), + reportLaunchPreflightWarning: ({ message }) => + Ref.update(reported, (current) => [...current, message]).pipe(Effect.as(true)), + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("thread-real-exec-healthy"); + const session = yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "grok"); + assert.isTrue((session.threadId ?? "").length > 0); + // The real configured executable was spawned exactly once, and the client + // still received the actionable warning. + const invocations = yield* Effect.promise(() => + NodeFSP.readFile(argvLogPath, "utf8").then( + (raw) => raw.split("\n").filter((line) => line.trim().length > 0).length, + () => 0, + ), + ); + assert.equal(invocations, 1); + assert.deepStrictEqual(yield* Ref.get(reported), [warningFinding.message]); + }).pipe(Effect.provide(fixture.layer)); + + yield* Effect.promise(() => NodeFSP.rm(dir, { recursive: true, force: true })); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/package.json b/apps/server/package.json index b7f1bce88768..7e557a257a02 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,6 +30,7 @@ "@opencode-ai/sdk": "^1.3.15", "diff": "8.0.3", "effect": "catalog:", + "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", "stream-chain": "^4.2.5", "stream-json": "3.6.0", diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts new file mode 100644 index 000000000000..b95788a5bdcf --- /dev/null +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -0,0 +1,1282 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - real temp directories exercise the bounded probes and the launch fixtures use JSON.stringify for failure detail. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { assert, it } from "@effect/vitest"; +import { CheckpointRef } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { writeFakeCli } from "../testUtils/fakeCli.ts"; +import * as LaunchPreflight from "./LaunchPreflight.ts"; + +const isWindows = process.platform === "win32"; + +/** Resolves the real Git executable the same way on every host. */ +const resolveRealGitPath = (): string => { + const finder = isWindows ? "where.exe" : "which"; + const output = NodeChildProcess.execFileSync(finder, ["git"], { encoding: "utf8" }); + const first = output + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (first === undefined) throw new Error("git was not found on PATH"); + return first; +}; + +/** + * Writes a portable executable named `git` into `binDir`. The behavior lives in + * a Node stub (launched by a `.cmd` shim on Windows, a `sh` launcher elsewhere) + * so the fixture runs natively on every host and the real resolver/launcher is + * exercised. `body` is module source with `process.argv.slice(2)` as Git's args; + * `__REAL_GIT__` is replaced with the real Git path. + */ +const writeGitStub = (binDir: string, body: string, realGit: string): string => { + NodeFS.mkdirSync(binDir, { recursive: true }); + return writeFakeCli({ + directory: binDir, + name: "git", + source: body.replaceAll("__REAL_GIT__", JSON.stringify(realGit)), + platform: process.platform, + }); +}; + +const probeError = ( + reason: LaunchPreflight.LaunchPreflightProbeFailureReason, + detail = "probe failed", +) => new LaunchPreflight.LaunchPreflightProbeError({ reason, detail }); + +const identity = ( + overrides: Partial = {}, +): LaunchPreflight.LaunchPreflightRepoIdentity => ({ + state: "not-a-repository", + topLevel: null, + commonDir: null, + detail: "", + ...overrides, +}); + +const gitProbe = ( + overrides: Partial = {}, +): LaunchPreflight.LaunchPreflightGitProbe => ({ + version: () => Effect.succeed("2.55.0"), + resolveIdentity: () => Effect.succeed(identity()), + isSparseCheckout: () => Effect.succeed(false), + probeSparseAdd: () => Effect.succeed("supported"), + ...overrides, +}); + +const input = (options: { + readonly root?: string; + readonly isSharedRoot?: boolean; + readonly configuredRoot?: string; + readonly git?: LaunchPreflight.LaunchPreflightGitProbe; + readonly files?: Partial; + readonly consumer?: LaunchPreflight.LaunchPreflightConsumer; + readonly gitEnvironment?: NodeJS.ProcessEnv; +}): LaunchPreflight.LaunchPreflightInput => ({ + root: options.root ?? "/session-root", + ...(options.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options.configuredRoot !== undefined ? { configuredRoot: options.configuredRoot } : {}), + ...(options.consumer !== undefined ? { consumer: options.consumer } : {}), + ...(options.gitEnvironment !== undefined ? { gitEnvironment: options.gitEnvironment } : {}), + git: options.git ?? gitProbe(), + files: { + exists: () => Effect.succeed(false), + stat: () => Effect.succeed({ type: "directory" }), + realPath: (target) => Effect.succeed(target), + readFirstBytes: () => Effect.succeed(16), + ...options.files, + }, +}); + +const codes = (result: LaunchPreflight.LaunchPreflightResult) => + result.findings.map((finding) => finding.code); + +const severities = (result: LaunchPreflight.LaunchPreflightResult) => + result.findings.map((finding) => finding.severity); + +const run = (probeInput: LaunchPreflight.LaunchPreflightInput) => + LaunchPreflight.runLaunchPreflight(probeInput).pipe(Effect.provide(NodeServices.layer)); + +it.effect("passes an ordinary repository session with a supported Git", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + }), + files: { + exists: (target) => + Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), + }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("passes a non-Git shared session root", () => + Effect.gen(function* () { + const result = yield* run(input({ isSharedRoot: true })); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("does not count a retired Git marker as a repository by its name alone", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => Effect.succeed(identity()), + }), + files: { + exists: (target) => Effect.succeed(target === "/Documents/.git.macfix-m1-retired"), + }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("flags an accidental umbrella repository at the shared root", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ state: "ok", topLevel: "/Documents", commonDir: "/Documents/.git" }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/.git") }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + assert.deepStrictEqual(severities(result), ["warning"]); + assert.include(result.warnings[0]?.message ?? "", NodePath.join("/Documents", ".git")); + }), +); + +it.effect("does not require child enumeration to detect the umbrella", () => + Effect.gen(function* () { + // No directory listing is part of the probe interface at all: detection is + // exact normalized root identity, so it cannot depend on child breadth. + const result = yield* run( + input({ + root: "/Documents", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ state: "ok", topLevel: "/Documents", commonDir: "/Documents/.git" }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/.git") }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + }), +); + +it.effect("does not flag an ordinary repository root that is not a shared root", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("does not flag a nested repository selected as the session cwd", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents/project", + isSharedRoot: false, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/Documents/project", + commonDir: "/Documents/project/.git", + }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/project/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("does not advise retiring the root .git when identity points elsewhere", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ state: "ok", topLevel: "/Documents", commonDir: "/elsewhere/.git" }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/.git") }, + }), + ); + + const message = result.warnings[0]?.message ?? ""; + assert.include(message, "points at a different repository"); + assert.notInclude(message, "Move or retire /Documents/.git"); + }), +); + +it.effect("warns (does not block) when Git cannot start under a non-repository root", () => + Effect.gen(function* () { + const result = yield* run( + input({ git: { ...gitProbe(), version: () => Effect.fail(probeError("unavailable")) } }), + ); + + assert.deepStrictEqual(codes(result), ["git-startup-failed"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +it.effect("blocks when Git cannot start and the session root is a repository", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: { ...gitProbe(), version: () => Effect.fail(probeError("unavailable")) }, + files: { exists: (target) => Effect.succeed(target === NodePath.join("/repo", ".git")) }, + }), + ); + + assert.deepStrictEqual(severities(result), ["blocker"]); + assert.include(result.blockers[0]?.message ?? "", "could not be started"); + }), +); + +it.effect("never blocks or warns about the harmless missing --path-format fast path", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + isSharedRoot: false, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.blockers, []); + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("warns when a sparse checkout's resolved Git lacks git add --sparse", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + probeSparseAdd: () => Effect.succeed("unsupported"), + isSparseCheckout: () => Effect.succeed(true), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(codes(result), ["git-sparse-add-unsupported"]); + assert.deepStrictEqual(severities(result), ["warning"]); + assert.include(result.warnings[0]?.message ?? "", "--sparse"); + }), +); + +it.effect("does not warn about git add --sparse when the repository is not sparse", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + isSparseCheckout: () => Effect.succeed(false), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("flags a Git probe that reports a timeout", () => + Effect.gen(function* () { + const result = yield* run( + input({ git: { ...gitProbe(), version: () => Effect.fail(probeError("timeout")) } }), + ); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +it.effect("bounds a hung Git probe instead of holding the launch", () => + Effect.gen(function* () { + const fiber = yield* run(input({ git: { ...gitProbe(), version: () => Effect.never } })).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("1500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + }), +); + +it.effect("flags a slow root read and bounds it instead of holding the launch", () => + Effect.gen(function* () { + const fiber = yield* run( + input({ + files: { + exists: (target) => Effect.succeed(target.endsWith("package.json")), + readFirstBytes: () => Effect.never, + }, + }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["root-read-slow"]); + }), +); + +it.effect("reports an incomplete preflight instead of clean when checks hang", () => + Effect.gen(function* () { + const fiber = yield* run( + input({ + files: { + exists: () => Effect.never, + readFirstBytes: () => Effect.never, + }, + }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("5 seconds"); + const result = yield* Fiber.join(fiber); + + // A hung metadata/existence check is not absence: the preflight is not + // clean, it returns its bounded findings, and it still completes. + assert.isTrue(result.findings.length > 0); + assert.isTrue(result.findings.every((finding) => finding.severity === "warning")); + assert.isTrue(codes(result).includes("root-read-slow")); + }), +); + +// --- R1 regression: incomplete checks warn, genuine absence stays quiet ------ + +it.effect("R1: a hung initial cwd stat warns and returns within its budget", () => + Effect.gen(function* () { + const fiber = yield* run(input({ files: { stat: () => Effect.never } })).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["root-read-slow"]); + assert.deepStrictEqual(severities(result), ["warning"]); + assert.include(result.warnings[0]?.message ?? "", "did not finish in time"); + }), +); + +it.effect("R1: a failed initial cwd stat warns instead of silently skipping", () => + Effect.gen(function* () { + const result = yield* run( + input({ files: { stat: () => Effect.fail(probeError("failed", "denied")) } }), + ); + + assert.deepStrictEqual(codes(result), ["root-read-failed"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +it.effect("R1: a hung candidate metadata check warns instead of reading as absent", () => + Effect.gen(function* () { + const fiber = yield* run(input({ files: { exists: () => Effect.never } })).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("1500 millis"); + const result = yield* Fiber.join(fiber); + + assert.isTrue(codes(result).includes("root-read-slow")); + assert.isTrue(result.findings.length > 0); + }), +); + +it.effect( + "R1: a required capability probe that hangs after healthy identity warns as incomplete", + () => + Effect.gen(function* () { + const fiber = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + probeSparseAdd: () => Effect.never, + }), + consumer: { driver: "opencode", snapshotsEnabled: true }, + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("1500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + }), +); + +it.effect("R1: a required capability probe failure warns as incomplete, never unsupported", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + probeSparseAdd: () => Effect.fail(probeError("failed")), + }), + consumer: { driver: "opencode", snapshotsEnabled: true }, + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(codes(result), ["git-probe-failed"]); + assert.notInclude(codes(result), "git-sparse-add-unsupported"); + }), +); + +it.effect("R1: genuinely absent optional files stay quiet", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +// --- R2 regression: equivalent physical paths share identity handling -------- + +it.effect("R2: alias spellings of the same physical root give local-metadata guidance", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/var/folders/x/shared", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/private/var/folders/x/shared", + commonDir: "/var/folders/x/shared/.git", + }), + ), + }), + files: { + exists: (target) => Effect.succeed(target.endsWith(".git")), + realPath: (target) => + Effect.succeed(target.startsWith("/var/") ? `/private${target}` : target), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + const message = result.warnings[0]?.message ?? ""; + assert.include(message, "Move or retire"); + assert.notInclude(message, "different repository"); + }), +); + +it.effect("R2: failed canonicalization does not assert an external repository", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ state: "ok", topLevel: "/Documents", commonDir: "/Documents/.git" }), + ), + }), + files: { + exists: (target) => Effect.succeed(target === "/Documents/.git"), + realPath: () => Effect.succeed(null), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + const message = result.warnings[0]?.message ?? ""; + assert.notInclude(message, "different repository"); + assert.notInclude(message, "Move or retire"); + assert.include(message, "could not be fully resolved"); + }), +); + +// --- W1-A: the production file probe preserves metadata failures -------------- + +it.effect("W1-A: genuine absence is quiet but a denied metadata read is a probe failure", () => + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + const probe = LaunchPreflight.makeFileProbe(real); + // Genuine absence stays `false` (quiet), never a warning. + assert.strictEqual( + yield* probe.exists(NodePath.join(NodeOS.tmpdir(), "envchk-absent-x")), + false, + ); + + // A denied lookup is not absence: the production adapter must surface it as + // a probe failure instead of converting it to `false`. + const deniedLayer = FileSystem.layerNoop({ + stat: (target) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: target, + }), + ), + }); + const denied = yield* FileSystem.FileSystem.pipe(Effect.provide(deniedLayer)); + const deniedProbe = LaunchPreflight.makeFileProbe(denied); + const error = yield* deniedProbe.exists("/denied/AGENTS.md").pipe(Effect.flip); + assert.strictEqual(error.reason, "failed"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("W1-A: a denied candidate metadata read warns instead of reading as absent", () => + Effect.gen(function* () { + const denied = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: "candidate", + }); + const result = yield* run( + input({ + root: "/repo", + files: { + // Every metadata read is denied, including the initial cwd stat. + exists: () => Effect.fail(probeError("failed", String(denied))), + stat: () => Effect.fail(probeError("failed", String(denied))), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["root-read-failed"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +// --- W1-B: incomplete relevant capability checks warn for sparse checkouts ---- + +it.effect( + "W1-B: a failed capability probe for a verified sparse checkout warns (non-OpenCode)", + () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + isSparseCheckout: () => Effect.succeed(true), + probeSparseAdd: () => Effect.fail(probeError("failed", "denied")), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + // Applicability came from the verified sparse checkout, not the consumer. + assert.deepStrictEqual(codes(result), ["git-probe-failed"]); + assert.notInclude(codes(result), "git-sparse-add-unsupported"); + }), +); + +it.effect("W1-B: a hung capability probe for a verified sparse checkout warns (non-OpenCode)", () => + Effect.gen(function* () { + const fiber = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + isSparseCheckout: () => Effect.succeed(true), + probeSparseAdd: () => Effect.never, + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("1500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + }), +); + +it.effect("W1-B: a non-required repository with an incomplete capability check stays quiet", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + isSparseCheckout: () => Effect.succeed(false), + probeSparseAdd: () => Effect.never, + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +// --- W1-C: canonical configured-root identity through both directions --------- + +it.effect("W1-C: alias spellings of the configured root still recognize shared intent", () => + Effect.gen(function* () { + // The configured root and the actual cwd are the same physical directory + // under different spellings; only the canonical compare can see that. + const result = yield* run( + input({ + root: "/private/var/folders/x/shared", + configuredRoot: "/var/folders/x/shared", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/private/var/folders/x/shared", + commonDir: "/private/var/folders/x/shared/.git", + }), + ), + }), + files: { + exists: (target) => Effect.succeed(target.endsWith(".git")), + realPath: (target) => + Effect.succeed(target.startsWith("/var/") ? `/private${target}` : target), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + }), +); + +it.effect("W1-C: a genuinely different configured root stays ordinary", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + configuredRoot: "/elsewhere", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("W1-C: a nested repository selected as cwd stays ordinary under a configured root", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents/project", + configuredRoot: "/Documents", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/Documents/project", + commonDir: "/Documents/project/.git", + }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/project/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("W1-C: an unresolved configured-root identity does not invent an umbrella", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/var/folders/x/shared", + configuredRoot: "/private/var/folders/x/shared", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/var/folders/x/shared", + commonDir: "/var/folders/x/shared/.git", + }), + ), + }), + files: { + exists: (target) => Effect.succeed(target.endsWith(".git")), + // Canonicalization is unavailable for both sides. + realPath: () => Effect.succeed(null), + }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("wires the real service probes and passes a healthy root", () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-launch-preflight-")), + ); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(directory, "package.json"), '{"name":"preflight"}\n'), + ); + + const layer = LaunchPreflight.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (processInput) => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: processInput.args.includes("--version") + ? "git version 2.55.0\n" + : processInput.args.includes("--show-toplevel") + ? `${directory}\n` + : processInput.args.includes("--git-common-dir") + ? `${NodePath.join(directory, ".git")}\n` + : `${NodePath.join(directory, ".git", "index")}\n`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + Layer.provide(NodeServices.layer), + ); + + const result = yield* LaunchPreflight.LaunchPreflight.pipe( + Effect.flatMap((preflight) => preflight.run(directory)), + Effect.provide(layer), + ); + + assert.deepStrictEqual(result.findings, []); + yield* Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })); + }), +); + +it.effect("real Git probes flag a disposable umbrella repository and never write to it", () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-launch-preflight-real-")), + ); + const umbrella = NodePath.join(base, "umbrella"); + const nested = NodePath.join(umbrella, "project"); + yield* Effect.promise(() => NodeFSP.mkdir(nested, { recursive: true })); + NodeChildProcess.execFileSync("git", ["init", "-q"], { cwd: umbrella }); + NodeChildProcess.execFileSync("git", ["init", "-q"], { cwd: nested }); + const before = (yield* Effect.promise(() => NodeFSP.readdir(umbrella))).sort(); + + const layer = LaunchPreflight.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(NodeServices.layer), + ); + + const result = yield* LaunchPreflight.LaunchPreflight.pipe( + Effect.flatMap((preflight) => preflight.run(umbrella, { isSharedRoot: true })), + Effect.provide(layer), + ); + + assert.include(codes(result), "shared-root-git"); + assert.deepStrictEqual((yield* Effect.promise(() => NodeFSP.readdir(umbrella))).sort(), before); + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }), +); + +// --- R1 regression: a Git without `--path-format` still launches and checkpoints ----------------- + +const E3VcsLayer = VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer)); +const E3PreflightLayer = LaunchPreflight.layer.pipe(Layer.provide(E3VcsLayer)); +const E3CombinedLayer = Layer.merge(E3VcsLayer, E3PreflightLayer); + +/** A `git` that rejects `--path-format` (like Git < 2.31) and delegates everything else. */ +const writePathFormatRejectingGit = (binDir: string, realGit: string) => + writeGitStub( + binDir, + [ + 'import { spawnSync } from "node:child_process";', + "const args = process.argv.slice(2);", + "for (const a of args) {", + ' if (a === "--path-format" || a.startsWith("--path-format=")) {', + " process.stderr.write(\"fatal: unknown option 'path-format'\\n\");", + " process.exit(129);", + " }", + "}", + 'const r = spawnSync(__REAL_GIT__, args, { stdio: "inherit" });', + "process.exit(r.status ?? 1);", + "", + ].join("\n"), + realGit, + ); + +it.effect( + "R1: a Git that rejects --path-format still launches and captures an ordinary checkpoint", + () => + Effect.gen(function* () { + const realGit = resolveRealGitPath(); + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-pathformat-")), + ); + const binDir = NodePath.join(base, "bin"); + const repo = NodePath.join(base, "repo"); + yield* Effect.promise(() => NodeFSP.mkdir(binDir, { recursive: true })); + yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); + writePathFormatRejectingGit(binDir, realGit); + + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.PATH; + process.env.PATH = `${binDir}${NodePath.delimiter}${previous ?? ""}`; + return previous; + }), + () => + Effect.gen(function* () { + // 1. The launch preflight proceeds with no finding at all: a Git + // without `--path-format` is a harmless optional fallback. + const preflight = yield* LaunchPreflight.LaunchPreflight; + const result = yield* preflight.run(repo, { isSharedRoot: false }); + assert.deepStrictEqual(result.blockers, []); + assert.deepStrictEqual(result.findings, []); + + // 2. An ordinary checkpoint succeeds through the temporary-index fallback. + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const git = (args: ReadonlyArray) => + driver.execute({ operation: "e3-path-format-test", cwd: repo, args }); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "initial\n"), + ); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "changed\n"), + ); + + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/e3-path-format"); + yield* driver.checkpoints.captureCheckpoint({ cwd: repo, checkpointRef }); + const shown = yield* git(["show", `${checkpointRef}:file.txt`]); + assert.strictEqual(shown.stdout, "changed\n"); + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.PATH; + else process.env.PATH = previous; + }), + ).pipe(Effect.provide(E3CombinedLayer)); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped), +); + +// --- R3 regression: real wall-clock bound and cleanup for a hung resolved Git ------------------- + +it.live( + "R3: a hung resolved Git is bounded by wall-clock and the owned processes are cleaned up", + () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-hung-git-")), + ); + const binDir = NodePath.join(base, "bin"); + const marker = NodePath.join(base, "finished"); + const childPidFile = NodePath.join(base, "child.pid"); + const grandchildPidFile = NodePath.join(base, "grandchild.pid"); + yield* Effect.promise(() => NodeFSP.mkdir(binDir, { recursive: true })); + // The owned fixture records its own PID and the PID of a descendant it + // spawns, then sleeps. Cleanup must terminate both, not just leave the + // post-sleep marker unwritten. + writeGitStub( + binDir, + [ + 'import { spawn } from "node:child_process";', + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(childPidFile)}, String(process.pid));`, + 'const descendant = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { stdio: "ignore" });', + `writeFileSync(${JSON.stringify(grandchildPidFile)}, String(descendant.pid));`, + `setTimeout(() => { writeFileSync(${JSON.stringify(marker)}, "done"); process.exit(0); }, 30000);`, + "", + ].join("\n"), + resolveRealGitPath(), + ); + + const isAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + const readPid = (file: string) => + Effect.promise(() => NodeFSP.readFile(file, "utf8").then((raw) => Number(raw.trim()))); + + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.PATH; + process.env.PATH = `${binDir}${NodePath.delimiter}${previous ?? ""}`; + return previous; + }), + () => + Effect.gen(function* () { + const preflight = yield* LaunchPreflight.LaunchPreflight; + const result = yield* preflight.run(base, { isSharedRoot: false }); + const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; + assert.include(codes(result), "git-probe-timed-out"); + assert.isBelow(elapsedMs, 3000, `preflight took ${elapsedMs}ms`); + + const childPid = yield* readPid(childPidFile); + const grandchildPid = yield* readPid(grandchildPidFile); + assert.isTrue(Number.isInteger(childPid) && childPid > 0); + assert.isTrue(Number.isInteger(grandchildPid) && grandchildPid > 0); + + // The hung wrapper and its descendant must have been terminated, not + // left sleeping. Only the fixture's own captured PIDs are observed. + yield* Effect.sleep("400 millis"); + assert.isFalse(isAlive(childPid), `owned child ${childPid} was not cleaned up`); + assert.isFalse( + isAlive(grandchildPid), + `owned descendant ${grandchildPid} was not cleaned up`, + ); + const finished = yield* Effect.promise(() => + NodeFSP.readFile(marker, "utf8").then( + () => true, + () => false, + ), + ); + assert.isFalse(finished, "hung git wrapper was not cleaned up"); + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.PATH; + else process.env.PATH = previous; + }), + ).pipe(Effect.provide(E3PreflightLayer)); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped), +); + +// --- F1 regression: relative `--git-common-dir` resolves against the invocation cwd ------------- + +const makeRealGitProbe = Effect.gen(function* () { + const vcsProcess = yield* VcsProcess.VcsProcess; + const path = yield* Path.Path; + return LaunchPreflight.makeGitProbe(vcsProcess, path); +}); + +const realpath = (target: string) => Effect.promise(() => NodeFSP.realpath(target)); + +/** Windows filesystem paths differ by case/separator; compare canonically. */ +const sameRealPath = (actual: string, expected: string): boolean => { + const normalize = (value: string) => { + const resolved = NodePath.resolve(value); + return isWindows ? resolved.toLowerCase() : resolved; + }; + return normalize(actual) === normalize(expected); +}; + +it.live("F1: git identity resolves relative common dirs against the invocation cwd", () => + Effect.gen(function* () { + const realGit = resolveRealGitPath(); + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e4-identity-")), + ); + const repo = NodePath.join(base, "repo"); + const sub = NodePath.join(repo, "sub"); + const worktree = NodePath.join(base, "linked-worktree"); + const git = (cwd: string, args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd }); + }); + + yield* Effect.promise(() => NodeFSP.mkdir(sub, { recursive: true })); + yield* git(repo, ["init"]); + yield* git(repo, ["config", "user.name", "Test"]); + yield* git(repo, ["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "hello\n")); + yield* git(repo, ["add", "."]); + yield* git(repo, ["commit", "-m", "initial"]); + yield* git(repo, ["worktree", "add", worktree, "-b", "linked"]); + + const probe = yield* makeRealGitProbe; + const repoReal = yield* realpath(repo); + const repoGitDir = NodePath.join(repoReal, ".git"); + + // 1. Root is the repository root: common dir is `/.git`. + const rootIdentity = yield* probe.resolveIdentity(repo); + assert.strictEqual(rootIdentity.state, "ok"); + assert.isTrue(sameRealPath(yield* realpath(rootIdentity.commonDir ?? ""), repoGitDir)); + + // 2. Root is a subdirectory: plain `git rev-parse --git-common-dir` returns + // a relative `../.git`; it must resolve to `/.git`, not outside. + const subIdentity = yield* probe.resolveIdentity(sub); + assert.strictEqual(subIdentity.state, "ok"); + assert.isTrue(sameRealPath(yield* realpath(subIdentity.topLevel ?? ""), repoReal)); + assert.isTrue(sameRealPath(yield* realpath(subIdentity.commonDir ?? ""), repoGitDir)); + + // 3. Root is a linked worktree: the common dir points back at the main + // repository, and its top level is the worktree itself. + const worktreeIdentity = yield* probe.resolveIdentity(worktree); + assert.strictEqual(worktreeIdentity.state, "ok"); + assert.isTrue(sameRealPath(yield* realpath(worktreeIdentity.commonDir ?? ""), repoGitDir)); + assert.isTrue( + sameRealPath(yield* realpath(worktreeIdentity.topLevel ?? ""), yield* realpath(worktree)), + ); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.provide(E3VcsLayer)), +); + +it.live("F3: the real probe reports git add --sparse support only for a sparse checkout", () => + Effect.gen(function* () { + const realGit = resolveRealGitPath(); + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e4-sparse-")), + ); + const repo = NodePath.join(base, "repo"); + const git = (args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd: repo }); + }); + + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.join(repo, "src"), { recursive: true })); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(repo, "src", "file.txt"), "hello\n"), + ); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + + const probe = yield* makeRealGitProbe; + assert.strictEqual(yield* probe.isSparseCheckout(repo), false); + // The selected OpenCode consumer requires `--sparse` even in an ordinary + // repository, so its capability is probed regardless of the sparse config. + assert.strictEqual(yield* probe.probeSparseAdd(repo), "supported"); + + yield* git(["sparse-checkout", "set", "src"]); + assert.strictEqual(yield* probe.isSparseCheckout(repo), true); + assert.strictEqual(yield* probe.probeSparseAdd(repo), "supported"); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.provide(E3VcsLayer)), +); + +// --- A1/A2: consumer-driven `--sparse` applicability and the final launch env ---------------- + +/** + * A `git` wrapper that lacks `git add --sparse`. It reports an `add -h` usage + * without `--sparse` (the exact thing the probe reads), delegates every other + * invocation to the real Git, and records its own path plus the harmless + * environment sentinel it inherited. This models a provider environment whose + * resolved Git is older than the host's. + */ +const writeSparseLessGit = (binDir: string, realGit: string, recordPath: string): void => { + writeGitStub( + binDir, + [ + 'import { appendFileSync } from "node:fs";', + 'import { spawnSync } from "node:child_process";', + "const args = process.argv.slice(2);", + `appendFileSync(${JSON.stringify(recordPath)}, process.argv[1] + "|" + (process.env.ENVCHK_SENTINEL ?? "") + "\\n");`, + 'if (args[0] === "add" && args[1] === "-h") {', + ' process.stdout.write("usage: git add [options] [--] ...\\n -n, --dry-run dry run\\n -v, --verbose be verbose\\n");', + " process.exit(0);", + "}", + 'const r = spawnSync(__REAL_GIT__, args, { stdio: "inherit" });', + "process.exit(r.status ?? 1);", + "", + ].join("\n"), + realGit, + ); +}; + +const ordinaryGitConsumer: LaunchPreflight.LaunchPreflightConsumer = { + driver: "opencode", + snapshotsEnabled: true, +}; + +const makeOrdinaryRepo = (repo: string, realGit: string) => + Effect.gen(function* () { + yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); + const git = (args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd: repo }); + }); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "hello\n")); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + }); + +it.live( + "A1/A2: ordinary OpenCode repo resolves the selected provider Git and warns on missing --sparse", + () => + Effect.gen(function* () { + const realGit = resolveRealGitPath(); + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-v5-consumer-")), + ); + const repo = NodePath.join(base, "repo"); + const providerBin = NodePath.join(base, "provider-bin"); + const recordPath = NodePath.join(base, "record.log"); + const sentinel = "envchk-provider-sentinel"; + yield* Effect.promise(() => NodeFSP.mkdir(providerBin, { recursive: true })); + writeSparseLessGit(providerBin, realGit, recordPath); + yield* makeOrdinaryRepo(repo, realGit); + + const preflight = yield* LaunchPreflight.LaunchPreflight; + + // Host/default PATH resolves a capable Git: the ordinary repo passes even + // for the OpenCode consumer. + const healthy = yield* preflight.run(repo, { consumer: ordinaryGitConsumer }); + assert.deepStrictEqual(healthy.findings, []); + + // The selected provider environment resolves the controlled Git that + // lacks `--sparse`; the ordinary (non-sparse) checkout still warns. + const providerEnvironment: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${providerBin}${NodePath.delimiter}${process.env.PATH ?? ""}`, + ENVCHK_SENTINEL: sentinel, + }; + const warned = yield* preflight.run(repo, { + consumer: ordinaryGitConsumer, + gitEnvironment: providerEnvironment, + }); + assert.deepStrictEqual(codes(warned), ["git-sparse-add-unsupported"]); + assert.deepStrictEqual(severities(warned), ["warning"]); + const message = warned.warnings[0]?.message ?? ""; + assert.include(message, "OpenCode"); + assert.include(message, "--sparse"); + + // The selected executable and the harmless environment sentinel are the + // ones from the provider environment, not the host default. + const recorded = (yield* Effect.promise(() => NodeFSP.readFile(recordPath, "utf8"))).trim(); + const recordLines = recorded.split("\n"); + assert.isTrue(recordLines.length > 0); + for (const line of recordLines) { + const [executable, recordedSentinel] = line.split("|"); + // The wrapper ran from the selected provider environment's directory + // (the launcher/stub lives in providerBin, not on the host). + assert.strictEqual(NodePath.dirname(executable ?? ""), providerBin); + assert.strictEqual(recordedSentinel, sentinel); + } + + // Snapshot-disabled control: the same Git is not required by the consumer + // (OpenCode snapshot staging off), so an ordinary checkout is silent. + const disabled = yield* preflight.run(repo, { + consumer: { driver: "opencode", snapshotsEnabled: false }, + gitEnvironment: providerEnvironment, + }); + assert.deepStrictEqual(disabled.findings, []); + + // Non-OpenCode control: no provider-specific Git requirement is applied + // globally to T3 sessions. + const otherConsumer = yield* preflight.run(repo, { + consumer: { driver: "codex", snapshotsEnabled: true }, + gitEnvironment: providerEnvironment, + }); + assert.deepStrictEqual(otherConsumer.findings, []); + + // Non-Git control: a plain directory has no repository to checkpoint. + const plain = NodePath.join(base, "plain"); + yield* Effect.promise(() => NodeFSP.mkdir(plain, { recursive: true })); + const nonGit = yield* preflight.run(plain, { + consumer: ordinaryGitConsumer, + gitEnvironment: providerEnvironment, + }); + assert.deepStrictEqual(nonGit.findings, []); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.provide(E3PreflightLayer)), +); diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts new file mode 100644 index 000000000000..24e83c7035e5 --- /dev/null +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -0,0 +1,864 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import type { VcsError } from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +/** + * Automatic launch preflight for the server's shared session root. + * + * The root is `ServerConfig.cwd`: the directory provider sessions default to and + * the directory auto-bootstrap roots a project at. Before sessions start, this + * checks the environment the harness actually depends on: + * + * - the `git` executable the launch actually resolves can start; + * - an accidental umbrella repository at an explicitly configured shared root + * is flagged using the exact, normalized Git identity reported for that root + * (never a folder name, a child count, or child enumeration); + * - one small, relevant file at the root is read to detect a stalled or + * cloud-offloaded filesystem without scanning the tree. + * + * Every probe is bounded, read-only and local. The preflight never mutates Git + * metadata, disables sync, kills processes, or calls a model. Findings warn by + * default. A blocker is emitted only for a demonstrated inability to execute + * usefully — the resolved Git cannot start while the exact session root is a + * repository, so its worktree/checkpoint plumbing cannot run. Optional Git + * capabilities (`rev-parse --path-format`) never block: maintained + * `GitVcsDriver` catches that failure and rebuilds the temporary index. Broad + * roots, nested regular repositories and retired markers never block. + */ + +const GIT_PROBE_TIMEOUT = "1500 millis"; +const NARROW_FS_TIMEOUT = "500 millis"; +const READ_PROBE_TIMEOUT = "500 millis"; +const TOTAL_PROBE_BUDGET = "5 seconds"; +const READ_PROBE_MAX_BYTES = 32 * 1024; +const GIT_VERSION_PATTERN = /\b(\d+\.\d+\.\d+)\b/; + +/** Files probed, in order, for the bounded root read. All are small and relevant. */ +const READ_PROBE_CANDIDATES = ["AGENTS.md", "package.json", "README.md", ".git/HEAD"] as const; + +export type LaunchPreflightFindingCode = + | "git-startup-failed" + | "git-probe-timed-out" + | "git-sparse-add-unsupported" + | "git-probe-failed" + | "shared-root-git" + | "root-read-slow" + | "root-read-failed"; + +export type LaunchPreflightSeverity = "warning" | "blocker"; + +export interface LaunchPreflightFinding { + readonly code: LaunchPreflightFindingCode; + readonly severity: LaunchPreflightSeverity; + readonly message: string; +} + +export interface LaunchPreflightResult { + readonly findings: ReadonlyArray; + readonly warnings: ReadonlyArray; + readonly blockers: ReadonlyArray; +} + +export type LaunchPreflightProbeFailureReason = "unavailable" | "timeout" | "failed"; + +export class LaunchPreflightProbeError extends Data.TaggedError("LaunchPreflightProbeError")<{ + readonly reason: LaunchPreflightProbeFailureReason; + readonly detail: string; +}> {} + +export interface LaunchPreflightGitProbe { + /** Returns the parsed Git version, or null when the output had none. */ + readonly version: ( + root: string, + env?: NodeJS.ProcessEnv, + ) => Effect.Effect; + /** + * Resolves the effective repository identity for the root using plain + * `git rev-parse --show-toplevel` / `--git-common-dir` queries. Uses + * `allowNonZeroExit`, so "not a repository" is an observable state, not a + * failure. Never depends on the optional `--path-format` flag. + */ + readonly resolveIdentity: ( + root: string, + env?: NodeJS.ProcessEnv, + ) => Effect.Effect; + /** + * Read-only: whether the root repository is a sparse checkout + * (`git config --bool core.sparseCheckout`). This establishes the + * repository-side applicability of `git add --sparse` separately from the + * consumer, so an incomplete capability probe can still be recognized as + * relevant. Uses `allowNonZeroExit`, so a missing key is `false`, not a + * failure. + */ + readonly isSparseCheckout: ( + root: string, + env?: NodeJS.ProcessEnv, + ) => Effect.Effect; + /** + * Probes the real capability `git add --sparse`. Read-only: it inspects + * `git add -h`, never stages anything. The caller decides applicability (a + * verified sparse checkout or the selected consumer) and only probes when the + * capability is relevant. The optional `rev-parse --path-format` fast path is + * deliberately not probed or warned about: its absence is a harmless + * optimization fallback handled inside `GitVcsDriver`. + */ + readonly probeSparseAdd: ( + root: string, + env?: NodeJS.ProcessEnv, + ) => Effect.Effect; +} + +export type LaunchPreflightSparseCapability = "supported" | "unsupported"; + +/** + * The consumer/operation about to run. `--sparse` applicability belongs to the + * consumer: OpenCode's snapshot staging uses `git add --all --sparse` whenever + * snapshots are enabled and the project is a Git repository, regardless of + * `core.sparseCheckout`. T3's own checkpoint path only uses `--sparse` in a + * sparse checkout. + */ +export interface LaunchPreflightConsumer { + /** Provider driver kind selected for this launch (e.g. "opencode"). */ + readonly driver: string; + /** + * Whether OpenCode-style snapshot staging is enabled for this launch. + * `undefined` means the effective configuration could not be read, so the + * requirement is not asserted; only an explicit `true` requires `--sparse`. + */ + readonly snapshotsEnabled: boolean | undefined; +} + +/** Whether the selected consumer/operation stages with `git add --sparse`. */ +export const consumerUsesSparseAdd = (consumer: LaunchPreflightConsumer | undefined): boolean => + consumer !== undefined && consumer.driver === "opencode" && consumer.snapshotsEnabled === true; + +export interface LaunchPreflightRepoIdentity { + readonly state: "ok" | "not-a-repository" | "failed"; + /** Effective work-tree top level when `state` is "ok". */ + readonly topLevel: string | null; + /** Effective common Git directory (resolved) when `state` is "ok". */ + readonly commonDir: string | null; + readonly detail: string; +} + +export type LaunchPreflightFsEntryType = "directory" | "file" | "other" | "missing"; + +export interface LaunchPreflightFsEntry { + readonly type: LaunchPreflightFsEntryType; +} + +export interface LaunchPreflightFileProbe { + /** + * Presence check that keeps genuine absence distinct from a failed metadata + * read. Returns `false` only when the entry is genuinely absent; a + * denied/stalled/I-O-failed lookup is a `LaunchPreflightProbeError` so the + * caller can warn instead of reading it as absence. + */ + readonly exists: (target: string) => Effect.Effect; + /** + * Bounded-type stat. Genuine absence is the observable `"missing"` state, so + * callers can keep it distinct from a denied/stalled metadata read. Any other + * stat error is a `LaunchPreflightProbeError`. + */ + readonly stat: ( + target: string, + ) => Effect.Effect; + /** + * Canonicalizes a path (resolving symlinks) so exact root identity compares + * correctly across `/var` ↔ `/private/var` style aliases. Returns null when it + * cannot be resolved. + */ + readonly realPath: (target: string) => Effect.Effect; + /** Reads at most `maxBytes` and returns the number of bytes read. */ + readonly readFirstBytes: ( + target: string, + maxBytes: number, + ) => Effect.Effect; +} + +export interface LaunchPreflightInput { + readonly root: string; + readonly git: LaunchPreflightGitProbe; + readonly files: LaunchPreflightFileProbe; + /** + * Whether `root` is an explicitly configured shared session root (as opposed + * to a selected nested repository). Only a shared root that is *itself* a + * repository is reported as an unexpected umbrella. When `configuredRoot` is + * provided, this boolean is derived from a bounded canonical comparison + * instead of being trusted as given. + */ + readonly isSharedRoot?: boolean; + /** + * The explicitly configured shared session root, as spelled in settings. The + * preflight canonicalizes it against the actual root through the same bounded + * filesystem probe so alias spellings of the same physical root + * (`/var` ↔ `/private/var`, a Windows junction) still recognize shared intent. + * Unset or empty means no root is configured and every session is ordinary. + */ + readonly configuredRoot?: string; + /** + * The selected consumer/operation. Determines whether `git add --sparse` is + * required even in an ordinary repository (OpenCode snapshots use it there). + * Absent means only a sparse checkout makes `--sparse` relevant. + */ + readonly consumer?: LaunchPreflightConsumer; + /** + * The environment the selected provider launch actually resolves `git` with + * (the same environment the adapter inherits). Absent means the host env. + */ + readonly gitEnvironment?: NodeJS.ProcessEnv; +} + +const parseGitVersion = (output: string): string | null => + output.match(GIT_VERSION_PATTERN)?.[1] ?? null; + +const blockerSuffix = + " T3 Code cannot checkpoint or resolve a worktree for this repository, so the session cannot run" + + " usefully. Fix Git, then restart T3 Code."; + +const normalizeForCompare = (path: Path.Path, value: string): string => { + const resolved = path.resolve(value); + // A trailing separator would make an otherwise-equal path compare unequal. + return resolved.length > 1 && resolved.endsWith(path.sep) + ? resolved.slice(0, -path.sep.length) + : resolved; +}; + +/** Normalizes a path into a stable key for exact-root comparisons. */ +export const normalizePathKey = (path: Path.Path, value: string): string => + normalizeForCompare(path, value); + +const samePath = (path: Path.Path, a: string | null, b: string): boolean => + a !== null && normalizeForCompare(path, a) === normalizeForCompare(path, b); + +/** + * Whether `cwd` is exactly the deliberately configured shared session root. + * This is the only source of shared-inbox intent: equality with an ordinary + * working directory (including `ServerConfig.cwd`) never declares one. An + * unset or empty setting means every session is ordinary. + */ +export const isConfiguredSharedSessionRoot = ( + path: Path.Path, + cwd: string, + configuredRoot: string | undefined, +): boolean => { + const trimmed = configuredRoot?.trim() ?? ""; + if (trimmed.length === 0) return false; + return normalizeForCompare(path, cwd) === normalizeForCompare(path, trimmed); +}; + +const isInside = (path: Path.Path, child: string | null, parent: string): boolean => { + if (child === null) return false; + const normalizedChild = normalizeForCompare(path, child); + const normalizedParent = normalizeForCompare(path, parent); + return ( + normalizedChild === normalizedParent || + normalizedChild.startsWith(`${normalizedParent}${path.sep}`) + ); +}; + +/** + * Runs every probe against `root` and returns findings. Bounded by construction: + * each capability call has its own timeout, the whole exploration is capped by + * {@link TOTAL_PROBE_BUDGET}, and the Git subprocesses carry their own bounds. + */ +export const runLaunchPreflight = ( + input: LaunchPreflightInput, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; + const configuredRoot = input.configuredRoot?.trim() ?? ""; + const collected = yield* Ref.make>([]); + const add = (finding: LaunchPreflightFinding) => + Ref.update(collected, (current) => [...current, finding]); + + type ExistsOutcome = + | { readonly state: "present" } + | { readonly state: "absent" } + | { readonly state: "unknown"; readonly reason: "timeout" | "failed" }; + + const existsOutcome = (target: string): Effect.Effect => + input.files.exists(target).pipe( + Effect.map((present): ExistsOutcome => + present ? { state: "present" } : { state: "absent" }, + ), + Effect.catch(() => Effect.succeed({ state: "unknown", reason: "failed" } as const)), + Effect.timeoutOption(NARROW_FS_TIMEOUT), + Effect.map( + Option.getOrElse((): ExistsOutcome => ({ state: "unknown", reason: "timeout" })), + ), + ); + + const realPathBounded = (target: string) => + input.files.realPath(target).pipe( + Effect.timeoutOption(NARROW_FS_TIMEOUT), + Effect.map(Option.getOrElse(() => null)), + Effect.orElseSucceed(() => null), + ); + + // One actionable warning shape for an incomplete filesystem metadata check. + // Genuine absence never reaches here: only timeout/permission/other failure. + const rootMetadataWarning = ( + target: string, + reason: "timeout" | "failed", + ): LaunchPreflightFinding => + reason === "timeout" + ? { + code: "root-read-slow", + severity: "warning", + message: + `Inspecting ${target} under the session root did not finish in time. The filesystem may ` + + 'be stalled or cloud-offloaded; use "Keep Downloaded" on the folder before starting ' + + "sessions.", + } + : { + code: "root-read-failed", + severity: "warning", + message: + `Could not inspect ${target} under the session root. The filesystem denied or failed ` + + "the metadata read. Sessions may fail to read the workspace.", + }; + + const explore = Effect.gen(function* () { + // Bound the initial session-cwd check here so a stalled, denied or + // cloud-offloaded path produces one actionable warning instead of being + // silently treated as clean. Genuine absence or a plain file stays + // distinct: the caller's own workspace read (and + // `ProviderWorkspaceMissingError`) reports that. + const rootStatOutcome = yield* input.files.stat(input.root).pipe( + Effect.map((entry) => ({ _tag: "ok" as const, entry })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(NARROW_FS_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + if (rootStatOutcome._tag === "timeout") { + yield* add(rootMetadataWarning(input.root, "timeout")); + return; + } + if (rootStatOutcome._tag === "error") { + yield* add(rootMetadataWarning(input.root, "failed")); + return; + } + if (rootStatOutcome.entry.type !== "directory") { + return; + } + + // Canonicalize the configured root so identity comparison is not confused + // by macOS `/var` ↔ `/private/var` aliases. `null` means canonicalization + // failed: identity must then stay unknown, never affirmative external. + const canonicalRoot = yield* realPathBounded(input.root); + + // Shared-root intent comes only from the explicit setting. When a + // configured root is present, resolve it through the same bounded + // canonicalization as the actual root: an alias spelling of the same + // physical directory still counts as the shared root, while a genuinely + // different configured root stays ordinary. Unlike a lexical compare, this + // does not lose intent before the probe runs. + let isSharedRoot = input.isSharedRoot === true; + if (configuredRoot.length > 0) { + const canonicalConfigured = yield* realPathBounded(configuredRoot); + isSharedRoot = + canonicalRoot !== null && canonicalConfigured !== null + ? samePath(path, canonicalRoot, canonicalConfigured) + : // Canonicalization failed for at least one side; a lexical compare + // is the neutral fallback and cannot invent shared intent. + isConfiguredSharedSessionRoot(path, input.root, configuredRoot); + } + + // Only a real `.git` entry counts as repository evidence. A retired marker + // such as `.git.macfix-m1-retired` is a different path and is ignored. An + // incomplete metadata check is not absence: warn about it. + const rootGitMarkerPath = path.join(input.root, ".git"); + const rootGitMarkerOutcome = yield* existsOutcome(rootGitMarkerPath); + if (rootGitMarkerOutcome.state === "unknown") { + yield* add(rootMetadataWarning(rootGitMarkerPath, rootGitMarkerOutcome.reason)); + } + const rootGitMarker = rootGitMarkerOutcome.state === "present"; + + const gitOutcome = yield* input.git.version(input.root, input.gitEnvironment).pipe( + Effect.map((version) => ({ _tag: "ok" as const, version })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + + let effectiveRootRepository = rootGitMarker; + // Whether the root is inside a Git work tree (even when the root is a + // subdirectory or worktree). OpenCode snapshots only run for Git projects. + let inGitWorkTree = rootGitMarker; + + if (gitOutcome._tag === "ok") { + if (gitOutcome.version === null) { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + "The Git version probe returned no version. Sessions that checkpoint, diff, or open a repository may fail.", + }); + } + + const identityOutcome = yield* input.git + .resolveIdentity(input.root, input.gitEnvironment) + .pipe( + Effect.map((identity) => ({ _tag: "ok" as const, identity })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + + if (identityOutcome._tag === "ok") { + const identity = identityOutcome.identity; + switch (identity.state) { + case "ok": { + // Exact normalized root identity: the root itself is the work tree + // top level. `topLevel === root` is what makes a shared root an + // umbrella; a nested repository selected as the session cwd has a + // different top level and stays an ordinary repository session. + // Compare canonical forms when both resolve; otherwise fall back + // to the raw spellings so a canonicalization failure cannot by + // itself invent an umbrella. + const topLevelCanonical = + identity.topLevel !== null ? yield* realPathBounded(identity.topLevel) : null; + const rootIsRepository = + identity.topLevel !== null && canonicalRoot !== null && topLevelCanonical !== null + ? samePath(path, topLevelCanonical, canonicalRoot) + : samePath(path, identity.topLevel, input.root); + effectiveRootRepository = rootIsRepository || rootGitMarker; + inGitWorkTree = true; + if (isSharedRoot && rootIsRepository) { + // Canonicalize the (already invocation-cwd-resolved) common dir + // before comparing, so `/var` ↔ `/private/var` alias spellings of + // the same physical root give the local-metadata guidance. + const commonDirCanonical = + identity.commonDir !== null ? yield* realPathBounded(identity.commonDir) : null; + const commonUnderRoot = + canonicalRoot !== null && commonDirCanonical !== null + ? isInside(path, commonDirCanonical, canonicalRoot) + : null; + yield* add({ + code: "shared-root-git", + severity: "warning", + message: + `The shared session root ${input.root} is itself a Git repository ` + + `(top-level ${identity.topLevel ?? input.root}). Projects beneath it would share that ` + + "repository." + + (commonUnderRoot === true + ? ` Move or retire ${path.join(input.root, ".git")} if that is unintended.` + : commonUnderRoot === false + ? " Its Git identity points at a different repository; no change to this root is implied." + : " Its Git identity could not be fully resolved, so T3 Code cannot confirm whether this root's own repository metadata is in use; no change to this root is implied."), + }); + } + break; + } + case "not-a-repository": { + effectiveRootRepository = rootGitMarker; + inGitWorkTree = rootGitMarker; + break; + } + case "failed": { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + "The Git repository probe failed. Git-backed sessions may fail; check the Git install and the session-root filesystem.", + }); + break; + } + } + } else if (identityOutcome._tag === "timeout") { + yield* add({ + code: "git-probe-timed-out", + severity: "warning", + message: + "The Git repository probe did not finish in time. Git or the session-root filesystem may be " + + "stalled; Git-backed sessions may hang. Materialize the root and check the Git install.", + }); + } else { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: `The Git repository probe failed (${identityOutcome.error.detail}). Git-backed sessions may fail.`, + }); + } + + // Applicability comes from the selected consumer/operation and from the + // repository's own sparse-checkout configuration. OpenCode snapshot + // staging passes `git add --all --sparse` for eligible files in ordinary + // repositories too, so a missing `--sparse` is actionable for that + // consumer. T3's own checkpoint path only needs it in a sparse checkout. + // Resolve the repository-side applicability *before* the capability probe + // so an incomplete probe is still recognized as relevant (W1-B): a + // failure after a verified sparse checkout must warn, not be dropped. + const requiredByConsumer = consumerUsesSparseAdd(input.consumer) && inGitWorkTree; + const sparseConfigOutcome = yield* input.git + .isSparseCheckout(input.root, input.gitEnvironment) + .pipe( + Effect.map((value) => ({ _tag: "ok" as const, value })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + // A verified sparse checkout makes the capability relevant for every + // consumer. When the config check itself is incomplete, only the + // consumer-derived requirement is known; the repository side stays + // unknown rather than assumed. + const sparseCheckoutVerified = + sparseConfigOutcome._tag === "ok" && sparseConfigOutcome.value; + const sparseApplicable = requiredByConsumer || sparseCheckoutVerified; + if (sparseApplicable) { + const sparseOutcome = yield* input.git + .probeSparseAdd(input.root, input.gitEnvironment) + .pipe( + Effect.map((state) => ({ _tag: "ok" as const, state })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + if (sparseOutcome._tag === "ok" && sparseOutcome.state === "unsupported") { + yield* add({ + code: "git-sparse-add-unsupported", + severity: "warning", + message: requiredByConsumer + ? "The selected OpenCode session snapshots this repository with `git add --sparse`, but the " + + "Git this launch resolves does not support `--sparse`. Staging changed and untracked files " + + "for a snapshot will fail, so snapshots may be incomplete. Install a newer Git (or disable " + + "OpenCode snapshots) to keep snapshots accurate; the session can still start." + : "This is a sparse Git checkout, but the Git this launch resolves does not support " + + "`git add --sparse`. T3 Code cannot tell which files the sparse rules exclude, so a " + + "checkpoint may record excluded files as deleted. Install a newer Git to keep sparse " + + "checkpoints accurate; the session can still start.", + }); + } else if (sparseOutcome._tag === "timeout") { + // The capability is relevant and could not be confirmed. Report it as + // incomplete rather than silently clean; never mislabel an unknown + // capability as unsupported, and never block. + yield* add({ + code: "git-probe-timed-out", + severity: "warning", + message: + "The Git `add --sparse` capability probe did not finish in time, so this launch could not " + + "be confirmed to support `git add --sparse`. Snapshots or sparse checkpoints may be " + + "incomplete; the session can still start. Check the Git install and the session-root " + + "filesystem.", + }); + } else if (sparseOutcome._tag === "error") { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + `The Git \`add --sparse\` capability probe failed (${sparseOutcome.error.detail}), so this ` + + "launch could not be confirmed to support `git add --sparse`. Snapshots or sparse " + + "checkpoints may be incomplete; the session can still start.", + }); + } + } + } else if (gitOutcome._tag === "timeout" || gitOutcome.error.reason === "timeout") { + yield* add({ + code: "git-probe-timed-out", + severity: "warning", + message: + "The Git version probe did not finish in time. Git or the session-root filesystem may be " + + "stalled; Git-backed sessions may hang. Materialize the root and check the Git install.", + }); + } else if (gitOutcome.error.reason === "unavailable") { + yield* add({ + code: "git-startup-failed", + severity: effectiveRootRepository ? "blocker" : "warning", + message: + "Git could not be started from the session-root PATH (not found or not executable)." + + (effectiveRootRepository + ? blockerSuffix + : " Sessions that checkpoint, diff, or open a repository will fail. Install Git or put its " + + "directory earlier on PATH, then restart T3 Code."), + }); + } else { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: `The Git version probe failed (${gitOutcome.error.detail}). Git-backed sessions may fail.`, + }); + } + + let readTarget: string | undefined; + for (const relativePath of READ_PROBE_CANDIDATES) { + const candidate = path.join(input.root, relativePath); + const outcome = yield* existsOutcome(candidate); + if (outcome.state === "present") { + readTarget = candidate; + break; + } + if (outcome.state === "unknown") { + // A denied or stalled metadata read is not absence. Report it once and + // stop scanning further optional candidates; missing optional files + // stay silent. + yield* add(rootMetadataWarning(candidate, outcome.reason)); + break; + } + } + + if (readTarget !== undefined) { + const readOutcome = yield* input.files + .readFirstBytes(readTarget, READ_PROBE_MAX_BYTES) + .pipe( + Effect.map((bytes) => ({ _tag: "ok" as const, bytes })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(READ_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + if (readOutcome._tag === "timeout") { + yield* add({ + code: "root-read-slow", + severity: "warning", + message: + `Reading ${readTarget} under the session root did not finish in time. The filesystem may ` + + 'be stalled or cloud-offloaded; use "Keep Downloaded" on the folder before starting ' + + "sessions.", + }); + } else if (readOutcome._tag === "error") { + yield* add({ + code: "root-read-failed", + severity: "warning", + message: + `Could not read ${readTarget} under the session root (${readOutcome.error.detail}). ` + + "Sessions may fail to read the workspace.", + }); + } + } + }); + + // The whole exploration shares one wall-clock budget. Findings already + // collected survive a mid-probe timeout, so an incomplete preflight is never + // reported clean; each probe is still individually bounded above. + const completed = Option.isSome(yield* explore.pipe(Effect.timeoutOption(TOTAL_PROBE_BUDGET))); + if (!completed) { + yield* add({ + code: "git-probe-timed-out", + severity: "warning", + message: + "The launch preflight did not finish within its budget, so its checks are incomplete. " + + "The session can still start; check the Git install and the session-root filesystem.", + }); + } + + const findings = yield* Ref.get(collected); + return { + findings, + warnings: findings.filter((finding) => finding.severity === "warning"), + blockers: findings.filter((finding) => finding.severity === "blocker"), + } satisfies LaunchPreflightResult; + }); + +const classifyGitProbeError = (error: VcsError): LaunchPreflightProbeError => { + if (error._tag === "VcsProcessSpawnError") { + return new LaunchPreflightProbeError({ reason: "unavailable", detail: error.message }); + } + if (error._tag === "VcsProcessTimeoutError") { + return new LaunchPreflightProbeError({ reason: "timeout", detail: error.message }); + } + return new LaunchPreflightProbeError({ reason: "failed", detail: error.message }); +}; + +export class LaunchPreflight extends Context.Service< + LaunchPreflight, + { + readonly run: ( + root: string, + options?: { + readonly isSharedRoot?: boolean; + readonly configuredRoot?: string; + readonly consumer?: LaunchPreflightConsumer; + readonly gitEnvironment?: NodeJS.ProcessEnv; + }, + ) => Effect.Effect; + } +>()("t3/environment/LaunchPreflight") {} + +/** Builds the production Git probe from the shared bounded VCS process runner. */ +export const makeGitProbe = ( + vcsProcess: VcsProcess.VcsProcess["Service"], + path: Path.Path, +): LaunchPreflightGitProbe => { + const runGit = ( + operation: string, + root: string, + args: ReadonlyArray, + allowNonZeroExit: boolean, + env?: NodeJS.ProcessEnv, + ) => + vcsProcess + .run({ + operation, + command: "git", + args, + cwd: root, + timeoutMs: 1_500, + maxOutputBytes: 4_000, + ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), + ...(env !== undefined ? { env } : {}), + }) + .pipe(Effect.mapError(classifyGitProbeError)); + + return { + version: (root, env) => + runGit("launch-preflight.git-version", root, ["--version"], false, env).pipe( + Effect.map((result) => parseGitVersion(result.stdout) ?? parseGitVersion(result.stderr)), + ), + resolveIdentity: (root, env) => + Effect.gen(function* () { + const top = yield* runGit( + "launch-preflight.git-toplevel", + root, + ["rev-parse", "--show-toplevel"], + true, + env, + ); + if (Number(top.exitCode) !== 0) { + const stderr = top.stderr.trim(); + if (/not a git repository/i.test(stderr) || /must be run in a work tree/i.test(stderr)) { + return { + state: "not-a-repository", + topLevel: null, + commonDir: null, + detail: stderr, + } satisfies LaunchPreflightRepoIdentity; + } + return { + state: "failed", + topLevel: null, + commonDir: null, + detail: stderr, + } satisfies LaunchPreflightRepoIdentity; + } + const topLevel = top.stdout.trim(); + const commonResult = yield* runGit( + "launch-preflight.git-common-dir", + root, + ["rev-parse", "--git-common-dir"], + true, + env, + ); + const rawCommonDir = Number(commonResult.exitCode) === 0 ? commonResult.stdout.trim() : ""; + // Plain `git rev-parse --git-common-dir` prints a path relative to the + // directory Git actually ran in (the invocation cwd), e.g. `repo/sub` + // yields `../.git`. Resolve against the exact invocation cwd `root`, + // not the top level, or an ordinary subdirectory launch would resolve + // to a path outside the repository. Absolute output is untouched. + const commonDir = + rawCommonDir.length > 0 + ? path.isAbsolute(rawCommonDir) + ? rawCommonDir + : path.resolve(root, rawCommonDir) + : null; + return { + state: "ok", + topLevel: topLevel.length > 0 ? topLevel : null, + commonDir, + detail: "", + } satisfies LaunchPreflightRepoIdentity; + }), + isSparseCheckout: (root, env) => + Effect.gen(function* () { + // Read-only: is the root repository actually a sparse checkout? + const sparseConfig = yield* runGit( + "launch-preflight.git-sparse-config", + root, + ["config", "--bool", "core.sparseCheckout"], + true, + env, + ); + return Number(sparseConfig.exitCode) === 0 && sparseConfig.stdout.trim() === "true"; + }), + probeSparseAdd: (root, env) => + Effect.gen(function* () { + // Read-only usage probe; `git add -h` never stages a file. + const help = yield* runGit( + "launch-preflight.git-sparse-add-help", + root, + ["add", "-h"], + true, + env, + ); + return /--(?:\[no-\])?sparse\b/.test(`${help.stdout}${help.stderr}`) + ? ("supported" satisfies LaunchPreflightSparseCapability) + : ("unsupported" satisfies LaunchPreflightSparseCapability); + }), + }; +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const makeFileProbe = (fileSystem: FileSystem.FileSystem): LaunchPreflightFileProbe => ({ + // Reuse the typed stat/absence distinction: a genuine NotFound is the only + // state that means "absent". A denied or I/O-failed lookup stays a probe + // error so the caller warns instead of reading it as absence. + exists: (target) => + fileSystem.stat(target).pipe( + Effect.as(true), + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed(false), + ), + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), + stat: (target) => + fileSystem.stat(target).pipe( + Effect.map((entry): LaunchPreflightFsEntry => ({ + type: entry.type === "Directory" ? "directory" : entry.type === "File" ? "file" : "other", + })), + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed({ type: "missing" } satisfies LaunchPreflightFsEntry), + ), + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), + realPath: (target) => fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => null)), + readFirstBytes: (target, maxBytes) => + fileSystem.stream(target, { bytesToRead: maxBytes }).pipe( + Stream.runCount, + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), +}); + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const vcsProcess = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = makeGitProbe(vcsProcess, path); + const files = makeFileProbe(fileSystem); + + return LaunchPreflight.of({ + run: (root: string, options) => + runLaunchPreflight({ + root, + git, + files, + ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options?.configuredRoot !== undefined + ? { configuredRoot: options.configuredRoot } + : {}), + ...(options?.consumer !== undefined ? { consumer: options.consumer } : {}), + ...(options?.gitEnvironment !== undefined + ? { gitEnvironment: options.gitEnvironment } + : {}), + }).pipe(Effect.provideService(Path.Path, path)), + }); +}); + +export const layer = Layer.effect(LaunchPreflight, make); diff --git a/apps/server/src/environment/LaunchPreflightWarningInbox.ts b/apps/server/src/environment/LaunchPreflightWarningInbox.ts new file mode 100644 index 000000000000..a33878f8c65c --- /dev/null +++ b/apps/server/src/environment/LaunchPreflightWarningInbox.ts @@ -0,0 +1,80 @@ +/** + * In-memory hand-off for launch-preflight warnings found before any provider + * session exists. + * + * The startup preflight runs while no thread is available, so its findings can + * only be logged there. This inbox carries them to the first provider session + * in the same working directory, which delivers them through the existing + * thread-activity warning transport — the same one a live launch uses. It is a + * process-scoped `Context.Reference` (like the shell command-resolution cache), + * never persisted, and not a dashboard or a store. + * + * Delivery is peek-then-clear: a pending notice is removed only after it was + * actually delivered, so a transient delivery failure does not silently discard + * a warning. The per-directory list is bounded so a long-running server cannot + * accumulate unbounded pending notices. + * + * @module LaunchPreflightWarningInbox + */ +import * as Context from "effect/Context"; + +import type { LaunchPreflightFindingCode } from "./LaunchPreflight.ts"; + +export interface PendingLaunchPreflightWarning { + readonly code: LaunchPreflightFindingCode; + readonly message: string; +} + +/** Cap on pending notices retained per working directory. */ +export const LAUNCH_PREFLIGHT_INBOX_LIMIT = 16; + +export const LaunchPreflightWarningInbox = Context.Reference< + Map> +>("@t3tools/server/LaunchPreflightWarningInbox", { + defaultValue: () => new Map(), +}); + +/** + * Records startup findings under an already-normalized working directory, + * bounded to {@link LAUNCH_PREFLIGHT_INBOX_LIMIT} notices per directory. + */ +export const recordLaunchPreflightWarnings = ( + inbox: Map>, + normalizedCwd: string, + warnings: ReadonlyArray, +): void => { + if (warnings.length === 0) return; + const combined = [...(inbox.get(normalizedCwd) ?? []), ...warnings]; + inbox.set( + normalizedCwd, + combined.length > LAUNCH_PREFLIGHT_INBOX_LIMIT + ? combined.slice(combined.length - LAUNCH_PREFLIGHT_INBOX_LIMIT) + : combined, + ); +}; + +/** + * Reads pending findings for an already-normalized directory without removing + * them. Call {@link clearLaunchPreflightWarnings} only after successful delivery. + */ +export const peekLaunchPreflightWarnings = ( + inbox: Map>, + normalizedCwd: string, +): ReadonlyArray => inbox.get(normalizedCwd) ?? []; + +/** + * Removes pending findings for an already-normalized directory after they were + * delivered. Anything still undelivered must be passed in `keep` so it survives + * to the next session in the same directory. + */ +export const clearLaunchPreflightWarnings = ( + inbox: Map>, + normalizedCwd: string, + keep: ReadonlyArray = [], +): void => { + if (keep.length === 0) { + inbox.delete(normalizedCwd); + return; + } + inbox.set(normalizedCwd, keep); +}; diff --git a/apps/server/src/environment/launchPreflightReporter.ts b/apps/server/src/environment/launchPreflightReporter.ts new file mode 100644 index 000000000000..8e9ee0b236d6 --- /dev/null +++ b/apps/server/src/environment/launchPreflightReporter.ts @@ -0,0 +1,48 @@ +/** + * Production reporter for launch-preflight warnings. + * + * The composition root injects this into `ProviderService`, which calls it for + * every warning it surfaces. It appends a `launch.preflight` thread activity + * through the real orchestration engine, so a warning reaches the same client + * subscription a normal thread activity does. It never fails a launch: a + * failed append returns `false` so the caller can keep the pending notice. + * + * @module launchPreflightReporter + */ +import { CommandId, EventId, type ThreadId } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import type { OrchestrationEngineShape } from "../orchestration/Services/OrchestrationEngine.ts"; +import type { LaunchPreflightFindingCode } from "./LaunchPreflight.ts"; + +export interface LaunchPreflightWarningReportInput { + readonly threadId: ThreadId; + readonly cwd: string; + readonly code: LaunchPreflightFindingCode; + readonly message: string; +} + +export const makeLaunchPreflightWarningReporter = + (orchestrationEngine: OrchestrationEngineShape, crypto: Crypto.Crypto) => + (input: LaunchPreflightWarningReportInput): Effect.Effect => + Effect.gen(function* () { + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId: input.threadId, + activity: { + id: EventId.make(yield* crypto.randomUUIDv4), + tone: "error", + kind: "launch.preflight", + summary: input.message, + payload: { code: input.code, cwd: input.cwd }, + turnId: null, + createdAt, + }, + createdAt, + }); + return true; + }).pipe(Effect.catchCause(() => Effect.succeed(false))); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index f714a70f783d..55bc7390f852 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -85,9 +85,8 @@ const testLayer = (input: { return Layer.mergeAll( ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), - Layer.succeed( - SpawnExecutableResolution, - (command) => input.resolveExecutable?.(command) ?? command, + Layer.succeed(SpawnExecutableResolution, (command) => + Effect.succeed(input.resolveExecutable?.(command) ?? command), ), ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849da..3d652e5342ff 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -151,10 +151,12 @@ describe("runProcess", () => { PATHEXT: ".COM;.EXE;.BAT;.CMD", }), Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => - env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && - env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" - ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" - : undefined, + Effect.succeed( + env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && + env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" + ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" + : undefined, + ), ), Effect.map((result) => { expect(result.stdout).toBe("[]"); diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts index 020fc48a4656..84396f50b4e4 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -18,7 +18,7 @@ function withWindowsResolution(input: { return (effect: Effect.Effect) => effect.pipe( Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand), + Effect.provideService(SpawnExecutableResolution, () => Effect.succeed(input.resolvedCommand)), Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)), ); } @@ -29,9 +29,9 @@ describe("resolveClaudeSdkExecutablePath", () => { expect( yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService(SpawnExecutableResolution, () => { - throw new Error("must not resolve on non-Windows platforms"); - }), + Effect.provideService(SpawnExecutableResolution, () => + Effect.die("must not resolve on non-Windows platforms"), + ), ), ).toBe("claude"); }), diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts index febfdb26f9e3..506af39fbc94 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -67,7 +67,7 @@ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecuta const resolveExecutable = yield* SpawnExecutableResolution; const isFile = yield* ClaudeExecutableFileCheck; - const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath; + const resolved = (yield* resolveExecutable(binaryPath, platform, environment)) ?? binaryPath; const extension = NodePath.win32.extname(resolved).toLowerCase(); if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) { return resolved; diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index cdeeb3b922d4..192cf323addb 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -101,6 +101,26 @@ export class ProviderWorkspaceMissingError extends Schema.TaggedError()( + "ProviderLaunchPreflightBlockedError", + { + threadId: Schema.String, + cwd: Schema.String, + code: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return this.detail; + } +} + /** * ProviderValidationError - Invalid provider API input. */ @@ -214,6 +234,7 @@ export type ProviderServiceError = | ProviderValidationError | ProviderUnsupportedError | ProviderWorkspaceMissingError + | ProviderLaunchPreflightBlockedError | ProviderInstanceNotFoundError | ProviderSessionNotFoundError | ProviderSessionDirectoryPersistenceError diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index a88cfdef5ffe..5a6cf181965a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -73,6 +73,7 @@ import { import { ProviderAdapterRequestError, type ProviderAdapterError, + ProviderLaunchPreflightBlockedError, ProviderValidationError, ProviderWorkspaceMissingError, } from "../Errors.ts"; @@ -87,9 +88,18 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as LaunchPreflight from "../../environment/LaunchPreflight.ts"; +import * as LaunchPreflightWarningInboxModule from "../../environment/LaunchPreflightWarningInbox.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { resolveLaunchPreflightConsumer } from "../launchPreflightConsumer.ts"; const isModelSelection = Schema.is(ModelSelection); const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +// Narrow-filesystem budget for the launch preflight directory checks, matching +// the preflight's own per-operation bound so a stalled path cannot hold a launch. +const NARROW_FS_TIMEOUT = "500 millis"; + interface SnapShotPromptAccessibilityNode { readonly role: string; readonly name?: string; @@ -257,6 +267,33 @@ export interface ProviderServiceLiveOptions { * test see whether a credential was requested at all. */ readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; + /** + * Sink for launch-preflight warnings, so they reach the user instead of only + * the server log. The composition root wires this to an existing + * user-visible transport (a thread activity append). It returns whether the + * notice was actually delivered; a failed delivery leaves a pending startup + * notice in the inbox instead of silently discarding it. + */ + readonly reportLaunchPreflightWarning?: (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }) => Effect.Effect; + /** + * Overrides the launch-preflight runner. Tests use this to force a warning or + * a blocker without a broken Git install. The options carry the selected + * consumer/operation and the exact environment the provider launch resolves + * `git` with. + */ + readonly launchPreflightRunner?: ( + root: string, + options?: { + readonly isSharedRoot?: boolean; + readonly consumer?: LaunchPreflight.LaunchPreflightConsumer; + readonly gitEnvironment?: NodeJS.ProcessEnv; + }, + ) => Effect.Effect; } interface TurnAnalyticsMetadata { @@ -508,6 +545,144 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; + const launchPreflight = yield* LaunchPreflight.LaunchPreflight; + const runLaunchPreflight = options?.launchPreflightRunner ?? launchPreflight.run; + + /** + * Runs the bounded launch preflight against the exact cwd a provider process + * is about to start in, before the caller's own workspace read. Warnings are + * surfaced through the existing log and the injected user-visible sink; + * blockers stop the launch with an actionable error. + */ + const guardProviderLaunch = Effect.fn("ProviderService.guardProviderLaunch")(function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly provider?: ProviderDriverKind; + readonly providerInstanceId?: ProviderInstanceId; + }) { + // The bounded launch preflight itself verifies that the exact cwd is a real + // directory, warns about a stalled/denied filesystem, and skips a genuinely + // absent or non-directory path. The caller's own workspace read (and + // `ProviderWorkspaceMissingError`) owns that case, so this no longer + // silently drops a failed/timed-out cwd stat. + + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => undefined)); + // Shared-root intent is carried into the bounded preflight as the exact + // configured root, so its canonical identity comparison can recognize alias + // spellings of the same physical directory. A lexical compare here would + // lose that intent before the probe ever runs. + const configuredRoot = settings?.sharedSessionRoot; + // The consumer/operation is derived from the selected production + // instance/runtime facts: the effective OpenCode snapshot configuration and + // whether an external OpenCode server owns the session. Non-OpenCode + // launches keep T3's own Git fallback and only need `--sparse` in a sparse + // checkout. + const consumer = resolveLaunchPreflightConsumer({ + ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.providerInstanceId !== undefined + ? { providerInstanceId: input.providerInstanceId } + : {}), + ...(settings !== undefined ? { settings } : {}), + }); + // The provider launch resolves `git` from the same environment the adapter + // inherits. Pass it through so the probe inspects the actual selected Git, + // not an unrelated host default. + const instanceEnvironment = + input.providerInstanceId === undefined + ? undefined + : settings?.providerInstances[input.providerInstanceId]?.environment; + const gitEnvironment = + instanceEnvironment === undefined || instanceEnvironment.length === 0 + ? undefined + : mergeProviderInstanceEnvironment(instanceEnvironment); + const result = yield* runLaunchPreflight(input.cwd, { + ...(configuredRoot !== undefined ? { configuredRoot } : {}), + ...(consumer !== undefined ? { consumer } : {}), + ...(gitEnvironment !== undefined ? { gitEnvironment } : {}), + }).pipe( + Effect.catchCause(() => + Effect.succeed({ + findings: [] as ReadonlyArray, + warnings: [] as ReadonlyArray, + blockers: [] as ReadonlyArray, + }), + ), + ); + + const report = options?.reportLaunchPreflightWarning; + const deliverWarning = (warning: { + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }) => + Effect.gen(function* () { + yield* Effect.logWarning(`launch preflight: ${warning.message}`, { + code: warning.code, + threadId: input.threadId, + cwd: input.cwd, + }); + if (report === undefined) { + // The startup phase already logged this; the log is the delivery. + return true; + } + return yield* report({ + threadId: input.threadId, + cwd: input.cwd, + code: warning.code, + message: warning.message, + }).pipe(Effect.catchCause(() => Effect.succeed(false))); + }); + + // Deliver warnings the startup preflight could only log (no thread existed + // yet) to this first affected session through the same transport. Remove a + // pending notice only after it was actually delivered; anything undelivered + // stays for the next session in the same directory. + const inbox = yield* LaunchPreflightWarningInboxModule.LaunchPreflightWarningInbox; + const inboxKey = LaunchPreflight.normalizePathKey(pathService, input.cwd); + const pendingWarnings = LaunchPreflightWarningInboxModule.peekLaunchPreflightWarnings( + inbox, + inboxKey, + ); + const deliveredCodes = new Set(); + const undelivered: Array = []; + for (const warning of pendingWarnings) { + if (deliveredCodes.has(warning.code)) continue; + const delivered = yield* deliverWarning(warning); + if (delivered) deliveredCodes.add(warning.code); + else undelivered.push(warning); + } + LaunchPreflightWarningInboxModule.clearLaunchPreflightWarnings(inbox, inboxKey, undelivered); + + for (const warning of result.warnings) { + if (deliveredCodes.has(warning.code)) continue; + deliveredCodes.add(warning.code); + yield* deliverWarning(warning); + } + + const blocker = result.blockers[0]; + if (blocker !== undefined) { + yield* Effect.logError(`launch preflight blocked provider launch: ${blocker.message}`, { + code: blocker.code, + threadId: input.threadId, + cwd: input.cwd, + }); + if (options?.reportLaunchPreflightWarning) { + yield* options + .reportLaunchPreflightWarning({ + threadId: input.threadId, + cwd: input.cwd, + code: blocker.code, + message: blocker.message, + }) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + } + return yield* new ProviderLaunchPreflightBlockedError({ + threadId: input.threadId, + cwd: input.cwd, + code: blocker.code, + detail: blocker.message, + }); + } + }); const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map(); const timedOutNativeCompactions = new Set(); @@ -1292,6 +1467,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + if (persistedCwd) { + yield* guardProviderLaunch({ + threadId: input.binding.threadId, + cwd: persistedCwd, + provider: input.binding.provider, + providerInstanceId: bindingInstanceId, + }); + } + yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); const resumed = yield* adapter .startSession({ @@ -1508,13 +1692,25 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); if (effectiveCwd !== undefined) { + yield* guardProviderLaunch({ + threadId, + cwd: effectiveCwd, + provider: resolvedProvider, + providerInstanceId: resolvedInstanceId, + }); // Fail fast with an actionable error when the workspace folder is // gone (e.g. moved, deleted, or replaced by a plain file). // Otherwise every adapter surfaces this as a misleading "failed to // spawn " process error. Stat failures other than "missing" - // fall through to the adapter. + // fall through to the adapter. Bounded so a stalled path cannot hold + // the launch; a timeout falls through and lets the adapter report it. const workspaceIsDirectory = yield* fileSystem.stat(effectiveCwd).pipe( - Effect.map((workspaceStat) => workspaceStat.type === "Directory"), + Effect.timeoutOption(NARROW_FS_TIMEOUT), + Effect.flatMap((statOption) => + Option.isSome(statOption) + ? Effect.succeed(statOption.value.type === "Directory") + : Effect.succeed(true), + ), Effect.catch((statError) => Effect.succeed(statError.reason._tag !== "NotFound")), ); if (!workspaceIsDirectory) { @@ -2444,11 +2640,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } satisfies ProviderService.ProviderService["Service"]; }); +// A self-contained preflight: the Git probe uses its own `VcsProcess` so this +// layer only needs the platform services (`FileSystem`, `Path`, +// `ChildProcessSpawner`) already present in the server and test harnesses. +const LaunchPreflightLive = LaunchPreflight.layer.pipe(Layer.provide(VcsProcess.layer)); + export const ProviderServiceLive = Layer.effect( ProviderService.ProviderService, makeProviderService(), -); +).pipe(Layer.provide(LaunchPreflightLive)); export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) { - return Layer.effect(ProviderService.ProviderService, makeProviderService(options)); + return Layer.effect(ProviderService.ProviderService, makeProviderService(options)).pipe( + Layer.provide(LaunchPreflightLive), + ); } diff --git a/apps/server/src/provider/launchPreflightConsumer.test.ts b/apps/server/src/provider/launchPreflightConsumer.test.ts new file mode 100644 index 000000000000..b1e4019b4b3c --- /dev/null +++ b/apps/server/src/provider/launchPreflightConsumer.test.ts @@ -0,0 +1,130 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + openCodeSnapshotsEnabled, + resolveLaunchPreflightConsumer, +} from "./launchPreflightConsumer.ts"; + +const opencode = ProviderDriverKind.make("opencode"); + +it.effect("W1-D: honors inline JSONC without comments or trailing commas misreading", () => + Effect.gen(function* () { + // Strict JSON still works. + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":false}'), false); + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":true}'), true); + assert.strictEqual(openCodeSnapshotsEnabled("{}"), true); + + // Valid JSONC (open comment, line comment, trailing commas) disables it. + assert.strictEqual( + openCodeSnapshotsEnabled('{\n /* disable staging */\n "snapshot": false,\n}\n'), + false, + ); + assert.strictEqual(openCodeSnapshotsEnabled('{\n // comment\n "snapshot": false,\n}'), false); + assert.strictEqual(openCodeSnapshotsEnabled('{ "snapshot": true, }'), true); + }), +); + +it.effect("W1-D: JSONC markers inside strings and escaped characters never alter detection", () => + Effect.gen(function* () { + // Comment markers inside a quoted value are data, not syntax. + assert.strictEqual( + openCodeSnapshotsEnabled('{"note":"// not a comment","snapshot":false}'), + false, + ); + assert.strictEqual( + openCodeSnapshotsEnabled('{"note":"/* not a block comment */","snapshot":true}'), + true, + ); + // Bracket/comma markers inside a string must not be read as trailing commas. + assert.strictEqual(openCodeSnapshotsEnabled('{"note":"a, ] } ,","snapshot":false}'), false); + // Escaped quotes and a trailing escaped backslash. + assert.strictEqual( + openCodeSnapshotsEnabled('{"note":"a \\"quoted\\" value","snapshot":false}'), + false, + ); + assert.strictEqual( + openCodeSnapshotsEnabled('{"note":"ends with a backslash \\\\","snapshot":true}'), + true, + ); + }), +); + +it.effect("W1-D: comments and trailing commas combine with in-string markers", () => + Effect.gen(function* () { + const config = [ + "{", + ' // keep the "snapshot" key addressable', + ' "note": "/* not a comment */ and a trailing comma , }",', + " /* block comment */", + ' "snapshot": false,', + "}", + ].join("\n"); + assert.strictEqual(openCodeSnapshotsEnabled(config), false); + + // A trailing comma after a value whose string ends in a backslash. + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot": true,\n}'), true); + }), +); + +it.effect("W1-D: unknown configuration is never asserted enabled", () => + Effect.gen(function* () { + assert.strictEqual(openCodeSnapshotsEnabled("not json at all"), undefined); + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":'), undefined); + assert.strictEqual(openCodeSnapshotsEnabled("[]"), undefined); + assert.strictEqual(openCodeSnapshotsEnabled('"a string"'), undefined); + // A non-boolean snapshot value is not a definite enable. + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":"off"}'), undefined); + // A malformed document is unknown, never a best-effort partial read. + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":false,} trailing'), undefined); + assert.strictEqual(openCodeSnapshotsEnabled("{ /* unterminated"), undefined); + }), +); + +it.effect("W1-D: production settings resolve JSONC snapshot:false to a disabled consumer", () => + Effect.gen(function* () { + const consumer = resolveLaunchPreflightConsumer({ + provider: opencode, + providerInstanceId: ProviderInstanceId.make("opencode"), + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("opencode")]: { + driver: opencode, + enabled: true, + environment: [ + { + name: "OPENCODE_CONFIG_CONTENT", + value: '{ /* snapshots off */ "snapshot": false, }', + sensitive: false, + }, + ], + }, + }, + }, + hostEnv: {}, + }); + + assert.deepStrictEqual(consumer, { driver: "opencode", snapshotsEnabled: false }); + }), +); + +it.effect("W1-D: the ordinary default keeps snapshots enabled", () => + Effect.gen(function* () { + const consumer = resolveLaunchPreflightConsumer({ + provider: opencode, + providerInstanceId: ProviderInstanceId.make("opencode"), + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("opencode")]: { driver: opencode, enabled: true }, + }, + }, + hostEnv: {}, + }); + + assert.deepStrictEqual(consumer, { driver: "opencode", snapshotsEnabled: true }); + }), +); diff --git a/apps/server/src/provider/launchPreflightConsumer.ts b/apps/server/src/provider/launchPreflightConsumer.ts new file mode 100644 index 000000000000..c51ade8be692 --- /dev/null +++ b/apps/server/src/provider/launchPreflightConsumer.ts @@ -0,0 +1,104 @@ +/** + * Derives the launch-preflight consumer from the selected production provider + * instance. The consumer is what decides whether `git add --sparse` is + * required by the launch itself (OpenCode snapshot staging) or only by the + * repository's sparse-checkout configuration (T3's own checkpoint fallback). + * + * The facts come from the same settings the provider runtime uses: + * `ServerSettings.providerInstances`, each instance's configured environment, + * and — for the default OpenCode provider — the effective + * `OPENCODE_CONFIG_CONTENT` snapshot setting. An instance pointed at an + * external OpenCode server owns its own process and Git, so the local Git this + * T3 server resolves cannot establish that process's capability. + * + * @module provider/launchPreflightConsumer + */ +import { + type ProviderDriverKind, + type ProviderInstanceId, + OpenCodeSettings, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { type ParseError, parse as parseJsonc } from "jsonc-parser"; + +import type { LaunchPreflightConsumer } from "../environment/LaunchPreflight.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts"; + +const decodeOpenCodeSettings = Schema.decodeUnknownOption(OpenCodeSettings); + +const OPENCODE_DRIVER: ProviderDriverKind = "opencode" as ProviderDriverKind; + +/** + * Parses inline OpenCode configuration, which accepts JSONC (comments and + * trailing commas), with the maintained `jsonc-parser` rather than an ad-hoc + * regular expression. A parse error is treated as unknown configuration rather + * than a best-effort partial value. + */ +const parseOpenCodeConfig = (configContent: string): unknown => { + const errors: Array = []; + const parsed: unknown = parseJsonc(configContent, errors, { allowTrailingComma: true }); + return errors.length > 0 ? undefined : parsed; +}; + +/** + * Whether the effective OpenCode config still stages Git snapshots. OpenCode + * accepts inline JSONC, so the value is parsed with a supported parser. An + * explicit boolean `false` disables the provider-specific requirement and an + * explicit `true` keeps it; any value that cannot be read as a boolean + * (unparseable content, a non-object, or a non-boolean `snapshot`) is + * `undefined` — unknown, never asserted enabled. This intentionally does not + * search config files or call out. + */ +export const openCodeSnapshotsEnabled = (configContent: string): boolean | undefined => { + const parsed = parseOpenCodeConfig(configContent); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + const snapshot = (parsed as { readonly snapshot?: unknown }).snapshot; + if (snapshot === false) return false; + if (snapshot === true || snapshot === undefined) return true; + return undefined; +}; + +export interface ResolveLaunchPreflightConsumerInput { + readonly provider?: ProviderDriverKind | undefined; + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly settings?: ServerSettings | undefined; + /** Host environment used as the instance environment's base. Defaults to `process.env`. */ + readonly hostEnv?: NodeJS.ProcessEnv | undefined; +} + +/** + * Resolves the consumer/operation about to launch. Returns `undefined` when no + * provider is known yet (nothing consumer-specific to check). + */ +export const resolveLaunchPreflightConsumer = ( + input: ResolveLaunchPreflightConsumerInput, +): LaunchPreflightConsumer | undefined => { + const { provider } = input; + if (provider === undefined) return undefined; + + if (provider !== OPENCODE_DRIVER) { + // T3's own checkpoint path only needs `--sparse` in a sparse checkout, which + // the preflight detects from the repository itself. No provider-specific + // requirement applies to other consumers. + return { driver: provider, snapshotsEnabled: true }; + } + + const entry = + input.providerInstanceId !== undefined + ? input.settings?.providerInstances[input.providerInstanceId] + : undefined; + const decoded = decodeOpenCodeSettings(entry?.config ?? {}); + const config = decoded._tag === "Some" ? decoded.value : undefined; + + if (config !== undefined && config.serverUrl.trim().length > 0) { + // External-server branch: no local OpenCode process is launched, so local + // PATH evidence cannot establish that server's Git. + return { driver: provider, snapshotsEnabled: false }; + } + + const environment = mergeProviderInstanceEnvironment(entry?.environment, input.hostEnv); + const snapshotsEnabled = openCodeSnapshotsEnabled(resolveOpenCodeConfigContent(environment)); + return { driver: provider, snapshotsEnabled }; +}; diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae..a4de598dc849 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -887,7 +887,7 @@ describe("providerMaintenanceRunner", () => { PATHEXT: ".COM;.EXE;.BAT;.CMD", }), Layer.succeed(SpawnExecutableResolution, (command) => - command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, + Effect.succeed(command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined), ), latestVersionHttpClient("0.0.0"), Layer.succeed( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3fd0bb7274a0..9f8d33df6521 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -9,6 +9,7 @@ import { type RepositoryIdentity, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -48,7 +49,8 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe import * as ModelManifest from "./provider/ModelManifest.ts"; import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; -import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -117,6 +119,7 @@ import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import { makeLaunchPreflightWarningReporter } from "./environment/launchPreflightReporter.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -267,7 +270,20 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( // `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical // NDJSON writers and is provided at the outer runtime layer so both // `ProviderService` and the per-instance drivers read the same logger pair. -const ProviderLayerLive = ProviderServiceLive.pipe( +// Launch-preflight warnings are surfaced through the existing thread-activity +// transport so they reach the user, not just the server log. The preflight +// service itself already runs inside `ProviderService` before each provider +// start; this sink is injected here because only the composition root has the +// orchestration engine. +const ProviderLayerLive = Layer.unwrap( + Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + return makeProviderServiceLive({ + reportLaunchPreflightWarning: makeLaunchPreflightWarningReporter(orchestrationEngine, crypto), + }); + }), +).pipe( Layer.provide(ProviderAdapterRegistryLive), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1468e1efecb0..e31bb44aa511 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,8 @@ import * as Scope from "effect/Scope"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; +import * as LaunchPreflight from "./environment/LaunchPreflight.ts"; +import * as LaunchPreflightWarningInboxModule from "./environment/LaunchPreflightWarningInbox.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -907,6 +909,8 @@ export const make = (options?: StartupOptions) => const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const launchPreflight = yield* LaunchPreflight.LaunchPreflight; + const pathService = yield* Path.Path; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const crypto = yield* Crypto.Crypto; @@ -960,6 +964,68 @@ export const make = (options?: StartupOptions) => ), ); + yield* Effect.logDebug("startup phase: running launch preflight"); + yield* runStartupPhase( + "launch.preflight", + Effect.gen(function* () { + // Shared-inbox intent comes only from the explicit setting; the + // server's own cwd is an ordinary working directory. The configured + // root is carried into the bounded preflight so alias spellings of the + // same physical root still recognize shared intent. + const sharedSessionRoot = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.sharedSessionRoot), + Effect.orElseSucceed(() => undefined), + ); + return yield* launchPreflight + .run(serverConfig.cwd, { + ...(sharedSessionRoot !== undefined ? { configuredRoot: sharedSessionRoot } : {}), + }) + .pipe( + Effect.tap((result) => + Effect.gen(function* () { + yield* Effect.forEach( + result.warnings, + (warning) => + Effect.logWarning(`launch preflight: ${warning.message}`, { + code: warning.code, + severity: warning.severity, + cwd: serverConfig.cwd, + }), + { discard: true }, + ); + // No thread exists yet, so carry these to the first provider + // session in this directory, which delivers them through the + // existing user-visible warning transport. + const inbox = + yield* LaunchPreflightWarningInboxModule.LaunchPreflightWarningInbox; + LaunchPreflightWarningInboxModule.recordLaunchPreflightWarnings( + inbox, + LaunchPreflight.normalizePathKey(pathService, serverConfig.cwd), + result.warnings.map((warning) => ({ + code: warning.code, + message: warning.message, + })), + ); + yield* Effect.forEach( + result.blockers, + (blocker) => + Effect.logError(`launch preflight: ${blocker.message}`, { + code: blocker.code, + severity: blocker.severity, + cwd: serverConfig.cwd, + }), + { discard: true }, + ); + }), + ), + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("launch preflight failed to run", { cause }), + ), + ); + }), + ); + yield* Effect.logDebug("startup phase: parking orchestration roots at activation"); yield* runStartupPhase( "reactors.start", @@ -1132,6 +1198,6 @@ export const make = (options?: StartupOptions) => }); export const layerWithOptions = (options?: StartupOptions) => - Layer.effect(ServerRuntimeStartup, make(options)); + Layer.effect(ServerRuntimeStartup, make(options)).pipe(Layer.provide(LaunchPreflight.layer)); export const layer = layerWithOptions(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fc1d71c66322..18a0cb0657cd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1220,6 +1220,17 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** + * The one directory this environment treats as a shared session inbox. It is + * an explicit, exact-root opt-in: provider sessions whose cwd resolves to + * this directory (and only this directory) are checked for an accidental + * umbrella repository, and a configured shared root is honored regardless of + * the backend's own working directory. Empty means ordinary — every + * repository session, including the server's own cwd, is treated as a normal + * working directory. Never inferred from a folder name, breadth, child + * repositories or the mere presence of Git. + */ + sharedSessionRoot: Schema.optionalKey(TrimmedString), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -1520,6 +1531,7 @@ export const ServerSettingsPatch = Schema.Struct({ newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), + sharedSessionRoot: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), generateThreadTitles: Schema.optionalKey(Schema.Boolean), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 621fe49b3087..91feeded28e4 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -498,9 +498,8 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } }, ).pipe( Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService( - SpawnExecutableResolution, - () => "C:\\Program Files\\npm & tools\\vp.cmd", + Effect.provideService(SpawnExecutableResolution, () => + Effect.succeed("C:\\Program Files\\npm & tools\\vp.cmd"), ), ); @@ -530,7 +529,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }), Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => { resolvedEnvironment = env; - return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd"; + return Effect.succeed("C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd"); }), ); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 11a45907cc1d..b7938b1bc7f7 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -89,46 +90,62 @@ export type SpawnExecutableResolver = ( command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -) => string | undefined; +) => Effect.Effect; +/** + * Asynchronous, bounded Windows executable resolution. It keeps the exact same + * PATH/PATHEXT candidate logic and npm `.cmd`/`.bat` wrapper semantics as the + * rest of the shell, but checks each candidate with an asynchronous `stat` + * instead of a synchronous `statSync`. A synchronous scan blocks the event + * loop, so an outer `Effect` timeout cannot interrupt a stalled lookup; an + * asynchronous one can. + */ function resolveSpawnExecutableWithNode( command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): string | undefined { - const path = platform === "win32" ? NodePath.win32 : NodePath.posix; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const candidates = resolveCommandCandidates( - command, - platform, - windowsPathExtensions, - path.extname, - ); - const isExecutable = (candidate: string) => { - try { - if (!NodeFS.statSync(candidate).isFile()) return false; - if (platform === "win32") { - return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); +): Effect.Effect { + return Effect.gen(function* () { + const path = platform === "win32" ? NodePath.win32 : NodePath.posix; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const candidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); + const isExecutable = (candidate: string) => + Effect.promise(async () => { + try { + const stat = await NodeFSP.stat(candidate); + if (!stat.isFile()) return false; + if (platform === "win32") { + return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); + } + await NodeFSP.access(candidate, NodeFS.constants.X_OK); + return true; + } catch { + return false; + } + }); + + if (command.includes("/") || command.includes("\\")) { + for (const candidate of candidates) { + if (yield* isExecutable(candidate)) return candidate; } - return canExecuteFile(candidate); - } catch { - return false; + return undefined; } - }; - if (command.includes("/") || command.includes("\\")) { - return candidates.find(isExecutable); - } - - for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { - const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); - if (normalizedPathEntry.length === 0) continue; - for (const candidate of candidates) { - const candidatePath = path.join(normalizedPathEntry, candidate); - if (isExecutable(candidatePath)) return candidatePath; + for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { + const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); + if (normalizedPathEntry.length === 0) continue; + for (const candidate of candidates) { + const candidatePath = path.join(normalizedPathEntry, candidate); + if (yield* isExecutable(candidatePath)) return candidatePath; + } } - } - return undefined; + return undefined; + }); } export const SpawnExecutableResolution = Context.Reference( @@ -643,7 +660,7 @@ export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(functi ? { ...hostEnvironment, ...options.env } : options.env; const resolveExecutable = yield* SpawnExecutableResolution; - const resolvedCommand = resolveExecutable(command, platform, env) ?? command; + const resolvedCommand = (yield* resolveExecutable(command, platform, env)) ?? command; const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); if (extension !== ".cmd" && extension !== ".bat") { return { command: resolvedCommand, args: [...args], shell: false }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d59982535f9c..b6abe9c43548 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,9 @@ importers: effect: specifier: 4.0.0-rc.115 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 node-pty: specifier: ^1.1.0 version: 1.1.0