Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 78 additions & 23 deletions packages/host-service/src/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,49 @@ export interface TerminalSessionSummary {
title: string | null;
}

/**
* Why this session operation failed, as a value rather than as prose.
*
* Every session producer in this module returns one of these, and each
* consumer (tRPC, the HTTP attach route, the websocket) decides its own
* mapping by switching on `kind`. `error` stays the human-readable string
* those consumers already surface, so it can be reworded freely without
* changing anyone's behaviour.
*/
export type TerminalSessionErrorKind =
/** No session with this id, in memory or on the daemon. */
| "SESSION_NOT_FOUND"
/** The session exists but is owned by a different workspace. */
| "SESSION_WRONG_WORKSPACE"
/** The session existed and its process has already ended. */
| "SESSION_EXITED"
/** Adoption was requested but the daemon has no live session by that id. */
| "SESSION_NOT_ACTIVE"
/** No workspace row for the requested workspace id. */
| "WORKSPACE_NOT_FOUND"
/** The workspace row exists but its worktree is gone from disk. */
| "WORKTREE_GONE"
/**
* The daemon could not be reached (stalled, restarting, bootstrap
* pending). The supervisor heals these and the session may still come up
* under the same id, so callers should retry rather than go dead.
*/
| "DAEMON_UNAVAILABLE"
/**
* Bootstrap failed permanently (missing daemon binary, no socket path,
* crash circuit open, handshake rejected) or the PTY would not open.
* Retrying will fail the same way; this is the only kind that means "we
* have a bug or a broken install".
*/
| "TERMINAL_START_FAILED";

export type TerminalSessionError = {
kind: TerminalSessionErrorKind;
error: string;
/** Retained for the attach route and websocket, which branch on it. */
transient?: boolean;
};

export function listTerminalSessions(
options: { workspaceId?: string; includeExited?: boolean } = {},
): TerminalSessionSummary[] {
Expand Down Expand Up @@ -843,16 +886,19 @@ export function writeInputToSession({
terminalId: string;
workspaceId: string;
data: string;
}): { success: true } | { error: string } {
}): { success: true } | TerminalSessionError {
const session = sessions.get(terminalId);
if (!session) {
return { error: "Terminal session not found" };
return { kind: "SESSION_NOT_FOUND", error: "Terminal session not found" };
}
if (session.workspaceId !== workspaceId) {
return { error: "Terminal session does not belong to this workspace" };
return {
kind: "SESSION_WRONG_WORKSPACE",
error: "Terminal session does not belong to this workspace",
};
}
if (session.exited) {
return { error: "Terminal session has exited" };
return { kind: "SESSION_EXITED", error: "Terminal session has exited" };
}

session.pty.write(data);
Expand Down Expand Up @@ -900,12 +946,15 @@ async function getOrAdoptSession({
workspaceId: string;
db: HostDb;
eventBus?: EventBus;
}): Promise<TerminalSession | { error: string }> {
}): Promise<TerminalSession | TerminalSessionError> {
for (;;) {
const existing = sessions.get(terminalId);
if (existing) {
if (existing.workspaceId !== workspaceId) {
return { error: "Terminal session does not belong to this workspace" };
return {
kind: "SESSION_WRONG_WORKSPACE",
error: "Terminal session does not belong to this workspace",
};
}
return existing;
}
Expand Down Expand Up @@ -961,7 +1010,7 @@ export async function writeFramedInputToSession({
submit: boolean;
db: HostDb;
eventBus?: EventBus;
}): Promise<{ success: true } | { error: string }> {
}): Promise<{ success: true } | TerminalSessionError> {
const session = await getOrAdoptSession({
terminalId,
workspaceId,
Expand All @@ -970,17 +1019,17 @@ export async function writeFramedInputToSession({
});
if ("error" in session) return session;
if (session.exited) {
return { error: "Terminal session has exited" };
return { kind: "SESSION_EXITED", error: "Terminal session has exited" };
}

// Serialize sends per session: the delayed Enter opens a window where a
// concurrent send's text (even a submit: false draft) would land between
// this text and its Enter and get submitted by it.
const previous = session.followUpWriteChain ?? Promise.resolve();
const task = previous.then(
async (): Promise<{ success: true } | { error: string }> => {
async (): Promise<{ success: true } | TerminalSessionError> => {
if (session.exited) {
return { error: "Terminal session has exited" };
return { kind: "SESSION_EXITED", error: "Terminal session has exited" };
}
const framed = session.modeTracker.isBracketedPasteActive()
? `\x1b[200~${text}\x1b[201~`
Expand All @@ -993,7 +1042,10 @@ export async function writeFramedInputToSession({
session.pty.write(framed);
await new Promise((r) => setTimeout(r, FOLLOW_UP_ENTER_DELAY_MS));
if (session.exited) {
return { error: "Terminal session has exited" };
return {
kind: "SESSION_EXITED",
error: "Terminal session has exited",
};
}
}
session.pty.write("\r");
Expand Down Expand Up @@ -1024,7 +1076,7 @@ export async function snapshotSession({
maxLines?: number;
db: HostDb;
eventBus?: EventBus;
}): Promise<({ success: true } & TerminalSnapshot) | { error: string }> {
}): Promise<({ success: true } & TerminalSnapshot) | TerminalSessionError> {
const session = await getOrAdoptSession({
terminalId,
workspaceId,
Expand Down Expand Up @@ -2309,12 +2361,7 @@ function getTerminalWorkspaceMismatchError({
return `Terminal session "${terminalId}" belongs to workspace "${ownerWorkspaceId}", not "${requestedWorkspaceId}".`;
}

/**
* `transient: true` = the daemon couldn't be reached (stalled, restarting,
* bootstrap pending) — the session may still come up under the same id; the
* attach path tells the renderer to keep retrying instead of going dead.
*/
type CreateSessionError = { error: string; transient?: boolean };
type CreateSessionError = TerminalSessionError;

export async function createTerminalSessionInternal({
terminalId,
Expand All @@ -2340,7 +2387,8 @@ export async function createTerminalSessionInternal({
ownerWorkspaceId: existing.workspaceId,
requestedWorkspaceId: workspaceId,
});
if (mismatchError) return { error: mismatchError };
if (mismatchError)
return { kind: "SESSION_WRONG_WORKSPACE", error: mismatchError };

if (listed) existing.listed = true;
if (initialCommand) queueInitialCommand(existing, initialCommand);
Expand All @@ -2355,17 +2403,19 @@ export async function createTerminalSessionInternal({
ownerWorkspaceId: existingRecord?.originWorkspaceId,
requestedWorkspaceId: workspaceId,
});
if (recordMismatchError) return { error: recordMismatchError };
if (recordMismatchError)
return { kind: "SESSION_WRONG_WORKSPACE", error: recordMismatchError };

const workspace = db.query.workspaces
.findFirst({ where: eq(workspaces.id, workspaceId) })
.sync();

if (!workspace) {
return { error: "Workspace not found" };
return { kind: "WORKSPACE_NOT_FOUND", error: "Workspace not found" };
}
if (!existsSync(workspace.worktreePath)) {
return {
kind: "WORKTREE_GONE",
error: `Workspace worktree no longer exists: ${workspace.worktreePath}`,
};
}
Expand Down Expand Up @@ -2431,10 +2481,12 @@ export async function createTerminalSessionInternal({
// daemon binary, no socket path, crash circuit open, handshake
// rejection) are permanent — surfacing them beats silently retrying
// a bootstrap that will fail the same way forever.
const unreachable = error instanceof DaemonUnavailableError;
return {
kind: unreachable ? "DAEMON_UNAVAILABLE" : "TERMINAL_START_FAILED",
error:
error instanceof Error ? error.message : "Failed to start terminal",
transient: error instanceof DaemonUnavailableError,
transient: unreachable,
};
}
let openResult: { pid: number };
Expand All @@ -2446,6 +2498,7 @@ export async function createTerminalSessionInternal({
);
if (!found) {
return {
kind: "SESSION_NOT_ACTIVE",
error: `Terminal session "${terminalId}" is not active; create it before connecting.`,
};
}
Expand Down Expand Up @@ -2498,10 +2551,12 @@ export async function createTerminalSessionInternal({
}
}
} catch (error) {
const unreachable = error instanceof DaemonUnavailableError;
return {
kind: unreachable ? "DAEMON_UNAVAILABLE" : "TERMINAL_START_FAILED",
error:
error instanceof Error ? error.message : "Failed to start terminal",
transient: error instanceof DaemonUnavailableError,
transient: unreachable,
};
}
const pty: DaemonPty = makeDaemonPty(daemon, terminalId, openResult.pid);
Expand Down
6 changes: 2 additions & 4 deletions packages/host-service/src/trpc/router/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createTerminalSessionInternal } from "../../../terminal/terminal";
import type { HostServiceContext } from "../../../types";
import { protectedProcedure, router } from "../../index";
import { resolveAttachmentPath } from "../attachments/storage";
import { toTerminalSessionError } from "../terminal/errors";

interface ResolvedHostAgentConfig {
id: string;
Expand Down Expand Up @@ -394,10 +395,7 @@ async function runTerminalAgent(
});

if ("error" in result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: result.error,
});
throw toTerminalSessionError(result);
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../../../terminal-agents/persistence";
import { protectedProcedure, router } from "../../index";
import { resolveHostAgentConfig } from "../agents/agents";
import { toTerminalSessionError } from "../terminal/errors";

type GetOrCreateResult = {
binding: TerminalAgentBinding;
Expand Down Expand Up @@ -212,10 +213,7 @@ export const terminalAgentsRouter = router({
});

if ("error" in created) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: created.error,
});
throw toTerminalSessionError(created);
}

try {
Expand Down
41 changes: 41 additions & 0 deletions packages/host-service/src/trpc/router/terminal/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { TRPCError } from "@trpc/server";
import type { TerminalSessionError } from "../../../terminal/terminal";

/**
* Single tRPC mapping for every terminal session failure.
*
* The switch is exhaustive: adding a kind to `TerminalSessionErrorKind`
* without deciding its code here is a type error, which is the point. Only
* TERMINAL_START_FAILED stays a 500, so it is the only kind the Sentry
* middleware reports.
*/
export function toTerminalSessionError(
failure: TerminalSessionError,
): TRPCError {
const { kind, error: message } = failure;
switch (kind) {
case "SESSION_WRONG_WORKSPACE":
return new TRPCError({ code: "FORBIDDEN", message, cause: { kind } });
case "SESSION_NOT_FOUND":
case "SESSION_EXITED":
case "SESSION_NOT_ACTIVE":
case "WORKSPACE_NOT_FOUND":
case "WORKTREE_GONE":
return new TRPCError({ code: "NOT_FOUND", message, cause: { kind } });
case "DAEMON_UNAVAILABLE":
return new TRPCError({
code: "SERVICE_UNAVAILABLE",
message,
cause: { kind },
});
case "TERMINAL_START_FAILED":
return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
default: {
const unhandled: never = kind;
return new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Unhandled terminal session error kind: ${String(unhandled)}`,
});
}
}
}
39 changes: 5 additions & 34 deletions packages/host-service/src/trpc/router/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,7 @@ import {
} from "../../../terminal/terminal";
import type { HostServiceContext } from "../../../types";
import { protectedProcedure, router } from "../../index";

function toTerminalIoError(message: string): TRPCError {
if (message.includes("belong")) {
return new TRPCError({ code: "FORBIDDEN", message });
}
// A worktree deleted underneath an open terminal is a routine lifecycle
// state, not a bug — the runtime reports it as a plain string, so match
// the stable prefix here to keep it out of Sentry.
if (message.startsWith("Workspace worktree no longer exists")) {
return new TRPCError({
code: "NOT_FOUND",
message,
cause: { kind: "WORKTREE_GONE" },
});
}
if (
message.includes("not found") ||
message.includes("not active") ||
message.includes("exited")
) {
return new TRPCError({ code: "NOT_FOUND", message });
}
return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
}
import { toTerminalSessionError } from "./errors";

const createSessionInputSchema = z.object({
workspaceId: z.string(),
Expand Down Expand Up @@ -73,10 +50,7 @@ async function createTerminalSessionFromInput({
});

if ("error" in result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: result.error,
});
throw toTerminalSessionError(result);
}

return {
Expand Down Expand Up @@ -180,10 +154,7 @@ export const terminalRouter = router({
.mutation(({ input }) => {
const result = writeInputToSession(input);
if ("error" in result) {
throw new TRPCError({
code: "NOT_FOUND",
message: result.error,
});
throw toTerminalSessionError(result);
}
return { success: true as const };
}),
Expand All @@ -207,7 +178,7 @@ export const terminalRouter = router({
eventBus: ctx.eventBus,
});
if ("error" in result) {
throw toTerminalIoError(result.error);
throw toTerminalSessionError(result);
}
return { terminalId: input.terminalId, submitted: input.submit };
}),
Expand All @@ -229,7 +200,7 @@ export const terminalRouter = router({
eventBus: ctx.eventBus,
});
if ("error" in result) {
throw toTerminalIoError(result.error);
throw toTerminalSessionError(result);
}
const { success: _success, ...snapshot } = result;
return { terminalId: input.terminalId, ...snapshot };
Expand Down
Loading