From 1bdb7a35ed261d83bb5e9afc424f870664fba158 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Thu, 14 May 2026 17:07:32 +0000 Subject: [PATCH 1/4] feat(api): persist project SSH terminal sessions --- .../api/src/services/terminal-sessions.ts | 518 +++++++++++++++--- packages/api/tests/terminal-sessions.test.ts | 137 ++++- .../app/src/lib/core/templates/dockerfile.ts | 2 +- .../tests/docker-git/actions-projects.test.ts | 15 +- packages/lib/src/core/templates/dockerfile.ts | 2 +- packages/lib/tests/core/templates.test.ts | 1 + 6 files changed, 574 insertions(+), 101 deletions(-) diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 2ffc5e9d..283c3653 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -1,23 +1,34 @@ -import { type AppError, prepareProjectSsh, probeProjectSshReady, renderError, waitForProjectSshReady } from "@effect-template/lib" +import { + type AppError, + listProjectItems, + prepareProjectSsh, + probeProjectSshReady, + renderError, + waitForProjectSshReady +} from "@effect-template/lib" import { runCommandCapture } from "@effect-template/lib/shell/command-runner" import { parseInspectNetworkEntry } from "@effect-template/lib/shell/docker-inspect-parse" import { CommandFailedError } from "@effect-template/lib/shell/errors" import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type * as CommandExecutor from "@effect/platform/CommandExecutor" import * as FileSystem from "@effect/platform/FileSystem" +import type * as PlatformPath from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" import { Effect, Either } from "effect" import { Buffer } from "node:buffer" import { spawn } from "node:child_process" import { randomUUID } from "node:crypto" -import { existsSync } from "node:fs" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import type { IncomingMessage, Server as HttpServer } from "node:http" import os from "node:os" +import path from "node:path" import type { Duplex } from "node:stream" import { WebSocket, WebSocketServer, type RawData } from "ws" import type { TerminalSession, TerminalSessionStatus } from "../api/contracts.js" -import { ApiBadRequestError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js" +import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js" import { emitProjectEvent, latestProjectCursor } from "./events.js" import { planTerminalImageFetch, @@ -35,7 +46,7 @@ import { type TerminalOutputBuffer } from "./terminal-output-buffer.js" import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js" -import { getProject, getProjectItemById, upProject } from "./projects.js" +import { getProject, getProjectItemById, getProjectItemByKey, upProject } from "./projects.js" import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js" type TerminalClientMessage = @@ -65,10 +76,36 @@ type TerminalRecord = { prepared: ReturnType } +type TerminalSessionRuntime = + | CommandExecutor.CommandExecutor + | FileSystem.FileSystem + | PlatformPath.Path + +type DurableTerminalSession = { + readonly id: string + readonly projectId: string + readonly projectKey: string + readonly projectDisplayName: string + readonly tmuxName: string + readonly sshCommand: string + readonly createdAt: string + readonly updatedAt: string + readonly status: TerminalSessionStatus + readonly startedAt?: string | undefined + readonly closedAt?: string | undefined +} + +type DurableTerminalSessionFile = { + readonly schemaVersion: 1 + readonly sessions: ReadonlyArray +} + const records = new Map() -const attachTimeoutMs = 30_000 const terminalWsPathPattern = /^(?:\/api)?\/projects\/([^/]+)\/terminal-sessions\/([^/]+)\/ws$/u const terminalWsByKeyPathPattern = /^(?:\/api)?\/projects\/by-key\/([^/]+)\/terminal-sessions\/([^/]+)\/ws$/u +const terminalSessionStateRelativePath: ReadonlyArray = [".orch", "state", "terminal-sessions.json"] +const tmuxMissingMessage = + "tmux is not installed in this project image. Apply or rebuild the project image, then reopen this SSH terminal session." const TerminalClientMessageSchema = Schema.parseJson( Schema.Union( @@ -94,8 +131,184 @@ const TerminalClientMessageSchema = Schema.parseJson( ) ) +const DurableTerminalSessionSchema = Schema.Struct({ + id: Schema.String, + projectId: Schema.String, + projectKey: Schema.String, + projectDisplayName: Schema.String, + tmuxName: Schema.String, + sshCommand: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + status: Schema.Literal("ready", "attached", "exited", "failed"), + startedAt: Schema.optional(Schema.String), + closedAt: Schema.optional(Schema.String) +}) + +const DurableTerminalSessionFileSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + sessions: Schema.Array(DurableTerminalSessionSchema) +}) + +const DurableTerminalSessionFileJsonSchema = Schema.parseJson(DurableTerminalSessionFileSchema) + +export const clearTerminalSessionRuntimeForTest = (): void => { + for (const record of records.values()) { + clearAttachTimeout(record) + clearDetachTimeout(record) + if (record.pty !== null) { + const pty = record.pty + record.pty = null + pty.kill() + } + closeRecordSockets(record) + } + records.clear() +} + const nowIso = (): string => new Date().toISOString() +const terminalSessionStatePath = (projectId: string): string => + path.join(projectId, ...terminalSessionStateRelativePath) + +const emptyTerminalSessionFile = (): DurableTerminalSessionFile => ({ + schemaVersion: 1, + sessions: [] +}) + +const decodeTerminalSessionFile = (input: string): DurableTerminalSessionFile | null => + Either.match(ParseResult.decodeUnknownEither(DurableTerminalSessionFileJsonSchema)(input), { + onLeft: () => null, + onRight: (value) => value + }) + +const readTerminalSessionFile = (projectId: string): DurableTerminalSessionFile => { + const statePath = terminalSessionStatePath(projectId) + if (!existsSync(statePath)) { + return emptyTerminalSessionFile() + } + try { + const decoded = decodeTerminalSessionFile(readFileSync(statePath, "utf8")) + return decoded ?? emptyTerminalSessionFile() + } catch { + return emptyTerminalSessionFile() + } +} + +const writeTerminalSessionFile = ( + projectId: string, + state: DurableTerminalSessionFile +): void => { + const statePath = terminalSessionStatePath(projectId) + mkdirSync(path.dirname(statePath), { recursive: true }) + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8") +} + +const tmuxNameForSessionId = (sessionId: string): string => { + const normalized = sessionId.replace(/[^A-Za-z0-9_-]/gu, "-").replace(/-+/gu, "-") + return `docker-git-${normalized.slice(0, 80)}` +} + +const terminalSessionFromDurable = ( + durable: DurableTerminalSession, + attachedClients: number +): TerminalSession => ({ + id: durable.id, + projectId: durable.projectId, + sshCommand: durable.sshCommand, + status: attachedClients > 0 + ? "attached" + : durable.status === "attached" + ? "ready" + : durable.status, + createdAt: durable.createdAt, + attachedClients, + ...(durable.startedAt === undefined ? {} : { startedAt: durable.startedAt }), + ...(durable.closedAt === undefined ? {} : { closedAt: durable.closedAt }) +}) + +const durableFromSession = ( + args: { + readonly projectDisplayName: string + readonly projectKey: string + readonly session: TerminalSession + readonly tmuxName: string + readonly updatedAt: string + } +): DurableTerminalSession => ({ + id: args.session.id, + projectId: args.session.projectId, + projectKey: args.projectKey, + projectDisplayName: args.projectDisplayName, + tmuxName: args.tmuxName, + sshCommand: args.session.sshCommand, + createdAt: args.session.createdAt, + updatedAt: args.updatedAt, + status: args.session.status, + ...(args.session.startedAt === undefined ? {} : { startedAt: args.session.startedAt }), + ...(args.session.closedAt === undefined ? {} : { closedAt: args.session.closedAt }) +}) + +const upsertDurableSession = ( + projectId: string, + durable: DurableTerminalSession +): void => { + const state = readTerminalSessionFile(projectId) + const sessions = state.sessions.filter((session) => session.id !== durable.id) + writeTerminalSessionFile(projectId, { + schemaVersion: 1, + sessions: [...sessions, durable] + }) +} + +const patchDurableSession = ( + record: TerminalRecord, + patch: Partial +): void => { + const state = readTerminalSessionFile(record.projectId) + const updatedAt = nowIso() + const sessions = state.sessions.map((session) => + session.id === record.session.id + ? durableFromSession({ + projectDisplayName: record.projectDisplayName, + projectKey: record.projectKey, + session: { + ...terminalSessionFromDurable(session, 0), + ...patch + }, + tmuxName: session.tmuxName, + updatedAt + }) + : session + ) + writeTerminalSessionFile(record.projectId, { + schemaVersion: 1, + sessions + }) +} + +const deleteDurableSession = ( + projectId: string, + sessionId: string +): boolean => { + const state = readTerminalSessionFile(projectId) + const sessions = state.sessions.filter((session) => session.id !== sessionId) + if (sessions.length === state.sessions.length) { + return false + } + writeTerminalSessionFile(projectId, { + schemaVersion: 1, + sessions + }) + return true +} + +const findDurableSession = ( + projectId: string, + sessionId: string +): DurableTerminalSession | null => + readTerminalSessionFile(projectId).sessions.find((session) => session.id === sessionId) ?? null + const isAppError = (value: unknown): value is AppError => typeof value === "object" && value !== null && "_tag" in value @@ -108,6 +321,7 @@ const updateSession = ( ...patch } records.set(record.session.id, record) + patchDurableSession(record, patch) } const attachedClientCount = (record: TerminalRecord): number => { @@ -131,6 +345,13 @@ const toApiInternalError = (error: unknown): ApiInternalError => cause: error }) +const toTerminalSessionLookupError = ( + error: unknown +): ApiConflictError | ApiInternalError | ApiNotFoundError => + error instanceof ApiConflictError || error instanceof ApiInternalError || error instanceof ApiNotFoundError + ? error + : toApiInternalError(error) + const normalizeSshKeyPermissions = (sshKeyPath: string | null) => sshKeyPath === null ? Effect.void @@ -311,32 +532,50 @@ const cleanupRecord = (record: TerminalRecord): void => { clearAttachTimeout(record) clearDetachTimeout(record) if (record.pty !== null) { - record.pty.kill() + const pty = record.pty record.pty = null + pty.kill() } closeRecordSockets(record) records.delete(record.session.id) } +const detachRecordPty = (record: TerminalRecord): void => { + if (record.pty === null) { + updateSession(record, { + attachedClients: attachedClientCount(record), + status: "ready" + }) + return + } + const pty = record.pty + record.pty = null + updateSession(record, { + attachedClients: attachedClientCount(record), + status: "ready" + }) + pty.kill() +} + const finalizeRecord = ( record: TerminalRecord, status: Extract, exitCode: number | null, signal: number | null ): void => { + const nextStatus = exitCode === 0 || exitCode === 130 ? "ready" : status + broadcastServerMessage(record, { type: "exit", exitCode, signal }) + closeRecordSockets(record) + record.pty = null + clearAttachTimeout(record) + clearDetachTimeout(record) updateSession(record, { attachedClients: attachedClientCount(record), closedAt: nowIso(), exitCode: exitCode ?? undefined, signal: signal ?? undefined, - status + status: nextStatus }) - broadcastServerMessage(record, { type: "exit", exitCode, signal }) - closeRecordSockets(record) - record.pty = null - clearAttachTimeout(record) - clearDetachTimeout(record) - records.delete(record.session.id) } const decodeClientMessage = (raw: RawData): TerminalClientMessage | null => @@ -568,6 +807,22 @@ const resizePty = (pty: PtyBridge | null, cols: number, rows: number): void => { } } +const renderRemoteTmuxCommand = (record: TerminalRecord): string => { + const tmuxName = findDurableSession(record.projectId, record.session.id)?.tmuxName ?? tmuxNameForSessionId( + record.session.id + ) + const script = [ + `if ! command -v tmux >/dev/null 2>&1; then printf '%s\\n' ${shellQuote(tmuxMissingMessage)} >&2; exit 127; fi`, + `exec tmux new-session -A -s ${shellQuote(tmuxName)} -c ${shellQuote(record.projectTargetDir)}` + ].join("; ") + return `sh -lc ${shellQuote(script)}` +} + +const preparedArgsForTmuxSession = (record: TerminalRecord): ReadonlyArray => [ + ...record.prepared.args, + renderRemoteTmuxCommand(record) +] + const startTerminalPty = ( record: TerminalRecord, cols: number, @@ -579,8 +834,9 @@ const startTerminalPty = ( } const resolvedCols = clampTerminalSize(cols, 120) const resolvedRows = clampTerminalSize(rows, 32) + record.outputBuffer = emptyTerminalOutputBuffer const pty = spawnPtyBridge({ - args: record.prepared.args, + args: preparedArgsForTmuxSession(record), command: record.prepared.command, cols: resolvedCols, cwd: record.prepared.cwd, @@ -595,6 +851,9 @@ const startTerminalPty = ( sendTerminalOutput(record, data) }) pty.onExit(({ exitCode, signal }) => { + if (record.pty !== pty) { + return + } finalizeRecord( record, exitCode === 0 || exitCode === 130 ? "exited" : "failed", @@ -604,30 +863,35 @@ const startTerminalPty = ( }) } -const createAttachTimeout = (sessionId: string): ReturnType => - setTimeout(() => { - const record = records.get(sessionId) - if (record !== undefined) { - cleanupRecord(record) - } - }, attachTimeoutMs) - const registerRecord = ( projectId: string, projectKey: string, projectDisplayName: string, prepared: ReturnType, projectContainerName: string, - projectTargetDir: string + projectTargetDir: string, + sessionId: string = randomUUID() ): TerminalSession => { + const createdAt = nowIso() const session: TerminalSession = { attachedClients: 0, - createdAt: nowIso(), - id: randomUUID(), + createdAt, + id: sessionId, projectId, sshCommand: renderPreparedSshCommand(prepared), status: "ready" } + const tmuxName = tmuxNameForSessionId(session.id) + upsertDurableSession( + projectId, + durableFromSession({ + projectDisplayName, + projectKey, + session, + tmuxName, + updatedAt: createdAt + }) + ) const record: TerminalRecord = { attachTimeout: null, detachTimeout: null, @@ -642,11 +906,77 @@ const registerRecord = ( session, sockets: new Set() } - record.attachTimeout = createAttachTimeout(session.id) records.set(session.id, record) return session } +const registerHydratedRecord = ( + durable: DurableTerminalSession, + prepared: ReturnType, + projectItem: ProjectItem +): TerminalRecord => { + const record: TerminalRecord = { + attachTimeout: null, + detachTimeout: null, + outputBuffer: emptyTerminalOutputBuffer, + prepared, + projectContainerName: projectItem.containerName, + projectDisplayName: durable.projectDisplayName, + projectId: durable.projectId, + projectKey: durable.projectKey, + projectTargetDir: projectItem.targetDir, + pty: null, + session: terminalSessionFromDurable(durable, 0), + sockets: new Set() + } + records.set(record.session.id, record) + return record +} + +const prepareRuntimeRecord = ( + durable: DurableTerminalSession, + projectItem: ProjectItem +): Effect.Effect => + Effect.gen(function*(_) { + const reachableProjectItem = yield* _(resolveControllerReachableProject(projectItem).pipe(Effect.mapError(toApiInternalError))) + yield* _(normalizeSshKeyPermissions(reachableProjectItem.sshKeyPath)) + return registerHydratedRecord(durable, prepareProjectSsh(reachableProjectItem), reachableProjectItem) + }) + +const hydrateProjectTerminalRecord = ( + projectItem: ProjectItem, + sessionId: string +): Effect.Effect => + Effect.gen(function*(_) { + const existing = records.get(sessionId) + if (existing !== undefined && existing.projectId === projectItem.projectDir) { + return existing + } + const durable = findDurableSession(projectItem.projectDir, sessionId) + if (durable === null) { + return yield* _(Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` }))) + } + return yield* _(prepareRuntimeRecord(durable, projectItem)) + }) + +const hydrateTerminalRecordByProjectId = ( + projectId: string, + sessionId: string +): Effect.Effect => + getProjectItemById(projectId).pipe( + Effect.mapError(toTerminalSessionLookupError), + Effect.flatMap((projectItem) => hydrateProjectTerminalRecord(projectItem, sessionId)) + ) + +const hydrateTerminalRecordByProjectKey = ( + projectKey: string, + sessionId: string +): Effect.Effect => + getProjectItemByKey(projectKey).pipe( + Effect.mapError(toTerminalSessionLookupError), + Effect.flatMap((projectItem) => hydrateProjectTerminalRecord(projectItem, sessionId)) + ) + const emitTerminalStatus = (projectId: string, phase: string, message: string) => Effect.sync(() => { emitProjectEvent(projectId, "project.deployment.status", { phase, message }) @@ -701,7 +1031,8 @@ export const createTerminalSession = ( project.displayName, prepared, projectItem.containerName, - projectItem.targetDir + projectItem.targetDir, + options.requestId ) yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId)) return { project, session } @@ -721,7 +1052,8 @@ export const createTerminalSession = ( project.displayName, prepared, reachableProjectItem.containerName, - reachableProjectItem.targetDir + reachableProjectItem.targetDir, + options.requestId ) yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId)) yield* _(emitTerminalStatus(projectId, "ssh.post-start", "Post-start self-heal continues in background")) @@ -772,12 +1104,15 @@ export const deleteTerminalSession = ( ): Effect.Effect => Effect.gen(function*(_) { const record = records.get(sessionId) - if (record === undefined || record.projectId !== projectId) { + const deleted = deleteDurableSession(projectId, sessionId) + if ((record === undefined || record.projectId !== projectId) && !deleted) { return yield* _( Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) ) } - cleanupRecord(record) + if (record !== undefined && record.projectId === projectId) { + cleanupRecord(record) + } yield* _( Effect.sync(() => { emitProjectEvent(projectId, "project.ssh.session", { @@ -789,12 +1124,14 @@ export const deleteTerminalSession = ( }) export const listProjectTerminalSessions = (projectId: string): ReadonlyArray => - [...records.values()] - .filter((record) => record.projectId === projectId) - .map((record) => { + readTerminalSessionFile(projectId).sessions.map((durable) => { + const record = records.get(durable.id) + if (record !== undefined && record.projectId === projectId) { syncAttachedClientCount(record) return record.session - }) + } + return terminalSessionFromDurable(durable, 0) + }) export const getProjectTerminalSession = ( projectId: string, @@ -802,13 +1139,17 @@ export const getProjectTerminalSession = ( ): Effect.Effect => Effect.gen(function*(_) { const record = records.get(sessionId) - if (record === undefined || record.projectId !== projectId) { + if (record !== undefined && record.projectId === projectId) { + syncAttachedClientCount(record) + return record.session + } + const durable = findDurableSession(projectId, sessionId) + if (durable === null) { return yield* _( Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) ) } - syncAttachedClientCount(record) - return record.session + return terminalSessionFromDurable(durable, 0) }) export const readProjectTerminalImage = ( @@ -842,24 +1183,44 @@ export const lookupTerminalSessionById = ( sessionId: string ): Effect.Effect< { readonly projectDisplayName: string; readonly projectKey: string; readonly session: TerminalSession }, - ApiNotFoundError + ApiNotFoundError, + TerminalSessionRuntime > => Effect.gen(function*(_) { const record = records.get(sessionId) - if (record === undefined) { - return yield* _( - Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) - ) + if (record !== undefined) { + syncAttachedClientCount(record) + return { + projectDisplayName: record.projectDisplayName, + projectKey: record.projectKey, + session: record.session + } } - syncAttachedClientCount(record) - return { - projectDisplayName: record.projectDisplayName, - projectKey: record.projectKey, - session: record.session + const projects = yield* _(listProjectItems) + for (const project of projects) { + const durable = findDurableSession(project.projectDir, sessionId) + if (durable !== null) { + return { + projectDisplayName: durable.projectDisplayName, + projectKey: durable.projectKey, + session: terminalSessionFromDurable(durable, 0) + } + } } - }) + return yield* _( + Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) + ) + }).pipe( + Effect.catchAll((error) => { + if (error instanceof ApiNotFoundError) { + return Effect.fail(error) + } + return Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) + }) + ) const handleCloseMessage = (record: TerminalRecord): void => { + deleteDurableSession(record.projectId, record.session.id) cleanupRecord(record) } @@ -871,9 +1232,14 @@ const detachSocketFromRecord = ( if (current === undefined) { return } - current.sockets.delete(socket) + if (!current.sockets.delete(socket)) { + return + } syncAttachedClientCount(current) clearDetachTimeout(current) + if (attachedClientCount(current) === 0) { + detachRecordPty(current) + } } const handleSocketMessage = (record: TerminalRecord, socket: WebSocket, raw: RawData): void => { @@ -959,6 +1325,13 @@ const denyUpgrade = (socket: Duplex): void => { socket.destroy() } +const resolveParsedTerminalRecord = ( + parsed: ParsedTerminalPath +): Effect.Effect => + parsed.kind === "projectId" + ? hydrateTerminalRecordByProjectId(parsed.projectId, parsed.sessionId) + : hydrateTerminalRecordByProjectKey(parsed.projectKey, parsed.sessionId) + export const attachTerminalWebSocketServer = (server: HttpServer): void => { const webSocketServer = new WebSocketServer({ noServer: true }) server.on("upgrade", (request, socket, head) => { @@ -966,24 +1339,26 @@ export const attachTerminalWebSocketServer = (server: HttpServer): void => { if (parsed === null) { return } - const record = records.get(parsed.sessionId) - const matchesProject = record !== undefined && ( - parsed.kind === "projectId" - ? record.projectId === parsed.projectId - : record.projectKey === parsed.projectKey + void Effect.runPromise( + resolveParsedTerminalRecord(parsed).pipe( + Effect.provide(NodeContext.layer), + Effect.match({ + onFailure: () => { + denyUpgrade(socket) + }, + onSuccess: (record) => { + webSocketServer.handleUpgrade(request, socket, head, (webSocket: WebSocket) => { + try { + attachSocketToRecord(record, webSocket, parsed.cols, parsed.rows) + } catch (error) { + sendServerMessage(webSocket, { type: "error", message: describeUnknown(error) }) + webSocket.close() + } + }) + } + }) + ) ) - if (!matchesProject || record === undefined) { - denyUpgrade(socket) - return - } - webSocketServer.handleUpgrade(request, socket, head, (webSocket: WebSocket) => { - try { - attachSocketToRecord(record, webSocket, parsed.cols, parsed.rows) - } catch (error) { - sendServerMessage(webSocket, { type: "error", message: describeUnknown(error) }) - webSocket.close() - } - }) }) } @@ -991,13 +1366,4 @@ export const verifyTerminalSession = ( projectId: string, sessionId: string ): Effect.Effect => - Effect.gen(function*(_) { - const record = records.get(sessionId) - if (record === undefined || record.projectId !== projectId) { - return yield* _( - Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) - ) - } - syncAttachedClientCount(record) - return record.session - }) + getProjectTerminalSession(projectId, sessionId) diff --git a/packages/api/tests/terminal-sessions.test.ts b/packages/api/tests/terminal-sessions.test.ts index 9dfae2b6..9f71d7bb 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -1,4 +1,7 @@ import { Effect } from "effect" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import path from "node:path" +import os from "node:os" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { ProjectItem } from "@effect-template/lib" @@ -6,21 +9,27 @@ import type { ProjectItem } from "@effect-template/lib" import type { ProjectDetails } from "../src/api/contracts.js" import { clearProjectEvents, listProjectEventsSince } from "../src/services/events.js" import { + clearTerminalSessionRuntimeForTest, createTerminalSession, deleteTerminalSession, + getProjectTerminalSession, listProjectTerminalSessions, + lookupTerminalSessionById, startTerminalSession } from "../src/services/terminal-sessions.js" +const listProjectItemsMock = vi.hoisted(() => vi.fn()) const prepareProjectSshMock = vi.hoisted(() => vi.fn()) const probeProjectSshReadyMock = vi.hoisted(() => vi.fn()) const runCommandCaptureMock = vi.hoisted(() => vi.fn()) const upProjectMock = vi.hoisted(() => vi.fn()) const getProjectMock = vi.hoisted(() => vi.fn()) const getProjectItemByIdMock = vi.hoisted(() => vi.fn()) +const getProjectItemByKeyMock = vi.hoisted(() => vi.fn()) const waitForProjectSshReadyMock = vi.hoisted(() => vi.fn()) vi.mock("@effect-template/lib", () => ({ + listProjectItems: Effect.sync(() => listProjectItemsMock()), prepareProjectSsh: prepareProjectSshMock, probeProjectSshReady: probeProjectSshReadyMock, renderError: vi.fn((error: unknown) => String(error)), @@ -34,28 +43,32 @@ vi.mock("@effect-template/lib/shell/command-runner", () => ({ vi.mock("../src/services/projects.js", () => ({ getProject: getProjectMock, getProjectItemById: getProjectItemByIdMock, + getProjectItemByKey: getProjectItemByKeyMock, upProject: upProjectMock })) -const projectId = "/controller/org/repo/issue-7" const projectKey = "repo-issue-7" const displayName = "org/repo" -const projectItem = { +let projectId = "" +let projectItem: ProjectItem +let projectDetails: ProjectDetails + +const makeProjectItem = (projectDir: string): ProjectItem => ({ authorizedKeysExists: true, - authorizedKeysPath: "/controller/org/repo/issue-7/authorized_keys", - codexAuthPath: "/controller/org/repo/issue-7/.orch/auth/codex", + authorizedKeysPath: path.join(projectDir, "authorized_keys"), + codexAuthPath: path.join(projectDir, ".orch", "auth", "codex"), codexHome: "/home/dev/.codex", containerName: "dg-repo-issue-7", displayName, - envGlobalPath: "/controller/org/repo/issue-7/.orch/env/global.env", - envProjectPath: "/controller/org/repo/issue-7/.orch/env/project.env", + envGlobalPath: path.join(projectDir, ".orch", "env", "global.env"), + envProjectPath: path.join(projectDir, ".orch", "env", "project.env"), gpu: "none", lastKnownStatus: "running", lastStartAction: "up", lastStartedAtEpochMs: 1_778_000_000_000, lastStartedAtIso: "2026-05-06T19:00:00.000Z", - projectDir: projectId, + projectDir, repoRef: "issue-7", repoUrl: "https://github.com/org/repo.git", serviceName: "app", @@ -64,21 +77,21 @@ const projectItem = { sshPort: 2222, sshUser: "dev", targetDir: "/home/dev/app" -} satisfies ProjectItem +}) -const projectDetails = { +const makeProjectDetails = (projectDir: string): ProjectDetails => ({ authorizedKeysExists: true, - authorizedKeysPath: "/controller/org/repo/issue-7/authorized_keys", + authorizedKeysPath: path.join(projectDir, "authorized_keys"), clonedOnHostname: "host", - codexAuthPath: "/controller/org/repo/issue-7/.orch/auth/codex", + codexAuthPath: path.join(projectDir, ".orch", "auth", "codex"), codexHome: "/home/dev/.codex", containerName: "dg-repo-issue-7", displayName, - envGlobalPath: "/controller/org/repo/issue-7/.orch/env/global.env", - envProjectPath: "/controller/org/repo/issue-7/.orch/env/project.env", + envGlobalPath: path.join(projectDir, ".orch", "env", "global.env"), + envProjectPath: path.join(projectDir, ".orch", "env", "project.env"), gpu: "none", - id: projectId, - projectDir: projectId, + id: projectDir, + projectDir, projectKey, repoRef: "issue-7", repoUrl: "https://github.com/org/repo.git", @@ -92,7 +105,7 @@ const projectDetails = { status: "running", statusLabel: "Up", targetDir: "/home/dev/app" -} satisfies ProjectDetails +}) const cleanupSessions = (): Effect.Effect => Effect.forEach( @@ -111,17 +124,46 @@ const phaseFromEvent = (event: { readonly payload: unknown }): string | null => return String(Reflect.get(event.payload, "phase")) } +const terminalSessionsStatePath = (): string => + path.join(projectId, ".orch", "state", "terminal-sessions.json") + +const readPersistedSessionIds = (): ReadonlyArray => { + const raw: unknown = JSON.parse(readFileSync(terminalSessionsStatePath(), "utf8")) + if (typeof raw !== "object" || raw === null) { + return [] + } + const sessions = Reflect.get(raw, "sessions") + if (!Array.isArray(sessions)) { + return [] + } + return sessions + .map((session) => + typeof session === "object" && session !== null ? Reflect.get(session, "id") : null + ) + .filter((id): id is string => typeof id === "string") +} + describe("terminal sessions service", () => { + let projectRoot = "" + beforeEach(() => { + projectRoot = mkdtempSync(path.join(os.tmpdir(), "docker-git-terminal-sessions-")) + projectId = projectRoot + projectItem = makeProjectItem(projectId) + projectDetails = makeProjectDetails(projectId) clearProjectEvents(projectId) + clearTerminalSessionRuntimeForTest() + listProjectItemsMock.mockReset() prepareProjectSshMock.mockReset() probeProjectSshReadyMock.mockReset() runCommandCaptureMock.mockReset() upProjectMock.mockReset() getProjectMock.mockReset() getProjectItemByIdMock.mockReset() + getProjectItemByKeyMock.mockReset() waitForProjectSshReadyMock.mockReset() + listProjectItemsMock.mockReturnValue([projectItem]) prepareProjectSshMock.mockReturnValue({ args: ["-p", "2222", "dev@localhost"], command: "ssh", @@ -130,11 +172,14 @@ describe("terminal sessions service", () => { }) runCommandCaptureMock.mockImplementation(() => Effect.fail(new Error("docker inspect skipped in tests"))) getProjectItemByIdMock.mockImplementation(() => Effect.succeed(projectItem)) + getProjectItemByKeyMock.mockImplementation(() => Effect.succeed(projectItem)) }) afterEach(() => { Effect.runSync(cleanupSessions()) + clearTerminalSessionRuntimeForTest() clearProjectEvents(projectId) + rmSync(projectRoot, { force: true, recursive: true }) }) it("creates a terminal session immediately when SSH is already ready", async () => { @@ -154,9 +199,52 @@ describe("terminal sessions service", () => { expect(result.project).toEqual(projectDetails) expect(result.session.projectId).toBe(projectId) expect(result.session.sshCommand).toBe("ssh -p 2222 dev@localhost") + expect(readPersistedSessionIds()).toEqual([result.session.id]) expect(phases).toEqual(["ssh.prepare", "ssh.fast-ready"]) }) + it("persists multiple sessions for one project with distinct stable IDs", async () => { + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + + const first = await runTestEffect(createTerminalSession(projectId)) + const second = await runTestEffect(createTerminalSession(projectId)) + const listed = listProjectTerminalSessions(projectId) + + expect(first.session.id).not.toBe(second.session.id) + expect(listed.map((session) => session.id)).toEqual([first.session.id, second.session.id]) + expect(readPersistedSessionIds()).toEqual([first.session.id, second.session.id]) + }) + + it("hydrates list, project lookup, and global lookup from persisted state after clearing runtime records", async () => { + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + + const first = await runTestEffect(createTerminalSession(projectId)) + const second = await runTestEffect(createTerminalSession(projectId)) + + clearTerminalSessionRuntimeForTest() + + expect(listProjectTerminalSessions(projectId).map((session) => session.id)).toEqual([ + first.session.id, + second.session.id + ]) + await expect(runTestEffect(getProjectTerminalSession(projectId, first.session.id))).resolves.toMatchObject({ + id: first.session.id, + projectId, + status: "ready" + }) + await expect(runTestEffect(lookupTerminalSessionById(second.session.id))).resolves.toMatchObject({ + projectDisplayName: displayName, + projectKey, + session: { + id: second.session.id, + projectId, + status: "ready" + } + }) + }) + it("falls back to project startup and SSH wait when SSH is not ready", async () => { probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(false)) upProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) @@ -194,8 +282,25 @@ describe("terminal sessions service", () => { const created = listProjectEventsSince(projectId, 0).find((event) => event.type === "project.ssh.session") expect(created?.payload).toMatchObject({ phase: "created", + sessionId: "request-1", requestId: "request-1" }) + expect(readPersistedSessionIds()).toContain("request-1") }) }) + + it("deletes a persisted session and makes future lookup fail", async () => { + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + + const result = await runTestEffect(createTerminalSession(projectId)) + clearTerminalSessionRuntimeForTest() + + await runTestEffect(deleteTerminalSession(projectId, result.session.id)) + + expect(readPersistedSessionIds()).toEqual([]) + await expect(runTestEffect(getProjectTerminalSession(projectId, result.session.id))).rejects.toThrow( + `Terminal session not found: ${result.session.id}` + ) + }) }) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 0071eedd..773b89dd 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -30,7 +30,7 @@ RUN set -eu; \ sleep $((attempt * 2)); \ done; \ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ - openssh-server git gh ca-certificates curl unzip bsdutils sudo \ + openssh-server git gh ca-certificates curl unzip bsdutils sudo tmux \ make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \ ncurses-term jq \ && rm -rf /var/lib/apt/lists/* diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index d9a85502..cf634683 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -149,7 +149,8 @@ describe("web project actions", () => { startProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(startTerminalAccepted("pending-session-id")) ) - loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(session)) + const acceptedSession = { ...session, id: "pending-session-id" } + loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(acceptedSession)) openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() const closeTerminalSession = vi.fn<(sessionId: string) => void>() @@ -164,7 +165,7 @@ describe("web project actions", () => { payload: { phase: "created", requestId: "pending-session-id", - sessionId: "session-1" + sessionId: "pending-session-id" }, projectId: "project-1", seq: 8, @@ -180,7 +181,7 @@ describe("web project actions", () => { throw new Error("missing pending terminal session") } expect(startProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", "pending-session-id") - expect(loadProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", "session-1") + expect(loadProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", "pending-session-id") expect(context.setSelectedProjectId).toHaveBeenCalledWith("project-1") expect(pendingSession).toMatchObject({ browserProjectId: "project-1", @@ -197,17 +198,17 @@ describe("web project actions", () => { browserProjectId: "project-1", browserProjectKey: "octocat/hello-world", browserProjectName: "octocat/hello-world", - closePath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1", + closePath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/pending-session-id", exitMessage: "SSH session ended.", header: "SSH terminal: octocat/hello-world", onExit: reloadDashboard, onReady: reloadDashboard, pendingDeleteMessage: "Terminal session was closed before attach: octocat/hello-world.", readyMessage: "SSH connected: octocat/hello-world.", - session, - sessionPath: "/ssh/session/session-1", + session: acceptedSession, + sessionPath: "/ssh/session/pending-session-id", subtitle: "ssh -p 22 dev@172.18.0.7", - websocketPath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1/ws" + websocketPath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/pending-session-id/ws" }) expect(eventStreamCloseMock).toHaveBeenCalledTimes(1) expect(setMessage).toHaveBeenLastCalledWith( diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 0071eedd..773b89dd 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -30,7 +30,7 @@ RUN set -eu; \ sleep $((attempt * 2)); \ done; \ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ - openssh-server git gh ca-certificates curl unzip bsdutils sudo \ + openssh-server git gh ca-certificates curl unzip bsdutils sudo tmux \ make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \ ncurses-term jq \ && rm -rf /var/lib/apt/lists/* diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 9dbdeb40..6965d7ea 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -65,6 +65,7 @@ describe("renderDockerfile", () => { "curl -fsSL --retry 5 --retry-all-errors --retry-delay 2", "glab --version", "ncurses-term jq", + "sudo tmux", "# Tooling: RTK (Rust Token Killer)", "ARG RTK_VERSION=v0.39.0", 'https://raw.githubusercontent.com/rtk-ai/rtk/${RTK_VERSION}/install.sh', From 28291db42f47ee7d76ae0bbfe21290f765530990 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Thu, 14 May 2026 18:11:46 +0000 Subject: [PATCH 2/4] fix(api): address terminal session review feedback --- packages/api/src/http.ts | 12 +- packages/api/src/services/container-tasks.ts | 3 +- .../api/src/services/terminal-sessions.ts | 448 ++++++++++++------ packages/api/tests/terminal-sessions.test.ts | 83 +++- 4 files changed, 382 insertions(+), 164 deletions(-) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index bdd21bc5..e1affca1 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -1438,7 +1438,11 @@ export const makeRouter = () => { projectKeyParams.pipe( Effect.flatMap(({ projectKey }) => getProjectItemByKey(projectKey).pipe( - Effect.map((project) => ({ sessions: listProjectTerminalSessions(project.projectDir) })) + Effect.flatMap((project) => + listProjectTerminalSessions(project.projectDir).pipe( + Effect.map((sessions) => ({ sessions })) + ) + ) ) ), Effect.flatMap((body) => jsonResponse(body, 200)), @@ -1484,7 +1488,11 @@ export const makeRouter = () => { HttpRouter.get( "/projects/:projectId/terminal-sessions", projectParams.pipe( - Effect.flatMap(({ projectId }) => Effect.succeed({ sessions: listProjectTerminalSessions(projectId) })), + Effect.flatMap(({ projectId }) => + listProjectTerminalSessions(projectId).pipe( + Effect.map((sessions) => ({ sessions })) + ) + ), Effect.flatMap((body) => jsonResponse(body, 200)), Effect.catchAll(errorResponse) ) diff --git a/packages/api/src/services/container-tasks.ts b/packages/api/src/services/container-tasks.ts index 0c243a0c..34b7bb2a 100644 --- a/packages/api/src/services/container-tasks.ts +++ b/packages/api/src/services/container-tasks.ts @@ -366,13 +366,14 @@ export const readContainerTaskSnapshot = ( ) ) const tasks = buildContainerTasks(processes, managedAgentPids, includeDefault) + const terminalSessions = yield* _(listProjectTerminalSessions(project.id)) return { projectId: project.id, containerName: project.containerName, generatedAt: new Date().toISOString(), sshConnections: distinctSshConnections(tasks), tasks, - terminalSessions: listProjectTerminalSessions(project.id), + terminalSessions, agents: listAgents(project.id) } }) diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 283c3653..7a319519 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -11,6 +11,7 @@ import { parseInspectNetworkEntry } from "@effect-template/lib/shell/docker-insp import { CommandFailedError } from "@effect-template/lib/shell/errors" import type { ProjectItem } from "@effect-template/lib/usecases/projects" import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import type * as PlatformPath from "@effect/platform/Path" import { NodeContext } from "@effect/platform-node" @@ -20,7 +21,7 @@ import { Effect, Either } from "effect" import { Buffer } from "node:buffer" import { spawn } from "node:child_process" import { randomUUID } from "node:crypto" -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { existsSync } from "node:fs" import type { IncomingMessage, Server as HttpServer } from "node:http" import os from "node:os" import path from "node:path" @@ -74,6 +75,7 @@ type TerminalRecord = { projectKey: string projectTargetDir: string prepared: ReturnType + tmuxName: string } type TerminalSessionRuntime = @@ -81,6 +83,11 @@ type TerminalSessionRuntime = | FileSystem.FileSystem | PlatformPath.Path +type TerminalSessionStateRuntime = + | CommandExecutor.CommandExecutor + | FileSystem.FileSystem + | PlatformPath.Path + type DurableTerminalSession = { readonly id: string readonly projectId: string @@ -101,11 +108,12 @@ type DurableTerminalSessionFile = { } const records = new Map() +const terminalSessionPersistenceQueues = new Map>() const terminalWsPathPattern = /^(?:\/api)?\/projects\/([^/]+)\/terminal-sessions\/([^/]+)\/ws$/u const terminalWsByKeyPathPattern = /^(?:\/api)?\/projects\/by-key\/([^/]+)\/terminal-sessions\/([^/]+)\/ws$/u const terminalSessionStateRelativePath: ReadonlyArray = [".orch", "state", "terminal-sessions.json"] const tmuxMissingMessage = - "tmux is not installed in this project image. Apply or rebuild the project image, then reopen this SSH terminal session." + "tmux is not available in this project container. Apply docker-git config or rebuild the project image so tmux is installed, then reopen this SSH terminal session." const TerminalClientMessageSchema = Schema.parseJson( Schema.Union( @@ -164,12 +172,28 @@ export const clearTerminalSessionRuntimeForTest = (): void => { closeRecordSockets(record) } records.clear() + terminalSessionPersistenceQueues.clear() } const nowIso = (): string => new Date().toISOString() -const terminalSessionStatePath = (projectId: string): string => - path.join(projectId, ...terminalSessionStateRelativePath) +const isPathInsideDirectory = (root: string, candidate: string): boolean => { + const resolvedRoot = path.resolve(root) + const resolvedCandidate = path.resolve(candidate) + if (resolvedCandidate === resolvedRoot) { + return false + } + const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}` + return resolvedCandidate.startsWith(prefix) +} + +const terminalSessionStatePath = (projectId: string): string => { + const projectRoot = path.resolve(projectId) + const statePath = path.resolve(projectRoot, ...terminalSessionStateRelativePath) + return isPathInsideDirectory(projectRoot, statePath) + ? statePath + : path.resolve(projectRoot, ".orch", "state", "terminal-sessions.json") +} const emptyTerminalSessionFile = (): DurableTerminalSessionFile => ({ schemaVersion: 1, @@ -182,27 +206,57 @@ const decodeTerminalSessionFile = (input: string): DurableTerminalSessionFile | onRight: (value) => value }) -const readTerminalSessionFile = (projectId: string): DurableTerminalSessionFile => { - const statePath = terminalSessionStatePath(projectId) - if (!existsSync(statePath)) { - return emptyTerminalSessionFile() - } - try { - const decoded = decodeTerminalSessionFile(readFileSync(statePath, "utf8")) - return decoded ?? emptyTerminalSessionFile() - } catch { - return emptyTerminalSessionFile() - } -} +const readTerminalSessionFile = ( + projectId: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const statePath = terminalSessionStatePath(projectId) + const exists = yield* _(Effect.either(fs.exists(statePath))) + const fileExists = Either.match(exists, { + onLeft: () => false, + onRight: (value) => value + }) + if (!fileExists) { + return emptyTerminalSessionFile() + } + const contents = yield* _(Effect.either(fs.readFileString(statePath))) + return Either.match(contents, { + onLeft: () => emptyTerminalSessionFile(), + onRight: (value) => decodeTerminalSessionFile(value) ?? emptyTerminalSessionFile() + }) + }).pipe(Effect.catchAll(() => Effect.succeed(emptyTerminalSessionFile()))) + +const toTerminalSessionStateError = ( + action: string, + projectId: string +) => + (error: PlatformError | ApiInternalError): ApiInternalError => + error instanceof ApiInternalError + ? error + : new ApiInternalError({ + message: `Failed to ${action} terminal session state for project: ${projectId}`, + cause: error + }) const writeTerminalSessionFile = ( projectId: string, state: DurableTerminalSessionFile -): void => { - const statePath = terminalSessionStatePath(projectId) - mkdirSync(path.dirname(statePath), { recursive: true }) - writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8") -} +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const statePath = terminalSessionStatePath(projectId) + yield* _( + fs.makeDirectory(path.dirname(statePath), { recursive: true }).pipe( + Effect.mapError(toTerminalSessionStateError("create", projectId)) + ) + ) + yield* _( + fs.writeFileString(statePath, `${JSON.stringify(state, null, 2)}\n`).pipe( + Effect.mapError(toTerminalSessionStateError("write", projectId)) + ) + ) + }) const tmuxNameForSessionId = (sessionId: string): string => { const normalized = sessionId.replace(/[^A-Za-z0-9_-]/gu, "-").replace(/-+/gu, "-") @@ -252,66 +306,95 @@ const durableFromSession = ( const upsertDurableSession = ( projectId: string, durable: DurableTerminalSession -): void => { - const state = readTerminalSessionFile(projectId) - const sessions = state.sessions.filter((session) => session.id !== durable.id) - writeTerminalSessionFile(projectId, { - schemaVersion: 1, - sessions: [...sessions, durable] +): Effect.Effect => + Effect.gen(function*(_) { + const state = yield* _(readTerminalSessionFile(projectId)) + const sessions = state.sessions.filter((session) => session.id !== durable.id) + yield* _(writeTerminalSessionFile(projectId, { + schemaVersion: 1, + sessions: [...sessions, durable] + })) }) -} const patchDurableSession = ( record: TerminalRecord, patch: Partial -): void => { - const state = readTerminalSessionFile(record.projectId) - const updatedAt = nowIso() - const sessions = state.sessions.map((session) => - session.id === record.session.id - ? durableFromSession({ - projectDisplayName: record.projectDisplayName, - projectKey: record.projectKey, - session: { - ...terminalSessionFromDurable(session, 0), - ...patch - }, - tmuxName: session.tmuxName, - updatedAt - }) - : session - ) - writeTerminalSessionFile(record.projectId, { - schemaVersion: 1, - sessions +): Effect.Effect => + Effect.gen(function*(_) { + const state = yield* _(readTerminalSessionFile(record.projectId)) + const updatedAt = nowIso() + const sessions = state.sessions.map((session) => + session.id === record.session.id + ? durableFromSession({ + projectDisplayName: record.projectDisplayName, + projectKey: record.projectKey, + session: { + ...terminalSessionFromDurable(session, 0), + ...patch + }, + tmuxName: session.tmuxName, + updatedAt + }) + : session + ) + yield* _(writeTerminalSessionFile(record.projectId, { + schemaVersion: 1, + sessions + })) }) -} const deleteDurableSession = ( projectId: string, sessionId: string -): boolean => { - const state = readTerminalSessionFile(projectId) - const sessions = state.sessions.filter((session) => session.id !== sessionId) - if (sessions.length === state.sessions.length) { - return false - } - writeTerminalSessionFile(projectId, { - schemaVersion: 1, - sessions +): Effect.Effect => + Effect.gen(function*(_) { + const state = yield* _(readTerminalSessionFile(projectId)) + const sessions = state.sessions.filter((session) => session.id !== sessionId) + if (sessions.length === state.sessions.length) { + return false + } + yield* _(writeTerminalSessionFile(projectId, { + schemaVersion: 1, + sessions + })) + return true }) - return true -} const findDurableSession = ( projectId: string, sessionId: string -): DurableTerminalSession | null => - readTerminalSessionFile(projectId).sessions.find((session) => session.id === sessionId) ?? null +): Effect.Effect => + readTerminalSessionFile(projectId).pipe( + Effect.map((state) => state.sessions.find((session) => session.id === sessionId) ?? null) + ) const isAppError = (value: unknown): value is AppError => typeof value === "object" && value !== null && "_tag" in value +const runTerminalSessionPersistence = ( + projectId: string, + effect: Effect.Effect +): void => { + const previous = terminalSessionPersistenceQueues.get(projectId) ?? Promise.resolve() + const next = previous + .catch(() => undefined) + .then(() => + Effect.runPromise( + effect.pipe( + Effect.provide(NodeContext.layer), + Effect.catchAll(() => Effect.void) + ) + ) + ) + .catch(() => undefined) + .finally(() => { + if (terminalSessionPersistenceQueues.get(projectId) === next) { + terminalSessionPersistenceQueues.delete(projectId) + } + }) + terminalSessionPersistenceQueues.set(projectId, next) +} + const updateSession = ( record: TerminalRecord, patch: Partial @@ -321,7 +404,7 @@ const updateSession = ( ...patch } records.set(record.session.id, record) - patchDurableSession(record, patch) + runTerminalSessionPersistence(record.projectId, patchDurableSession(record, patch)) } const attachedClientCount = (record: TerminalRecord): number => { @@ -352,6 +435,11 @@ const toTerminalSessionLookupError = ( ? error : toApiInternalError(error) +const toTerminalSessionProjectError = ( + error: unknown +): ApiInternalError | ApiNotFoundError => + error instanceof ApiNotFoundError ? error : toApiInternalError(error) + const normalizeSshKeyPermissions = (sshKeyPath: string | null) => sshKeyPath === null ? Effect.void @@ -807,17 +895,28 @@ const resizePty = (pty: PtyBridge | null, cols: number, rows: number): void => { } } -const renderRemoteTmuxCommand = (record: TerminalRecord): string => { - const tmuxName = findDurableSession(record.projectId, record.session.id)?.tmuxName ?? tmuxNameForSessionId( - record.session.id - ) +export const renderTmuxAttachCommand = ( + args: { + readonly missingMessage?: string + readonly targetDir: string + readonly tmuxName: string + } +): string => { const script = [ - `if ! command -v tmux >/dev/null 2>&1; then printf '%s\\n' ${shellQuote(tmuxMissingMessage)} >&2; exit 127; fi`, - `exec tmux new-session -A -s ${shellQuote(tmuxName)} -c ${shellQuote(record.projectTargetDir)}` + `if ! command -v tmux >/dev/null 2>&1; then printf '%s\\n' ${ + shellQuote(args.missingMessage ?? tmuxMissingMessage) + } >&2; exit 127; fi`, + `exec tmux new-session -A -s ${shellQuote(args.tmuxName)} -c ${shellQuote(args.targetDir)}` ].join("; ") return `sh -lc ${shellQuote(script)}` } +const renderRemoteTmuxCommand = (record: TerminalRecord): string => + renderTmuxAttachCommand({ + targetDir: record.projectTargetDir, + tmuxName: record.tmuxName + }) + const preparedArgsForTmuxSession = (record: TerminalRecord): ReadonlyArray => [ ...record.prepared.args, renderRemoteTmuxCommand(record) @@ -871,44 +970,46 @@ const registerRecord = ( projectContainerName: string, projectTargetDir: string, sessionId: string = randomUUID() -): TerminalSession => { - const createdAt = nowIso() - const session: TerminalSession = { - attachedClients: 0, - createdAt, - id: sessionId, - projectId, - sshCommand: renderPreparedSshCommand(prepared), - status: "ready" - } - const tmuxName = tmuxNameForSessionId(session.id) - upsertDurableSession( - projectId, - durableFromSession({ +): Effect.Effect => + Effect.gen(function*(_) { + const createdAt = nowIso() + const session: TerminalSession = { + attachedClients: 0, + createdAt, + id: sessionId, + projectId, + sshCommand: renderPreparedSshCommand(prepared), + status: "ready" + } + const tmuxName = tmuxNameForSessionId(session.id) + yield* _(upsertDurableSession( + projectId, + durableFromSession({ + projectDisplayName, + projectKey, + session, + tmuxName, + updatedAt: createdAt + }) + )) + const record: TerminalRecord = { + attachTimeout: null, + detachTimeout: null, + outputBuffer: emptyTerminalOutputBuffer, + prepared, + projectContainerName, projectDisplayName, + projectId, projectKey, + projectTargetDir, + pty: null, session, - tmuxName, - updatedAt: createdAt - }) - ) - const record: TerminalRecord = { - attachTimeout: null, - detachTimeout: null, - outputBuffer: emptyTerminalOutputBuffer, - prepared, - projectContainerName, - projectDisplayName, - projectId, - projectKey, - projectTargetDir, - pty: null, - session, - sockets: new Set() - } - records.set(session.id, record) - return session -} + sockets: new Set(), + tmuxName + } + records.set(session.id, record) + return session + }) const registerHydratedRecord = ( durable: DurableTerminalSession, @@ -927,7 +1028,8 @@ const registerHydratedRecord = ( projectTargetDir: projectItem.targetDir, pty: null, session: terminalSessionFromDurable(durable, 0), - sockets: new Set() + sockets: new Set(), + tmuxName: durable.tmuxName } records.set(record.session.id, record) return record @@ -952,7 +1054,7 @@ const hydrateProjectTerminalRecord = ( if (existing !== undefined && existing.projectId === projectItem.projectDir) { return existing } - const durable = findDurableSession(projectItem.projectDir, sessionId) + const durable = yield* _(findDurableSession(projectItem.projectDir, sessionId)) if (durable === null) { return yield* _(Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` }))) } @@ -977,6 +1079,38 @@ const hydrateTerminalRecordByProjectKey = ( Effect.flatMap((projectItem) => hydrateProjectTerminalRecord(projectItem, sessionId)) ) +const renderRemoteTmuxProbeCommand = (): string => + `sh -lc ${shellQuote("command -v tmux >/dev/null 2>&1")}` + +const probeProjectTmuxAvailable = ( + projectItem: ProjectItem +): Effect.Effect => { + const prepared = prepareProjectSsh(projectItem) + return runCommandCapture( + { + cwd: prepared.cwd, + command: prepared.command, + args: [...prepared.args, renderRemoteTmuxProbeCommand()] + }, + [0], + (exitCode) => new CommandFailedError({ command: "ssh command -v tmux", exitCode }) + ).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false) + ) +} + +const ensureProjectTmuxAvailable = ( + projectItem: ProjectItem +): Effect.Effect => + probeProjectTmuxAvailable(projectItem).pipe( + Effect.flatMap((available) => + available + ? Effect.void + : Effect.fail(new ApiConflictError({ message: tmuxMissingMessage })) + ) + ) + const emitTerminalStatus = (projectId: string, phase: string, message: string) => Effect.sync(() => { emitProjectEvent(projectId, "project.deployment.status", { phase, message }) @@ -1015,49 +1149,52 @@ export const createTerminalSession = ( } = {} ) => Effect.gen(function*(_) { - yield* _(emitTerminalStatus(projectId, "ssh.prepare", "Preparing SSH session")) - const loadedProjectItem = yield* _(getProjectItemById(projectId)) + const project = yield* _(getProject(projectId)) + const resolvedProjectId = project.id + yield* _(emitTerminalStatus(resolvedProjectId, "ssh.prepare", "Preparing SSH session")) + const loadedProjectItem = yield* _(getProjectItemById(resolvedProjectId)) const projectItem = yield* _(resolveControllerReachableProject(loadedProjectItem)) yield* _(normalizeSshKeyPermissions(projectItem.sshKeyPath)) const sshAlreadyReady = yield* _(probeProjectSshReady(projectItem).pipe(Effect.orElseSucceed(() => false))) if (sshAlreadyReady) { - yield* _(emitTerminalStatus(projectId, "ssh.fast-ready", "SSH is already ready")) - const project = yield* _(getProject(projectId)) + yield* _(emitTerminalStatus(resolvedProjectId, "ssh.fast-ready", "SSH is already ready")) + yield* _(ensureProjectTmuxAvailable(projectItem)) const prepared = prepareProjectSsh(projectItem) - const session = registerRecord( - projectId, + const session = yield* _(registerRecord( + resolvedProjectId, project.projectKey, project.displayName, prepared, projectItem.containerName, projectItem.targetDir, options.requestId - ) - yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId)) + )) + yield* _(emitTerminalSessionCreated(resolvedProjectId, session.id, options.requestId)) return { project, session } } - const project = yield* _(upProject(projectId, undefined, true, { startupMode: "ssh-open" })) - const refreshedProjectItem = yield* _(getProjectItemById(projectId)) + const startedProject = yield* _(upProject(resolvedProjectId, undefined, true, { startupMode: "ssh-open" })) + const refreshedProjectItem = yield* _(getProjectItemById(resolvedProjectId)) const reachableProjectItem = yield* _(resolveControllerReachableProject(refreshedProjectItem)) yield* _(normalizeSshKeyPermissions(reachableProjectItem.sshKeyPath)) - yield* _(emitTerminalStatus(projectId, "ssh.wait", "Waiting for SSH")) + yield* _(emitTerminalStatus(resolvedProjectId, "ssh.wait", "Waiting for SSH")) yield* _(waitForProjectSshReady(reachableProjectItem).pipe(Effect.mapError(toApiInternalError))) - yield* _(emitTerminalStatus(projectId, "ssh.ready", "SSH is ready")) + yield* _(emitTerminalStatus(resolvedProjectId, "ssh.ready", "SSH is ready")) + yield* _(ensureProjectTmuxAvailable(reachableProjectItem)) const prepared = prepareProjectSsh(reachableProjectItem) - const session = registerRecord( - projectId, - project.projectKey, - project.displayName, + const session = yield* _(registerRecord( + resolvedProjectId, + startedProject.projectKey, + startedProject.displayName, prepared, reachableProjectItem.containerName, reachableProjectItem.targetDir, options.requestId - ) - yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId)) - yield* _(emitTerminalStatus(projectId, "ssh.post-start", "Post-start self-heal continues in background")) - return { project, session } + )) + yield* _(emitTerminalSessionCreated(resolvedProjectId, session.id, options.requestId)) + yield* _(emitTerminalStatus(resolvedProjectId, "ssh.post-start", "Post-start self-heal continues in background")) + return { project: startedProject, session } }) // CHANGE: start SSH terminal creation asynchronously for web clients behind request timeouts @@ -1101,21 +1238,23 @@ export const startTerminalSession = ( export const deleteTerminalSession = ( projectId: string, sessionId: string -): Effect.Effect => +): Effect.Effect => Effect.gen(function*(_) { + const project = yield* _(getProject(projectId).pipe(Effect.mapError(toTerminalSessionProjectError))) + const resolvedProjectId = project.id const record = records.get(sessionId) - const deleted = deleteDurableSession(projectId, sessionId) - if ((record === undefined || record.projectId !== projectId) && !deleted) { + const deleted = yield* _(deleteDurableSession(resolvedProjectId, sessionId)) + if ((record === undefined || record.projectId !== resolvedProjectId) && !deleted) { return yield* _( Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) ) } - if (record !== undefined && record.projectId === projectId) { + if (record !== undefined && record.projectId === resolvedProjectId) { cleanupRecord(record) } yield* _( Effect.sync(() => { - emitProjectEvent(projectId, "project.ssh.session", { + emitProjectEvent(resolvedProjectId, "project.ssh.session", { phase: "closed", sessionId }) @@ -1123,27 +1262,36 @@ export const deleteTerminalSession = ( ) }) -export const listProjectTerminalSessions = (projectId: string): ReadonlyArray => - readTerminalSessionFile(projectId).sessions.map((durable) => { - const record = records.get(durable.id) - if (record !== undefined && record.projectId === projectId) { - syncAttachedClientCount(record) - return record.session - } - return terminalSessionFromDurable(durable, 0) +export const listProjectTerminalSessions = ( + projectId: string +): Effect.Effect, ApiInternalError | ApiNotFoundError, TerminalSessionStateRuntime> => + Effect.gen(function*(_) { + const project = yield* _(getProject(projectId).pipe(Effect.mapError(toTerminalSessionProjectError))) + const resolvedProjectId = project.id + const state = yield* _(readTerminalSessionFile(resolvedProjectId)) + return state.sessions.map((durable) => { + const record = records.get(durable.id) + if (record !== undefined && record.projectId === resolvedProjectId) { + syncAttachedClientCount(record) + return record.session + } + return terminalSessionFromDurable(durable, 0) + }) }) export const getProjectTerminalSession = ( projectId: string, sessionId: string -): Effect.Effect => +): Effect.Effect => Effect.gen(function*(_) { + const project = yield* _(getProject(projectId).pipe(Effect.mapError(toTerminalSessionProjectError))) + const resolvedProjectId = project.id const record = records.get(sessionId) - if (record !== undefined && record.projectId === projectId) { + if (record !== undefined && record.projectId === resolvedProjectId) { syncAttachedClientCount(record) return record.session } - const durable = findDurableSession(projectId, sessionId) + const durable = yield* _(findDurableSession(resolvedProjectId, sessionId)) if (durable === null) { return yield* _( Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) @@ -1158,11 +1306,14 @@ export const readProjectTerminalImage = ( imagePath: string ): Effect.Effect< { readonly bytes: Buffer; readonly mediaType: string }, - ApiBadRequestError | ApiInternalError | ApiNotFoundError + ApiBadRequestError | ApiInternalError | ApiNotFoundError, + TerminalSessionStateRuntime > => Effect.gen(function*(_) { + const project = yield* _(getProject(projectId).pipe(Effect.mapError(toTerminalSessionProjectError))) + const resolvedProjectId = project.id const record = records.get(sessionId) - if (record === undefined || record.projectId !== projectId) { + if (record === undefined || record.projectId !== resolvedProjectId) { return yield* _( Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) ) @@ -1198,7 +1349,7 @@ export const lookupTerminalSessionById = ( } const projects = yield* _(listProjectItems) for (const project of projects) { - const durable = findDurableSession(project.projectDir, sessionId) + const durable = yield* _(findDurableSession(project.projectDir, sessionId)) if (durable !== null) { return { projectDisplayName: durable.projectDisplayName, @@ -1220,7 +1371,10 @@ export const lookupTerminalSessionById = ( ) const handleCloseMessage = (record: TerminalRecord): void => { - deleteDurableSession(record.projectId, record.session.id) + runTerminalSessionPersistence( + record.projectId, + deleteDurableSession(record.projectId, record.session.id).pipe(Effect.asVoid) + ) cleanupRecord(record) } @@ -1365,5 +1519,5 @@ export const attachTerminalWebSocketServer = (server: HttpServer): void => { export const verifyTerminalSession = ( projectId: string, sessionId: string -): Effect.Effect => +): Effect.Effect => getProjectTerminalSession(projectId, sessionId) diff --git a/packages/api/tests/terminal-sessions.test.ts b/packages/api/tests/terminal-sessions.test.ts index 9f71d7bb..78ab6261 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -1,10 +1,11 @@ import { Effect } from "effect" -import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" import path from "node:path" import os from "node:os" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { ProjectItem } from "@effect-template/lib" +import { NodeContext } from "@effect/platform-node" import type { ProjectDetails } from "../src/api/contracts.js" import { clearProjectEvents, listProjectEventsSince } from "../src/services/events.js" @@ -15,6 +16,8 @@ import { getProjectTerminalSession, listProjectTerminalSessions, lookupTerminalSessionById, + readProjectTerminalImage, + renderTmuxAttachCommand, startTerminalSession } from "../src/services/terminal-sessions.js" @@ -107,15 +110,18 @@ const makeProjectDetails = (projectDir: string): ProjectDetails => ({ targetDir: "/home/dev/app" }) -const cleanupSessions = (): Effect.Effect => - Effect.forEach( - listProjectTerminalSessions(projectId), - (session) => deleteTerminalSession(projectId, session.id).pipe(Effect.catchAll(() => Effect.void)), - { discard: true } - ) +const cleanupSessions = (): Effect.Effect => + Effect.gen(function*(_) { + const sessions = yield* _(listProjectTerminalSessions(projectId).pipe(Effect.catchAll(() => Effect.succeed([])))) + yield* _(Effect.forEach( + sessions, + (session) => deleteTerminalSession(projectId, session.id).pipe(Effect.catchAll(() => Effect.void)), + { discard: true } + )) + }) const runTestEffect = (effect: Effect.Effect): Promise => - Effect.runPromise(effect as Effect.Effect) + Effect.runPromise(effect.pipe(Effect.provide(NodeContext.layer)) as Effect.Effect) const phaseFromEvent = (event: { readonly payload: unknown }): string | null => { if (typeof event.payload !== "object" || event.payload === null || !Object.hasOwn(event.payload, "phase")) { @@ -128,6 +134,9 @@ const terminalSessionsStatePath = (): string => path.join(projectId, ".orch", "state", "terminal-sessions.json") const readPersistedSessionIds = (): ReadonlyArray => { + if (!existsSync(terminalSessionsStatePath())) { + return [] + } const raw: unknown = JSON.parse(readFileSync(terminalSessionsStatePath(), "utf8")) if (typeof raw !== "object" || raw === null) { return [] @@ -170,13 +179,18 @@ describe("terminal sessions service", () => { cwd: "/repo", item: projectItem }) - runCommandCaptureMock.mockImplementation(() => Effect.fail(new Error("docker inspect skipped in tests"))) + runCommandCaptureMock.mockImplementation((command: { readonly command: string }) => + command.command === "ssh" + ? Effect.succeed("/usr/bin/tmux\n") + : Effect.fail(new Error("docker inspect skipped in tests")) + ) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) getProjectItemByIdMock.mockImplementation(() => Effect.succeed(projectItem)) getProjectItemByKeyMock.mockImplementation(() => Effect.succeed(projectItem)) }) - afterEach(() => { - Effect.runSync(cleanupSessions()) + afterEach(async () => { + await runTestEffect(cleanupSessions()) clearTerminalSessionRuntimeForTest() clearProjectEvents(projectId) rmSync(projectRoot, { force: true, recursive: true }) @@ -203,19 +217,59 @@ describe("terminal sessions service", () => { expect(phases).toEqual(["ssh.prepare", "ssh.fast-ready"]) }) + it("renders the remote tmux attach command with availability guard", () => { + const command = renderTmuxAttachCommand({ + missingMessage: "tmux missing", + targetDir: "/home/dev/project with spaces", + tmuxName: "docker-git-session-1" + }) + + expect(command).toContain("command -v tmux") + expect(command).toContain("tmux missing") + expect(command).toContain("tmux new-session -A -s") + expect(command).toContain("docker-git-session-1") + expect(command).toContain("/home/dev/project with spaces") + }) + + it("fails before creating a durable session when tmux is unavailable", async () => { + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + runCommandCaptureMock.mockImplementation((command: { readonly command: string }) => + command.command === "ssh" + ? Effect.fail(new Error("tmux missing")) + : Effect.fail(new Error("docker inspect skipped in tests")) + ) + + await expect(runTestEffect(createTerminalSession(projectId))).rejects.toThrow( + "tmux is not available in this project container" + ) + expect(readPersistedSessionIds()).toEqual([]) + }) + it("persists multiple sessions for one project with distinct stable IDs", async () => { probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) const first = await runTestEffect(createTerminalSession(projectId)) const second = await runTestEffect(createTerminalSession(projectId)) - const listed = listProjectTerminalSessions(projectId) + const listed = await runTestEffect(listProjectTerminalSessions(projectId)) expect(first.session.id).not.toBe(second.session.id) expect(listed.map((session) => session.id)).toEqual([first.session.id, second.session.id]) expect(readPersistedSessionIds()).toEqual([first.session.id, second.session.id]) }) + it("resolves project aliases before checking terminal image session ownership", async () => { + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + + const result = await runTestEffect(createTerminalSession(projectId)) + + await expect( + runTestEffect(readProjectTerminalImage("repo-alias", result.session.id, "../image.png")) + ).rejects.toThrow("Image path must not contain '.' or '..' segments.") + }) + it("hydrates list, project lookup, and global lookup from persisted state after clearing runtime records", async () => { probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) @@ -225,7 +279,8 @@ describe("terminal sessions service", () => { clearTerminalSessionRuntimeForTest() - expect(listProjectTerminalSessions(projectId).map((session) => session.id)).toEqual([ + const listed = await runTestEffect(listProjectTerminalSessions(projectId)) + expect(listed.map((session) => session.id)).toEqual([ first.session.id, second.session.id ]) @@ -259,7 +314,7 @@ describe("terminal sessions service", () => { expect(upProjectMock).toHaveBeenCalledWith(projectId, undefined, true, { startupMode: "ssh-open" }) expect(waitForProjectSshReadyMock).toHaveBeenCalledTimes(1) - expect(getProjectMock).not.toHaveBeenCalled() + expect(getProjectMock).toHaveBeenCalledWith(projectId) expect(result.project).toEqual(projectDetails) expect(result.session.projectId).toBe(projectId) expect(phases).toEqual(["ssh.prepare", "ssh.wait", "ssh.ready", "ssh.post-start"]) From 9894c39ae54479bd2b3172021bc668cb64c57b01 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Thu, 14 May 2026 18:27:19 +0000 Subject: [PATCH 3/4] fix(api): clarify terminal persistence diagnostics --- packages/api/src/services/terminal-sessions.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 7a319519..36cbb756 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -382,7 +382,11 @@ const runTerminalSessionPersistence = ( Effect.runPromise( effect.pipe( Effect.provide(NodeContext.layer), - Effect.catchAll(() => Effect.void) + Effect.catchAll((error) => + Effect.logWarning( + `[terminal-sessions] Failed to persist state for project ${projectId}: ${describeUnknown(error)}` + ) + ) ) ) ) @@ -651,6 +655,7 @@ const finalizeRecord = ( exitCode: number | null, signal: number | null ): void => { + // A clean tmux-backed PTY exit leaves the project session reattachable. const nextStatus = exitCode === 0 || exitCode === 130 ? "ready" : status broadcastServerMessage(record, { type: "exit", exitCode, signal }) closeRecordSockets(record) From e32050348b194928f58aa9e43871a31cbdb53c19 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Thu, 14 May 2026 18:47:12 +0000 Subject: [PATCH 4/4] fix(api): tighten terminal session request ids --- packages/api/src/api/schema.ts | 2 +- .../api/src/services/terminal-sessions.ts | 21 ++++++++------- packages/api/tests/terminal-sessions.test.ts | 11 ++++---- packages/app/src/web/actions-projects.ts | 17 +++++++++++- .../tests/docker-git/actions-projects.test.ts | 27 +++++++++---------- 5 files changed, 47 insertions(+), 31 deletions(-) diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index c4cfaf41..41e5a3c5 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -161,7 +161,7 @@ export const UpProjectRequestSchema = Schema.Struct({ }) export const StartProjectTerminalSessionRequestSchema = Schema.Struct({ - requestId: Schema.String + requestId: Schema.UUID }) export const ProjectPortForwardRequestSchema = Schema.Struct({ diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 36cbb756..6dacce07 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -78,6 +78,7 @@ type TerminalRecord = { tmuxName: string } +// Effect encodes combined service requirements as a union of Context tags; intersections reject valid composition. type TerminalSessionRuntime = | CommandExecutor.CommandExecutor | FileSystem.FileSystem @@ -114,6 +115,7 @@ const terminalWsByKeyPathPattern = /^(?:\/api)?\/projects\/by-key\/([^/]+)\/term const terminalSessionStateRelativePath: ReadonlyArray = [".orch", "state", "terminal-sessions.json"] const tmuxMissingMessage = "tmux is not available in this project container. Apply docker-git config or rebuild the project image so tmux is installed, then reopen this SSH terminal session." +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu const TerminalClientMessageSchema = Schema.parseJson( Schema.Union( @@ -177,6 +179,9 @@ export const clearTerminalSessionRuntimeForTest = (): void => { const nowIso = (): string => new Date().toISOString() +const requestSessionId = (requestId: string | undefined): string | undefined => + requestId !== undefined && uuidPattern.test(requestId) ? requestId : undefined + const isPathInsideDirectory = (root: string, candidate: string): boolean => { const resolvedRoot = path.resolve(root) const resolvedCandidate = path.resolve(candidate) @@ -1156,6 +1161,7 @@ export const createTerminalSession = ( Effect.gen(function*(_) { const project = yield* _(getProject(projectId)) const resolvedProjectId = project.id + const requestedSessionId = requestSessionId(options.requestId) yield* _(emitTerminalStatus(resolvedProjectId, "ssh.prepare", "Preparing SSH session")) const loadedProjectItem = yield* _(getProjectItemById(resolvedProjectId)) const projectItem = yield* _(resolveControllerReachableProject(loadedProjectItem)) @@ -1173,7 +1179,7 @@ export const createTerminalSession = ( prepared, projectItem.containerName, projectItem.targetDir, - options.requestId + requestedSessionId )) yield* _(emitTerminalSessionCreated(resolvedProjectId, session.id, options.requestId)) return { project, session } @@ -1195,7 +1201,7 @@ export const createTerminalSession = ( prepared, reachableProjectItem.containerName, reachableProjectItem.targetDir, - options.requestId + requestedSessionId )) yield* _(emitTerminalSessionCreated(resolvedProjectId, session.id, options.requestId)) yield* _(emitTerminalStatus(resolvedProjectId, "ssh.post-start", "Post-start self-heal continues in background")) @@ -1339,7 +1345,7 @@ export const lookupTerminalSessionById = ( sessionId: string ): Effect.Effect< { readonly projectDisplayName: string; readonly projectKey: string; readonly session: TerminalSession }, - ApiNotFoundError, + ApiInternalError | ApiNotFoundError, TerminalSessionRuntime > => Effect.gen(function*(_) { @@ -1367,12 +1373,9 @@ export const lookupTerminalSessionById = ( Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) ) }).pipe( - Effect.catchAll((error) => { - if (error instanceof ApiNotFoundError) { - return Effect.fail(error) - } - return Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) - }) + Effect.mapError((error) => + error instanceof ApiNotFoundError ? error : toApiInternalError(error) + ) ) const handleCloseMessage = (record: TerminalRecord): void => { diff --git a/packages/api/tests/terminal-sessions.test.ts b/packages/api/tests/terminal-sessions.test.ts index 78ab6261..8d73df37 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -323,24 +323,25 @@ describe("terminal sessions service", () => { it("starts terminal session asynchronously and emits a correlated created event", async () => { probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + const requestId = "00000000-0000-4000-8000-000000000001" - const accepted = await runTestEffect(startTerminalSession(projectId, "request-1")) + const accepted = await runTestEffect(startTerminalSession(projectId, requestId)) expect(accepted).toEqual({ accepted: true, cursor: 0, projectId, - requestId: "request-1" + requestId }) await vi.waitFor(() => { const created = listProjectEventsSince(projectId, 0).find((event) => event.type === "project.ssh.session") expect(created?.payload).toMatchObject({ phase: "created", - sessionId: "request-1", - requestId: "request-1" + sessionId: requestId, + requestId }) - expect(readPersistedSessionIds()).toContain("request-1") + expect(readPersistedSessionIds()).toContain(requestId) }) }) diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 5cf90232..c155db04 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -91,12 +91,27 @@ const randomHex = (bytes: number): string => { return Date.now().toString(16).padStart(bytes * 2, "0").slice(0, bytes * 2) } +const formatUuidV4 = (hex: string): string => { + const value = hex.padEnd(32, "0").slice(0, 32) + const variant = ((Number.parseInt(value.slice(16, 18), 16) & 0x3f) | 0x80) + .toString(16) + .padStart(2, "0") + const segments = [ + value.slice(0, 8), + value.slice(8, 12), + `4${value.slice(13, 16)}`, + `${variant}${value.slice(18, 20)}`, + value.slice(20, 32) + ] + return segments.join("-") +} + const createPendingTerminalSessionId = (): string => { if (typeof globalThis.crypto.randomUUID === "function") { return globalThis.crypto.randomUUID() } - return `pending-${Date.now().toString(16)}-${randomHex(8)}` + return formatUuidV4(randomHex(16)) } type ProjectActiveTerminalSessionArgs = Omit< diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index cf634683..e0faaa31 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -145,11 +145,10 @@ describe("web project actions", () => { it.effect("adds a new SSH terminal session instead of replacing terminal state", () => Effect.gen(function*(_) { - vi.stubGlobal("crypto", { randomUUID: () => "pending-session-id" }) - startProjectTerminalSessionMock.mockImplementation(() => - Effect.succeed(startTerminalAccepted("pending-session-id")) - ) - const acceptedSession = { ...session, id: "pending-session-id" } + const pendingSessionId = "00000000-0000-4000-8000-000000000002" + vi.stubGlobal("crypto", { randomUUID: () => pendingSessionId }) + startProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(startTerminalAccepted(pendingSessionId))) + const acceptedSession = { ...session, id: pendingSessionId } loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(acceptedSession)) openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() @@ -164,8 +163,8 @@ describe("web project actions", () => { at: "2026-04-21T10:00:01.000Z", payload: { phase: "created", - requestId: "pending-session-id", - sessionId: "pending-session-id" + requestId: pendingSessionId, + sessionId: pendingSessionId }, projectId: "project-1", seq: 8, @@ -180,8 +179,8 @@ describe("web project actions", () => { if (pendingSession === undefined) { throw new Error("missing pending terminal session") } - expect(startProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", "pending-session-id") - expect(loadProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", "pending-session-id") + expect(startProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", pendingSessionId) + expect(loadProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", pendingSessionId) expect(context.setSelectedProjectId).toHaveBeenCalledWith("project-1") expect(pendingSession).toMatchObject({ browserProjectId: "project-1", @@ -198,7 +197,7 @@ describe("web project actions", () => { browserProjectId: "project-1", browserProjectKey: "octocat/hello-world", browserProjectName: "octocat/hello-world", - closePath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/pending-session-id", + closePath: `/projects/by-key/octocat%2Fhello-world/terminal-sessions/${pendingSessionId}`, exitMessage: "SSH session ended.", header: "SSH terminal: octocat/hello-world", onExit: reloadDashboard, @@ -206,9 +205,9 @@ describe("web project actions", () => { pendingDeleteMessage: "Terminal session was closed before attach: octocat/hello-world.", readyMessage: "SSH connected: octocat/hello-world.", session: acceptedSession, - sessionPath: "/ssh/session/pending-session-id", + sessionPath: `/ssh/session/${pendingSessionId}`, subtitle: "ssh -p 22 dev@172.18.0.7", - websocketPath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/pending-session-id/ws" + websocketPath: `/projects/by-key/octocat%2Fhello-world/terminal-sessions/${pendingSessionId}/ws` }) expect(eventStreamCloseMock).toHaveBeenCalledTimes(1) expect(setMessage).toHaveBeenLastCalledWith( @@ -218,7 +217,6 @@ describe("web project actions", () => { it.effect("starts SSH terminal creation from getRandomValues when randomUUID is unavailable", () => Effect.gen(function*(_) { - const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x1_9A_11_7B_D6_1F) vi.stubGlobal("crypto", { getRandomValues: (values: Uint8Array): Uint8Array => { values.set([0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE]) @@ -237,10 +235,9 @@ describe("web project actions", () => { yield* _(connectProjectAndWaitForStream(context)) expect(startProjectTerminalSessionMock).toHaveBeenCalledTimes(1) const requestId = startProjectTerminalSessionMock.mock.calls[0]?.[1] - expect(requestId).toBe("pending-19a117bd61f-1032547698badcfe") + expect(requestId).toBe("10325476-98ba-4cfe-8000-000000000000") expect(addTerminalSession).toHaveBeenCalledTimes(1) expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) - dateNowMock.mockRestore() })) it.effect("applies a selected project through the project apply endpoint", () =>