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/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 2ffc5e9d..6dacce07 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -1,9 +1,20 @@ -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 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" import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" import { Effect, Either } from "effect" @@ -13,11 +24,12 @@ import { randomUUID } from "node:crypto" import { existsSync } 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 +47,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 = @@ -63,12 +75,47 @@ type TerminalRecord = { projectKey: string projectTargetDir: string prepared: ReturnType + tmuxName: string +} + +// Effect encodes combined service requirements as a union of Context tags; intersections reject valid composition. +type TerminalSessionRuntime = + | CommandExecutor.CommandExecutor + | FileSystem.FileSystem + | PlatformPath.Path + +type TerminalSessionStateRuntime = + | 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 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 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( @@ -94,11 +141,269 @@ 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() + terminalSessionPersistenceQueues.clear() +} + 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) + 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, + sessions: [] +}) + +const decodeTerminalSessionFile = (input: string): DurableTerminalSessionFile | null => + Either.match(ParseResult.decodeUnknownEither(DurableTerminalSessionFileJsonSchema)(input), { + onLeft: () => null, + onRight: (value) => value + }) + +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 +): 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, "-") + 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 +): 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 +): 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 +): 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 + }) + +const findDurableSession = ( + projectId: string, + sessionId: string +): 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((error) => + Effect.logWarning( + `[terminal-sessions] Failed to persist state for project ${projectId}: ${describeUnknown(error)}` + ) + ) + ) + ) + ) + .catch(() => undefined) + .finally(() => { + if (terminalSessionPersistenceQueues.get(projectId) === next) { + terminalSessionPersistenceQueues.delete(projectId) + } + }) + terminalSessionPersistenceQueues.set(projectId, next) +} + const updateSession = ( record: TerminalRecord, patch: Partial @@ -108,6 +413,7 @@ const updateSession = ( ...patch } records.set(record.session.id, record) + runTerminalSessionPersistence(record.projectId, patchDurableSession(record, patch)) } const attachedClientCount = (record: TerminalRecord): number => { @@ -131,6 +437,18 @@ 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 toTerminalSessionProjectError = ( + error: unknown +): ApiInternalError | ApiNotFoundError => + error instanceof ApiNotFoundError ? error : toApiInternalError(error) + const normalizeSshKeyPermissions = (sshKeyPath: string | null) => sshKeyPath === null ? Effect.void @@ -311,32 +629,51 @@ 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 => { + // 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) + 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 +905,33 @@ const resizePty = (pty: PtyBridge | null, cols: number, rows: number): void => { } } +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(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) +] + const startTerminalPty = ( record: TerminalRecord, cols: number, @@ -579,8 +943,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 +960,9 @@ const startTerminalPty = ( sendTerminalOutput(record, data) }) pty.onExit(({ exitCode, signal }) => { + if (record.pty !== pty) { + return + } finalizeRecord( record, exitCode === 0 || exitCode === 130 ? "exited" : "failed", @@ -604,49 +972,155 @@ 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 -): TerminalSession => { - const session: TerminalSession = { - attachedClients: 0, - createdAt: nowIso(), - id: randomUUID(), - projectId, - sshCommand: renderPreparedSshCommand(prepared), - status: "ready" - } + projectTargetDir: string, + sessionId: string = randomUUID() +): 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, + sockets: new Set(), + tmuxName + } + 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, - projectDisplayName, - projectId, - projectKey, - projectTargetDir, + projectContainerName: projectItem.containerName, + projectDisplayName: durable.projectDisplayName, + projectId: durable.projectId, + projectKey: durable.projectKey, + projectTargetDir: projectItem.targetDir, pty: null, - session, - sockets: new Set() + session: terminalSessionFromDurable(durable, 0), + sockets: new Set(), + tmuxName: durable.tmuxName } - record.attachTimeout = createAttachTimeout(session.id) - records.set(session.id, record) - return session + 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 = yield* _(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 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 }) @@ -685,47 +1159,53 @@ 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 + const requestedSessionId = requestSessionId(options.requestId) + 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 - ) - yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId)) + projectItem.targetDir, + requestedSessionId + )) + 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 - ) - yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId)) - yield* _(emitTerminalStatus(projectId, "ssh.post-start", "Post-start self-heal continues in background")) - return { project, session } + reachableProjectItem.targetDir, + requestedSessionId + )) + 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 @@ -769,18 +1249,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) - if (record === undefined || record.projectId !== projectId) { + 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}` })) ) } - cleanupRecord(record) + if (record !== undefined && record.projectId === resolvedProjectId) { + cleanupRecord(record) + } yield* _( Effect.sync(() => { - emitProjectEvent(projectId, "project.ssh.session", { + emitProjectEvent(resolvedProjectId, "project.ssh.session", { phase: "closed", sessionId }) @@ -788,27 +1273,42 @@ export const deleteTerminalSession = ( ) }) -export const listProjectTerminalSessions = (projectId: string): ReadonlyArray => - [...records.values()] - .filter((record) => record.projectId === projectId) - .map((record) => { - syncAttachedClientCount(record) - return record.session +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 = yield* _(findDurableSession(resolvedProjectId, 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 = ( @@ -817,11 +1317,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}` })) ) @@ -842,24 +1345,44 @@ export const lookupTerminalSessionById = ( sessionId: string ): Effect.Effect< { readonly projectDisplayName: string; readonly projectKey: string; readonly session: TerminalSession }, - ApiNotFoundError + ApiInternalError | 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 = yield* _(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.mapError((error) => + error instanceof ApiNotFoundError ? error : toApiInternalError(error) + ) + ) const handleCloseMessage = (record: TerminalRecord): void => { + runTerminalSessionPersistence( + record.projectId, + deleteDurableSession(record.projectId, record.session.id).pipe(Effect.asVoid) + ) cleanupRecord(record) } @@ -871,9 +1394,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 +1487,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,38 +1501,31 @@ 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() - } - }) }) } 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 - }) +): Effect.Effect => + getProjectTerminalSession(projectId, sessionId) diff --git a/packages/api/tests/terminal-sessions.test.ts b/packages/api/tests/terminal-sessions.test.ts index 9dfae2b6..8d73df37 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -1,26 +1,38 @@ import { Effect } from "effect" +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" import { + clearTerminalSessionRuntimeForTest, createTerminalSession, deleteTerminalSession, + getProjectTerminalSession, listProjectTerminalSessions, + lookupTerminalSessionById, + readProjectTerminalImage, + renderTmuxAttachCommand, 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 +46,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 +80,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,17 +108,20 @@ const projectDetails = { status: "running", statusLabel: "Up", targetDir: "/home/dev/app" -} satisfies ProjectDetails +}) -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")) { @@ -111,30 +130,70 @@ 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 => { + if (!existsSync(terminalSessionsStatePath())) { + return [] + } + 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", 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 }) }) it("creates a terminal session immediately when SSH is already ready", async () => { @@ -154,9 +213,93 @@ 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("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 = 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)) + + const first = await runTestEffect(createTerminalSession(projectId)) + const second = await runTestEffect(createTerminalSession(projectId)) + + clearTerminalSessionRuntimeForTest() + + const listed = await runTestEffect(listProjectTerminalSessions(projectId)) + expect(listed.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)) @@ -171,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"]) @@ -180,22 +323,40 @@ 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", - requestId: "request-1" + sessionId: requestId, + requestId }) + expect(readPersistedSessionIds()).toContain(requestId) }) }) + + 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/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 d9a85502..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,11 @@ 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")) - ) - loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(session)) + 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>() const closeTerminalSession = vi.fn<(sessionId: string) => void>() @@ -163,8 +163,8 @@ describe("web project actions", () => { at: "2026-04-21T10:00:01.000Z", payload: { phase: "created", - requestId: "pending-session-id", - sessionId: "session-1" + requestId: pendingSessionId, + sessionId: pendingSessionId }, projectId: "project-1", seq: 8, @@ -179,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", "session-1") + 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", @@ -197,17 +197,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/${pendingSessionId}`, 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/${pendingSessionId}`, 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/${pendingSessionId}/ws` }) expect(eventStreamCloseMock).toHaveBeenCalledTimes(1) expect(setMessage).toHaveBeenLastCalledWith( @@ -217,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]) @@ -236,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", () => 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',