diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index c4cfaf41..243c807f 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -161,7 +161,11 @@ export const UpProjectRequestSchema = Schema.Struct({ }) export const StartProjectTerminalSessionRequestSchema = Schema.Struct({ - requestId: Schema.String + requestId: Schema.UUID +}) + +export const ActiveProjectTerminalSessionRequestSchema = Schema.Struct({ + sessionId: Schema.String }) export const ProjectPortForwardRequestSchema = Schema.Struct({ diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index bdd21bc5..1a6a3a51 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -15,6 +15,7 @@ import { federationJsonLdResponseContentType, type ApplyProjectRequest } from ". import { AuthMenuRequestSchema, AuthTerminalSessionRequestSchema, + ActiveProjectTerminalSessionRequestSchema, ApplyProjectRequestSchema, ApplyAllRequestSchema, CodexAuthImportRequestSchema, @@ -138,7 +139,9 @@ import { getProjectTerminalSession, listProjectTerminalSessions, lookupTerminalSessionById, + readProjectTerminalSessions, readProjectTerminalImage, + setProjectActiveTerminalSession, startTerminalSession } from "./services/terminal-sessions.js" import { @@ -420,6 +423,8 @@ const readCodexAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(CodexA const readProjectAuthRequest = () => HttpServerRequest.schemaBodyJson(ProjectAuthRequestSchema) const readProjectPromptUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectPromptUpdateRequestSchema) const readProjectSkillUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectSkillUpdateRequestSchema) +const readActiveProjectTerminalSessionRequest = () => + HttpServerRequest.schemaBodyJson(ActiveProjectTerminalSessionRequestSchema) const skillScopeFromId = (scopeId: string): ProjectSkillScope | null => { switch (scopeId) { @@ -1438,7 +1443,7 @@ export const makeRouter = () => { projectKeyParams.pipe( Effect.flatMap(({ projectKey }) => getProjectItemByKey(projectKey).pipe( - Effect.map((project) => ({ sessions: listProjectTerminalSessions(project.projectDir) })) + Effect.flatMap((project) => readProjectTerminalSessions(project.projectDir)) ) ), Effect.flatMap((body) => jsonResponse(body, 200)), @@ -1484,7 +1489,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) ) @@ -1567,6 +1576,18 @@ export const makeRouter = () => { ) const withProjectTerminalStart = withProjectLifecycle.pipe( + HttpRouter.put( + "/projects/by-key/:projectKey/terminal-sessions/active", + Effect.gen(function*(_) { + const { projectKey } = yield* _(projectKeyParams) + const request = yield* _(readActiveProjectTerminalSessionRequest()) + const project = yield* _(getProjectItemByKey(projectKey)) + const session = yield* _(setProjectActiveTerminalSession(project.projectDir, request.sessionId)) + return yield* _(jsonResponse({ ok: true, session }, 200)) + }).pipe( + Effect.catchAll(errorResponse) + ) + ), HttpRouter.post( "/projects/by-key/:projectKey/terminal-sessions/start", projectKeyParams.pipe( 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..f8912a8b 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,48 @@ 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 lastActiveSessionId?: string | undefined + 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 +142,303 @@ 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), + lastActiveSessionId: Schema.optional(Schema.String), + 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 validActiveSessionId = (state: DurableTerminalSessionFile): string | null => { + const activeSessionId = state.lastActiveSessionId + return activeSessionId !== undefined && state.sessions.some((session) => session.id === activeSessionId) + ? activeSessionId + : null +} + +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, + options: { + readonly activate?: boolean + } = {} +): Effect.Effect => + Effect.gen(function*(_) { + const state = yield* _(readTerminalSessionFile(projectId)) + const sessions = state.sessions.filter((session) => session.id !== durable.id) + yield* _(writeTerminalSessionFile(projectId, { + ...(options.activate === true ? { lastActiveSessionId: durable.id } : { lastActiveSessionId: validActiveSessionId(state) ?? undefined }), + 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, { + lastActiveSessionId: validActiveSessionId({ ...state, sessions }) ?? undefined, + 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, { + lastActiveSessionId: state.lastActiveSessionId === sessionId + ? undefined + : validActiveSessionId({ ...state, sessions }) ?? undefined, + schemaVersion: 1, + sessions + })) + return true + }) + +const setActiveDurableSession = ( + projectId: string, + sessionId: string +): Effect.Effect => + Effect.gen(function*(_) { + const state = yield* _(readTerminalSessionFile(projectId)) + const durable = state.sessions.find((session) => session.id === sessionId) + if (durable === undefined) { + return yield* _(Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` }))) + } + yield* _(writeTerminalSessionFile(projectId, { + lastActiveSessionId: sessionId, + schemaVersion: 1, + sessions: state.sessions + })) + return durable + }) + +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 +448,7 @@ const updateSession = ( ...patch } records.set(record.session.id, record) + runTerminalSessionPersistence(record.projectId, patchDurableSession(record, patch)) } const attachedClientCount = (record: TerminalRecord): number => { @@ -131,6 +472,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 +664,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 +940,37 @@ 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`, + `tmux has-session -t ${shellQuote(args.tmuxName)} 2>/dev/null || tmux new-session -d -s ${ + shellQuote(args.tmuxName) + } -c ${shellQuote(args.targetDir)}`, + `tmux set-option -t ${shellQuote(args.tmuxName)} status off >/dev/null 2>&1 || true`, + `exec tmux attach-session -t ${shellQuote(args.tmuxName)}` + ].join("; ") + return `bash --noprofile --norc -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 +982,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 +999,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 +1011,163 @@ 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 + }), + { activate: true } + )) + 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 => + `bash --noprofile --norc -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.catchAll((error) => + error._tag === "CommandFailedError" && error.exitCode === 1 + ? Effect.succeed(false) + : Effect.fail(new ApiInternalError({ + cause: error, + message: `Failed to probe tmux availability for project: ${projectItem.projectDir}` + })) + ) + ) } +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 +1206,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 +1296,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 +1320,83 @@ export const deleteTerminalSession = ( ) }) -export const listProjectTerminalSessions = (projectId: string): ReadonlyArray => - [...records.values()] - .filter((record) => record.projectId === projectId) - .map((record) => { +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 readProjectTerminalSessions = ( + projectId: string +): Effect.Effect< + { readonly activeSessionId: string | null; readonly sessions: ReadonlyArray }, + ApiInternalError | ApiNotFoundError, + TerminalSessionStateRuntime +> => + Effect.gen(function*(_) { + const project = yield* _(getProject(projectId).pipe(Effect.mapError(toTerminalSessionProjectError))) + const resolvedProjectId = project.id + const state = yield* _(readTerminalSessionFile(resolvedProjectId)) + const sessions = 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) + }) + return { + activeSessionId: validActiveSessionId(state), + sessions + } + }) + +export const setProjectActiveTerminalSession = ( + projectId: string, + sessionId: string +): Effect.Effect => + Effect.gen(function*(_) { + const project = yield* _(getProject(projectId).pipe(Effect.mapError(toTerminalSessionProjectError))) + const resolvedProjectId = project.id + const durable = yield* _(setActiveDurableSession(resolvedProjectId, sessionId)) + const record = records.get(sessionId) + 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 +1405,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 +1433,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 +1482,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 +1575,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 +1589,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..e91077d0 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -1,26 +1,41 @@ 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 { CommandFailedError } from "@effect-template/lib/shell/errors" import type { ProjectDetails } from "../src/api/contracts.js" import { clearProjectEvents, listProjectEventsSince } from "../src/services/events.js" import { + clearTerminalSessionRuntimeForTest, createTerminalSession, deleteTerminalSession, + getProjectTerminalSession, listProjectTerminalSessions, + lookupTerminalSessionById, + readProjectTerminalSessions, + readProjectTerminalImage, + renderTmuxAttachCommand, + setProjectActiveTerminalSession, 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 +49,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 +83,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 +111,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) +const runTestEffect = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(NodeContext.layer))) const phaseFromEvent = (event: { readonly payload: unknown }): string | null => { if (typeof event.payload !== "object" || event.payload === null || !Object.hasOwn(event.payload, "phase")) { @@ -111,30 +133,87 @@ 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 isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const readPersistedSessionState = (): Record | null => { + if (!existsSync(terminalSessionsStatePath())) { + return null + } + const raw: unknown = JSON.parse(readFileSync(terminalSessionsStatePath(), "utf8")) + return isRecord(raw) ? raw : null +} + +const readPersistedSessionIds = (): ReadonlyArray => { + const raw = readPersistedSessionState() + if (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") +} + +const readPersistedActiveSessionId = (): string | null => { + const raw = readPersistedSessionState() + if (raw === null) { + return null + } + const value = Reflect.get(raw, "lastActiveSessionId") + return typeof value === "string" ? value : null +} + 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 +233,146 @@ 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("bash --noprofile --norc -lc") + expect(command).toContain("tmux missing") + expect(command).toContain("tmux new-session -d -s") + expect(command).toContain("tmux set-option") + expect(command).toContain("status off") + expect(command).toContain("tmux attach-session -t") + 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 CommandFailedError({ command: "ssh command -v tmux", exitCode: 1 })) + : 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("surfaces tmux probe transport failures instead of reporting missing tmux", async () => { + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + runCommandCaptureMock.mockImplementation((command: { readonly command: string }) => + command.command === "ssh" + ? Effect.fail(new CommandFailedError({ command: "ssh command -v tmux", exitCode: 255 })) + : Effect.fail(new Error("docker inspect skipped in tests")) + ) + + await expect(runTestEffect(createTerminalSession(projectId))).rejects.toThrow("Failed to probe tmux availability") + 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]) + expect(readPersistedActiveSessionId()).toBe(second.session.id) + }) + + it("persists and hydrates the active terminal session for a project", async () => { + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + + const first = await runTestEffect(createTerminalSession(projectId)) + const second = await runTestEffect(createTerminalSession(projectId)) + + await expect(runTestEffect(setProjectActiveTerminalSession(projectId, first.session.id))).resolves.toMatchObject({ + id: first.session.id, + projectId + }) + expect(readPersistedActiveSessionId()).toBe(first.session.id) + + clearTerminalSessionRuntimeForTest() + + await expect(runTestEffect(readProjectTerminalSessions(projectId))).resolves.toMatchObject({ + activeSessionId: first.session.id, + sessions: [ + expect.objectContaining({ id: first.session.id }), + expect.objectContaining({ id: second.session.id }) + ] + }) + }) + + it("rejects active terminal selection for a missing project session", async () => { + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) + + await runTestEffect(createTerminalSession(projectId)) + + await expect(runTestEffect(setProjectActiveTerminalSession(projectId, "missing-session"))).rejects.toThrow( + "Terminal session not found: missing-session" + ) + }) + + 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 +387,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 +396,41 @@ 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([]) + expect(readPersistedActiveSessionId()).toBeNull() + 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 693715fb..c5d3c7d8 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -15,6 +15,15 @@ import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" // COMPLEXITY: O(1)/O(1) const dockerGitBaseImage = "konard/box-js:2.1.1" +// CHANGE: include tmux in generated project images for durable terminal multiplexing. +// WHY: stable project SSH links attach to persisted tmux sessions instead of one-off shell processes. +// QUOTE(ТЗ): n/a +// REF: PR-309 +// SOURCE: n/a +// PURITY: CORE +// INVARIANT: generated base image contains the terminal multiplexer required by project SSH sessions. +// COMPLEXITY: O(1)/O(1) + /** * Renders the base image, root user, apt mirror, core packages, and sudo prelude. * @@ -56,7 +65,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..c7ac5ad6 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -91,12 +91,36 @@ const randomHex = (bytes: number): string => { return Date.now().toString(16).padStart(bytes * 2, "0").slice(0, bytes * 2) } +/** + * Converts a hex buffer into an RFC 4122-shaped UUID v4 string. + * + * @pure true + * @precondition hex contains hexadecimal characters and may be shorter or longer than 32 chars. + * @postcondition result length is 36, result[14] is "4", and result[19] has RFC variant bits 10xx. + * @invariant output preserves the padded/truncated 128-bit payload except version and variant bits. + * @complexity O(n) time for input normalization, O(1) output space. + */ +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/src/web/api-auth-schema.ts b/packages/app/src/web/api-auth-schema.ts new file mode 100644 index 00000000..a60bce0e --- /dev/null +++ b/packages/app/src/web/api-auth-schema.ts @@ -0,0 +1,63 @@ +import * as Schema from "@effect/schema/Schema" + +import { NullableString } from "./api-project-schema.js" + +export const GithubTokenStatusSchema = Schema.Struct({ + key: Schema.String, + label: Schema.String, + login: NullableString, + status: Schema.Union( + Schema.Literal("valid"), + Schema.Literal("invalid"), + Schema.Literal("unknown") + ) +}) + +export const GithubAuthStatusSchema = Schema.Struct({ + summary: Schema.String, + tokens: Schema.Array(GithubTokenStatusSchema) +}) + +export const GithubStatusResponseSchema = Schema.Struct({ + ok: Schema.optional(Schema.Boolean), + status: GithubAuthStatusSchema +}) + +export const AuthSnapshotSchema = Schema.Struct({ + claudeAuthEntries: Schema.Number, + claudeAuthPath: Schema.String, + geminiAuthEntries: Schema.Number, + geminiAuthPath: Schema.String, + githubTokenEntries: Schema.Number, + gitTokenEntries: Schema.Number, + gitUserEntries: Schema.Number, + globalEnvPath: Schema.String, + totalEntries: Schema.Number +}) + +export const AuthSnapshotResponseSchema = Schema.Struct({ + ok: Schema.optional(Schema.Boolean), + snapshot: AuthSnapshotSchema +}) + +export const ProjectAuthSnapshotSchema = Schema.Struct({ + activeClaudeLabel: NullableString, + activeGeminiLabel: NullableString, + activeGithubLabel: NullableString, + activeGitLabel: NullableString, + claudeAuthEntries: Schema.Number, + claudeAuthPath: Schema.String, + envGlobalPath: Schema.String, + envProjectPath: Schema.String, + geminiAuthEntries: Schema.Number, + geminiAuthPath: Schema.String, + githubTokenEntries: Schema.Number, + gitTokenEntries: Schema.Number, + projectDir: Schema.String, + projectName: Schema.String +}) + +export const ProjectAuthSnapshotResponseSchema = Schema.Struct({ + ok: Schema.optional(Schema.Boolean), + snapshot: ProjectAuthSnapshotSchema +}) diff --git a/packages/app/src/web/api-project-schema.ts b/packages/app/src/web/api-project-schema.ts new file mode 100644 index 00000000..277e428b --- /dev/null +++ b/packages/app/src/web/api-project-schema.ts @@ -0,0 +1,60 @@ +import * as Schema from "@effect/schema/Schema" + +export const NullableString = Schema.NullOr(Schema.String) + +export const ProjectStatusSchema = Schema.Union( + Schema.Literal("running"), + Schema.Literal("stopped"), + Schema.Literal("unknown") +) + +const projectSummaryFields = { + clonedOnHostname: Schema.optional(Schema.String), + containerName: Schema.optional(Schema.String), + displayName: Schema.String, + id: Schema.String, + projectKey: Schema.String, + repoRef: Schema.String, + repoUrl: Schema.String, + sshSessions: Schema.Number, + startedAtEpochMs: Schema.NullOr(Schema.Number), + startedAtIso: NullableString, + status: ProjectStatusSchema, + statusLabel: Schema.String +} + +export const ProjectSummarySchema = Schema.Struct(projectSummaryFields) + +export const ProjectDetailsSchema = Schema.Struct({ + ...projectSummaryFields, + authorizedKeysExists: Schema.Boolean, + authorizedKeysPath: Schema.String, + codexAuthPath: Schema.String, + codexHome: Schema.String, + containerName: Schema.String, + envGlobalPath: Schema.String, + envProjectPath: Schema.String, + gpu: Schema.Union(Schema.Literal("none"), Schema.Literal("all")), + projectDir: Schema.String, + serviceName: Schema.String, + sshCommand: Schema.String, + sshPort: Schema.Number, + sshUser: Schema.String, + targetDir: Schema.String +}) + +export const HealthResponseSchema = Schema.Struct({ + cwd: Schema.String, + ok: Schema.Boolean, + projectsRoot: Schema.String, + revision: NullableString +}) + +export const ProjectsResponseSchema = Schema.Struct({ + projects: Schema.Array(ProjectSummarySchema) +}) + +export const ProjectResponseSchema = Schema.Struct({ + ok: Schema.optional(Schema.Boolean), + project: ProjectDetailsSchema +}) diff --git a/packages/app/src/web/api-schema.ts b/packages/app/src/web/api-schema.ts index 7c598569..3f022676 100644 --- a/packages/app/src/web/api-schema.ts +++ b/packages/app/src/web/api-schema.ts @@ -1,8 +1,25 @@ import * as Schema from "@effect/schema/Schema" -import { TerminalSessionSchema } from "../shared/terminal-session-schema.js" +import { NullableString } from "./api-project-schema.js" +export { + AuthSnapshotResponseSchema, + AuthSnapshotSchema, + GithubAuthStatusSchema, + GithubStatusResponseSchema, + GithubTokenStatusSchema, + ProjectAuthSnapshotResponseSchema, + ProjectAuthSnapshotSchema +} from "./api-auth-schema.js" export { ApiEventSchema, ProjectEventsPollResponseSchema } from "./api-events-schema.js" +export { + HealthResponseSchema, + ProjectDetailsSchema, + ProjectResponseSchema, + ProjectsResponseSchema, + ProjectStatusSchema, + ProjectSummarySchema +} from "./api-project-schema.js" export { ProjectPromptFileSchema, ProjectPromptKindSchema, @@ -25,65 +42,14 @@ export { ContainerTaskSnapshotResponseSchema, ContainerTaskSnapshotSchema } from "./api-task-schema.js" - -const NullableString = Schema.NullOr(Schema.String) - -export const ProjectStatusSchema = Schema.Union( - Schema.Literal("running"), - Schema.Literal("stopped"), - Schema.Literal("unknown") -) - -const projectSummaryFields = { - id: Schema.String, - projectKey: Schema.String, - displayName: Schema.String, - repoUrl: Schema.String, - repoRef: Schema.String, - containerName: Schema.optional(Schema.String), - status: ProjectStatusSchema, - statusLabel: Schema.String, - sshSessions: Schema.Number, - startedAtIso: NullableString, - startedAtEpochMs: Schema.NullOr(Schema.Number), - clonedOnHostname: Schema.optional(Schema.String) -} - -export const ProjectSummarySchema = Schema.Struct(projectSummaryFields) - -export const ProjectDetailsSchema = Schema.Struct({ - ...projectSummaryFields, - containerName: Schema.String, - serviceName: Schema.String, - sshUser: Schema.String, - sshPort: Schema.Number, - gpu: Schema.Union(Schema.Literal("none"), Schema.Literal("all")), - targetDir: Schema.String, - projectDir: Schema.String, - sshCommand: Schema.String, - authorizedKeysPath: Schema.String, - authorizedKeysExists: Schema.Boolean, - envGlobalPath: Schema.String, - envProjectPath: Schema.String, - codexAuthPath: Schema.String, - codexHome: Schema.String -}) - -export const HealthResponseSchema = Schema.Struct({ - ok: Schema.Boolean, - revision: NullableString, - cwd: Schema.String, - projectsRoot: Schema.String -}) - -export const ProjectsResponseSchema = Schema.Struct({ - projects: Schema.Array(ProjectSummarySchema) -}) - -export const ProjectResponseSchema = Schema.Struct({ - ok: Schema.optional(Schema.Boolean), - project: ProjectDetailsSchema -}) +export { + AuthTerminalSessionResponseSchema, + ProjectTerminalSessionResponseSchema, + ProjectTerminalSessionsResponseSchema, + TerminalServerMessageSchema, + TerminalSessionLookupResponseSchema, + TerminalSessionResponseSchema +} from "./api-terminal-schema.js" export const CreateProjectAcceptedResponseSchema = Schema.Struct({ accepted: Schema.Literal(true), @@ -223,92 +189,6 @@ export const OutputResponseSchema = Schema.Struct({ output: Schema.String }) -export const GithubTokenStatusSchema = Schema.Struct({ - key: Schema.String, - label: Schema.String, - status: Schema.Union( - Schema.Literal("valid"), - Schema.Literal("invalid"), - Schema.Literal("unknown") - ), - login: NullableString -}) - -export const GithubAuthStatusSchema = Schema.Struct({ - summary: Schema.String, - tokens: Schema.Array(GithubTokenStatusSchema) -}) - -export const GithubStatusResponseSchema = Schema.Struct({ - ok: Schema.optional(Schema.Boolean), - status: GithubAuthStatusSchema -}) - -export const AuthSnapshotSchema = Schema.Struct({ - globalEnvPath: Schema.String, - claudeAuthPath: Schema.String, - geminiAuthPath: Schema.String, - totalEntries: Schema.Number, - githubTokenEntries: Schema.Number, - gitTokenEntries: Schema.Number, - gitUserEntries: Schema.Number, - claudeAuthEntries: Schema.Number, - geminiAuthEntries: Schema.Number -}) - -export const AuthSnapshotResponseSchema = Schema.Struct({ - ok: Schema.optional(Schema.Boolean), - snapshot: AuthSnapshotSchema -}) - -export const ProjectAuthSnapshotSchema = Schema.Struct({ - projectDir: Schema.String, - projectName: Schema.String, - envGlobalPath: Schema.String, - envProjectPath: Schema.String, - claudeAuthPath: Schema.String, - geminiAuthPath: Schema.String, - githubTokenEntries: Schema.Number, - gitTokenEntries: Schema.Number, - claudeAuthEntries: Schema.Number, - geminiAuthEntries: Schema.Number, - activeGithubLabel: NullableString, - activeGitLabel: NullableString, - activeClaudeLabel: NullableString, - activeGeminiLabel: NullableString -}) - -export const ProjectAuthSnapshotResponseSchema = Schema.Struct({ - ok: Schema.optional(Schema.Boolean), - snapshot: ProjectAuthSnapshotSchema -}) - -export const TerminalSessionResponseSchema = Schema.Struct({ - ok: Schema.optional(Schema.Boolean), - project: ProjectDetailsSchema, - session: TerminalSessionSchema -}) - -export const ProjectTerminalSessionsResponseSchema = Schema.Struct({ - sessions: Schema.Array(TerminalSessionSchema) -}) - -export const ProjectTerminalSessionResponseSchema = Schema.Struct({ - session: TerminalSessionSchema -}) - -export const TerminalSessionLookupResponseSchema = Schema.Struct({ - projectDisplayName: Schema.String, - projectKey: Schema.String, - session: TerminalSessionSchema -}) - -export const AuthTerminalSessionResponseSchema = Schema.Struct({ - ok: Schema.optional(Schema.Boolean), - session: TerminalSessionSchema -}) - -export { TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" export type { ApiEvent, AuthMenuFlow, diff --git a/packages/app/src/web/api-terminal-schema.ts b/packages/app/src/web/api-terminal-schema.ts new file mode 100644 index 00000000..1dbd085e --- /dev/null +++ b/packages/app/src/web/api-terminal-schema.ts @@ -0,0 +1,32 @@ +import * as Schema from "@effect/schema/Schema" + +import { TerminalSessionSchema } from "../shared/terminal-session-schema.js" +import { ProjectDetailsSchema } from "./api-project-schema.js" + +export const TerminalSessionResponseSchema = Schema.Struct({ + ok: Schema.optional(Schema.Boolean), + project: ProjectDetailsSchema, + session: TerminalSessionSchema +}) + +export const ProjectTerminalSessionsResponseSchema = Schema.Struct({ + activeSessionId: Schema.NullOr(Schema.String), + sessions: Schema.Array(TerminalSessionSchema) +}) + +export const ProjectTerminalSessionResponseSchema = Schema.Struct({ + session: TerminalSessionSchema +}) + +export const TerminalSessionLookupResponseSchema = Schema.Struct({ + projectDisplayName: Schema.String, + projectKey: Schema.String, + session: TerminalSessionSchema +}) + +export const AuthTerminalSessionResponseSchema = Schema.Struct({ + ok: Schema.optional(Schema.Boolean), + session: TerminalSessionSchema +}) + +export { TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index 80aff3a5..6bb0302d 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -215,6 +215,26 @@ export const loadProjectTerminalSessions = (projectKey: string) => Effect.map((response) => response.sessions) ) +export const loadProjectTerminalWorkspace = (projectKey: string) => + requestJson( + "GET", + `/projects/by-key/${encodeURIComponent(projectKey)}/terminal-sessions`, + ProjectTerminalSessionsResponseSchema + ) + +export const setProjectActiveTerminalSession = ( + projectKey: string, + sessionId: string +) => + requestJson( + "PUT", + `/projects/by-key/${encodeURIComponent(projectKey)}/terminal-sessions/active`, + ProjectTerminalSessionResponseSchema, + { sessionId } + ).pipe( + Effect.map((response) => response.session) + ) + export const loadProjectTerminalSession = ( projectKey: string, sessionId: string diff --git a/packages/app/src/web/app-ready-ssh-link-hook.ts b/packages/app/src/web/app-ready-ssh-link-hook.ts index 08344a8b..7ad316b6 100644 --- a/packages/app/src/web/app-ready-ssh-link-hook.ts +++ b/packages/app/src/web/app-ready-ssh-link-hook.ts @@ -1,13 +1,20 @@ import { Effect } from "effect" import { useEffect, useRef } from "react" +import { connectProjectById } from "./actions-projects.js" import type { BrowserActionContext } from "./actions-shared.js" -import { loadTerminalSessionById } from "./api.js" +import type { TerminalSession } from "./api-types.js" +import { loadProjectTerminalWorkspace, loadTerminalSessionById } from "./api.js" import type { DashboardData } from "./api.js" import { browserMenuIndex } from "./menu.js" import { projectPickerScreen } from "./screen.js" -import { terminalSessionId } from "./terminal-state.js" -import { type ActiveTerminalSession, buildProjectActiveTerminalSession, terminalSessionRoutePath } from "./terminal.js" +import { terminalSessionId, terminalSessionsForProject } from "./terminal-state.js" +import { + type ActiveTerminalSession, + buildProjectActiveTerminalSession, + projectSshRoutePath, + terminalSessionRoutePath +} from "./terminal.js" type SshLinkArgs = { readonly actionContext: BrowserActionContext @@ -25,8 +32,8 @@ type ConnectTimerRef = { current: ReturnType | nul type SshTokenRef = { current: string | null } type DashboardProject = DashboardData["projects"][number] type SessionLookupResult = { readonly sessionId: string } -type ProjectLookupResult = { readonly token: string } -type SshLinkRequest = +type ProjectLookupResult = { readonly terminalId?: string | undefined; readonly token: string } +export type SshLinkRequest = | ({ readonly kind: "project" } & ProjectLookupResult) | ({ readonly kind: "session" } & SessionLookupResult) type SshLinkEffectArgs = Omit & { @@ -64,19 +71,23 @@ const readSshPathRequest = (url: URL): SshLinkRequest | null => { return readSessionPathRequest(tail) } const decoded = decodePathTail(tail) - return decoded.length === 0 ? null : { kind: "project", token: decoded } + const terminalId = url.searchParams.get("terminal")?.trim() || url.searchParams.get("t")?.trim() || undefined + return decoded.length === 0 ? null : { kind: "project", terminalId, token: decoded } } const readSshQueryRequest = (url: URL): SshLinkRequest | null => { const queryToken = url.searchParams.get("ssh")?.trim() ?? "" - return queryToken.length === 0 ? null : { kind: "project", token: queryToken } + const terminalId = url.searchParams.get("terminal")?.trim() || url.searchParams.get("t")?.trim() || undefined + return queryToken.length === 0 ? null : { kind: "project", terminalId, token: queryToken } } -const readSshLinkRequest = (): SshLinkRequest | null => { - const url = new URL(globalThis.location.href) +export const readSshLinkRequestFromHref = (href: string): SshLinkRequest | null => { + const url = new URL(href, "http://localhost") return readSshPathRequest(url) ?? readSshQueryRequest(url) } +const readSshLinkRequest = (): SshLinkRequest | null => readSshLinkRequestFromHref(globalThis.location.href) + const findProjectBySshToken = ( projects: DashboardData["projects"], token: string @@ -94,8 +105,100 @@ const findLocalTerminalSession = ( sessionId: string ): ActiveTerminalSession | undefined => sessions.find((session) => terminalSessionId(session) === sessionId) +const newestTerminalSession = ( + sessions: ReadonlyArray +): A | null => { + const reusableSessions = sessions.filter((session) => session.status !== "failed") + const candidates = reusableSessions.length === 0 ? sessions : reusableSessions + return candidates.toSorted((left, right) => right.createdAt.localeCompare(left.createdAt))[0] ?? null +} + +const selectByExactIdOrUniquePrefix = ( + sessions: ReadonlyArray, + selector: string +): A | null => { + const exact = sessions.find((session) => session.id === selector) + if (exact !== undefined) { + return exact + } + const matches = sessions.filter((session) => session.id.startsWith(selector)) + return matches.length === 1 ? matches[0] ?? null : null +} + +const selectLocalProjectTerminal = ( + sessions: ReadonlyArray, + activeTerminalSessionId: string | null, + projectId: string, + terminalId: string | undefined +): ActiveTerminalSession | null => { + const projectSessions = terminalSessionsForProject(sessions, projectId) + if (terminalId !== undefined) { + return selectByExactIdOrUniquePrefix( + projectSessions.map((session) => ({ ...session, id: terminalSessionId(session) })), + terminalId + ) + } + const active = projectSessions.find((session) => terminalSessionId(session) === activeTerminalSessionId) + if (active !== undefined) { + return active + } + const newest = newestTerminalSession(projectSessions.map((session) => session.session)) + return newest === null + ? null + : projectSessions.find((session) => terminalSessionId(session) === newest.id) ?? null +} + +export const selectWorkspaceTerminalSession = ( + sessions: ReadonlyArray, + activeSessionId: string | null, + terminalId?: string +): TerminalSession | null => { + if (terminalId !== undefined) { + return selectByExactIdOrUniquePrefix(sessions, terminalId) + } + if (activeSessionId !== null) { + const active = sessions.find((session) => session.id === activeSessionId) + if (active !== undefined) { + return active + } + } + return newestTerminalSession(sessions) +} + +const buildProjectTerminalSession = ( + args: SshLinkEffectArgs, + project: DashboardProject, + session: TerminalSession +): ActiveTerminalSession => + buildProjectActiveTerminalSession({ + onExit: args.actionContext.reloadDashboard, + onReady: args.actionContext.reloadDashboard, + projectDisplayName: project.displayName, + projectId: project.id, + projectKey: project.projectKey, + session + }) + +const attachProjectWorkspaceSessions = ( + args: SshLinkEffectArgs, + project: DashboardProject, + sessions: ReadonlyArray, + selectedSession: TerminalSession +): void => { + const orderedSessions = sessions.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) + for (const session of orderedSessions) { + if (session.id !== selectedSession.id) { + args.addTerminalSession(buildProjectTerminalSession(args, project, session)) + } + } + args.addTerminalSession(buildProjectTerminalSession(args, project, selectedSession)) + args.selectTerminalSession(selectedSession.id) +} + const sshLinkRequestKey = (request: SshLinkRequest): string => - request.kind === "session" ? `session:${request.sessionId}` : `project:${request.token}` + request.kind === "session" + ? `session:${request.sessionId}` + : `project:${request.token}:${request.terminalId ?? ""}` export const resolveMissingSshSessionFallbackPath = ( href: string, @@ -130,6 +233,11 @@ const scheduleTerminalSessionAttach = (args: SshLinkEffectArgs, sessionId: strin args.actionContext.setMessage(error) }, onSuccess: ({ projectDisplayName, projectKey, session }) => { + globalThis.history.replaceState( + globalThis.history.state, + "", + projectSshRoutePath(projectKey, session.id) + ) showProjectTerminalScreen(args.actionContext, session.projectId) args.addTerminalSession(buildProjectActiveTerminalSession({ onExit: args.actionContext.reloadDashboard, @@ -147,15 +255,71 @@ const scheduleTerminalSessionAttach = (args: SshLinkEffectArgs, sessionId: strin }, 0) } -const handleProjectSshLink = (args: SshLinkEffectArgs, request: { readonly token: string }): void => { +const attachExistingProjectLink = ( + args: SshLinkEffectArgs, + project: DashboardProject, + request: { readonly terminalId?: string | undefined } +): boolean => { + const localSession = selectLocalProjectTerminal( + args.terminalSessions, + args.activeTerminalSessionId, + project.id, + request.terminalId + ) + if (localSession === null) { + return false + } + clearConnectTimer(args.connectTimerRef) + showProjectTerminalScreen(args.actionContext, project.id) + args.selectTerminalSession(terminalSessionId(localSession)) + args.actionContext.setMessage(`Opened SSH terminal for ${project.displayName}.`) + return true +} + +const scheduleProjectTerminalAttach = ( + args: SshLinkEffectArgs, + project: DashboardProject, + request: { readonly terminalId?: string | undefined } +): void => { + clearConnectTimer(args.connectTimerRef) + showProjectTerminalScreen(args.actionContext, project.id) + args.connectTimerRef.current = globalThis.setTimeout(() => { + args.connectTimerRef.current = null + void Effect.runPromise( + loadProjectTerminalWorkspace(project.projectKey).pipe( + Effect.match({ + onFailure: (error) => { + args.actionContext.setMessage(error) + }, + onSuccess: ({ activeSessionId, sessions }) => { + const selectedSession = selectWorkspaceTerminalSession(sessions, activeSessionId, request.terminalId) + if (selectedSession === null) { + if (request.terminalId !== undefined) { + args.actionContext.setMessage(`SSH terminal link was not found: ${request.terminalId}.`) + return + } + connectProjectById(project.id, args.actionContext, project.projectKey) + return + } + attachProjectWorkspaceSessions(args, project, sessions, selectedSession) + args.actionContext.setMessage(`Attached SSH terminal for ${project.displayName}.`) + } + }) + ) + ) + }, 0) +} + +const handleProjectSshLink = (args: SshLinkEffectArgs, request: ProjectLookupResult): void => { const project = findProjectBySshToken(args.projects, request.token) if (project === undefined) { args.actionContext.setMessage(`Project link was not found: ${request.token}.`) return } - clearConnectTimer(args.connectTimerRef) - showProjectTerminalScreen(args.actionContext, project.id) - args.deactivateTerminalWorkspace() + if (attachExistingProjectLink(args, project, request)) { + return + } + scheduleProjectTerminalAttach(args, project, request) } const handleSessionSshLink = (args: SshLinkEffectArgs, request: { readonly sessionId: string }): void => { diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx index e10d8035..46fef009 100644 --- a/packages/app/src/web/app-ready-terminal-screen.tsx +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -10,7 +10,7 @@ import { TerminalPanel } from "./panel-terminal.js" import { type BrowserScreen, projectPickerScreen } from "./screen.js" import { shouldShowTerminalTabs } from "./terminal-mobile-layout.js" import { terminalSessionId } from "./terminal-state.js" -import { type ActiveTerminalSession, isPendingActiveTerminalSession } from "./terminal.js" +import { type ActiveTerminalSession, isPendingActiveTerminalSession, terminalTitleById } from "./terminal.js" type TerminalWorkspaceView = "terminal" | "tasks" @@ -136,7 +136,16 @@ const pendingTerminalBodyStyle: CSSProperties = { whiteSpace: "pre-wrap" } -const terminalTabLabel = (session: ActiveTerminalSession): string => session.browserProjectName ?? session.header +const fallbackTerminalTabLabel = (session: ActiveTerminalSession): string => + session.browserProjectName ?? session.header + +const terminalTabLabel = ( + session: ActiveTerminalSession, + labels: ReadonlyMap +): string => + session.browserProjectId === undefined + ? fallbackTerminalTabLabel(session) + : labels.get(terminalSessionId(session)) ?? fallbackTerminalTabLabel(session) const projectSkillerAction = ( projectKey: string | undefined, @@ -222,12 +231,14 @@ const TerminalTab = ( active, compactMobile, onSelect, - session + session, + terminalLabels }: { readonly active: boolean readonly compactMobile: boolean readonly onSelect: () => void readonly session: ActiveTerminalSession + readonly terminalLabels: ReadonlyMap } ): JSX.Element => ( - {terminalTabLabel(session)} + {terminalTabLabel(session, terminalLabels)} ) @@ -258,6 +269,7 @@ const TerminalTabs = ( readonly compactMobile: boolean } ): JSX.Element => { + const terminalLabels = terminalTitleById(terminalSessions.map((session) => session.session)) if (compactMobile) { return (
) })} @@ -323,6 +336,7 @@ const TerminalTabs = ( onSelectTerminal(sessionId) }} session={session} + terminalLabels={terminalLabels} /> ) })} diff --git a/packages/app/src/web/app-ready-terminal-state-hook.ts b/packages/app/src/web/app-ready-terminal-state-hook.ts index e9af1959..38997f72 100644 --- a/packages/app/src/web/app-ready-terminal-state-hook.ts +++ b/packages/app/src/web/app-ready-terminal-state-hook.ts @@ -1,15 +1,12 @@ -import * as ParseResult from "@effect/schema/ParseResult" -import * as Schema from "@effect/schema/Schema" import { Effect, Either } from "effect" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" -import { JsonValueSchema } from "../shared/json-schema.js" -import type { JsonObject, JsonValue } from "../shared/json-schema.js" +import { setProjectActiveTerminalSession } from "./api.js" +import { readStoredTerminalWorkspace, writeStoredTerminalWorkspace } from "./app-ready-terminal-storage.js" import { activeTerminalSession, addTerminalSessionState, deactivateTerminalWorkspaceState, - emptyTerminalWorkspaceState, removeTerminalSessionState, selectTerminalSessionState, type TerminalWorkspaceState @@ -26,269 +23,157 @@ export type TerminalWorkspaceReadyState = { readonly terminalSessions: ReadonlyArray } -type StoredActiveTerminalSession = - & Omit - & { - readonly pendingConnectionMessage?: string | undefined - readonly pendingConnectionPhase?: NonNullable["phase"] | undefined - } - -type StoredTerminalWorkspaceState = { - readonly activeTerminalSessionId: string | null - readonly savedAt: number - readonly terminalSessions: ReadonlyArray +type ProjectActiveTerminalSelection = { + readonly projectKey: string + readonly sessionId: string } -const terminalWorkspaceStorageKey = "docker-git.terminal-workspace.v1" -const JsonValueFromStringSchema: Schema.Schema = Schema.parseJson(JsonValueSchema) - -const isRecord = (value: JsonValue | undefined): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value) - -const readString = (value: JsonValue | undefined): string | null => typeof value === "string" ? value : null - -const readOptionalString = (value: JsonValue | undefined): string | undefined => readString(value) ?? undefined - -const readStoredActiveSessionId = (value: JsonValue | undefined): string | null | undefined => - value === null ? null : readString(value) ?? undefined - -const readJsonArray = (value: JsonValue | undefined): ReadonlyArray | null => - Array.isArray(value) ? value : null - -const isStoredTerminalStatus = ( - value: string | null -): value is ActiveTerminalSession["session"]["status"] => - value === "ready" || value === "attached" || value === "exited" || value === "failed" - -type StoredTerminalSessionFields = { - readonly createdAt: string | null - readonly id: string | null - readonly projectId: string | null - readonly sshCommand: string | null - readonly status: string | null +type ProjectActiveTerminalPersistenceRequest = ProjectActiveTerminalSelection & { + readonly selectionKey: string } -const readStoredTerminalSessionFields = (value: JsonObject): StoredTerminalSessionFields => ({ - createdAt: readString(value["createdAt"]), - id: readString(value["id"]), - projectId: readString(value["projectId"]), - sshCommand: readString(value["sshCommand"]), - status: readString(value["status"]) -}) - -const hasStoredTerminalSessionFields = ( - fields: StoredTerminalSessionFields -): fields is StoredTerminalSessionFields & { - readonly createdAt: string - readonly id: string - readonly projectId: string - readonly sshCommand: string - readonly status: ActiveTerminalSession["session"]["status"] -} => - [fields.createdAt, fields.id, fields.projectId, fields.sshCommand].every((field) => field !== null) && - isStoredTerminalStatus(fields.status) - -const decodeStoredTerminalSessionCore = ( - value: JsonValue | undefined -): ActiveTerminalSession["session"] | null => { - if (!isRecord(value)) { - return null - } - const fields = readStoredTerminalSessionFields(value) - if (!hasStoredTerminalSessionFields(fields)) { - return null - } - return { - attachedClients: typeof value["attachedClients"] === "number" ? value["attachedClients"] : undefined, - closedAt: readOptionalString(value["closedAt"]), - createdAt: fields.createdAt, - exitCode: typeof value["exitCode"] === "number" ? value["exitCode"] : undefined, - id: fields.id, - projectId: fields.projectId, - signal: typeof value["signal"] === "number" ? value["signal"] : undefined, - sshCommand: fields.sshCommand, - startedAt: readOptionalString(value["startedAt"]), - status: fields.status - } +type ProjectActiveTerminalPersistenceState = { + readonly inFlightRequest: ProjectActiveTerminalPersistenceRequest | null + readonly latestRequest: ProjectActiveTerminalPersistenceRequest | null + readonly persistedSelectionKey: string | null } -type StoredActiveTerminalSessionFields = { - readonly closePath: string | null - readonly exitMessage: string | null - readonly header: string | null - readonly pendingConnectionMessage: string | null - readonly pendingConnectionPhase: string | null - readonly pendingDeleteMessage: string | null - readonly readyMessage: string | null - readonly sessionPath: string | null - readonly session: ActiveTerminalSession["session"] | null - readonly subtitle: string | null - readonly websocketPath: string | null +type ProjectActiveTerminalPersistenceRef = { + current: ProjectActiveTerminalPersistenceState } -const readStoredActiveTerminalSessionFields = (value: JsonObject): StoredActiveTerminalSessionFields => ({ - closePath: readString(value["closePath"]), - exitMessage: readString(value["exitMessage"]), - header: readString(value["header"]), - pendingConnectionMessage: readString(value["pendingConnectionMessage"]), - pendingConnectionPhase: readString(value["pendingConnectionPhase"]), - pendingDeleteMessage: readString(value["pendingDeleteMessage"]), - readyMessage: readString(value["readyMessage"]), - sessionPath: readString(value["sessionPath"]), - session: decodeStoredTerminalSessionCore(value["session"]), - subtitle: readString(value["subtitle"]), - websocketPath: readString(value["websocketPath"]) +type SetProjectActiveTerminalSessionEffect = ReturnType +type ProjectActiveTerminalPersistResult = Either.Either< + Effect.Effect.Success, + Effect.Effect.Error +> + +/** + * Returns the project-bound active terminal selection when it is ready to persist. + * + * @pure true + * @effect none; CORE selector reads immutable session state only. + * @invariant pending or non-project sessions never produce a persistence request. + * @precondition active is either null or an ActiveTerminalSession snapshot. + * @postcondition result is null or contains the exact browserProjectKey and session.id from active. + * @complexity O(1) time / O(1) space. + */ +export const projectActiveTerminalSelection = ( + active: ActiveTerminalSession | null +): ProjectActiveTerminalSelection | null => + active?.browserProjectKey === undefined || active.pendingConnection !== undefined + ? null + : { projectKey: active.browserProjectKey, sessionId: active.session.id } + +const projectActiveTerminalSelectionKey = ( + selection: ProjectActiveTerminalSelection +): string => `${selection.projectKey}\0${selection.sessionId}` + +const emptyProjectActiveTerminalPersistenceState = (): ProjectActiveTerminalPersistenceState => ({ + inFlightRequest: null, + latestRequest: null, + persistedSelectionKey: null }) -const isStoredPendingConnectionPhase = ( - value: string | null -): value is NonNullable["phase"] => - value === "connecting" || value === "error" - -const hasStoredActiveTerminalSessionFields = ( - fields: StoredActiveTerminalSessionFields -): fields is StoredActiveTerminalSessionFields & { - readonly closePath: string - readonly exitMessage: string - readonly header: string - readonly pendingConnectionMessage: string | null - readonly pendingConnectionPhase: NonNullable["phase"] | null - readonly pendingDeleteMessage: string - readonly readyMessage: string - readonly sessionPath: string | null - readonly session: ActiveTerminalSession["session"] - readonly subtitle: string - readonly websocketPath: string -} => - [ - fields.closePath, - fields.exitMessage, - fields.header, - fields.pendingDeleteMessage, - fields.readyMessage, - fields.session, - fields.subtitle, - fields.websocketPath - ].every((field) => field !== null) && - (fields.pendingConnectionPhase === null || isStoredPendingConnectionPhase(fields.pendingConnectionPhase)) +/** + * Creates a mutable React-compatible ref for active terminal persistence state. + * + * @pure true + * @effect none; factory allocates only local in-memory state. + * @invariant new refs start with no in-flight, latest, or persisted selection. + * @precondition no external state is required. + * @postcondition returned ref.current equals the empty persistence state. + * @complexity O(1) time / O(1) space. + */ +export const createProjectActiveTerminalPersistenceRef = (): ProjectActiveTerminalPersistenceRef => ({ + current: emptyProjectActiveTerminalPersistenceState() +}) -const decodeStoredActiveTerminalSession = (value: JsonValue | undefined): ActiveTerminalSession | null => { - if (!isRecord(value)) { - return null - } - const fields = readStoredActiveTerminalSessionFields(value) - if (!hasStoredActiveTerminalSessionFields(fields)) { - return null - } - return { - browserProjectId: readOptionalString(value["browserProjectId"]), - browserProjectKey: readOptionalString(value["browserProjectKey"]), - browserProjectName: readOptionalString(value["browserProjectName"]), - closePath: fields.closePath, - exitMessage: fields.exitMessage, - header: fields.header, - ...(fields.pendingConnectionMessage !== null && fields.pendingConnectionPhase !== null - ? { - pendingConnection: { - message: fields.pendingConnectionMessage, - phase: fields.pendingConnectionPhase - } - } - : {}), - pendingDeleteMessage: fields.pendingDeleteMessage, - readyMessage: fields.readyMessage, - sessionPath: fields.sessionPath ?? undefined, - session: fields.session, - subtitle: fields.subtitle, - websocketPath: fields.websocketPath - } -} +const projectActiveTerminalPersistenceRequest = ( + selection: ProjectActiveTerminalSelection +): ProjectActiveTerminalPersistenceRequest => ({ + ...selection, + selectionKey: projectActiveTerminalSelectionKey(selection) +}) -const decodeStoredTerminalWorkspace = (value: JsonValue | undefined): TerminalWorkspaceState | null => { - if (!isRecord(value)) { - return null +const completeProjectActiveTerminalPersistRequest = ( + persistedSelectionRef: ProjectActiveTerminalPersistenceRef, + request: ProjectActiveTerminalPersistenceRequest, + result: ProjectActiveTerminalPersistResult +): Effect.Effect => + Effect.sync(() => { + if (persistedSelectionRef.current.inFlightRequest?.selectionKey !== request.selectionKey) { + return + } + const persistedSelectionKey = Either.match(result, { + onLeft: () => persistedSelectionRef.current.persistedSelectionKey, + onRight: () => request.selectionKey + }) + persistedSelectionRef.current = { + ...persistedSelectionRef.current, + inFlightRequest: null, + persistedSelectionKey + } + const latestRequest = persistedSelectionRef.current.latestRequest + if (latestRequest !== null && latestRequest.selectionKey !== request.selectionKey) { + runProjectActiveTerminalPersistRequest(persistedSelectionRef) + } + }) + +const runProjectActiveTerminalPersistRequest = ( + persistedSelectionRef: ProjectActiveTerminalPersistenceRef +): void => { + const { inFlightRequest, latestRequest, persistedSelectionKey } = persistedSelectionRef.current + if ( + inFlightRequest !== null || + latestRequest === null || + latestRequest.selectionKey === persistedSelectionKey + ) { + return } - const savedAt = typeof value["savedAt"] === "number" ? value["savedAt"] : null - const activeTerminalSessionId = readStoredActiveSessionId(value["activeTerminalSessionId"]) - const rawSessions = readJsonArray(value["terminalSessions"]) - if (savedAt === null || activeTerminalSessionId === undefined || rawSessions === null) { - return null + persistedSelectionRef.current = { + ...persistedSelectionRef.current, + inFlightRequest: latestRequest } - const terminalSessions = rawSessions - .map((session) => decodeStoredActiveTerminalSession(session)) - .filter((session): session is ActiveTerminalSession => session !== null) - return terminalSessions.length === 0 - ? emptyTerminalWorkspaceState - : { - activeTerminalSessionId, - terminalSessions - } -} - -const readStoredTerminalWorkspace = (): TerminalWorkspaceState => { - const read = Effect.try({ - try: () => globalThis.sessionStorage.getItem(terminalWorkspaceStorageKey), - catch: () => null - }).pipe( - Effect.either, - Effect.map((result) => - Either.match(result, { - onLeft: () => emptyTerminalWorkspaceState, - onRight: (raw) => { - if (raw === null) { - return emptyTerminalWorkspaceState - } - const parsed = Either.getOrNull(ParseResult.decodeUnknownEither(JsonValueFromStringSchema)(raw)) - const decoded = decodeStoredTerminalWorkspace(parsed ?? undefined) - return decoded === null ? emptyTerminalWorkspaceState : deactivateTerminalWorkspaceState(decoded) - } - }) + void Effect.runPromise( + setProjectActiveTerminalSession(latestRequest.projectKey, latestRequest.sessionId).pipe( + Effect.either, + Effect.flatMap((result) => + completeProjectActiveTerminalPersistRequest(persistedSelectionRef, latestRequest, result) + ) ) ) - return Effect.runSync(read) } -const toStoredActiveTerminalSession = (session: ActiveTerminalSession): StoredActiveTerminalSession => ({ - browserProjectId: session.browserProjectId, - browserProjectKey: session.browserProjectKey, - browserProjectName: session.browserProjectName, - closePath: session.closePath, - exitMessage: session.exitMessage, - header: session.header, - pendingConnectionMessage: session.pendingConnection?.message, - pendingConnectionPhase: session.pendingConnection?.phase, - pendingDeleteMessage: session.pendingDeleteMessage, - readyMessage: session.readyMessage, - sessionPath: session.sessionPath, - session: session.session, - subtitle: session.subtitle, - websocketPath: session.websocketPath -}) - -const writeStoredTerminalWorkspace = (state: TerminalWorkspaceState): void => { - const write = Effect.try({ - try: () => { - if (state.terminalSessions.length === 0) { - globalThis.sessionStorage.removeItem(terminalWorkspaceStorageKey) - return - } - const payload: StoredTerminalWorkspaceState = { - activeTerminalSessionId: state.activeTerminalSessionId, - savedAt: Date.now(), - terminalSessions: state.terminalSessions.map((session) => toStoredActiveTerminalSession(session)) - } - globalThis.sessionStorage.setItem(terminalWorkspaceStorageKey, JSON.stringify(payload)) - }, - catch: () => null - }).pipe( - Effect.either, - Effect.asVoid - ) - Effect.runSync(write) +/** + * Queues and runs latest-wins persistence for the active project terminal selection. + * + * @pure false + * @effect setProjectActiveTerminalSession via Effect.runPromise. + * @invariant at most one backend persistence request is in flight per ref. + * @precondition persistedSelectionRef was created by createProjectActiveTerminalPersistenceRef. + * @postcondition ready project selections become latestRequest and are persisted without older completions winning. + * @complexity O(1) time / O(1) space per invocation. + */ +export const persistProjectActiveTerminalSelection = ( + state: TerminalWorkspaceState, + persistedSelectionRef: ProjectActiveTerminalPersistenceRef +): void => { + const active = projectActiveTerminalSelection(activeTerminalSession(state)) + if (active === null) { + return + } + const latestRequest = projectActiveTerminalPersistenceRequest(active) + persistedSelectionRef.current = { + ...persistedSelectionRef.current, + latestRequest + } + runProjectActiveTerminalPersistRequest(persistedSelectionRef) } export const useTerminalWorkspaceState = (): TerminalWorkspaceReadyState => { const [terminalWorkspace, setTerminalWorkspace] = useState(readStoredTerminalWorkspace) + const persistedSelectionRef = useRef(emptyProjectActiveTerminalPersistenceState()) const addTerminalSession = useCallback((session: ActiveTerminalSession) => { setTerminalWorkspace((state) => addTerminalSessionState(state, session)) }, []) @@ -306,6 +191,10 @@ export const useTerminalWorkspaceState = (): TerminalWorkspaceReadyState => { writeStoredTerminalWorkspace(terminalWorkspace) }, [terminalWorkspace]) + useEffect(() => { + persistProjectActiveTerminalSelection(terminalWorkspace, persistedSelectionRef) + }, [terminalWorkspace]) + return { activeTerminalSession: activeTerminalSession(terminalWorkspace), activeTerminalSessionId: terminalWorkspace.activeTerminalSessionId, diff --git a/packages/app/src/web/app-ready-terminal-storage.ts b/packages/app/src/web/app-ready-terminal-storage.ts new file mode 100644 index 00000000..9d221b4a --- /dev/null +++ b/packages/app/src/web/app-ready-terminal-storage.ts @@ -0,0 +1,273 @@ +import * as ParseResult from "@effect/schema/ParseResult" +import * as Schema from "@effect/schema/Schema" +import { Effect, Either } from "effect" + +import { JsonValueSchema } from "../shared/json-schema.js" +import type { JsonObject, JsonValue } from "../shared/json-schema.js" +import { + deactivateTerminalWorkspaceState, + emptyTerminalWorkspaceState, + type TerminalWorkspaceState +} from "./terminal-state.js" +import type { ActiveTerminalSession } from "./terminal.js" + +type StoredActiveTerminalSession = + & Omit + & { + readonly pendingConnectionMessage?: string | undefined + readonly pendingConnectionPhase?: NonNullable["phase"] | undefined + } + +type StoredTerminalWorkspaceState = { + readonly activeTerminalSessionId: string | null + readonly savedAt: number + readonly terminalSessions: ReadonlyArray +} + +const terminalWorkspaceStorageKey = "docker-git.terminal-workspace.v1" +const JsonValueFromStringSchema: Schema.Schema = Schema.parseJson(JsonValueSchema) + +const isRecord = (value: JsonValue | undefined): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value) + +const readString = (value: JsonValue | undefined): string | null => typeof value === "string" ? value : null + +const readOptionalString = (value: JsonValue | undefined): string | undefined => readString(value) ?? undefined + +const readStoredActiveSessionId = (value: JsonValue | undefined): string | null | undefined => + value === null ? null : readString(value) ?? undefined + +const readJsonArray = (value: JsonValue | undefined): ReadonlyArray | null => + Array.isArray(value) ? value : null + +const isStoredTerminalStatus = ( + value: string | null +): value is ActiveTerminalSession["session"]["status"] => + value === "ready" || value === "attached" || value === "exited" || value === "failed" + +type StoredTerminalSessionFields = { + readonly createdAt: string | null + readonly id: string | null + readonly projectId: string | null + readonly sshCommand: string | null + readonly status: string | null +} + +const readStoredTerminalSessionFields = (value: JsonObject): StoredTerminalSessionFields => ({ + createdAt: readString(value["createdAt"]), + id: readString(value["id"]), + projectId: readString(value["projectId"]), + sshCommand: readString(value["sshCommand"]), + status: readString(value["status"]) +}) + +const hasStoredTerminalSessionFields = ( + fields: StoredTerminalSessionFields +): fields is StoredTerminalSessionFields & { + readonly createdAt: string + readonly id: string + readonly projectId: string + readonly sshCommand: string + readonly status: ActiveTerminalSession["session"]["status"] +} => + [fields.createdAt, fields.id, fields.projectId, fields.sshCommand].every((field) => field !== null) && + isStoredTerminalStatus(fields.status) + +const decodeStoredTerminalSessionCore = ( + value: JsonValue | undefined +): ActiveTerminalSession["session"] | null => { + if (!isRecord(value)) { + return null + } + const fields = readStoredTerminalSessionFields(value) + if (!hasStoredTerminalSessionFields(fields)) { + return null + } + return { + attachedClients: typeof value["attachedClients"] === "number" ? value["attachedClients"] : undefined, + closedAt: readOptionalString(value["closedAt"]), + createdAt: fields.createdAt, + exitCode: typeof value["exitCode"] === "number" ? value["exitCode"] : undefined, + id: fields.id, + projectId: fields.projectId, + signal: typeof value["signal"] === "number" ? value["signal"] : undefined, + sshCommand: fields.sshCommand, + startedAt: readOptionalString(value["startedAt"]), + status: fields.status + } +} + +type StoredActiveTerminalSessionFields = { + readonly closePath: string | null + readonly exitMessage: string | null + readonly header: string | null + readonly pendingConnectionMessage: string | null + readonly pendingConnectionPhase: string | null + readonly pendingDeleteMessage: string | null + readonly readyMessage: string | null + readonly session: ActiveTerminalSession["session"] | null + readonly sessionPath: string | null + readonly subtitle: string | null + readonly websocketPath: string | null +} + +const readStoredActiveTerminalSessionFields = (value: JsonObject): StoredActiveTerminalSessionFields => ({ + closePath: readString(value["closePath"]), + exitMessage: readString(value["exitMessage"]), + header: readString(value["header"]), + pendingConnectionMessage: readString(value["pendingConnectionMessage"]), + pendingConnectionPhase: readString(value["pendingConnectionPhase"]), + pendingDeleteMessage: readString(value["pendingDeleteMessage"]), + readyMessage: readString(value["readyMessage"]), + session: decodeStoredTerminalSessionCore(value["session"]), + sessionPath: readString(value["sessionPath"]), + subtitle: readString(value["subtitle"]), + websocketPath: readString(value["websocketPath"]) +}) + +const isStoredPendingConnectionPhase = ( + value: string | null +): value is NonNullable["phase"] => + value === "connecting" || value === "error" + +const hasStoredActiveTerminalSessionFields = ( + fields: StoredActiveTerminalSessionFields +): fields is StoredActiveTerminalSessionFields & { + readonly closePath: string + readonly exitMessage: string + readonly header: string + readonly pendingConnectionMessage: string | null + readonly pendingConnectionPhase: NonNullable["phase"] | null + readonly pendingDeleteMessage: string + readonly readyMessage: string + readonly session: ActiveTerminalSession["session"] + readonly sessionPath: string | null + readonly subtitle: string + readonly websocketPath: string +} => + [ + fields.closePath, + fields.exitMessage, + fields.header, + fields.pendingDeleteMessage, + fields.readyMessage, + fields.session, + fields.subtitle, + fields.websocketPath + ].every((field) => field !== null) && + (fields.pendingConnectionPhase === null || isStoredPendingConnectionPhase(fields.pendingConnectionPhase)) + +const decodeStoredActiveTerminalSession = (value: JsonValue | undefined): ActiveTerminalSession | null => { + if (!isRecord(value)) { + return null + } + const fields = readStoredActiveTerminalSessionFields(value) + if (!hasStoredActiveTerminalSessionFields(fields)) { + return null + } + return { + browserProjectId: readOptionalString(value["browserProjectId"]), + browserProjectKey: readOptionalString(value["browserProjectKey"]), + browserProjectName: readOptionalString(value["browserProjectName"]), + closePath: fields.closePath, + exitMessage: fields.exitMessage, + header: fields.header, + ...(fields.pendingConnectionMessage !== null && fields.pendingConnectionPhase !== null + ? { + pendingConnection: { + message: fields.pendingConnectionMessage, + phase: fields.pendingConnectionPhase + } + } + : {}), + pendingDeleteMessage: fields.pendingDeleteMessage, + readyMessage: fields.readyMessage, + session: fields.session, + sessionPath: fields.sessionPath ?? undefined, + subtitle: fields.subtitle, + websocketPath: fields.websocketPath + } +} + +const decodeStoredTerminalWorkspace = (value: JsonValue | undefined): TerminalWorkspaceState | null => { + if (!isRecord(value)) { + return null + } + const activeTerminalSessionId = readStoredActiveSessionId(value["activeTerminalSessionId"]) + const rawSessions = readJsonArray(value["terminalSessions"]) + const savedAt = typeof value["savedAt"] === "number" ? value["savedAt"] : null + if (activeTerminalSessionId === undefined || rawSessions === null || savedAt === null) { + return null + } + const terminalSessions = rawSessions + .map((session) => decodeStoredActiveTerminalSession(session)) + .filter((session): session is ActiveTerminalSession => session !== null) + return terminalSessions.length === 0 + ? emptyTerminalWorkspaceState + : { + activeTerminalSessionId, + terminalSessions + } +} + +export const readStoredTerminalWorkspace = (): TerminalWorkspaceState => { + const read = Effect.try({ + try: () => globalThis.sessionStorage.getItem(terminalWorkspaceStorageKey), + catch: () => null + }).pipe( + Effect.either, + Effect.map((result) => + Either.match(result, { + onLeft: () => emptyTerminalWorkspaceState, + onRight: (raw) => { + if (raw === null) { + return emptyTerminalWorkspaceState + } + const parsed = Either.getOrNull(ParseResult.decodeUnknownEither(JsonValueFromStringSchema)(raw)) + const decoded = decodeStoredTerminalWorkspace(parsed ?? undefined) + return decoded === null ? emptyTerminalWorkspaceState : deactivateTerminalWorkspaceState(decoded) + } + }) + ) + ) + return Effect.runSync(read) +} + +const toStoredActiveTerminalSession = (session: ActiveTerminalSession): StoredActiveTerminalSession => ({ + browserProjectId: session.browserProjectId, + browserProjectKey: session.browserProjectKey, + browserProjectName: session.browserProjectName, + closePath: session.closePath, + exitMessage: session.exitMessage, + header: session.header, + pendingConnectionMessage: session.pendingConnection?.message, + pendingConnectionPhase: session.pendingConnection?.phase, + pendingDeleteMessage: session.pendingDeleteMessage, + readyMessage: session.readyMessage, + session: session.session, + sessionPath: session.sessionPath, + subtitle: session.subtitle, + websocketPath: session.websocketPath +}) + +export const writeStoredTerminalWorkspace = (state: TerminalWorkspaceState): void => { + const write = Effect.try({ + try: () => { + if (state.terminalSessions.length === 0) { + globalThis.sessionStorage.removeItem(terminalWorkspaceStorageKey) + return + } + const payload: StoredTerminalWorkspaceState = { + activeTerminalSessionId: state.activeTerminalSessionId, + savedAt: Date.now(), + terminalSessions: state.terminalSessions.map((session) => toStoredActiveTerminalSession(session)) + } + globalThis.sessionStorage.setItem(terminalWorkspaceStorageKey, JSON.stringify(payload)) + }, + catch: () => null + }).pipe( + Effect.either, + Effect.asVoid + ) + Effect.runSync(write) +} diff --git a/packages/app/src/web/app-ready-url.ts b/packages/app/src/web/app-ready-url.ts index bb44c8f5..e3588b13 100644 --- a/packages/app/src/web/app-ready-url.ts +++ b/packages/app/src/web/app-ready-url.ts @@ -4,7 +4,7 @@ import type { DashboardData } from "./api.js" import type { BrowserShortcutArgs } from "./app-ready-shortcut-runtime.js" import { browserMenuIndex, browserMenuItems, type BrowserMenuTag } from "./menu.js" import { type BrowserScreen, isProjectMenu, menuScreen, outputScreen, screenForMenu } from "./screen.js" -import { type ActiveTerminalSession, terminalSessionRoutePath } from "./terminal.js" +import { type ActiveTerminalSession, projectSshRoutePath } from "./terminal.js" type ReadyUrlNavigation = { readonly activeScreen: BrowserScreen @@ -81,10 +81,10 @@ const projectToken = (project: DashboardData["projects"][number] | undefined, fa project?.projectKey ?? fallback const activeTerminalReadyPath = (session: ActiveTerminalSession | null): string | null => { - if (session?.browserProjectId === undefined) { + if (session?.browserProjectId === undefined || session.browserProjectKey === undefined) { return null } - return session.sessionPath ?? terminalSessionRoutePath(session.session.id) + return projectSshRoutePath(session.browserProjectKey, session.session.id) } const selectReadyPath = (token: string | null): string => diff --git a/packages/app/src/web/app-terminal-session-core.ts b/packages/app/src/web/app-terminal-session-core.ts index e31f8197..f134191a 100644 --- a/packages/app/src/web/app-terminal-session-core.ts +++ b/packages/app/src/web/app-terminal-session-core.ts @@ -1,9 +1,7 @@ import type { ProjectTerminalSessionLookup } from "./api.js" import { type ActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" -export type WebAppRoute = - | { readonly tag: "Dashboard" } - | { readonly tag: "TerminalSession"; readonly sessionId: string } +export type WebAppRoute = { readonly tag: "Dashboard" } const terminalSessionRoutePrefix = "/ssh/session/" @@ -17,11 +15,8 @@ export const readTerminalSessionRoute = (pathname: string): string | null => { return sessionId.length === 0 ? null : sessionId } -export const resolveWebAppRoute = (pathname: string): WebAppRoute => { - const sessionId = readTerminalSessionRoute(pathname) - return sessionId === null - ? { tag: "Dashboard" } - : { tag: "TerminalSession", sessionId } +export const resolveWebAppRoute = (_pathname: string): WebAppRoute => { + return { tag: "Dashboard" } } export const buildTerminalOnlyActiveSession = ( diff --git a/packages/app/src/web/app-terminal-session-handlers.ts b/packages/app/src/web/app-terminal-session-handlers.ts index 33a7245f..e0ce6621 100644 --- a/packages/app/src/web/app-terminal-session-handlers.ts +++ b/packages/app/src/web/app-terminal-session-handlers.ts @@ -14,7 +14,7 @@ import { stopProjectTask } from "./api.js" import { openUrl } from "./open-url.js" -import { terminalSessionRoutePath } from "./terminal.js" +import { projectSshRoutePath } from "./terminal.js" export type StateMessageUpdater = (message: string | null) => void @@ -91,8 +91,11 @@ const runApplyProject = ( ) } -const handleTerminalCreated = (sessionId: string, setMessage: StateMessageUpdater): void => { - const targetUrl = `${globalThis.location.origin}${terminalSessionRoutePath(sessionId)}` +export const newProjectTerminalUrl = (origin: string, projectKey: string, sessionId: string): string => + `${origin}${projectSshRoutePath(projectKey, sessionId)}` + +const handleTerminalCreated = (projectKey: string, sessionId: string, setMessage: StateMessageUpdater): void => { + const targetUrl = newProjectTerminalUrl(globalThis.location.origin, projectKey, sessionId) if (!openUrl(targetUrl)) { setMessage(`New terminal popup was blocked. Open ${targetUrl} manually.`) } @@ -106,7 +109,7 @@ const runOpenTerminal = (projectKey: string, setMessage: StateMessageUpdater): v setMessage(`Failed to open new terminal: ${error}`) }, onSuccess: (created) => { - handleTerminalCreated(created.session.id, setMessage) + handleTerminalCreated(projectKey, created.session.id, setMessage) } }) ) diff --git a/packages/app/src/web/app.tsx b/packages/app/src/web/app.tsx index 4659905b..bac000d7 100644 --- a/packages/app/src/web/app.tsx +++ b/packages/app/src/web/app.tsx @@ -7,7 +7,6 @@ import { loadDashboard, resolveApiBaseUrl } from "./api.js" import { createDashboardRefreshReducer, type DashboardState } from "./app-dashboard-state.js" import { AppReady } from "./app-ready.js" import { resolveWebAppRoute } from "./app-terminal-session-core.js" -import { AppTerminalSession } from "./app-terminal-session.js" import { ErrorScreen, LoadingScreen } from "./panels.js" import { resolveViewportLayout, type ViewportLayout, type ViewportSize } from "./viewport-layout.js" @@ -207,10 +206,6 @@ export const App = (): JSX.Element => { {Match.value(route).pipe( Match.when({ tag: "Dashboard" }, () => ), - Match.when( - { tag: "TerminalSession" }, - ({ sessionId }) => - ), Match.exhaustive )} diff --git a/packages/app/src/web/elements.tsx b/packages/app/src/web/elements.tsx index c9776194..95c6e7a1 100644 --- a/packages/app/src/web/elements.tsx +++ b/packages/app/src/web/elements.tsx @@ -77,12 +77,13 @@ const baseStyle = (props: GridElementProps): CSSProperties => ({ const interactiveStyle = ( onClick: MouseEventHandler | undefined, + backgroundColor: GridElementProps["backgroundColor"], width: GridElementProps["width"] ): CSSProperties => onClick === undefined ? {} : { - background: "transparent", + backgroundColor: backgroundColor ?? "transparent", cursor: "pointer", font: "inherit", textAlign: "left", @@ -111,7 +112,7 @@ export const Box = ({ children, onClick, ...props }: GridElementProps): JSX.Elem }) satisfies MouseEventHandler, style: { ...baseStyle(props), - ...interactiveStyle(onClick, props.width) + ...interactiveStyle(onClick, props.backgroundColor, props.width) }, type: onClick === undefined ? undefined : "button" }) diff --git a/packages/app/src/web/panel-project-details.tsx b/packages/app/src/web/panel-project-details.tsx index dd6ff2e9..f21251ce 100644 --- a/packages/app/src/web/panel-project-details.tsx +++ b/packages/app/src/web/panel-project-details.tsx @@ -13,6 +13,7 @@ import { Box, Text } from "../ui/primitives.js" import { HelpLines } from "../ui/shared.js" import { loadProjectTerminalSessions, type ProjectDetails, type ProjectSummary, type TerminalSession } from "./api.js" import type { BrowserMenuTag } from "./menu.js" +import { terminalTitleById } from "./terminal.js" type ProjectTerminalSessionsState = { readonly error: string | null @@ -123,7 +124,8 @@ const ProjectTerminalSessionRow = ( projectId, projectKey, projectName, - session + session, + title }: { readonly onAttachProjectTerminalSession: ( projectId: string, @@ -136,11 +138,12 @@ const ProjectTerminalSessionRow = ( readonly projectKey: string readonly projectName: string readonly session: TerminalSession + readonly title: string } ): JSX.Element => ( - session {session.id.slice(0, 12)} + {title} {session.status} {typeof session.attachedClients === "number" ? ` • clients ${session.attachedClients}` : ""} @@ -215,17 +218,21 @@ const ProjectTerminalSessionsSection = ( ? No live SSH terminals. Start one with `new terminal`. : null} - {sortedTerminalSessions(sessionsState.sessions).map((session) => ( - - ))} + {(() => { + const terminalLabels = terminalTitleById(sessionsState.sessions) + return sortedTerminalSessions(sessionsState.sessions).map((session) => ( + + )) + })()} ) diff --git a/packages/app/src/web/terminal.ts b/packages/app/src/web/terminal.ts index ec591d3d..3fcab31f 100644 --- a/packages/app/src/web/terminal.ts +++ b/packages/app/src/web/terminal.ts @@ -73,6 +73,45 @@ type ProjectTerminalSessionBase = Pick< export const terminalSessionRoutePath = (sessionId: string): string => `/ssh/session/${encodeURIComponent(sessionId)}` +const encodeProjectKeyPath = (projectKey: string): string => + projectKey.split("/").map((segment) => encodeURIComponent(segment)).join("/") + +const terminalUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu + +export const terminalRouteToken = (sessionId: string): string => + terminalUuidPattern.test(sessionId) ? sessionId.slice(0, 8) : sessionId + +export const projectSshRoutePath = (projectKey: string, terminalId?: string): string => { + const path = `/ssh/${encodeProjectKeyPath(projectKey)}` + return terminalId === undefined ? path : `${path}?t=${encodeURIComponent(terminalRouteToken(terminalId))}` +} + +type TerminalLabelSession = { + readonly createdAt: string + readonly id: string +} + +const compareTerminalLabelSession = (left: TerminalLabelSession, right: TerminalLabelSession): number => { + const byCreatedAt = left.createdAt.localeCompare(right.createdAt) + return byCreatedAt === 0 ? left.id.localeCompare(right.id) : byCreatedAt +} + +export const terminalTitle = (index: number): string => `Terminal ${index + 1}` + +const terminalTitleEntry = ( + session: TerminalLabelSession, + index: number +): readonly [string, string] => [session.id, terminalTitle(index)] + +export const terminalTitleById = ( + sessions: ReadonlyArray +): ReadonlyMap => + new Map( + sessions + .toSorted(compareTerminalLabelSession) + .map((session, index) => terminalTitleEntry(session, index)) + ) + export const isPendingActiveTerminalSession = ( session: ActiveTerminalSession ): session is PendingActiveTerminalSession => session.pendingConnection !== undefined @@ -110,7 +149,7 @@ export const buildProjectActiveTerminalSession = ( ...(onReady === undefined ? {} : { onReady }), pendingDeleteMessage: `Terminal session was closed before attach: ${projectDisplayName}.`, session, - sessionPath: terminalSessionRoutePath(session.id), + sessionPath: projectSshRoutePath(projectKey, session.id), subtitle: session.sshCommand } } @@ -164,7 +203,7 @@ export const buildPendingProjectActiveTerminalSession = ( sshCommand: "Preparing SSH session...", status: phase === "error" ? "failed" : "ready" }, - sessionPath: terminalSessionRoutePath(pendingSessionId), + sessionPath: projectSshRoutePath(projectKey, pendingSessionId), subtitle: resolvedMessage } } diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index d9a85502..749414f1 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/octocat/hello-world?t=${pendingSessionId.slice(0, 8)}`, 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/app/tests/docker-git/app-ready-ssh-link-hook.test.ts b/packages/app/tests/docker-git/app-ready-ssh-link-hook.test.ts index e96a3afb..4bb122ba 100644 --- a/packages/app/tests/docker-git/app-ready-ssh-link-hook.test.ts +++ b/packages/app/tests/docker-git/app-ready-ssh-link-hook.test.ts @@ -1,8 +1,159 @@ +import * as fc from "fast-check" import { describe, expect, it } from "vitest" -import { resolveMissingSshSessionFallbackPath } from "../../src/web/app-ready-ssh-link-hook.js" +import type { TerminalSession } from "../../src/web/api-types.js" +import { + readSshLinkRequestFromHref, + resolveMissingSshSessionFallbackPath, + selectWorkspaceTerminalSession +} from "../../src/web/app-ready-ssh-link-hook.js" + +const makeSession = (id: string, createdAt: string, status: TerminalSession["status"] = "ready"): TerminalSession => ({ + createdAt, + id, + projectId: "project-1", + sshCommand: `ssh dev@${id}`, + status +}) + +const makeSessionPair = (): ReadonlyArray => [ + makeSession("session-1", "2026-04-15T00:00:00.000Z"), + makeSession("session-2", "2026-04-16T00:00:00.000Z") +] + +const createdAtForIndex = (index: number): string => `2026-04-${String(index + 1).padStart(2, "0")}T00:00:00.000Z` + +const sessionIdsArbitrary = fc.uniqueArray(fc.integer({ max: 1_000_000, min: 1 }), { + maxLength: 8, + minLength: 1 +}) + +const sessionsFromIds = ( + ids: ReadonlyArray, + statusForIndex: (index: number) => TerminalSession["status"] = () => "ready" +): ReadonlyArray => + ids.map((id, index) => makeSession(`session-${id}`, createdAtForIndex(index), statusForIndex(index))) describe("app-ready ssh link hook", () => { + it("parses stable project SSH routes with an optional terminal selector", () => { + expect(readSshLinkRequestFromHref("https://docker-git.local/ssh/octocat/hello-world?t=session-2")) + .toEqual({ + kind: "project", + terminalId: "session-2", + token: "octocat/hello-world" + }) + }) + + it("keeps full terminal selector links backward compatible", () => { + expect(readSshLinkRequestFromHref("https://docker-git.local/ssh/octocat/hello-world?terminal=session-2")) + .toEqual({ + kind: "project", + terminalId: "session-2", + token: "octocat/hello-world" + }) + }) + + it("parses legacy terminal session routes for compatibility redirects", () => { + expect(readSshLinkRequestFromHref("https://docker-git.local/ssh/session/session-1")).toEqual({ + kind: "session", + sessionId: "session-1" + }) + }) + + it("selects the requested workspace terminal before the active one", () => { + expect(selectWorkspaceTerminalSession(makeSessionPair(), "session-1", "session-2")?.id).toBe("session-2") + }) + + it("selects a workspace terminal by a unique short prefix", () => { + const sessions = [ + makeSession("a5f1c873-358b-4de9-9444-92ee8f8522fb", "2026-04-15T00:00:00.000Z"), + makeSession("1b73cfc9-6d07-489e-bdc5-99d43f2da2cb", "2026-04-16T00:00:00.000Z") + ] + + expect(selectWorkspaceTerminalSession(sessions, null, "a5f1c873")?.id).toBe( + "a5f1c873-358b-4de9-9444-92ee8f8522fb" + ) + }) + + it("does not select a workspace terminal by an ambiguous short prefix", () => { + const sessions = [ + makeSession("aaaaaaaa-358b-4de9-9444-92ee8f8522fb", "2026-04-15T00:00:00.000Z"), + makeSession("aaaaaaaa-6d07-489e-bdc5-99d43f2da2cb", "2026-04-16T00:00:00.000Z") + ] + + expect(selectWorkspaceTerminalSession(sessions, null, "aaaaaaaa")).toBeNull() + }) + + it("does not silently replace a missing requested terminal with another session", () => { + expect(selectWorkspaceTerminalSession(makeSessionPair(), "session-1", "missing")).toBeNull() + }) + + it("falls back to the newest non-failed workspace terminal", () => { + const sessions = [ + makeSession("ready-old", "2026-04-15T00:00:00.000Z"), + makeSession("failed-new", "2026-04-16T00:00:00.000Z", "failed") + ] + + expect(selectWorkspaceTerminalSession(sessions, null)?.id).toBe("ready-old") + }) + + it("preserves exact terminal selector invariants for generated sessions", () => { + fc.assert( + fc.property(sessionIdsArbitrary, fc.nat(), (ids, seed) => { + const sessions = sessionsFromIds(ids) + const expected = sessions[seed % sessions.length] + + expect(selectWorkspaceTerminalSession(sessions, null, expected?.id)?.id).toBe(expected?.id) + }), + { numRuns: 50 } + ) + }) + + it("preserves unique and ambiguous terminal prefix invariants", () => { + fc.assert( + fc.property(fc.integer({ max: 1_000_000, min: 1 }), (seed) => { + const prefix = `prefix-${seed}-` + const uniqueSessions = [ + makeSession(`${prefix}a`, "2026-04-15T00:00:00.000Z"), + makeSession(`other-${seed}`, "2026-04-16T00:00:00.000Z") + ] + const ambiguousSessions = [ + makeSession(`${prefix}a`, "2026-04-15T00:00:00.000Z"), + makeSession(`${prefix}b`, "2026-04-16T00:00:00.000Z") + ] + + expect(selectWorkspaceTerminalSession(uniqueSessions, null, prefix)?.id).toBe(`${prefix}a`) + expect(selectWorkspaceTerminalSession(ambiguousSessions, null, prefix)).toBeNull() + }), + { numRuns: 50 } + ) + }) + + it("preserves missing terminal selector invariants for generated sessions", () => { + fc.assert( + fc.property(sessionIdsArbitrary, fc.integer({ max: 1_000_000, min: 1 }), (ids, seed) => { + const sessions = sessionsFromIds(ids) + + expect(selectWorkspaceTerminalSession(sessions, sessions[0]?.id ?? null, `missing-${seed}`)).toBeNull() + }), + { numRuns: 50 } + ) + }) + + it("preserves newest non-failed fallback invariants for generated sessions", () => { + fc.assert( + fc.property(sessionIdsArbitrary, fc.nat(), (ids, seed) => { + const sessions = sessionsFromIds(ids, (index) => index % 2 === seed % 2 ? "failed" : "ready") + const reusable = sessions.filter((session) => session.status !== "failed") + const candidates = reusable.length === 0 ? sessions : reusable + const expected = candidates.toSorted((left, right) => right.createdAt.localeCompare(left.createdAt))[0] + + expect(selectWorkspaceTerminalSession(sessions, null)?.id).toBe(expected?.id) + }), + { numRuns: 50 } + ) + }) + it("falls back to the Select screen for a stale SSH session route", () => { expect( resolveMissingSshSessionFallbackPath( diff --git a/packages/app/tests/docker-git/app-ready-terminal-state-hook.test.ts b/packages/app/tests/docker-git/app-ready-terminal-state-hook.test.ts new file mode 100644 index 00000000..6a6ba998 --- /dev/null +++ b/packages/app/tests/docker-git/app-ready-terminal-state-hook.test.ts @@ -0,0 +1,195 @@ +import { it as effectIt } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + createProjectActiveTerminalPersistenceRef, + persistProjectActiveTerminalSelection, + projectActiveTerminalSelection +} from "../../src/web/app-ready-terminal-state-hook.js" +import type { TerminalWorkspaceState } from "../../src/web/terminal-state.js" +import type { ActiveTerminalSession } from "../../src/web/terminal.js" + +const apiMock = vi.hoisted(() => ({ + setProjectActiveTerminalSession: vi.fn() +})) + +vi.mock("../../src/web/api.js", () => ({ + setProjectActiveTerminalSession: apiMock.setProjectActiveTerminalSession +})) + +type PendingPersistCall = { + readonly complete: () => void + readonly projectKey: string + readonly sessionId: string +} + +const pendingPersistCalls: Array = [] + +const makeSession = ( + overrides: Partial = {} +): ActiveTerminalSession => ({ + browserProjectId: "project-1", + browserProjectKey: "octocat/hello-world", + browserProjectName: "octocat/hello-world", + closePath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1", + exitMessage: "done", + header: "SSH terminal: octocat/hello-world", + pendingDeleteMessage: "closed", + readyMessage: "ready", + session: { + createdAt: "2026-04-15T00:00:00.000Z", + id: "session-1", + projectId: "project-1", + sshCommand: "ssh dev@127.0.0.1", + status: "ready" + }, + subtitle: "ssh dev@127.0.0.1", + websocketPath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1/ws", + ...overrides +}) + +const idSegmentArbitrary = fc.integer({ max: 1_000_000, min: 1 }).map((value) => `id-${value}`) +const projectKeyArbitrary = fc.tuple(idSegmentArbitrary, idSegmentArbitrary).map(([owner, repo]) => `${owner}/${repo}`) +const sessionIdArbitrary = fc.integer({ max: 1_000_000, min: 1 }).map((value) => `session-${value}`) + +const makeSessionWithId = ( + sessionId: string, + overrides: Partial = {} +): ActiveTerminalSession => { + const base = makeSession() + return makeSession({ + ...overrides, + session: { + ...base.session, + id: sessionId + } + }) +} + +const makeWorkspace = (session: ActiveTerminalSession): TerminalWorkspaceState => ({ + activeTerminalSessionId: session.session.id, + terminalSessions: [session] +}) + +type ProjectActiveTerminalPersistenceRef = ReturnType + +const persistSessionSelection = ( + persistenceRef: ProjectActiveTerminalPersistenceRef, + sessionId: string +): void => { + persistProjectActiveTerminalSelection(makeWorkspace(makeSessionWithId(sessionId)), persistenceRef) +} + +describe("app-ready terminal state hook", () => { + beforeEach(() => { + pendingPersistCalls.length = 0 + apiMock.setProjectActiveTerminalSession.mockReset() + apiMock.setProjectActiveTerminalSession.mockImplementation((projectKey: string, sessionId: string) => + Effect.async((resume) => { + pendingPersistCalls.push({ + complete: () => { + resume(Effect.succeed(true)) + }, + projectKey, + sessionId + }) + }) + ) + }) + + it("persists active project terminal selection by project key and session id", () => { + expect(projectActiveTerminalSelection(makeSession())).toEqual({ + projectKey: "octocat/hello-world", + sessionId: "session-1" + }) + }) + + it("does not persist pending terminal selection before the API session exists", () => { + expect( + projectActiveTerminalSelection(makeSession({ + pendingConnection: { + message: "Connecting", + phase: "connecting" + } + })) + ).toBeNull() + }) + + it("does not persist non-project terminal selection", () => { + expect(projectActiveTerminalSelection(makeSession({ browserProjectKey: undefined }))).toBeNull() + }) + + it("preserves active project terminal selection invariants", () => { + fc.assert( + fc.property(projectKeyArbitrary, sessionIdArbitrary, (projectKey, sessionId) => { + expect(projectActiveTerminalSelection(makeSessionWithId(sessionId, { browserProjectKey: projectKey }))) + .toEqual({ projectKey, sessionId }) + }), + { numRuns: 50 } + ) + }) + + it("preserves pending terminal non-persistence invariants", () => { + fc.assert( + fc.property( + projectKeyArbitrary, + sessionIdArbitrary, + fc.constantFrom("connecting", "error"), + idSegmentArbitrary, + (projectKey, sessionId, phase, message) => { + expect(projectActiveTerminalSelection(makeSessionWithId(sessionId, { + browserProjectKey: projectKey, + pendingConnection: { message, phase } + }))).toBeNull() + } + ), + { numRuns: 50 } + ) + }) + + it("preserves non-project terminal non-persistence invariants", () => { + fc.assert( + fc.property(sessionIdArbitrary, (sessionId) => { + expect(projectActiveTerminalSelection(makeSessionWithId(sessionId, { browserProjectKey: undefined }))) + .toBeNull() + }), + { numRuns: 50 } + ) + }) + + effectIt.effect( + "serializes active session persistence so latest wins and superseded selections are skipped", + () => + Effect.gen(function*(_) { + const persistenceRef = createProjectActiveTerminalPersistenceRef() + + persistSessionSelection(persistenceRef, "session-1") + expect(apiMock.setProjectActiveTerminalSession).toHaveBeenCalledTimes(1) + expect(apiMock.setProjectActiveTerminalSession).toHaveBeenLastCalledWith("octocat/hello-world", "session-1") + + persistSessionSelection(persistenceRef, "session-2") + expect(apiMock.setProjectActiveTerminalSession).toHaveBeenCalledTimes(1) + + pendingPersistCalls[0]?.complete() + yield* _(Effect.yieldNow()) + + expect(apiMock.setProjectActiveTerminalSession).toHaveBeenCalledTimes(2) + expect(apiMock.setProjectActiveTerminalSession).toHaveBeenLastCalledWith("octocat/hello-world", "session-2") + + pendingPersistCalls.length = 0 + apiMock.setProjectActiveTerminalSession.mockClear() + const switchedBackPersistenceRef = createProjectActiveTerminalPersistenceRef() + persistSessionSelection(switchedBackPersistenceRef, "session-1") + persistSessionSelection(switchedBackPersistenceRef, "session-2") + persistSessionSelection(switchedBackPersistenceRef, "session-1") + + pendingPersistCalls[0]?.complete() + yield* _(Effect.yieldNow()) + + expect(apiMock.setProjectActiveTerminalSession).toHaveBeenCalledTimes(1) + expect(apiMock.setProjectActiveTerminalSession).toHaveBeenLastCalledWith("octocat/hello-world", "session-1") + }) + ) +}) diff --git a/packages/app/tests/docker-git/app-ready-url.test.ts b/packages/app/tests/docker-git/app-ready-url.test.ts index 00549427..035968fc 100644 --- a/packages/app/tests/docker-git/app-ready-url.test.ts +++ b/packages/app/tests/docker-git/app-ready-url.test.ts @@ -61,7 +61,7 @@ describe("app ready URL state", () => { })).toBe("/databases/octocat/hello-world") }) - it("renders active SSH project terminals as SSH deep links", () => { + it("renders active SSH project terminals as stable project SSH links", () => { expect(readyUrlPath({ activeScreen: { tag: "ProjectPicker" }, activeTerminalSession: { @@ -86,7 +86,7 @@ describe("app ready URL state", () => { currentMenu: "Select", selectedProjectId: "project-1", selectedProjectSummary - })).toBe("/ssh/session/session-1") + })).toBe("/ssh/octocat/hello-world?t=session-1") }) it("renders SSH project selection as a project terminal list deep link", () => { diff --git a/packages/app/tests/docker-git/app-terminal-session-core.test.ts b/packages/app/tests/docker-git/app-terminal-session-core.test.ts index 84d2a92e..bc9b7d65 100644 --- a/packages/app/tests/docker-git/app-terminal-session-core.test.ts +++ b/packages/app/tests/docker-git/app-terminal-session-core.test.ts @@ -20,15 +20,10 @@ const lookup: ProjectTerminalSessionLookup = { } describe("terminal-only SSH route core", () => { - it("routes direct SSH session URLs outside the dashboard", () => { - expect(resolveWebAppRoute("/ssh/session/session-1")).toEqual({ - tag: "TerminalSession", - sessionId: "session-1" - }) - expect(resolveWebAppRoute("/ssh/session/session%2Fencoded")).toEqual({ - tag: "TerminalSession", - sessionId: "session/encoded" - }) + it("keeps legacy SSH session URLs on the dashboard route for project workspace attach", () => { + expect(resolveWebAppRoute("/ssh/session/session-1")).toEqual({ tag: "Dashboard" }) + expect(resolveWebAppRoute("/ssh/session/session%2Fencoded")).toEqual({ tag: "Dashboard" }) + expect(readTerminalSessionRoute("/ssh/session/session%2Fencoded")).toBe("session/encoded") }) it("keeps dashboard and project SSH links on the dashboard route", () => { @@ -49,7 +44,7 @@ describe("terminal-only SSH route core", () => { browserProjectKey: "octocat/hello-world", browserProjectName: "octocat/hello-world", closePath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1", - sessionPath: "/ssh/session/session-1", + sessionPath: "/ssh/octocat/hello-world?t=session-1", websocketPath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1/ws" }) expect("onReady" in session).toBe(false) diff --git a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts index 657d9a5e..5fe96f7b 100644 --- a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts +++ b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest" -import { type ProjectHandlers, useProjectActionHandlers } from "../../src/web/app-terminal-session-handlers.js" +import { + newProjectTerminalUrl, + type ProjectHandlers, + useProjectActionHandlers +} from "../../src/web/app-terminal-session-handlers.js" const noopMessage = (_message: string | null): void => {} const noopOpenTaskManager = (): void => {} @@ -18,6 +22,12 @@ const buildHandlers = ( }) describe("useProjectActionHandlers", () => { + it("builds new terminal popups as project SSH links with a terminal selector", () => { + expect( + newProjectTerminalUrl("https://docker-git.local", "octocat/hello-world", "session-1") + ).toBe("https://docker-git.local/ssh/octocat/hello-world?t=session-1") + }) + it("returns all four project action handlers when project context is present", () => { const handlers = buildHandlers() expect(typeof handlers.onApplyProject).toBe("function") diff --git a/packages/app/tests/docker-git/terminal.test.ts b/packages/app/tests/docker-git/terminal.test.ts index acfdbf3b..1f098387 100644 --- a/packages/app/tests/docker-git/terminal.test.ts +++ b/packages/app/tests/docker-git/terminal.test.ts @@ -13,7 +13,13 @@ import { shouldShowTerminalTabs } from "../../src/web/terminal-mobile-layout.js" import { resolveTerminalReconnectDelay } from "../../src/web/terminal-reconnect.js" -import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "../../src/web/terminal.js" +import { + parseTerminalServerMessage, + projectSshRoutePath, + resolveTerminalWebSocketUrl, + terminalRouteToken, + terminalTitleById +} from "../../src/web/terminal.js" import type { TerminalServerMessage } from "../../src/web/terminal.js" const resolveApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>()) @@ -79,6 +85,33 @@ describe("browser terminal helpers", () => { expect(parsed).toEqual(readyMessagePayload) }) + it("builds stable project SSH routes with optional terminal selectors", () => { + expect(projectSshRoutePath("octocat/hello-world")).toBe("/ssh/octocat/hello-world") + expect(projectSshRoutePath("octocat/hello world", "a5f1c873-358b-4de9-9444-92ee8f8522fb")).toBe( + "/ssh/octocat/hello%20world?t=a5f1c873" + ) + expect(projectSshRoutePath("octocat/hello world", "session/1")).toBe("/ssh/octocat/hello%20world?t=session%2F1") + }) + + it("shortens UUID terminal selectors while preserving non-UUID ids", () => { + expect(terminalRouteToken("a5f1c873-358b-4de9-9444-92ee8f8522fb")).toBe("a5f1c873") + expect(terminalRouteToken("session-1")).toBe("session-1") + }) + + it("builds stable human terminal titles from creation order", () => { + expect( + [ + ...terminalTitleById([ + { createdAt: "2026-04-08T10:02:00.000Z", id: "session-b" }, + { createdAt: "2026-04-08T10:01:00.000Z", id: "session-a" } + ]).entries() + ] + ).toEqual([ + ["session-a", "Terminal 1"], + ["session-b", "Terminal 2"] + ]) + }) + it("rejects malformed terminal messages", () => { expect(parseTerminalServerMessage("{\"type\":\"output\",\"data\":1}")).toBeNull() }) diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 693715fb..c5d3c7d8 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -15,6 +15,15 @@ import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" // COMPLEXITY: O(1)/O(1) const dockerGitBaseImage = "konard/box-js:2.1.1" +// CHANGE: include tmux in generated project images for durable terminal multiplexing. +// WHY: stable project SSH links attach to persisted tmux sessions instead of one-off shell processes. +// QUOTE(ТЗ): n/a +// REF: PR-309 +// SOURCE: n/a +// PURITY: CORE +// INVARIANT: generated base image contains the terminal multiplexer required by project SSH sessions. +// COMPLEXITY: O(1)/O(1) + /** * Renders the base image, root user, apt mirror, core packages, and sudo prelude. * @@ -56,7 +65,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 707b7526..ede2c7f5 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -197,6 +197,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',