import * as NodeServices from "@effect/platform-node/NodeServices";import { assert, it } from "@effect/vitest";import { DEFAULT_TERMINAL_ID, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput,} from "@t3tools/contracts";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import * as Data from "effect/Data";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";import * as Exit from "effect/Exit";import * as Fiber from "effect/Fiber";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as PlatformError from "effect/PlatformError";import * as Path from "effect/Path";import * as Ref from "effect/Ref";import * as Schedule from "effect/Schedule";import * as Scope from "effect/Scope";import * as TestClock from "effect/testing/TestClock";import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts";import * as TerminalManager from "./Manager.ts";import * as PtyAdapter from "./PtyAdapter.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ readonly message: string;}> {} class FakePtyProcess implements PtyAdapter.PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array<string | undefined> = []; readonly pid: number; writeFailure: unknown | undefined; resizeFailure: unknown | undefined; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); killed = false; constructor(pid: number) { this.pid = pid; } write(data: string): void { if (this.writeFailure !== undefined) { throw this.writeFailure; } this.writes.push(data); } resize(cols: number, rows: number): void { if (this.resizeFailure !== undefined) { throw this.resizeFailure; } this.resizeCalls.push({ cols, rows }); } kill(signal?: string): void { this.killed = true; this.killSignals.push(signal); } onData(callback: (data: string) => void): () => void { this.dataListeners.add(callback); return () => { this.dataListeners.delete(callback); }; } onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); }; } emitData(data: string): void { for (const listener of this.dataListeners) { listener(data); } } emitExit(event: PtyAdapter.PtyExitEvent): void { for (const listener of this.exitListeners) { listener(event); } }} class FakePtyAdapter { readonly spawnInputs: PtyAdapter.PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; private readonly mode: "sync" | "async"; private nextPid = 9000; constructor(mode: "sync" | "async" = "sync") { this.mode = mode; } spawn( input: PtyAdapter.PtySpawnInput, ): Effect.Effect<PtyAdapter.PtyProcess, PtyAdapter.PtySpawnError> { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { return Effect.fail( new PtyAdapter.PtySpawnError({ adapter: "fake", shell: input.shell, cause: failure, }), ); } const process = new FakePtyProcess(this.nextPid++); this.processes.push(process); if (this.mode === "async") { return Effect.tryPromise({ try: async () => process, catch: (cause) => new PtyAdapter.PtySpawnError({ adapter: "fake", shell: input.shell, cause, }), }); } return Effect.succeed(process); }} const waitFor = <E, R>( predicate: Effect.Effect<boolean, E, R>, timeout: Duration.Input = 800,): Effect.Effect<void, WaitForConditionError | E, R> => predicate.pipe( Effect.filterOrFail( (done) => done, () => new WaitForConditionError({ message: "Condition not met" }), ), Effect.retry(Schedule.spaced("15 millis")), Effect.timeoutOption(timeout), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), onSome: () => Effect.void, }), ), ); function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, };} function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, };} const historyLogPath = (logsDir: string, threadId = "thread-1") => Effect.service(Path.Path).pipe( Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), ); const multiTerminalHistoryLogPath = ( logsDir: string, threadId = "thread-1", terminalId = DEFAULT_TERMINAL_ID,) => Effect.service(Path.Path).pipe( Effect.map(({ join }) => { const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; return join( logsDir, terminalId === DEFAULT_TERMINAL_ID ? `${threadPart}.log` : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, ); }), ); interface CreateManagerOptions { shellResolver?: () => string; env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; readonly processIds: ReadonlyArray<number>; }>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; ptyAdapter?: FakePtyAdapter;} interface ManagerFixture { readonly baseDir: string; readonly logsDir: string; readonly ptyAdapter: FakePtyAdapter; readonly manager: TerminalManager.TerminalManager["Service"]; readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>;} const createManager = ( historyLineLimit = 5, options: CreateManagerOptions = {},): Effect.Effect< ManagerFixture, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner> => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); const logsDir = join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); const manager = yield* TerminalManager.makeWithOptions({ logsDir, historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } : {}), ...(options.subprocessPollIntervalMs !== undefined ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), processKillGraceMs: options.processKillGraceMs ?? 1, ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), }); const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); const unsubscribe = yield* manager.subscribe((event) => Ref.update(eventsRef, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); return { baseDir, logsDir, join, ptyAdapter, manager, getEvents: Ref.get(eventsRef), }; }), ); const withHostPlatform = (platform: NodeJS.Platform) => Layer.succeed(HostProcessPlatform, platform); it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true },)("TerminalManager", (it) => { it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); const [first, second] = yield* Effect.all( [manager.open(openInput()), manager.open(openInput())], { concurrency: "unbounded" }, ); const third = yield* manager.open(openInput()); assert.equal(first.threadId, "thread-1"); assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); assert.equal(second.threadId, "thread-1"); assert.equal(third.threadId, "thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); it.effect("attaches to running sessions without restarting them", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); const unsubscribe = yield* manager.attachStream( { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 100, rows: 40, }, (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); expect(snapshot).toBeDefined(); if (!snapshot || snapshot.type !== "snapshot") return; assert.equal(snapshot.snapshot.threadId, "thread-1"); assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); it.effect("keeps attach streams live when a terminal id is closed and reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); const unsubscribe = yield* manager.attachStream(openInput(), (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); yield* manager.close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, deleteHistory: true, }); yield* manager.open(openInput()); const events = yield* Ref.get(attachEvents); expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); expect( events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), ).toEqual(["running", "running"]); expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); it.effect("attaches to exited sessions without restarting them", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), "1200 millis", ); const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); const unsubscribe = yield* manager.attachStream( openInput({ env: { T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", }, worktreePath: "/tmp/should-not-restart", }), (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); expect(snapshot).toBeDefined(); if (!snapshot || snapshot.type !== "snapshot") return; assert.equal(snapshot.snapshot.status, "exited"); assert.equal(snapshot.snapshot.worktreePath, null); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); it.effect("restarts inactive sessions from attach only when requested", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), "1200 millis", ); const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); const unsubscribe = yield* manager.attachStream( { ...openInput({ env: { T3CODE_WORKTREE_PATH: "/tmp/restart-requested", }, worktreePath: "/tmp/restart-requested", }), restartIfNotRunning: true, }, (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); expect(snapshot).toBeDefined(); if (!snapshot || snapshot.type !== "snapshot") return; assert.equal(snapshot.snapshot.status, "running"); assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); const makeDirectory = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.makeDirectory(filePath, { recursive: true }), ); const chmod = (filePath: string, mode: number) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); const pathExists = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); const readFileString = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); const writeFileString = (filePath: string, contents: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.writeFileString(filePath, contents), ); it.effect("reports a missing cwd without an artificial cause", () => Effect.gen(function* () { const path = yield* Path.Path; const { manager, baseDir } = yield* createManager(); const cwd = path.join(baseDir, "missing-cwd"); const error = yield* Effect.flip(manager.open(openInput({ cwd }))); expect(error).toMatchObject({ _tag: "TerminalCwdNotFoundError", cwd, }); expect("cause" in error).toBe(false); }), ); it.effect("reports a cwd that is not a directory", () => Effect.gen(function* () { const path = yield* Path.Path; const { manager, baseDir } = yield* createManager(); const cwd = path.join(baseDir, "cwd-file"); yield* writeFileString(cwd, "not a directory"); const error = yield* Effect.flip(manager.open(openInput({ cwd }))); expect(error).toMatchObject({ _tag: "TerminalCwdNotDirectoryError", cwd, }); expect("cause" in error).toBe(false); }), ); it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; const path = yield* Path.Path; const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); yield* makeDirectory(blockedCwd); yield* chmod(blockedRoot, 0o000); const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), ); expect(error).toMatchObject({ _tag: "TerminalCwdStatError", cwd: blockedCwd, cause: { _tag: "PlatformError", },Lines 1–500 of 1704. Load more
import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ readonly message: string; }> {} class FakePtyProcess implements PtyAdapter.PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array<string | undefined> = []; readonly pid: number; writeFailure: unknown | undefined; resizeFailure: unknown | undefined; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); killed = false; constructor(pid: number) { this.pid = pid; } write(data: string): void { if (this.writeFailure !== undefined) { throw this.writeFailure; } this.writes.push(data); } resize(cols: number, rows: number): void { if (this.resizeFailure !== undefined) { throw this.resizeFailure; } this.resizeCalls.push({ cols, rows }); } kill(signal?: string): void { this.killed = true; this.killSignals.push(signal); } onData(callback: (data: string) => void): () => void { this.dataListeners.add(callback); return () => { this.dataListeners.delete(callback); }; } onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); }; } emitData(data: string): void { for (const listener of this.dataListeners) { listener(data); } } emitExit(event: PtyAdapter.PtyExitEvent): void { for (const listener of this.exitListeners) { listener(event); } } } class FakePtyAdapter { readonly spawnInputs: PtyAdapter.PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; private readonly mode: "sync" | "async"; private nextPid = 9000; constructor(mode: "sync" | "async" = "sync") { this.mode = mode; } spawn( input: PtyAdapter.PtySpawnInput, ): Effect.Effect<PtyAdapter.PtyProcess, PtyAdapter.PtySpawnError> { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { return Effect.fail( new PtyAdapter.PtySpawnError({ adapter: "fake", shell: input.shell, cause: failure, }), ); } const process = new FakePtyProcess(this.nextPid++); this.processes.push(process); if (this.mode === "async") { return Effect.tryPromise({ try: async () => process, catch: (cause) => new PtyAdapter.PtySpawnError({ adapter: "fake", shell: input.shell, cause, }), }); } return Effect.succeed(process); } } const waitFor = <E, R>( predicate: Effect.Effect<boolean, E, R>, timeout: Duration.Input = 800, ): Effect.Effect<void, WaitForConditionError | E, R> => predicate.pipe( Effect.filterOrFail( (done) => done, () => new WaitForConditionError({ message: "Condition not met" }), ), Effect.retry(Schedule.spaced("15 millis")), Effect.timeoutOption(timeout), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), onSome: () => Effect.void, }), ), ); function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, }; } function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, }; } const historyLogPath = (logsDir: string, threadId = "thread-1") => Effect.service(Path.Path).pipe( Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), ); const multiTerminalHistoryLogPath = ( logsDir: string, threadId = "thread-1", terminalId = DEFAULT_TERMINAL_ID, ) => Effect.service(Path.Path).pipe( Effect.map(({ join }) => { const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; return join( logsDir, terminalId === DEFAULT_TERMINAL_ID ? `${threadPart}.log` : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, ); }), ); interface CreateManagerOptions { shellResolver?: () => string; env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; readonly processIds: ReadonlyArray<number>; }>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; ptyAdapter?: FakePtyAdapter; } interface ManagerFixture { readonly baseDir: string; readonly logsDir: string; readonly ptyAdapter: FakePtyAdapter; readonly manager: TerminalManager.TerminalManager["Service"]; readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>; } const createManager = ( historyLineLimit = 5, options: CreateManagerOptions = {}, ): Effect.Effect< ManagerFixture, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner > => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); const logsDir = join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); const manager = yield* TerminalManager.makeWithOptions({ logsDir, historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } : {}), ...(options.subprocessPollIntervalMs !== undefined ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), processKillGraceMs: options.processKillGraceMs ?? 1, ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), }); const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); const unsubscribe = yield* manager.subscribe((event) => Ref.update(eventsRef, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); return { baseDir, logsDir, join, ptyAdapter, manager, getEvents: Ref.get(eventsRef), }; }), ); const withHostPlatform = (platform: NodeJS.Platform) => Layer.succeed(HostProcessPlatform, platform); it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, )("TerminalManager", (it) => { it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); const [first, second] = yield* Effect.all( [manager.open(openInput()), manager.open(openInput())], { concurrency: "unbounded" }, ); const third = yield* manager.open(openInput()); assert.equal(first.threadId, "thread-1"); assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); assert.equal(second.threadId, "thread-1"); assert.equal(third.threadId, "thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); it.effect("attaches to running sessions without restarting them", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); const unsubscribe = yield* manager.attachStream( { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 100, rows: 40, }, (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); expect(snapshot).toBeDefined(); if (!snapshot || snapshot.type !== "snapshot") return; assert.equal(snapshot.snapshot.threadId, "thread-1"); assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); it.effect("keeps attach streams live when a terminal id is closed and reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); const unsubscribe = yield* manager.attachStream(openInput(), (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); yield* manager.close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, deleteHistory: true, }); yield* manager.open(openInput()); const events = yield* Ref.get(attachEvents); expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); expect( events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), ).toEqual(["running", "running"]); expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); it.effect("attaches to exited sessions without restarting them", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), "1200 millis", ); const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); const unsubscribe = yield* manager.attachStream( openInput({ env: { T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", }, worktreePath: "/tmp/should-not-restart", }), (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); expect(snapshot).toBeDefined(); if (!snapshot || snapshot.type !== "snapshot") return; assert.equal(snapshot.snapshot.status, "exited"); assert.equal(snapshot.snapshot.worktreePath, null); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); it.effect("restarts inactive sessions from attach only when requested", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), "1200 millis", ); const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); const unsubscribe = yield* manager.attachStream( { ...openInput({ env: { T3CODE_WORKTREE_PATH: "/tmp/restart-requested", }, worktreePath: "/tmp/restart-requested", }), restartIfNotRunning: true, }, (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); expect(snapshot).toBeDefined(); if (!snapshot || snapshot.type !== "snapshot") return; assert.equal(snapshot.snapshot.status, "running"); assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); const makeDirectory = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.makeDirectory(filePath, { recursive: true }), ); const chmod = (filePath: string, mode: number) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); const pathExists = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); const readFileString = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); const writeFileString = (filePath: string, contents: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.writeFileString(filePath, contents), ); it.effect("reports a missing cwd without an artificial cause", () => Effect.gen(function* () { const path = yield* Path.Path; const { manager, baseDir } = yield* createManager(); const cwd = path.join(baseDir, "missing-cwd"); const error = yield* Effect.flip(manager.open(openInput({ cwd }))); expect(error).toMatchObject({ _tag: "TerminalCwdNotFoundError", cwd, }); expect("cause" in error).toBe(false); }), ); it.effect("reports a cwd that is not a directory", () => Effect.gen(function* () { const path = yield* Path.Path; const { manager, baseDir } = yield* createManager(); const cwd = path.join(baseDir, "cwd-file"); yield* writeFileString(cwd, "not a directory"); const error = yield* Effect.flip(manager.open(openInput({ cwd }))); expect(error).toMatchObject({ _tag: "TerminalCwdNotDirectoryError", cwd, }); expect("cause" in error).toBe(false); }), ); it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; const path = yield* Path.Path; const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); yield* makeDirectory(blockedCwd); yield* chmod(blockedRoot, 0o000); const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), ); expect(error).toMatchObject({ _tag: "TerminalCwdStatError", cwd: blockedCwd, cause: { _tag: "PlatformError", },