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
33 changes: 16 additions & 17 deletions apps/desktop/docs/V2_LAUNCH_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ Launch dispatch uses the **pending row as the transport** between the
pending page (producer) and the V2 workspace page (consumer). **Zero V1
primitives.** Same pattern V2 preset execution uses
(`useV2PresetExecution`): live-query a record, open a pane in the V2
`@superset/panes` store, call `workspaceTrpc.terminal.ensureSession` to
attach PTY.
`@superset/panes` store, and pass any terminal startup command as transient
pane data. `TerminalPane` attaches the PTY through the terminal WebSocket.

```
┌─────────────────────────────────────────────────────────────┐
Expand Down Expand Up @@ -62,7 +62,7 @@ attach PTY.
│ │
│ if row.terminalLaunch: │
│ store.addTab({ panes: [{ kind:"terminal", … }] }) │
│ TerminalPane mounts → ensureSession → write command
│ TerminalPane mounts → WebSocket open → initialCommand
│ update(row, { terminalLaunch: null }) │
│ │
│ if row.chatLaunch: │
Expand Down Expand Up @@ -207,20 +207,19 @@ fixing before the dispatch rewrite is considered done:
branch for `workspaceTrpc.filesystem.writeFile`. Touches V1, V2,
chat, and every consumer — deliberate staged PR, not a quick fix.

2. **Reload-mid-launch spawns a second PTY.** `consumeTerminalLaunch`
calls `crypto.randomUUID()` for `terminalId` each time it fires. If
the user reloads the app between `terminalLaunch` being applied to
the pending row and the consume clearing it, the fresh consume
generates a new terminalId and calls `ensureSession` again — first
PTY orphaned, second one created. Fix: store the `terminalId` on
`PendingTerminalLaunch` itself (generate once in `dispatchForkLaunch`);
`ensureSession` becomes idempotent on repeat consumes.

3. **Silent failure in the consume hook.** `ensureSession` /
`addTab` failures `console.warn` and return — user sees no pane
open and no error UI. Wrap in try/toast with the error message.
Low urgency while `[v2-launch]` debug logs are present; becomes
visible when those are removed.
2. **Reload-mid-launch can create a new terminal ID.**
`consumeTerminalLaunch` calls `crypto.randomUUID()` for `terminalId`
each time it fires. If the user reloads the app between
`terminalLaunch` being applied to the pending row and the consume
clearing it, the fresh consume can generate a new terminal ID. Fix:
store the `terminalId` on `PendingTerminalLaunch` itself (generate
once in `dispatchForkLaunch`).

3. **Silent failure in the consume hook.** `addTab` failures
`console.warn` and return — user sees no pane open and no error UI.
Wrap in try/toast with the error message. Low urgency while
`[v2-launch]` debug logs are present; becomes visible when those are
removed.

4. **`joinPath` assumes POSIX separators.** Fine on Mac/Linux hosts
where the worktree paths come from. When remote-host launch lands
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ Disable Claude (or set Superset Chat as preferred via order in settings).
- [ ] **D2. Attachment write fails** — manually `chmod` the worktree
read-only, submit with attachments. Dispatch logs warning; pane
still opens; files missing (expected degradation).
- [ ] **D3. `ensureSession` fails** — stop host-service after create but
before navigation. Consume hook logs warning. `terminalLaunch`
stays set. Restart host-service, refresh. Consume re-fires.
- [ ] **D3. Terminal WebSocket attach fails** — stop host-service after
create but before navigation. Terminal pane opens and reports the
connection failure. Restart host-service, refresh. Consume re-fires
only if `terminalLaunch` was not cleared before attach.
- [ ] **D4. Agent disabled mid-flow** — enable agent, start submit, disable
before create completes. Pending page finishes. No pane opens.
Pending row `terminalLaunch` stays null.
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/lib/host-service-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ import { HOOK_PROTOCOL_VERSION } from "./terminal/env";
* which is how we prevent the renderer from talking to a stale host-service
* that's missing newly-added procedures/params.
*
* 0.4.0: terminal launch moved from `terminal.ensureSession` to
* `terminal.launchSession` plus WebSocket attach params.
* 0.3.0: host-service registers via cloud `host.ensure` (was
* `device.ensureV2Host`); v2_hosts/v2_users_hosts/v2_workspaces use
* machineId text instead of uuid surrogates.
* 0.2.0: `workspaceCreation.adopt` gained optional `worktreePath`.
*/
const MIN_HOST_SERVICE_VERSION = "0.3.0";
const MIN_HOST_SERVICE_VERSION = "0.4.0";

export type HostServiceStatus = "starting" | "running" | "stopped";

Expand Down
25 changes: 15 additions & 10 deletions apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,28 +164,33 @@ class TerminalRuntimeRegistryImpl {

/**
* Open (or re-use) the WebSocket transport for this terminal.
* Caller is responsible for ensuring the server session exists before
* calling — otherwise the server replies "Session not found".
* The WebSocket route can create the server session when the URL includes
* workspaceId; initialCommand is sent as the first frame after open.
*
* Idempotent: no-op if already connected/connecting to the same URL.
*/
connect(terminalId: string, wsUrl: string, instanceId = terminalId) {
connect(
terminalId: string,
wsUrl: string,
instanceId = terminalId,
options: { initialCommand?: string } = {},
) {
const entry = this.getEntry(terminalId, instanceId);
if (!entry?.runtime) return;
connect(entry.transport, entry.runtime.terminal, wsUrl);
connect(entry.transport, entry.runtime.terminal, wsUrl, options);
}

/**
* Swap the transport onto a new URL when it's already been brought up
* once. Used by effects watching `websocketUrl` — they fire on initial
* mount when the transport is still `"disconnected"` and ensureSession
* is in-flight, and we must not pre-empt that with a premature connect.
* mount when the transport is still `"disconnected"` and the mount effect
* owns the initial connect.
*
* Skipped states: `"disconnected"` (never opened; caller should use
* `connect()` via the ensureSession path). Allowed states: `"connecting"`
* (connect() cleanly aborts the in-flight socket), `"open"` (standard
* swap), and `"closed"` (previously live and mid-auto-reconnect — swap
* the URL so the reconnect targets the new endpoint).
* `connect()` from the mount path). Allowed states: `"connecting"` (connect()
* cleanly aborts the in-flight socket), `"open"` (standard swap), and
* `"closed"` (previously live and mid-auto-reconnect — swap the URL so the
* reconnect targets the new endpoint).
*/
reconnect(terminalId: string, wsUrl: string, instanceId = terminalId) {
const entry = this.getEntry(terminalId, instanceId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export function connect(
transport: TerminalTransport,
terminal: XTerm,
wsUrl: string,
options: { initialCommand?: string } = {},
) {
// Idempotent: skip if already connected/connecting to the same endpoint.
const isActive =
Expand All @@ -145,6 +146,14 @@ export function connect(
transport._reconnectAttempt = 0;
setConnectionState(transport, "open");
sendResize(transport, terminal.cols, terminal.rows);
if (options.initialCommand) {
socket.send(
JSON.stringify({
type: "initialCommand",
data: options.initialCommand,
}),
);
}
});

socket.addEventListener("message", (event) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { WorkspaceStore } from "@superset/panes";
import { toast } from "@superset/ui/sonner";
import { workspaceTrpc } from "@superset/workspace-client";
import { eq } from "@tanstack/db";
import { useLiveQuery } from "@tanstack/react-db";
import { useCallback, useEffect, useRef } from "react";
Expand Down Expand Up @@ -33,9 +32,6 @@ export function useConsumePendingLaunch({
store,
}: UseConsumePendingLaunchArgs): void {
const collections = useCollections();
const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation();
const ensureSessionRef = useRef(ensureSession);
ensureSessionRef.current = ensureSession;
const consumedRef = useRef<Set<string>>(new Set());

const { data: matches } = useLiveQuery(
Expand Down Expand Up @@ -87,10 +83,9 @@ export function useConsumePendingLaunch({
console.log("[v2-launch] useConsumePendingLaunch: consuming terminal", {
command: pending.terminalLaunch?.command.slice(0, 120),
});
void consumeTerminalLaunch({
consumeTerminalLaunch({
pending,
store,
ensureSession: ensureSessionRef.current.mutateAsync,
clear: () => updateRow({ terminalLaunch: null }),
});
}
Expand All @@ -107,21 +102,15 @@ export function useConsumePendingLaunch({
}, [pending, store, updateRow, workspaceId]);
}

async function consumeTerminalLaunch({
function consumeTerminalLaunch({
pending,
store,
ensureSession,
clear,
}: {
pending: PendingWorkspaceRow;
store: StoreApi<WorkspaceStore<PaneViewerData>>;
ensureSession: (input: {
terminalId: string;
workspaceId: string;
initialCommand?: string;
}) => Promise<unknown>;
clear: () => void;
}): Promise<void> {
}): void {
const launch = pending.terminalLaunch;
if (!launch || !pending.workspaceId) {
console.warn("[v2-launch] consumeTerminalLaunch: bailing", {
Expand All @@ -138,30 +127,16 @@ async function consumeTerminalLaunch({
}

const terminalId = crypto.randomUUID();
console.log("[v2-launch] consumeTerminalLaunch: ensureSession", {
console.log("[v2-launch] consumeTerminalLaunch: addTab", {
terminalId,
workspaceId: pending.workspaceId,
commandPreview: launch.command.slice(0, 120),
});

try {
await ensureSession({
terminalId,
workspaceId: pending.workspaceId,
initialCommand: launch.command,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
"[v2-launch] consumeTerminalLaunch: ensureSession failed:",
err,
);
toast.error("Couldn't start agent terminal", { description: msg });
return;
}

const data: TerminalPaneData = { terminalId };
console.log("[v2-launch] consumeTerminalLaunch: addTab", { terminalId });
const data: TerminalPaneData = {
terminalId,
initialCommand: launch.command,
};
store.getState().addTab({
panes: [
{
Expand Down
Loading
Loading