Files
kandev/apps/web/lib/api/domains/user-shell-api.ts
T
José Almeida 6fa38cd62c fix(terminal): re-key user-shell RPCs to task_environment_id (#770)
* refactor(backend): re-key user-shell RPCs to task_environment_id

user_shell.list/create/stop now take task_environment_id directly
instead of session_id with a session→env translation. Sessions in the
same task share one shell list, so the env is the natural scope.
HTTP route /api/v1/sessions/:id/terminals → /api/v1/environments/:id/terminals.
shell.status/subscribe/input (agent passthrough) stay session-keyed.

* fix(backend): heal task_sessions.task_environment_id on boot

Sessions created via paths that didn't write the FK left shell ops
broken — every env-keyed user-shell RPC failed to resolve the env and
the frontend silently swallowed the error. The boot heal links each
orphan session to its task's existing env (idempotent, runs after
backfillTaskEnvironments + healDuplicateTaskEnvironments).

* refactor(web): drop session_id from user-shell API, hooks, and slice

createUserShell/stopUserShell/fetchTerminals take environmentId.
useUserShells takes envId directly (no environmentIdBySessionId
translation). useTerminals takes both (env for shell ops, session for
tab UX persistence). Slice actions key writes by env id directly,
removing the silent no-op when the session→env mapping wasn't set yet.

* refactor(web): wire env id through dockview, right-panel, and SSR call sites

Dockview "+" menu, terminal scripts dropdown, dev-preview button,
right-panel "+" button, Ctrl+\` toggle, watermark, and panel-close
cleanup all source the env id from useEnvironmentId() / store and pass
it to createUserShell/stopUserShell. Shell-mode passthrough terminal
no longer blocks on session id — env id is sufficient. SSR resolves
the active session's task_environment_id from the session list and
calls fetchTerminals with the env id.

* test(e2e): cover env-keyed terminal new-tab flows + WS payload guard

New spec exercises the right-panel "+" button and the dockview "+ →
Terminal" menu, asserts the new tab connects, and intercepts outgoing
user_shell.create WS frames to verify the payload carries
task_environment_id and never session_id. A heal-coverage assertion
checks every listed session has task_environment_id populated post-boot.

* fix(e2e): drop right-panel '+' test, dockview is the only '+' path on desktop

The mobile TaskRightPanel SessionTabs aren't rendered at desktop
viewports — the dockview '+' menu is the sole new-tab affordance, so
testing the mobile path on chromium just timed out clicking a button
that never mounted. Consolidate into a single dockview '+ → Terminal'
test that asserts the new panel gets the backend-assigned 'Terminal 2'
label (proving env-shared shell list) plus the WS-payload regression guard.

* fix(e2e): assert dockview terminal-tab count, not 'Terminal 2' label

Dockview hardcodes the panel title to 'Terminal' for every tab — only
the mobile right-panel SessionTabs surface the backend's 'Terminal N'
label. Switch the assertion to count tabs to keep the e2e green.

* test(backend): cover SQL NULL branch of healSessionTaskEnvironmentIDs

Adds insertSessionWithNullEnvID and TestHealSessionTaskEnvironmentIDs_LinksNullOrphans
so the WHERE-clause IS NULL predicate is exercised — the existing tests only
seeded empty-string FKs. Also adds LIMIT 1 to the heal subquery as a
defensive measure: the unique index already guarantees ≤1 row per task at
runtime, but the SQL now reads as deterministic in isolation.

* refactor(web): drop dead taskGateOk on agent terminal WS gate

Agent passthrough routes by sessionId, not taskId — taskId was only used
for logging. Gating the WS effect on taskId forced agent terminals to
wait for useSession() to hydrate, adding a refresh-race window that
didn't guard any real precondition. connectionKey + canConnect already
cover the meaningful preconditions.

* fix(web): stamp env id on terminal panels for accurate cleanup

addTerminalPanel now bakes environmentId into the panel's params at
creation time. The dockview-layout-setup onDidRemovePanel handler
prefers the stamped env over re-deriving from the active session, so
closing a terminal that was opened under a different task no longer
calls stopUserShell with the wrong env (which silently no-ops on the
backend and leaks the shell process until session teardown). Falls back
to the active session's env for legacy panels persisted before the
param existed.

* fix(web): keep task id logging out of terminal reconnect deps
2026-05-02 15:19:02 +01:00

117 lines
3.3 KiB
TypeScript

import { getWebSocketClient } from "@/lib/ws/connection";
import { getBackendConfig } from "@/lib/config";
/**
* Terminal info returned by HTTP API (for SSR).
*/
export type TerminalInfo = {
terminal_id: string;
process_id: string;
running: boolean;
label: string;
closable: boolean;
initial_command?: string;
};
/**
* Fetch terminals for a task environment via HTTP (for SSR).
* This endpoint auto-creates the first "Terminal" if none exist.
*
* User shells are env-scoped — sessions in the same task share one shell list.
*/
export async function fetchTerminals(environmentId: string): Promise<TerminalInfo[]> {
const { apiBaseUrl } = getBackendConfig();
const url = `${apiBaseUrl}/api/v1/environments/${environmentId}/terminals`;
try {
const response = await fetch(url);
if (!response.ok) {
console.warn("Failed to fetch terminals:", response.status);
return [];
}
const data = await response.json();
return data.terminals ?? [];
} catch (error) {
console.warn("Failed to fetch terminals:", error);
return [];
}
}
/**
* Result of creating a new user shell.
*/
export type CreateUserShellResult = {
terminalId: string;
label: string;
closable: boolean;
initialCommand?: string;
};
/**
* Options for {@link createUserShell}. Pass `scriptId` to run a stored
* RepositoryScript, or `command` (with optional `label`) to run an arbitrary
* command — used for the repository's dev_script. Omit both for a plain shell.
*/
export type CreateUserShellOptions = {
scriptId?: string;
command?: string;
label?: string;
};
/**
* Create a new user shell terminal.
* Backend assigns the terminal ID, label, and closable status.
* First terminal is "Terminal" and not closable, subsequent are "Terminal 2", etc. and closable.
*
* User shells are env-scoped — pass the task environment ID, not a session ID.
*/
export async function createUserShell(
environmentId: string,
options?: CreateUserShellOptions,
): Promise<CreateUserShellResult> {
const client = getWebSocketClient();
if (!client) {
throw new Error("WebSocket client not available");
}
const payload: Record<string, string> = { task_environment_id: environmentId };
if (options?.scriptId) payload.script_id = options.scriptId;
if (options?.command) payload.command = options.command;
if (options?.label) payload.label = options.label;
const response = (await client.request("user_shell.create", payload)) as {
terminal_id: string;
label: string;
closable: boolean;
initial_command?: string;
};
return {
terminalId: response.terminal_id,
label: response.label,
closable: response.closable,
initialCommand: response.initial_command,
};
}
/**
* Stop a user shell terminal process.
* This is called when closing a terminal tab to clean up the backend process.
*/
export async function stopUserShell(environmentId: string, terminalId: string): Promise<void> {
const client = getWebSocketClient();
if (!client) {
// WebSocket not available, silently fail (best-effort cleanup)
return;
}
try {
await client.request("user_shell.stop", {
task_environment_id: environmentId,
terminal_id: terminalId,
});
} catch (error) {
// Log but don't throw - this is best-effort cleanup
console.warn("Failed to stop user shell:", error);
}
}