diff --git a/AGENTS.md b/AGENTS.md index 99d5f162956..3aab2d3a656 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,13 +48,6 @@ When a user asks for UI verification through the Chrome DevTools Protocol (CDP): 5. **Treat a mismatch as an incomplete reproduction** - if the test passes but the user still observes the bug, re-check the target instance, exact steps, input method, persisted keys, and lifecycle timing. Reproduce the failure before changing code; do not assume the report is disproven by a synthetic smoke test. 6. **Use an evidence gate** - for a reported bug or regression, do not claim it is verified until the original interaction demonstrably fails before the fix and passes after it under the same observations. For a new feature, record equivalent baseline evidence and demonstrate the expected behavior. In all cases, state clearly which checks were end-to-end, which were synthetic, and whether screenshots were actually captured. -## CLI End-to-End Verification - -Run cross-process agent-session coverage with `bun run test:cli-e2e`; it uses an -isolated home and a production-ABI PTY daemon. When the acceptance criterion -includes the real desktop UI, follow `apps/desktop/docs/CLI_CDP_E2E.md` and -preserve CLI output separately from genuine CDP screenshot evidence. - ## Agent Rules 1. **Type safety** - avoid `any` unless necessary 2. **Prefer `gh` CLI** - when performing git operations (PRs, issues, checkout, etc.), prefer the GitHub CLI (`gh`) over raw `git` commands where possible diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index e2e3e6fee62..ad8a0ff53c7 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -49,11 +49,6 @@ export const createMyRouter = () => { To check a change end-to-end against the real API/DB, drive the running dev app over CDP. Launch with an unused port, for example `RENDERER_REMOTE_DEBUG_PORT=9222 bun dev` (full stack; the app may restore a signed-in session), then attach via the page target's `webSocketDebuggerUrl` over a WebSocket (Bun built-in, no deps). Example: `scripts/cdp-smoke-integrations.ts`. -For a CLI-initiated journey, follow `docs/CLI_CDP_E2E.md`. It documents how to -run the development CLI against this exact Electron instance's home directory -and host manifest, then tie CLI-returned workspace/session IDs to real CDP input -and `Page.captureScreenshot` evidence. - **Never assume port 9222 or attach to a renderer from another worktree.** Multiple Superset workspaces commonly run at once, each with different renderer, API, and CDP ports. Before testing: 1. Read this workspace's final `DESKTOP_VITE_PORT` and `NEXT_PUBLIC_API_URL` values from the root `.env`. @@ -70,18 +65,14 @@ Check which setup script provisioned the workspace before repairing auth: - `.superset/setup.local.sh` creates a per-workspace local stack and runs the idempotent `bun run db:seed-dev`, but intentionally leaves sign-in as a separate step. If the account may be missing, rerun `bun run db:seed-dev` while the local DB stack is running. - `.superset/setup.sh` seeds `superset-dev-data/auth-token.enc` from `$HOME/.superset/auth-token.enc` when available. Rerunning it without `--force` can fill a missing token. Do not use `--force` merely to repair auth: it resets `superset-dev-data/` before reseeding. -The desktop hydrates a persisted token into an in-memory bearer-token closure. A raw `Runtime.evaluate` sign-in `fetch` can create a cookie session, but it does not populate or persist the bearer token used by the Electron host-service coordinator. For a real Electron E2E journey, prefer the app-owned local-dev sign-in flow: navigate the matched renderer to `#/sign-in` and click **Sign in as Local Admin (dev)** through CDP `Input.dispatchMouseEvent`. That flow signs in, calls `electronTrpc.auth.persistToken`, updates the in-memory auth client, and navigates to the workspace UI. Do not replace this click with a raw sign-in request when the test needs host-backed features such as projects, workspaces, terminals, or agents. - -After the app-owned sign-in, verify `/api/auth/get-session` from the matched renderer with `credentials: "include"` and require both `session` and `session.activeOrganizationId`. If diagnosing bearer hydration, import `/lib/auth-client.ts` and return only a boolean such as `Boolean(getAuthToken())`; never return or print the token itself. - -If a test only needs a cookie-authenticated API probe and does not need the Electron host service, a workspace created by `setup.local.sh` can repair its CDP cookie session as follows: +The desktop hydrates a persisted token into an in-memory bearer-token closure. A raw `Runtime.evaluate` `fetch` cannot read that closure, and the local-dev sign-in button persists a bearer token but uses `credentials: "omit"`; neither guarantees the cookie required by a raw CDP probe. For a workspace created by `setup.local.sh`, repair the CDP session as follows: 1. Require a localhost API origin; never send dev credentials to a remote or shared API. 2. From `apps/desktop` (so workspace imports resolve), import `DEV_EMAIL` and `DEV_PASSWORD` from `@superset/shared/dev-credentials`; do not copy their literal values into scripts or logs. 3. Through `Runtime.evaluate` in the matched renderer, POST them to `${NEXT_PUBLIC_API_URL}/api/auth/sign-in/email` with JSON content type and `credentials: "include"`. Do not print the returned token or response body. 4. Re-fetch `/api/auth/get-session` with `credentials: "include"` and require both `session` and `session.activeOrganizationId` before running the test. -This credentialed local-dev sign-in creates the browser session cookie needed by subsequent in-renderer fetches, but it is not a substitute for the app-owned sign-in when host-backed behavior is under test. If it fails, report the sign-in/session status codes only. For a non-local setup, use the app's normal sign-in flow; never substitute local dev credentials. +This credentialed local-dev sign-in creates the browser session cookie needed by subsequent in-renderer fetches. If it fails, report the sign-in/session status codes only. For a non-local setup, use the app's normal sign-in flow; never substitute local dev credentials. For a non-local workspace, the normal desktop flow intentionally restores an encrypted bearer token into the renderer's in-memory auth client without creating a browser cookie. If the renderer is on an authenticated route but a raw cookie-only probe returns no session, use `Runtime.evaluate` to import `/lib/auth-client.ts` from the renderer dev server and call `authClient.getSession({ fetchOptions: { throw: false } })`. This still verifies `/api/auth/get-session` through the app's real authenticated request path. Return only the status and `session.activeOrganizationId`; never call or print `getAuthToken()`. diff --git a/apps/desktop/docs/CLI_CDP_E2E.md b/apps/desktop/docs/CLI_CDP_E2E.md deleted file mode 100644 index 2143b6ffe35..00000000000 --- a/apps/desktop/docs/CLI_CDP_E2E.md +++ /dev/null @@ -1,132 +0,0 @@ -# CLI to desktop CDP end-to-end testing - -Use this runbook when a CLI change must be verified against the real Electron -app. `bun run test:cli-e2e` is the isolated, renderer-free acceptance suite; -this workflow uses the development CLI against Electron's real host service and -then verifies the returned state in the renderer through CDP. - -There is no CLI `--test` flag. Run every command from the worktree under test, -use its provisioned `SUPERSET_HOME_DIR`, and never point this workflow at -production or the default `~/.superset` directory. - -## Start and identify the app - -Confirm the worktree's generated ports and home without sourcing `.env`: - -```bash -pwd -awk -F= '/^(DESKTOP_VITE_PORT|SUPERSET_HOME_DIR|NEXT_PUBLIC_API_URL)=/ { print $1 "=" $2 }' .env -``` - -Choose an unused CDP port and launch the full stack in terminal A. A -renderer-only Vite process does not own the host service or PTY daemon and is -not an end-to-end target. - -```bash -cdp_port=29422 -lsof -nP -iTCP:"$cdp_port" -sTCP:LISTEN -RENDERER_REMOTE_DEBUG_PORT="$cdp_port" bun dev -``` - -In terminal B, require a `page` target whose URL uses this worktree's renderer -port. A responding CDP endpoint by itself is insufficient. - -```bash -cdp_port=29422 -desktop_port=$(awk -F= '/^DESKTOP_VITE_PORT=/{ gsub(/"/, "", $2); print $2 }' .env) -curl -fsS "http://127.0.0.1:$cdp_port/json/list" \ - | jq --arg origin "http://localhost:$desktop_port/" \ - '[.[] | select(.type == "page" and (.url | startswith($origin))) | {title, url, webSocketDebuggerUrl}]' -ps -axo pid=,ppid=,command= | rg "$PWD|remote-debugging-port=$cdp_port" -``` - -Verify the renderer session using the cookie or in-memory bearer flow in -`apps/desktop/AGENTS.md`. Never print a token, host secret, config file, or -manifest. If local state is missing, use the applicable setup script without -`--force`. - -## Run the CLI against Electron's host - -First prove the development CLI sees the live host manifest written beneath -this worktree's `SUPERSET_HOME_DIR`: - -```bash -case_id="cli-cdp-$(date +%s)" -evidence_dir="test-results/cli-cdp/$case_id" -mkdir -p "$evidence_dir" - -bun run --cwd packages/cli dev -- --json status \ - > "$evidence_dir/cli-status.json" -jq '{running, healthy, organizationId, hostId, port}' \ - "$evidence_dir/cli-status.json" -``` - -Stop if `running` or `healthy` is not true. Then create an unambiguous fixture -and retain the command's stdout, stderr, and exit code: - -```bash -project_id=$(bun run --cwd packages/cli dev -- --json projects list --local \ - | jq -r '[.[] | select(.setUp == "yes")][0].id') -test -n "$project_id" && test "$project_id" != "null" - -if bun run --cwd packages/cli dev -- --json workspaces create \ - --local --project "$project_id" --name "$case_id" --branch "$case_id" \ - --agent codex --prompt "Report this test id: $case_id" \ - > "$evidence_dir/workspace-create.json" \ - 2> "$evidence_dir/workspace-create.stderr"; then - cli_exit=0 -else - cli_exit=$? -fi -printf '%s\n' "$cli_exit" > "$evidence_dir/workspace-create.exit-code" -test "$cli_exit" -eq 0 - -workspace_id=$(jq -r '.workspace.id' "$evidence_dir/workspace-create.json") -session_id=$(jq -r '.agents[] | select(.ok and .kind == "terminal") | .sessionId' \ - "$evidence_dir/workspace-create.json") -test -n "$workspace_id" && test "$workspace_id" != "null" -test -n "$session_id" && test "$session_id" != "null" - -bun run --cwd packages/cli dev -- --json agents sessions read \ - "$session_id" --local --lines 120 > "$evidence_dir/session-read.json" -printf 'Continue and report the same test id.\n' \ - | bun run --cwd packages/cli dev -- --json agents sessions send \ - "$session_id" --local --wait --timeout 5m \ - > "$evidence_dir/session-send.json" -``` - -Use an agent configured on the target host if `codex` is unavailable. - -## Verify and capture through CDP - -Tie the renderer evidence to the exact returned IDs: - -1. Wait for `$case_id` to appear in the sidebar. -2. Navigate with real UI input. `Runtime.evaluate` may locate a bounding box, - but activate it with `Input.dispatchMouseEvent`, not `element.click()` or a - store mutation. -3. Open or deep-link the returned workspace and terminal. On macOS: - - ```bash - open "superset://v2-workspace/$workspace_id?terminalId=$session_id&focusRequestId=$(date +%s)" - ``` - -4. Record `window.location.href`, the visible workspace/session identifiers, - and focused state through CDP. -5. Capture the visible result with `Page.captureScreenshot`. - -`apps/desktop/scripts/cdp-smoke-integrations.ts` demonstrates selecting the -correct renderer and sending CDP commands over its `webSocketDebuggerUrl`. - -A complete `test-results/cli-cdp//` result contains the commit and -worktree, renderer URL and CDP port, CLI exit/stdout/stderr, returned IDs, -machine-readable CDP assertions, and a genuine screenshot. CLI output proves -the command path; the screenshot proves the real renderer displayed that same -state. Neither substitutes for the other. - -Clean up only the fixture created by this run: - -```bash -bun run --cwd packages/cli dev -- --json workspaces delete \ - "$workspace_id" --local > "$evidence_dir/workspace-delete.json" -``` diff --git a/apps/docs/content/docs/cli/cli-reference.mdx b/apps/docs/content/docs/cli/cli-reference.mdx index 51679715425..ad0cbf6f245 100644 --- a/apps/docs/content/docs/cli/cli-reference.mdx +++ b/apps/docs/content/docs/cli/cli-reference.mdx @@ -545,28 +545,12 @@ List workspaces accessible to the active organization. With neither { flag: "--command ", description: "Shell command to run in the new workspace after creation." }, { flag: "--attachment ", description: "Local file to upload for the spawned agent. Repeatable." }, ]} - output={`{ - workspace: Workspace; - terminals: Array<{ terminalId: string; label: string }>; - agents: Array< - | { ok: true; kind: "terminal" | "chat"; sessionId: string; label: string } - | { ok: false; error: string } - >; - alreadyExists: boolean; - autoNameWarning?: string; - txid: number | null; -}`} + output="Workspace" > Create a workspace on the target host. Specify exactly one of `--branch` or `--pr`. If you pass `--agent`, you must also pass `--prompt`. `--command` runs a one-off shell command in the worktree and is independent of -`--agent`/`--prompt`. Pass either or both. Attachments require `--agent` and -local paths are uploaded to the target host before launch. - -Workspace creation and the inline agent launch are separate outcomes. If the -workspace is created but the agent fails to start, the command exits non-zero, -prints the retained workspace ID, and suggests an `agents create` retry instead -of deleting the workspace or reporting full success. +`--agent`/`--prompt`. Pass either or both. ```bash superset workspaces create \ @@ -580,15 +564,6 @@ superset workspaces create \ --name "review-pr-123" \ --pr 123 \ --local - -superset workspaces create \ - --project prj_… \ - --name "review-with-context" \ - --branch review/with-context \ - --local \ - --agent codex \ - --prompt "Review the attached trace" \ - --attachment ./trace.log ``` @@ -656,12 +631,11 @@ superset workspaces open ws_… --print #### Opening a specific session `workspaces open` targets a workspace, not a session. A session started with -[`agents create`](#superset-agents-create) syncs to the desktop app but has no pane in -the workspace view until you navigate to it. Use -[`agents sessions list`](#superset-agents-sessions-list) to recover a live -terminal session ID. `workspaces open` can't focus a session, so build the deep -link yourself and append a query param chosen by the session `kind` returned by -`agents create`: +[`agents run`](#superset-agents-run) syncs to the desktop app but has no pane in +the workspace view until you navigate to it, and there is no session list, so a +freshly created session is effectively invisible until opened. `workspaces open` +can't focus a session, so build the deep link yourself and append a query param +chosen by the session `kind` returned by `agents run`: | `kind` | Example agents | Query param | | --- | --- | --- | @@ -670,7 +644,7 @@ link yourself and append a query param chosen by the session `kind` returned by ```bash # Run the agent with --json, then pull kind + sessionId out of the payload: -session=$(superset agents create --workspace ws_… --agent claude --prompt "…" --json) +session=$(superset agents run --workspace ws_… --agent claude --prompt "…" --json) kind=$(echo "$session" | jq -r '.kind') # "chat" or "terminal" sessionId=$(echo "$session" | jq -r '.sessionId') @@ -742,15 +716,7 @@ superset agents list --host host_… > Create an agent session in an existing workspace. Resolves the host that owns the workspace and starts the named preset (or HostAgentConfig instance) in a -fresh terminal session on that host. A successful response means the configured -agent process started; immediate launch failures return an error instead of an -idle terminal session ID. Large, multiline, Unicode, and attachment-expanded -prompts bypass the interactive shell's line editor so their complete bytes reach -argv- and stdin-based agents. - -Local `--attachment` paths are uploaded to the workspace's host before launch. -Use `--attachment-id` for files already uploaded to that same host. Both options -are repeatable and may be combined. +fresh terminal session on that host. ```bash superset agents create --workspace ws_… --agent claude --prompt "Audit the login flow" @@ -758,157 +724,6 @@ superset agents create --workspace ws_… --agent codex --prompt "Review this di ``` -The session commands use these JSON shapes: - -```ts -type AgentSession = { - terminalId: string; - workspaceId: string; - agentId: string; - agentSessionId?: string; - definitionId?: string; - startedAt: number; - lastEventAt: number; - lastEventType: string; - status: "working" | "permission" | "idle" | "failed"; -}; - -type AgentSessionRead = { - terminalId: string; - workspaceId: string; - status: AgentSession["status"]; - output: string; - truncated: boolean; -}; -``` - -", description: "Filter to one host machineId." }, - { flag: "--local", description: "Filter to this machine." }, - { flag: "--workspace ", description: "Filter to one workspace." }, - ]} - output={`Array<{ - status: "working" | "permission" | "idle" | "failed"; - agent: string; - workspaceId: string; - host: string; - hostId: string; - lastEventAt: string; - sessionId: string; -}>`} -> -List live terminal-agent sessions across reachable hosts. With no host filter, -the command queries online organization hosts plus a running local host service; -an unreachable host becomes a warning without hiding results from other hosts. -These commands control terminal agents only—Superset chat sessions are not part -of this live PTY interface. - -```bash -superset agents sessions list -superset agents sessions list --local --workspace ws_… -``` - - -", description: "Logical lines to return (default 120, maximum 1000)." }, - { flag: "--host ", description: "Probe only one host." }, - { flag: "--local", description: "Probe only this machine." }, - ]} - output={`{ - terminalId: string; - workspaceId: string; - status: "working" | "permission" | "idle" | "failed"; - output: string; - truncated: boolean; -}`} -> -Read a recent plain-text terminal snapshot without attaching to or focusing the -session. The snapshot is bounded and reports truncation in JSON mode; it is not -a canonical transcript. The terminal ID must be the full ID returned by -`agents create` or `agents sessions list`. Reads also work after a host-service -restart while the PTY daemon still owns the live process. - -```bash -superset agents sessions read --lines 120 -``` - - -", description: "Read the prompt from a file; use - for stdin." }, - { flag: "--wait", description: "Wait for the resulting turn to settle." }, - { flag: "--timeout ", description: "Maximum wait, such as 30s or 5m." }, - { flag: "--host ", description: "Probe only one host." }, - { flag: "--local", description: "Probe only this machine." }, - ]} - output={`Without --wait: -{ - terminalId: string; - accepted: true; - sentAt: number; -} - -With --wait: -{ - terminalId: string; - accepted: { accepted: true; sentAt: number }; - final: AgentSession | { status: "exited" }; - read: AgentSessionRead | null; -}`} -> -Send one semantic follow-up to a live agent. Choose exactly one prompt source: -positional text, `--file`, or piped stdin. Multiline input is submitted as one -bracketed paste and one submit key. `--wait` ignores an idle state that predates -the send, then returns when the resulting turn becomes idle, requests -permission, fails, or exits. Its response includes a fresh snapshot unless the -process exited. - -```bash -superset agents sessions send "Now fix the failing test" -superset agents sessions send --file follow-up.md --wait --timeout 5m -printf 'Check both files\nand run tests\n' | superset agents sessions send -``` - - -", required: true, description: "working, permission, idle, or failed." }, - { flag: "--timeout ", description: "Maximum wait (default 5m)." }, - { flag: "--host ", description: "Probe only one host." }, - { flag: "--local", description: "Probe only this machine." }, - ]} - output={`AgentSession | { status: "exited" }`} -> -Poll a live agent until it reaches the requested state, exits, disappears, the -timeout expires, or the command is interrupted. `--timeout` accepts milliseconds, -seconds, minutes, or hours, such as `250ms`, `30s`, `5m`, or `1h`. - -```bash -superset agents sessions wait --for idle --timeout 5m -``` - - -All four session commands operate through the host service and PTY daemon; the -desktop renderer does not need to be open. They address only a currently live -process. Resuming an exited native agent session is agent-specific and is not -part of this interface. - --- ### terminals (alias: `term`) diff --git a/bun.lock b/bun.lock index 60375702df4..a3fef3b5441 100644 --- a/bun.lock +++ b/bun.lock @@ -984,7 +984,7 @@ }, "packages/pty-daemon": { "name": "@superset/pty-daemon", - "version": "0.2.6", + "version": "0.2.5", "bin": { "pty-daemon": "./src/main.ts", }, diff --git a/package.json b/package.json index 350d4f0c559..eca10c4ce14 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "dev:relay": "turbo dev --filter=@superset/relay", "build": "turbo build --filter=@superset/desktop", "test": "turbo test --concurrency=2", - "test:cli-e2e": "bun run --cwd packages/cli test:e2e:agents", "lint": "./scripts/lint.sh", "check:desktop-git-env": "./scripts/check-desktop-git-env.sh", "lint:fix": "bunx @biomejs/biome@2.4.2 migrate --write && bunx @biomejs/biome@2.4.2 check --write --unsafe .", diff --git a/packages/cli-framework/src/parser.test.ts b/packages/cli-framework/src/parser.test.ts deleted file mode 100644 index ff05f99049a..00000000000 --- a/packages/cli-framework/src/parser.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { ProcessedBuilderConfig } from "./option"; -import { parseArgv } from "./parser"; - -const stringOption = (name: string): ProcessedBuilderConfig => ({ - name, - type: "string", - aliases: [], -}); - -describe("parseArgv", () => { - it("accepts the conventional stdin sentinel as a string option value", () => { - const result = parseArgv(["bun", "superset", "--file", "-"], { - file: stringOption("file"), - }); - - expect(result.options.file).toBe("-"); - }); - - it("does not consume another option as a string value", () => { - expect(() => - parseArgv(["bun", "superset", "--file", "--other"], { - file: stringOption("file"), - other: stringOption("other"), - }), - ).toThrow("Option --file requires a value"); - }); -}); diff --git a/packages/cli-framework/src/parser.ts b/packages/cli-framework/src/parser.ts index 7293b09e503..1ec95faa89b 100644 --- a/packages/cli-framework/src/parser.ts +++ b/packages/cli-framework/src/parser.ts @@ -146,11 +146,7 @@ export function parseArgv( // String/number: consume next arg as value const nextArg = args[i + 1]; - const isStdinSentinel = entry[1].type === "string" && nextArg === "-"; - if ( - nextArg === undefined || - (nextArg.startsWith("-") && !isStdinSentinel) - ) { + if (nextArg === undefined || nextArg.startsWith("-")) { throw new CLIError( `Option ${arg} requires a value`, entry[1].enumVals diff --git a/packages/cli/package.json b/packages/cli/package.json index a1a6e2750b5..734f69a68c8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,6 @@ "build:linux-x64": "cli-framework build --target=bun-linux-x64 --outfile=./dist/superset-linux-x64", "build:all": "bun run build:darwin-arm64 && bun run build:linux-x64", "build:dist": "bun run scripts/build-dist.ts", - "test:e2e:agents": "bun test/e2e/agent-sessions.e2e.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/cli/src/commands/agents/sessions/list/command.ts b/packages/cli/src/commands/agents/sessions/list/command.ts deleted file mode 100644 index 1547d1ce4a3..00000000000 --- a/packages/cli/src/commands/agents/sessions/list/command.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { boolean, string, table } from "@superset/cli-framework"; -import { command } from "../../../../lib/command"; -import { listHostAgentSessions } from "../../../../lib/host-agent-sessions"; -import { resolveHostFilter } from "../../../../lib/host-target"; -import { printWarnings, requireOrganizationId } from "../shared"; - -export default command({ - description: "List running terminal-agent sessions", - options: { - host: string().desc("Filter to a specific host (machineId)"), - local: boolean().desc("Filter to this machine"), - workspace: string().desc("Filter to a workspace id"), - }, - display: (data) => - table( - data as Record[], - ["status", "agent", "workspaceId", "host", "lastEventAt", "sessionId"], - ["STATUS", "AGENT", "WORKSPACE", "HOST", "LAST EVENT", "SESSION ID"], - [12, 18, 36, 24, 24, 36], - ), - run: async ({ ctx, options }) => { - const organizationId = requireOrganizationId(ctx); - const hostId = resolveHostFilter({ - host: options.host ?? undefined, - local: options.local ?? undefined, - }); - const { matches, warnings } = await listHostAgentSessions({ - api: ctx.api, - organizationId, - userJwt: ctx.bearer, - ...(hostId ? { hostId } : {}), - }); - printWarnings(warnings); - return matches - .filter( - ({ session }) => - !options.workspace || session.workspaceId === options.workspace, - ) - .map(({ session, hostId: resolvedHostId, hostName }) => ({ - status: session.status, - agent: session.definitionId ?? session.agentId, - workspaceId: session.workspaceId, - host: hostName, - hostId: resolvedHostId, - lastEventAt: new Date(session.lastEventAt).toISOString(), - sessionId: session.terminalId, - })); - }, -}); diff --git a/packages/cli/src/commands/agents/sessions/meta.ts b/packages/cli/src/commands/agents/sessions/meta.ts deleted file mode 100644 index a84fe672282..00000000000 --- a/packages/cli/src/commands/agents/sessions/meta.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - description: "Inspect and continue running agent sessions", -}; diff --git a/packages/cli/src/commands/agents/sessions/read/command.ts b/packages/cli/src/commands/agents/sessions/read/command.ts deleted file mode 100644 index 791a3902b97..00000000000 --- a/packages/cli/src/commands/agents/sessions/read/command.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { boolean, number, positional, string } from "@superset/cli-framework"; -import { command } from "../../../../lib/command"; -import { resolveSession } from "../shared"; - -export default command({ - description: "Read recent plain-text output from an agent session", - args: [positional("sessionId").required().desc("Full terminal session id")], - options: { - host: string().desc("Target a specific host (machineId)"), - local: boolean().desc("Target this machine"), - lines: number().int().min(1).max(1000).default(120).desc("Logical lines"), - }, - run: async ({ ctx, args, options }) => { - const sessionId = args.sessionId as string; - const match = await resolveSession( - ctx, - { - host: options.host ?? undefined, - local: options.local ?? undefined, - }, - sessionId, - ); - const result = await match.client.terminalAgents.read.query({ - terminalId: sessionId, - lines: options.lines, - }); - return { data: result, message: result.output }; - }, -}); diff --git a/packages/cli/src/commands/agents/sessions/send/command.test.ts b/packages/cli/src/commands/agents/sessions/send/command.test.ts deleted file mode 100644 index a2eb493beac..00000000000 --- a/packages/cli/src/commands/agents/sessions/send/command.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { resolvePrompt } from "./command"; - -const directories: string[] = []; - -afterEach(() => { - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } -}); - -describe("resolvePrompt", () => { - it("accepts positional, file, and piped prompt sources", async () => { - expect(await resolvePrompt(["follow", "up"], undefined, false)).toBe( - "follow up", - ); - const directory = mkdtempSync(join(tmpdir(), "cli-session-send-")); - directories.push(directory); - const file = join(directory, "prompt.md"); - writeFileSync(file, "from file\n"); - expect(await resolvePrompt([], file, false)).toBe("from file\n"); - expect( - await resolvePrompt([], undefined, true, async () => "from stdin\n"), - ).toBe("from stdin\n"); - }); - - it("allows --file - to name stdin without double-counting it", async () => { - expect( - await resolvePrompt([], "-", true, async () => "stdin via file\n"), - ).toBe("stdin via file\n"); - }); - - it("does not read ambient stdin when --file names a path", async () => { - const directory = mkdtempSync(join(tmpdir(), "cli-session-send-")); - directories.push(directory); - const file = join(directory, "prompt.md"); - writeFileSync(file, "from explicit file\n"); - let readStdinCalled = false; - - expect( - await resolvePrompt([], file, true, async () => { - readStdinCalled = true; - return "ambient stdin"; - }), - ).toBe("from explicit file\n"); - expect(readStdinCalled).toBe(false); - }); - - it("rejects conflicting or empty sources", async () => { - await expect( - resolvePrompt(["positional"], undefined, true, async () => "piped"), - ).rejects.toThrow("exactly one prompt source"); - await expect( - resolvePrompt([], undefined, true, async () => " \n"), - ).rejects.toThrow("empty prompt"); - }); -}); diff --git a/packages/cli/src/commands/agents/sessions/send/command.ts b/packages/cli/src/commands/agents/sessions/send/command.ts deleted file mode 100644 index be571a15c11..00000000000 --- a/packages/cli/src/commands/agents/sessions/send/command.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { readFileSync } from "node:fs"; -import { boolean, CLIError, positional, string } from "@superset/cli-framework"; -import { command } from "../../../../lib/command"; -import { parseDuration, resolveSession, waitForSession } from "../shared"; - -async function readStdin(): Promise { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - return Buffer.concat(chunks).toString("utf8"); -} - -export async function resolvePrompt( - positionals: string[], - file: string | undefined, - stdinIsPiped = !process.stdin.isTTY, - readStdinInput: () => Promise = readStdin, -): Promise { - const hasPositional = positionals.length > 0; - const hasFile = file !== undefined; - const stdinPrompt = - file === "-" || (stdinIsPiped && !hasFile) - ? await readStdinInput() - : undefined; - const hasPipedStdin = - file !== "-" && stdinPrompt !== undefined && stdinPrompt.length > 0; - const sourceCount = - Number(hasPositional) + Number(hasFile) + Number(hasPipedStdin); - if (sourceCount !== 1) { - throw new CLIError( - "Provide exactly one prompt source", - "Use positional text, --file , or piped stdin.", - ); - } - const prompt = hasPositional - ? positionals.join(" ") - : hasFile - ? file === "-" - ? (stdinPrompt ?? "") - : readFileSync(file, "utf8") - : (stdinPrompt ?? ""); - if (!prompt.trim()) throw new CLIError("Refusing to send an empty prompt"); - return prompt; -} - -export default command({ - description: "Send a follow-up prompt to a running agent session", - args: [ - positional("sessionId").required().desc("Full terminal session id"), - positional("prompt").variadic().desc("Follow-up prompt text"), - ], - options: { - host: string().desc("Target a specific host (machineId)"), - local: boolean().desc("Target this machine"), - file: string().desc("Read the prompt from a file; use - for stdin"), - wait: boolean().desc("Wait for the resulting turn to settle"), - timeout: string().default("5m").desc("Maximum --wait duration"), - }, - run: async ({ ctx, args, options, signal }) => { - const sessionId = args.sessionId as string; - const prompt = await resolvePrompt( - (args.prompt as string[] | undefined) ?? [], - options.file ?? undefined, - ); - const match = await resolveSession( - ctx, - { - host: options.host ?? undefined, - local: options.local ?? undefined, - }, - sessionId, - ); - const accepted = await match.client.terminalAgents.send.mutate({ - terminalId: sessionId, - prompt, - }); - if (!options.wait) { - return { - data: { terminalId: sessionId, ...accepted }, - message: `Prompt accepted by session ${sessionId}`, - }; - } - - const final = await waitForSession({ - match, - statuses: new Set(["idle", "permission", "failed"]), - timeoutMs: parseDuration(options.timeout), - minEventAt: accepted.sentAt, - signal, - }); - const read = - final.status === "exited" - ? null - : await match.client.terminalAgents.read.query({ - terminalId: sessionId, - lines: 120, - }); - return { - data: { terminalId: sessionId, accepted, final, read }, - message: read - ? `Session ${sessionId}: ${final.status}\n\n${read.output}` - : `Session ${sessionId}: exited`, - }; - }, -}); diff --git a/packages/cli/src/commands/agents/sessions/shared.test.ts b/packages/cli/src/commands/agents/sessions/shared.test.ts deleted file mode 100644 index d5a3b774c1d..00000000000 --- a/packages/cli/src/commands/agents/sessions/shared.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { HostAgentSessionMatch } from "../../../lib/host-agent-sessions"; -import { - parseDuration, - parseStatus, - selectExactSession, - waitForSession, -} from "./shared"; - -describe("session option parsing", () => { - it("parses duration suffixes", () => { - expect(parseDuration("250ms")).toBe(250); - expect(parseDuration("30s")).toBe(30_000); - expect(parseDuration("5m")).toBe(300_000); - expect(parseDuration("1h")).toBe(3_600_000); - }); - - it("validates lifecycle states", () => { - expect(parseStatus("permission")).toBe("permission"); - expect(() => parseStatus("done")).toThrow("Invalid session status"); - }); -}); - -describe("selectExactSession", () => { - const makeMatch = (hostId: string, terminalId = "session-1") => - ({ hostId, session: { terminalId } }) as HostAgentSessionMatch; - - it("selects one exact full id across hosts", () => { - expect( - selectExactSession( - [makeMatch("host-a", "other"), makeMatch("host-b")], - "session-1", - ).hostId, - ).toBe("host-b"); - }); - - it("rejects missing and duplicate ids", () => { - expect(() => selectExactSession([], "missing")).toThrow("not found"); - expect(() => - selectExactSession( - [makeMatch("host-a"), makeMatch("host-b")], - "session-1", - ), - ).toThrow("multiple hosts"); - }); -}); - -describe("waitForSession", () => { - it("does not accept a pre-send idle state before the event cursor", async () => { - const states = [ - { status: "idle", lastEventAt: 99 }, - { status: "working", lastEventAt: 101 }, - { status: "idle", lastEventAt: 102 }, - ] as const; - let call = 0; - let querySignal: AbortSignal | undefined; - const base = { - terminalId: "session-1", - workspaceId: "workspace-1", - agentId: "claude", - startedAt: 1, - lastEventType: "Stop", - }; - const match = { - session: { ...base, ...states[0] }, - client: { - terminalAgents: { - get: { - query: async ( - _input: unknown, - options?: { signal?: AbortSignal }, - ) => { - querySignal = options?.signal; - return { - ...base, - ...states[Math.min(call++, states.length - 1)], - }; - }, - }, - }, - }, - } as unknown as HostAgentSessionMatch; - - const controller = new AbortController(); - const result = await waitForSession({ - match, - statuses: new Set(["idle"]), - timeoutMs: 1000, - minEventAt: 100, - signal: controller.signal, - pollIntervalMs: 1, - }); - - expect(result.status).toBe("idle"); - expect("lastEventAt" in result ? result.lastEventAt : 0).toBe(102); - expect(call).toBe(3); - expect(querySignal).toBe(controller.signal); - }); - - it("returns exited when the session disappears", async () => { - const match = { - session: { - terminalId: "session-1", - status: "working", - }, - client: { - terminalAgents: { - get: { - query: async () => { - throw Object.assign(new Error("missing"), { - data: { code: "NOT_FOUND" }, - }); - }, - }, - }, - }, - } as unknown as HostAgentSessionMatch; - expect( - await waitForSession({ - match, - statuses: new Set(["idle"]), - timeoutMs: 100, - signal: new AbortController().signal, - pollIntervalMs: 1, - }), - ).toEqual({ status: "exited" }); - }); -}); diff --git a/packages/cli/src/commands/agents/sessions/shared.ts b/packages/cli/src/commands/agents/sessions/shared.ts deleted file mode 100644 index 4a01ab0df98..00000000000 --- a/packages/cli/src/commands/agents/sessions/shared.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { CLIError } from "@superset/cli-framework"; -import type { CliContext } from "../../../lib/command"; -import { - type HostAgentSessionMatch, - listHostAgentSessions, -} from "../../../lib/host-agent-sessions"; -import { resolveHostFilter } from "../../../lib/host-target"; - -export const SESSION_STATUSES = [ - "working", - "permission", - "idle", - "failed", -] as const; -export type SessionStatus = (typeof SESSION_STATUSES)[number]; - -export function requireOrganizationId(ctx: CliContext): string { - const organizationId = ctx.config.organizationId; - if (!organizationId) { - throw new CLIError("No active organization", "Run: superset auth login"); - } - return organizationId; -} - -export function printWarnings(warnings: string[]): void { - for (const warning of warnings) process.stderr.write(`Warning: ${warning}\n`); -} - -export function selectExactSession( - matches: HostAgentSessionMatch[], - terminalId: string, -): HostAgentSessionMatch { - const exact = matches.filter( - (candidate) => candidate.session.terminalId === terminalId, - ); - if (exact.length === 0) { - throw new CLIError( - `Agent session not found: ${terminalId}`, - "Run: superset agents sessions list", - ); - } - if (exact.length > 1) { - throw new CLIError( - `Agent session id matched multiple hosts: ${terminalId}`, - "Pass --host or --local to choose one host.", - ); - } - const match = exact[0]; - if (!match) throw new Error("unreachable: exact match disappeared"); - return match; -} - -export async function resolveSession( - ctx: CliContext, - flags: { host?: string; local?: boolean }, - terminalId: string, -): Promise { - const organizationId = requireOrganizationId(ctx); - const hostId = resolveHostFilter({ - host: flags.host, - local: flags.local, - }); - const { matches, warnings } = await listHostAgentSessions({ - api: ctx.api, - organizationId, - userJwt: ctx.bearer, - ...(hostId ? { hostId } : {}), - }); - printWarnings(warnings); - return selectExactSession(matches, terminalId); -} - -export function parseDuration(value: string): number { - const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(value.trim()); - if (!match) { - throw new CLIError( - `Invalid timeout: ${value}`, - "Use a duration such as 30s, 5m, or 1h.", - ); - } - const amount = Number(match[1]); - const unit = match[2] ?? "s"; - const multipliers: Record = { - ms: 1, - s: 1000, - m: 60_000, - h: 3_600_000, - }; - const multiplier = multipliers[unit] ?? 1000; - const duration = amount * multiplier; - if (!Number.isFinite(duration) || duration <= 0) { - throw new CLIError(`Invalid timeout: ${value}`); - } - return duration; -} - -export function parseStatus(value: string): SessionStatus { - if ((SESSION_STATUSES as readonly string[]).includes(value)) { - return value as SessionStatus; - } - throw new CLIError( - `Invalid session status: ${value}`, - `Choose one of: ${SESSION_STATUSES.join(", ")}`, - ); -} - -export async function waitForSession({ - match, - statuses, - timeoutMs, - minEventAt, - signal, - pollIntervalMs = 500, -}: { - match: HostAgentSessionMatch; - statuses: ReadonlySet; - timeoutMs: number; - minEventAt?: number; - signal: AbortSignal; - pollIntervalMs?: number; -}): Promise { - const deadline = Date.now() + timeoutMs; - let lastStatus = match.session.status; - while (Date.now() <= deadline) { - if (signal.aborted) throw new CLIError("Interrupted while waiting"); - let session: HostAgentSessionMatch["session"]; - try { - session = await match.client.terminalAgents.get.query( - { terminalId: match.session.terminalId }, - { signal }, - ); - } catch (error) { - if (isNotFoundError(error)) return { status: "exited" }; - throw error; - } - lastStatus = session.status; - if ( - statuses.has(session.status) && - (minEventAt === undefined || session.lastEventAt >= minEventAt) - ) { - return session; - } - await abortableDelay(pollIntervalMs, signal); - } - throw new CLIError( - `Timed out waiting for agent session ${match.session.terminalId}`, - `Last observed status: ${lastStatus}`, - ); -} - -function isNotFoundError(error: unknown): boolean { - if (typeof error !== "object" || error === null) return false; - const data = "data" in error ? error.data : undefined; - if ( - typeof data === "object" && - data !== null && - "code" in data && - data.code === "NOT_FOUND" - ) { - return true; - } - return ( - "message" in error && - typeof error.message === "string" && - error.message.includes("No live agent session") - ); -} - -function abortableDelay(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - reject(new CLIError("Interrupted while waiting")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); -} diff --git a/packages/cli/src/commands/agents/sessions/wait/command.ts b/packages/cli/src/commands/agents/sessions/wait/command.ts deleted file mode 100644 index 49242443334..00000000000 --- a/packages/cli/src/commands/agents/sessions/wait/command.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { boolean, positional, string } from "@superset/cli-framework"; -import { command } from "../../../../lib/command"; -import { - parseDuration, - parseStatus, - resolveSession, - waitForSession, -} from "../shared"; - -export default command({ - description: "Wait for an agent session to reach a lifecycle state", - args: [positional("sessionId").required().desc("Full terminal session id")], - options: { - host: string().desc("Target a specific host (machineId)"), - local: boolean().desc("Target this machine"), - for: string() - .required() - .desc("State: working, permission, idle, or failed"), - timeout: string().default("5m").desc("Maximum wait, e.g. 30s or 5m"), - }, - run: async ({ ctx, args, options, signal }) => { - const sessionId = args.sessionId as string; - const match = await resolveSession( - ctx, - { - host: options.host ?? undefined, - local: options.local ?? undefined, - }, - sessionId, - ); - const result = await waitForSession({ - match, - statuses: new Set([parseStatus(options.for)]), - timeoutMs: parseDuration(options.timeout), - signal, - }); - return { - data: result, - message: `Session ${sessionId}: ${result.status}`, - }; - }, -}); diff --git a/packages/cli/src/commands/workspaces/create/agent-results.test.ts b/packages/cli/src/commands/workspaces/create/agent-results.test.ts deleted file mode 100644 index 3ecd2d2079b..00000000000 --- a/packages/cli/src/commands/workspaces/create/agent-results.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { assertRequestedAgentsStarted } from "./agent-results"; - -const base = { - workspace: { id: "workspace-1", name: "attachment-fix" }, - alreadyExists: false, -}; - -describe("assertRequestedAgentsStarted", () => { - it("accepts successful launches and requests without agents", () => { - expect(() => - assertRequestedAgentsStarted({ ...base, agents: [{ ok: true }] }, 1), - ).not.toThrow(); - expect(() => - assertRequestedAgentsStarted({ ...base, agents: [] }, 0), - ).not.toThrow(); - }); - - it("reports partial failure with the retained workspace and retry command", () => { - try { - assertRequestedAgentsStarted( - { - ...base, - agents: [{ ok: false, error: "launcher exited with status 127" }], - }, - 1, - ); - throw new Error("expected assertion to throw"); - } catch (error) { - expect(error).toMatchObject({ - message: - 'Workspace "attachment-fix" was created, but the requested agent failed to start', - }); - expect((error as { suggestion?: string }).suggestion).toContain( - "launcher exited with status 127", - ); - expect((error as { suggestion?: string }).suggestion).toContain( - "superset agents create --workspace workspace-1", - ); - } - }); - - it("rejects a missing host result instead of silently succeeding", () => { - expect(() => - assertRequestedAgentsStarted({ ...base, agents: [] }, 1), - ).toThrow("failed to start"); - }); -}); diff --git a/packages/cli/src/commands/workspaces/create/agent-results.ts b/packages/cli/src/commands/workspaces/create/agent-results.ts deleted file mode 100644 index bb2832ece07..00000000000 --- a/packages/cli/src/commands/workspaces/create/agent-results.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { CLIError } from "@superset/cli-framework"; - -interface WorkspaceAgentResult { - ok: boolean; - error?: string; -} - -interface WorkspaceCreateAgentSummary { - workspace: { id: string; name: string }; - alreadyExists: boolean; - agents: WorkspaceAgentResult[]; -} - -/** - * Workspace creation intentionally survives an agent launch failure, but the - * CLI must not turn that partial success into exit 0. Keep that policy at the - * command boundary: the host returns structured per-agent results so desktop - * callers can choose their own UX, while scripts get a truthful failure and - * the workspace ID needed to retry with `agents create`. - */ -export function assertRequestedAgentsStarted( - result: WorkspaceCreateAgentSummary, - expectedCount: number, -): void { - if (expectedCount === 0) return; - - const failures = result.agents.filter((agent) => !agent.ok); - const missingCount = Math.max(0, expectedCount - result.agents.length); - if (failures.length === 0 && missingCount === 0) return; - - const requestedLabel = expectedCount === 1 ? "agent" : "agents"; - const action = result.alreadyExists ? "reused" : "created"; - const details = [ - ...failures.map((failure) => failure.error ?? "Unknown launch error"), - ...(missingCount > 0 - ? [`Host returned no result for ${missingCount} requested launch(es).`] - : []), - ]; - throw new CLIError( - `Workspace "${result.workspace.name}" was ${action}, but the requested ${requestedLabel} failed to start`, - `Workspace ID: ${result.workspace.id}. ${details.join(" ")} Retry with: superset agents create --workspace ${result.workspace.id} --agent --prompt `, - ); -} diff --git a/packages/cli/src/commands/workspaces/create/command.ts b/packages/cli/src/commands/workspaces/create/command.ts index 46422719400..a1fec9a4dc6 100644 --- a/packages/cli/src/commands/workspaces/create/command.ts +++ b/packages/cli/src/commands/workspaces/create/command.ts @@ -2,7 +2,6 @@ import { boolean, CLIError, number, string } from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { requireHostTarget, resolveHostTarget } from "../../../lib/host-target"; import { uploadAttachments } from "../../../lib/upload-attachments"; -import { assertRequestedAgentsStarted } from "./agent-results"; export default command({ description: "Create a workspace on a host", @@ -98,7 +97,6 @@ export default command({ agents, command: options.command ?? undefined, }); - assertRequestedAgentsStarted(result, agents?.length ?? 0); return { data: result, diff --git a/packages/cli/src/lib/host-agent-sessions.ts b/packages/cli/src/lib/host-agent-sessions.ts deleted file mode 100644 index c639e047fed..00000000000 --- a/packages/cli/src/lib/host-agent-sessions.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ApiClient } from "./api-client"; -import { type HostServiceClient, queryHostTargets } from "./host-target"; - -export type HostAgentSession = Awaited< - ReturnType ->[number]; - -export interface HostAgentSessionMatch { - session: HostAgentSession; - hostId: string; - hostName: string; - client: HostServiceClient; -} - -interface ListOptions { - api: ApiClient; - organizationId: string; - userJwt: string; - hostId?: string; -} - -export async function listHostAgentSessions(options: ListOptions): Promise<{ - matches: HostAgentSessionMatch[]; - warnings: string[]; -}> { - const { results, warnings } = await queryHostTargets(options, (client) => - client.terminalAgents.list.query(), - ); - const matches: HostAgentSessionMatch[] = []; - for (const result of results) { - for (const session of result.value) { - matches.push({ - session, - hostId: result.hostId, - hostName: result.hostName, - client: result.client, - }); - } - } - return { matches, warnings }; -} diff --git a/packages/cli/src/lib/host-target/index.ts b/packages/cli/src/lib/host-target/index.ts index c675918e640..3e590507828 100644 --- a/packages/cli/src/lib/host-target/index.ts +++ b/packages/cli/src/lib/host-target/index.ts @@ -1,8 +1,3 @@ -export { - type HostInfo, - type HostQueryResult, - queryHostTargets, -} from "./queryHostTargets"; export { type HostFlags, requireHostTarget, diff --git a/packages/cli/src/lib/host-target/queryHostTargets.test.ts b/packages/cli/src/lib/host-target/queryHostTargets.test.ts deleted file mode 100644 index d7834409e9b..00000000000 --- a/packages/cli/src/lib/host-target/queryHostTargets.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; -import type { ApiClient } from "../api-client"; -import type { HostServiceClient } from "./resolveHostTarget"; - -const LOCAL_HOST_ID = "local-host"; - -let discoveredHosts: Array<{ id: string; name: string; online: boolean }> = []; -let discoveryError: Error | undefined; -let localManifestLive = false; - -const hostListQuery = mock(async () => { - if (discoveryError) throw discoveryError; - return discoveredHosts; -}); - -const clients = new Map(); -const resolveHostTarget = mock( - ({ requestedHostId }: { requestedHostId: string }) => { - const client = clients.get(requestedHostId); - if (!client) throw new Error(`missing client for ${requestedHostId}`); - return { client }; - }, -); - -mock.module("@superset/shared/host-info", () => ({ - getHostId: () => LOCAL_HOST_ID, -})); - -mock.module("../host/manifest", () => ({ - isProcessAlive: () => localManifestLive, - readManifest: () => (localManifestLive ? { pid: 1234 } : null), -})); - -mock.module("./resolveHostTarget", () => ({ resolveHostTarget })); - -const { queryHostTargets } = await import("./queryHostTargets"); - -function createApi(): ApiClient { - return { - host: { list: { query: hostListQuery } }, - } as unknown as ApiClient; -} - -function addClient(hostId: string): HostServiceClient { - const client = { hostId } as unknown as HostServiceClient; - clients.set(hostId, client); - return client; -} - -const options = { - api: createApi(), - organizationId: "org-1", - userJwt: "jwt-1", -}; - -beforeEach(() => { - discoveredHosts = []; - discoveryError = undefined; - localManifestLive = false; - clients.clear(); - hostListQuery.mockClear(); - resolveHostTarget.mockClear(); -}); - -describe("queryHostTargets", () => { - test("restricts an explicit query to the requested host", async () => { - discoveredHosts = [ - { id: "host-a", name: "Alpha", online: true }, - { id: "host-b", name: "Beta", online: true }, - ]; - addClient("host-b"); - - const result = await queryHostTargets( - { ...options, hostId: "host-b" }, - async (client) => (client as unknown as { hostId: string }).hostId, - ); - - expect(result.results).toMatchObject([ - { hostId: "host-b", hostName: "Beta", value: "host-b" }, - ]); - expect(resolveHostTarget).toHaveBeenCalledTimes(1); - expect(result.warnings).toEqual([]); - }); - - test("falls back to the local host when cloud discovery fails", async () => { - discoveryError = new Error("network down"); - addClient(LOCAL_HOST_ID); - - const result = await queryHostTargets(options, async () => "ok"); - - expect(result.results).toMatchObject([ - { hostId: LOCAL_HOST_ID, hostName: LOCAL_HOST_ID, value: "ok" }, - ]); - expect(result.warnings).toEqual([ - "Cloud host discovery failed (network down); checking this machine's host only", - ]); - }); - - test("includes a live local manifest alongside online discovered hosts", async () => { - discoveredHosts = [{ id: "remote-host", name: "Remote", online: true }]; - localManifestLive = true; - addClient("remote-host"); - addClient(LOCAL_HOST_ID); - - const result = await queryHostTargets( - options, - async (client) => (client as unknown as { hostId: string }).hostId, - ); - - expect(result.results.map(({ hostId }) => hostId)).toEqual([ - "remote-host", - LOCAL_HOST_ID, - ]); - expect(result.warnings).toEqual([]); - }); - - test("keeps successful hosts when another host query rejects", async () => { - discoveredHosts = [ - { id: "host-a", name: "Alpha", online: true }, - { id: "host-b", name: "Beta", online: true }, - ]; - addClient("host-a"); - addClient("host-b"); - - const result = await queryHostTargets(options, async (client) => { - const hostId = (client as unknown as { hostId: string }).hostId; - if (hostId === "host-b") throw new Error("connection refused"); - return hostId; - }); - - expect(result.results).toMatchObject([ - { hostId: "host-a", hostName: "Alpha", value: "host-a" }, - ]); - expect(result.warnings).toEqual([ - "Host Beta unreachable: connection refused", - ]); - }); - - test("does not query when every discovered host is offline", async () => { - discoveredHosts = [ - { id: "host-a", name: "Alpha", online: false }, - { id: "host-b", name: "Beta", online: false }, - ]; - const query = mock(async () => "unreachable"); - - const result = await queryHostTargets(options, query); - - expect(result.results).toEqual([]); - expect(query).not.toHaveBeenCalled(); - expect(resolveHostTarget).not.toHaveBeenCalled(); - expect(result.warnings).toEqual([ - "No hosts are currently online; nothing to query", - ]); - }); -}); diff --git a/packages/cli/src/lib/host-target/queryHostTargets.ts b/packages/cli/src/lib/host-target/queryHostTargets.ts deleted file mode 100644 index f102dc435f8..00000000000 --- a/packages/cli/src/lib/host-target/queryHostTargets.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { getHostId } from "@superset/shared/host-info"; -import type { ApiClient } from "../api-client"; -import { isProcessAlive, readManifest } from "../host/manifest"; -import { type HostServiceClient, resolveHostTarget } from "./resolveHostTarget"; - -export interface HostInfo { - id: string; - name: string; - online: boolean; -} - -export interface HostQueryResult { - value: T; - hostId: string; - hostName: string; - client: HostServiceClient; -} - -export interface QueryHostTargetsOptions { - api: ApiClient; - organizationId: string; - userJwt: string; - /** Restrict the fan-out to a single host. */ - hostId?: string; -} - -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** Discover reachable host-service targets and run the same query on each. */ -export async function queryHostTargets( - options: QueryHostTargetsOptions, - query: (client: HostServiceClient) => Promise, -): Promise<{ - results: HostQueryResult[]; - hosts: HostInfo[]; - warnings: string[]; -}> { - const warnings: string[] = []; - const localHostId = getHostId(); - let hosts: HostInfo[] = []; - let discoveryError: unknown; - try { - hosts = await options.api.host.list.query({ - organizationId: options.organizationId, - }); - } catch (error) { - discoveryError = error; - } - - let targetHostIds: string[]; - if (options.hostId) { - targetHostIds = [options.hostId]; - } else if (hosts.length > 0) { - const ids = new Set( - hosts.filter((host) => host.online).map((host) => host.id), - ); - const manifest = readManifest(options.organizationId); - if (manifest && isProcessAlive(manifest.pid)) ids.add(localHostId); - targetHostIds = [...ids]; - if (targetHostIds.length === 0) { - warnings.push("No hosts are currently online; nothing to query"); - } - } else { - if (discoveryError !== undefined) { - warnings.push( - `Cloud host discovery failed (${describeError(discoveryError)}); checking this machine's host only`, - ); - } - targetHostIds = [localHostId]; - } - - const hostNames = new Map(hosts.map((host) => [host.id, host.name])); - const settled = await Promise.allSettled( - targetHostIds.map(async (hostId) => { - const target = resolveHostTarget({ - requestedHostId: hostId, - organizationId: options.organizationId, - userJwt: options.userJwt, - }); - return { - hostId, - client: target.client, - value: await query(target.client), - }; - }), - ); - - const results: HostQueryResult[] = []; - settled.forEach((result, index) => { - const hostId = targetHostIds[index]; - if (!hostId) return; - const hostName = hostNames.get(hostId) ?? hostId; - if (result.status === "rejected") { - warnings.push( - `Host ${hostName} unreachable: ${describeError(result.reason)}`, - ); - return; - } - results.push({ ...result.value, hostName }); - }); - - return { results, hosts, warnings }; -} diff --git a/packages/cli/src/lib/host-workspaces/listHostWorkspaces.ts b/packages/cli/src/lib/host-workspaces/listHostWorkspaces.ts index a93253cd904..1d7ebaf5cdc 100644 --- a/packages/cli/src/lib/host-workspaces/listHostWorkspaces.ts +++ b/packages/cli/src/lib/host-workspaces/listHostWorkspaces.ts @@ -1,12 +1,14 @@ import { CLIError } from "@superset/cli-framework"; +import { getHostId } from "@superset/shared/host-info"; import type { ApiClient } from "../api-client"; -import { - type HostInfo, - type HostServiceClient, - queryHostTargets, -} from "../host-target"; +import { isProcessAlive, readManifest } from "../host/manifest"; +import { type HostServiceClient, resolveHostTarget } from "../host-target"; -export type { HostInfo } from "../host-target"; +export interface HostInfo { + id: string; + name: string; + online: boolean; +} export type HostWorkspaceRow = Awaited< ReturnType @@ -28,6 +30,10 @@ export interface HostWorkspacesResult { warnings: string[]; } +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** * Workspace records are host-owned: discover the org's hosts via the cloud * (`host.list` stays cloud-owned), then query each online host's @@ -39,11 +45,72 @@ export interface HostWorkspacesResult { export async function listHostWorkspaces( options: ListHostWorkspacesOptions, ): Promise { - const { results, hosts, warnings } = await queryHostTargets( - options, - (client) => client.workspace.list.query(), + const warnings: string[] = []; + const localHostId = getHostId(); + + // Host discovery stays cloud-owned; tolerate the cloud being unreachable + // (host names degrade to ids, targeting falls back to the local host). + let hosts: HostInfo[] = []; + let discoveryError: unknown; + try { + hosts = await options.api.host.list.query({ + organizationId: options.organizationId, + }); + } catch (error) { + discoveryError = error; + } + + let targetHostIds: string[]; + if (options.hostId) { + targetHostIds = [options.hostId]; + } else if (hosts.length > 0) { + const ids = new Set( + hosts.filter((host) => host.online).map((host) => host.id), + ); + // Cloud presence can lag: always include this machine's host when its + // host-service manifest points at a live process. + const manifest = readManifest(options.organizationId); + if (manifest && isProcessAlive(manifest.pid)) { + ids.add(localHostId); + } + targetHostIds = [...ids]; + } else { + if (discoveryError !== undefined) { + warnings.push( + `Cloud host discovery failed (${describeError(discoveryError)}); listing this machine's host only`, + ); + } + targetHostIds = [localHostId]; + } + + const hostNameById = new Map(hosts.map((host) => [host.id, host.name])); + const describeHost = (hostId: string): string => + hostNameById.get(hostId) ?? hostId; + + const settled = await Promise.allSettled( + targetHostIds.map(async (hostId) => { + const target = resolveHostTarget({ + requestedHostId: hostId, + organizationId: options.organizationId, + userJwt: options.userJwt, + }); + return target.client.workspace.list.query(); + }), ); - const workspaces = results.flatMap((result) => result.value); + + const workspaces: HostWorkspaceRow[] = []; + settled.forEach((result, index) => { + const hostId = targetHostIds[index]; + if (!hostId) return; + if (result.status === "fulfilled") { + workspaces.push(...result.value); + } else { + warnings.push( + `Host ${describeHost(hostId)} unreachable: ${describeError(result.reason)}`, + ); + } + }); + return { workspaces, hosts, warnings }; } diff --git a/packages/cli/test/e2e/agent-sessions.e2e.ts b/packages/cli/test/e2e/agent-sessions.e2e.ts deleted file mode 100644 index daab4668f0c..00000000000 --- a/packages/cli/test/e2e/agent-sessions.e2e.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { CliE2EHarness, type CommandEvidence, sha256 } from "./harness"; - -const repoRoot = resolve( - dirname(fileURLToPath(import.meta.url)), - "../../../..", -); -const artifactsFlag = process.argv.indexOf("--artifacts"); -const requestedArtifactsDir = - artifactsFlag >= 0 ? process.argv[artifactsFlag + 1] : undefined; -const artifactsDir = requestedArtifactsDir ?? "test-results/cli-agent-sessions"; - -function json(command: CommandEvidence): T { - if (command.exitCode !== 0) { - throw new Error(`${command.name} failed: ${command.stderr}`); - } - return JSON.parse(command.stdout) as T; -} - -function promptDescriptor(prompt: string): string { - return ``; -} - -interface SessionLaunch { - kind: "terminal"; - sessionId: string; - label: string; -} - -interface SessionListItem { - status: string; - agent: string; - workspaceId: string; - host: string; - hostId: string; - lastEventAt: string; - sessionId: string; -} - -interface SessionRead { - terminalId: string; - workspaceId: string; - status: string; - output: string; - truncated: boolean; -} - -interface SessionWait { - terminalId?: string; - status: string; -} - -const harness = new CliE2EHarness({ repoRoot, artifactsDir }); - -let failure: unknown; - -try { - await harness.start(); - - const largePrompt = [ - "Verify the complete prompt reaches the fake agent.\n", - "x".repeat(87_900), - "\nUnicode tail: 雪だるま ☃️ — fin\n", - ].join(""); - const largeLaunchCommand = await harness.cli({ - name: "launch an 88 KB agent prompt", - args: [ - "agents", - "create", - "--workspace", - harness.workspaceId, - "--agent", - "e2e", - "--prompt", - largePrompt, - ], - displayArgs: [ - "agents", - "create", - "--workspace", - harness.workspaceId, - "--agent", - "e2e", - "--prompt", - promptDescriptor(largePrompt), - ], - }); - const largeLaunch = json(largeLaunchCommand); - harness.check( - "large launch returned a terminal session", - largeLaunch.kind === "terminal" && Boolean(largeLaunch.sessionId), - `session ${largeLaunch.sessionId}`, - ); - await harness.waitForCaptureCount(1); - const initialCapture = harness.readCapture()[0]; - harness.check( - "large prompt arrived byte-for-byte", - initialCapture?.prompt === largePrompt, - `${Buffer.byteLength(largePrompt)} bytes; sha256 ${sha256(largePrompt)}`, - ); - // Prompt capture precedes the fake agent's initial Stop hook. Give that - // asynchronous HTTP hook time to persist the binding before listing it. - await Bun.sleep(250); - - const listCommand = await harness.cli({ - name: "list live local agent sessions", - args: ["agents", "sessions", "list", "--local"], - }); - const listed = json(listCommand); - const listedLarge = listed.find( - (session) => session.sessionId === largeLaunch.sessionId, - ); - harness.check( - "sessions list exposes identity, workspace, agent, and state", - listedLarge?.workspaceId === harness.workspaceId && - listedLarge.agent === "custom:e2e" && - listedLarge.status === "idle", - `status ${listedLarge?.status}; workspace ${listedLarge?.workspaceId}; agent ${listedLarge?.agent}`, - ); - - const readCommand = await harness.cli({ - name: "read a live session without attaching", - args: [ - "agents", - "sessions", - "read", - largeLaunch.sessionId, - "--local", - "--lines", - "40", - ], - }); - const initialRead = json(readCommand); - harness.check( - "headless read returns the agent acknowledgement", - initialRead.output.includes(`sha256=${sha256(largePrompt)}`) && - initialRead.status === "idle", - `idle snapshot contains the ${sha256(largePrompt).slice(0, 12)}… digest`, - ); - - const multilinePrompt = "first line\nsecond line with 雪\nthird line"; - const multilineResult = json<{ - final: SessionWait; - read: SessionRead; - }>( - await harness.cli({ - name: "send multiline stdin and wait for idle", - args: [ - "agents", - "sessions", - "send", - largeLaunch.sessionId, - "--local", - "--wait", - "--timeout", - "5s", - ], - stdin: multilinePrompt, - displayArgs: [ - "agents", - "sessions", - "send", - largeLaunch.sessionId, - "--local", - "--wait", - "--timeout", - "5s", - "< multiline.txt", - ], - }), - ); - await harness.waitForCaptureCount(2); - harness.check( - "multiline stdin remains one semantic prompt", - harness.readCapture()[1]?.prompt === multilinePrompt && - multilineResult.final.status === "idle", - `${Buffer.byteLength(multilinePrompt)} exact bytes; final state idle`, - ); - - await harness.restartHost(); - const restartedRead = json( - await harness.cli({ - name: "read the same PTY after host-service restart", - args: [ - "agents", - "sessions", - "read", - largeLaunch.sessionId, - "--local", - "--lines", - "40", - ], - }), - ); - const restartPrompt = "continue after host restart"; - const restartedSend = json<{ final: SessionWait }>( - await harness.cli({ - name: "continue the same PTY after host-service restart", - args: [ - "agents", - "sessions", - "send", - largeLaunch.sessionId, - restartPrompt, - "--local", - "--wait", - "--timeout", - "5s", - ], - }), - ); - await harness.waitForCaptureCount(3); - harness.check( - "read and send adopt a daemon-owned session after host restart", - restartedRead.terminalId === largeLaunch.sessionId && - harness.readCapture()[2]?.prompt === restartPrompt && - restartedSend.final.status === "idle", - "same terminal id, exact follow-up, final state idle", - ); - - const missingLaunch = await harness.cli({ - name: "reject an executable that cannot launch", - args: [ - "agents", - "create", - "--workspace", - harness.workspaceId, - "--agent", - "e2e-missing", - "--prompt", - "this must not report a session id", - ], - }); - harness.check( - "immediate exec failure cannot report false success", - missingLaunch.exitCode !== 0 && - !missingLaunch.stdout.includes("sessionId") && - missingLaunch.stderr.includes("failed"), - `exit ${missingLaunch.exitCode}; no session id emitted`, - ); - - const exitResult = json<{ final: SessionWait; read: null }>( - await harness.cli({ - name: "exit a live fake-agent process", - args: [ - "agents", - "sessions", - "send", - largeLaunch.sessionId, - "EXIT", - "--local", - "--wait", - "--timeout", - "5s", - ], - }), - ); - const exitedRead = await harness.cli({ - name: "reject a read after the agent process exited", - args: ["agents", "sessions", "read", largeLaunch.sessionId, "--local"], - }); - harness.check( - "exited sessions are not silently recreated", - exitResult.final.status === "exited" && - exitResult.read === null && - exitedRead.exitCode !== 0, - "send observed exited; subsequent read failed", - ); -} catch (error) { - failure = error; -} finally { - await harness.finish(failure); -} - -if (failure) throw failure; - -const passed = harness.assertions.filter( - (assertion) => assertion.passed, -).length; -console.log( - `CLI agent-session E2E passed: ${passed}/${harness.assertions.length} assertions, ${harness.commands.length} commands`, -); -console.log(`Artifacts: ${resolve(repoRoot, artifactsDir)}`); diff --git a/packages/cli/test/e2e/harness.ts b/packages/cli/test/e2e/harness.ts deleted file mode 100644 index a3b32242f75..00000000000 --- a/packages/cli/test/e2e/harness.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { - type ChildProcessWithoutNullStreams, - spawn, - spawnSync, -} from "node:child_process"; -import { createHash } from "node:crypto"; -import { - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, join, relative, resolve } from "node:path"; - -export interface CommandEvidence { - name: string; - command: string; - exitCode: number; - durationMs: number; - stdout: string; - stderr: string; -} - -export interface AssertionEvidence { - name: string; - passed: boolean; - detail: string; -} - -interface ManagedProcess { - child: ChildProcessWithoutNullStreams; - stdout: string; - stderr: string; -} - -interface RunCommandOptions { - name: string; - command: string; - args: string[]; - cwd: string; - env: NodeJS.ProcessEnv; - stdin?: string; - displayArgs?: string[]; -} - -const PROCESS_TIMEOUT_MS = 15_000; - -export function sha256(value: string | Buffer): string { - return createHash("sha256").update(value).digest("hex"); -} - -function quote(value: string): string { - if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) return value; - return JSON.stringify(value); -} - -function renderCommand(command: string, args: string[]): string { - return [command, ...args].map(quote).join(" "); -} - -function startProcess( - command: string, - args: string[], - options: { cwd: string; env: NodeJS.ProcessEnv }, -): ManagedProcess { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env, - stdio: ["pipe", "pipe", "pipe"], - }); - const managed: ManagedProcess = { child, stdout: "", stderr: "" }; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - managed.stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - managed.stderr += chunk; - }); - return managed; -} - -async function waitFor( - predicate: () => boolean, - describe: string, - process?: ManagedProcess, - timeoutMs = PROCESS_TIMEOUT_MS, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - if (process && process.child.exitCode !== null) { - throw new Error( - `${describe} exited early (${process.child.exitCode})\n${process.stderr}`, - ); - } - await Bun.sleep(25); - } - throw new Error(`Timed out waiting for ${describe}`); -} - -async function stopProcess(process: ManagedProcess | undefined): Promise { - if (!process || process.child.exitCode !== null) return; - process.child.kill("SIGTERM"); - const deadline = Date.now() + 5_000; - while (process.child.exitCode === null && Date.now() < deadline) { - await Bun.sleep(25); - } - if (process.child.exitCode === null) process.child.kill("SIGKILL"); -} - -async function runCommand( - options: RunCommandOptions, -): Promise { - const startedAt = Date.now(); - const process = startProcess(options.command, options.args, options); - if (options.stdin !== undefined) process.child.stdin.end(options.stdin); - else process.child.stdin.end(); - const exitCode = await new Promise((resolveExit, reject) => { - process.child.once("error", reject); - process.child.once("exit", (code) => resolveExit(code ?? 1)); - }); - return { - name: options.name, - command: renderCommand( - basename(options.command), - options.displayArgs ?? options.args, - ), - exitCode, - durationMs: Date.now() - startedAt, - stdout: process.stdout.trim(), - stderr: process.stderr.trim(), - }; -} - -function findElectronBinary(repoRoot: string): string { - const bunModules = join(repoRoot, "node_modules", ".bun"); - if (!existsSync(bunModules)) { - throw new Error("node_modules/.bun is missing; run `bun install` first"); - } - const packages = readdirSync(bunModules).filter((entry) => - entry.startsWith("electron@"), - ); - for (const packageDir of packages) { - const distDir = join( - bunModules, - packageDir, - "node_modules", - "electron", - "dist", - ); - const candidates = [ - join(distDir, "Electron.app", "Contents", "MacOS", "Electron"), - join(distDir, "electron"), - join(distDir, "electron.exe"), - ]; - const candidate = candidates.find(existsSync); - if (candidate) return candidate; - } - throw new Error("Electron binary not found; run `bun install` first"); -} - -export class CliE2EHarness { - readonly repoRoot: string; - readonly cliRoot: string; - readonly tempRoot: string; - readonly artifactsDir: string; - readonly commands: CommandEvidence[] = []; - readonly assertions: AssertionEvidence[] = []; - readonly organizationId = "10000000-0000-4000-8000-000000000001"; - readonly projectId = "20000000-0000-4000-8000-000000000001"; - readonly workspaceId = "30000000-0000-4000-8000-000000000001"; - readonly secret = "e2e-host-secret"; - readonly capturePath: string; - readonly dbPath: string; - readonly homeDir: string; - readonly workspacePath: string; - readonly worktreeBasePath: string; - private daemon?: ManagedProcess; - private host?: ManagedProcess; - private endpoint = ""; - private hostPort = 0; - private readonly stoppedHostLogs: string[] = []; - - constructor(options: { repoRoot: string; artifactsDir: string }) { - this.repoRoot = options.repoRoot; - this.cliRoot = join(this.repoRoot, "packages", "cli"); - this.tempRoot = mkdtempSync(join(tmpdir(), "superset-cli-e2e-")); - this.artifactsDir = resolve(this.repoRoot, options.artifactsDir); - this.capturePath = join(this.tempRoot, "capture.jsonl"); - this.dbPath = join(this.tempRoot, "host.db"); - this.homeDir = join(this.tempRoot, "home"); - this.workspacePath = join(this.tempRoot, "workspace"); - this.worktreeBasePath = join(this.tempRoot, "worktrees"); - mkdirSync(this.workspacePath, { recursive: true }); - mkdirSync(this.worktreeBasePath, { recursive: true }); - mkdirSync(this.homeDir, { recursive: true, mode: 0o700 }); - this.initializeFixtureRepository(); - } - - private initializeFixtureRepository(): void { - const gitEnv = { - ...process.env, - GIT_AUTHOR_NAME: "Superset CLI E2E", - GIT_AUTHOR_EMAIL: "cli-e2e@superset.invalid", - GIT_COMMITTER_NAME: "Superset CLI E2E", - GIT_COMMITTER_EMAIL: "cli-e2e@superset.invalid", - }; - writeFileSync(join(this.workspacePath, "README.md"), "# E2E fixture\n"); - for (const args of [ - ["init", "--initial-branch=main"], - ["add", "README.md"], - ["commit", "-m", "test: initialize CLI E2E fixture"], - ]) { - const result = spawnSync("git", args, { - cwd: this.workspacePath, - env: gitEnv, - encoding: "utf8", - }); - if (result.status !== 0) { - throw new Error( - `Failed to initialize fixture repository: ${result.stderr.trim()}`, - ); - } - } - } - - private commonEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - CI: "1", - NODE_ENV: "test", - ORGANIZATION_ID: this.organizationId, - SUPERSET_API_KEY: "e2e-api-key", - SUPERSET_API_URL: "http://127.0.0.1:9", - SUPERSET_HOME_DIR: this.homeDir, - HOST_MANIFEST_DIR: join(this.homeDir, "host", this.organizationId), - }; - } - - async start(): Promise { - const daemonBundle = join( - this.repoRoot, - "packages", - "pty-daemon", - "dist", - "pty-daemon.js", - ); - if (!existsSync(daemonBundle)) { - const build = spawnSync( - process.execPath, - ["run", "--cwd", "packages/pty-daemon", "build:daemon"], - { cwd: this.repoRoot, stdio: "inherit" }, - ); - if (build.status !== 0) throw new Error("Failed to build pty-daemon"); - } - - const socketPath = join(this.tempRoot, "pty.sock"); - this.daemon = startProcess( - findElectronBinary(this.repoRoot), - [daemonBundle, `--socket=${socketPath}`], - { - cwd: this.repoRoot, - env: { - ...this.commonEnv(), - ELECTRON_RUN_AS_NODE: "1", - SUPERSET_PTY_DAEMON_VERSION: "0.2.6-e2e", - }, - }, - ); - await waitFor( - () => existsSync(socketPath), - "production-ABI PTY daemon socket", - this.daemon, - ); - process.env.SUPERSET_PTY_DAEMON_SOCKET = socketPath; - - writeFileSync( - join(this.homeDir, "config.json"), - JSON.stringify({ organizationId: this.organizationId }), - { mode: 0o600 }, - ); - await this.startHost(socketPath); - } - - private async startHost(socketPath: string): Promise { - const agentRuntimePath = Bun.which("node"); - if (!agentRuntimePath) { - throw new Error("Node runtime not found for the fake terminal agent"); - } - const fixture = join( - this.repoRoot, - "packages", - "host-service", - "test", - "fixtures", - "agent-sessions-cli-host.ts", - ); - this.host = startProcess(process.execPath, [fixture], { - cwd: this.repoRoot, - env: { - ...this.commonEnv(), - PORT: String(this.hostPort), - HOST_DB_PATH: this.dbPath, - HOST_MIGRATIONS_FOLDER: join( - this.repoRoot, - "packages", - "host-service", - "drizzle", - ), - HOST_SERVICE_SECRET: this.secret, - SUPERSET_E2E_WORKSPACE_PATH: this.workspacePath, - SUPERSET_E2E_WORKTREE_BASE_PATH: this.worktreeBasePath, - SUPERSET_E2E_AGENT_PATH: join( - this.repoRoot, - "packages", - "cli", - "test", - "fixtures", - "fake-terminal-agent.mjs", - ), - SUPERSET_E2E_CAPTURE_PATH: this.capturePath, - SUPERSET_E2E_AGENT_RUNTIME_PATH: agentRuntimePath, - SUPERSET_PTY_DAEMON_SOCKET: socketPath, - }, - }); - await waitFor( - () => this.host?.stdout.includes("E2E_HOST_READY") ?? false, - "host-service fixture", - this.host, - ); - const match = /E2E_HOST_READY\s+(\S+)/.exec(this.host.stdout); - if (!match?.[1]) - throw new Error("Host fixture did not report its endpoint"); - this.endpoint = match[1].replace(/\/$/, ""); - this.hostPort = Number(new URL(this.endpoint).port); - this.writeManifest(this.host.child.pid ?? 0); - } - - private writeManifest(pid: number): void { - const manifestDir = join(this.homeDir, "host", this.organizationId); - mkdirSync(manifestDir, { recursive: true, mode: 0o700 }); - writeFileSync( - join(manifestDir, "manifest.json"), - JSON.stringify({ - pid, - endpoint: this.endpoint, - authToken: this.secret, - startedAt: Date.now(), - organizationId: this.organizationId, - }), - { mode: 0o600 }, - ); - } - - async restartHost(): Promise { - const socketPath = process.env.SUPERSET_PTY_DAEMON_SOCKET; - if (!socketPath) throw new Error("PTY daemon socket was not initialized"); - await stopProcess(this.host); - this.stoppedHostLogs.push( - `${this.host?.stdout ?? ""}\n${this.host?.stderr ?? ""}`.trim(), - ); - this.host = undefined; - await this.startHost(socketPath); - } - - async cli(options: { - name: string; - args: string[]; - stdin?: string; - displayArgs?: string[]; - }): Promise { - const executable = join( - this.cliRoot, - "node_modules", - ".bin", - "cli-framework", - ); - const evidence = await runCommand({ - name: options.name, - command: process.execPath, - args: [executable, "dev", "--json", ...options.args], - displayArgs: [ - "superset", - "--json", - ...(options.displayArgs ?? options.args), - ], - cwd: this.cliRoot, - env: this.commonEnv(), - ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), - }); - evidence.command = evidence.command.replace(/^bun superset /, "superset "); - this.commands.push(this.scrubCommand(evidence)); - return evidence; - } - - check(name: string, condition: boolean, detail: string): void { - this.assertions.push({ name, passed: condition, detail }); - if (!condition) throw new Error(`Assertion failed: ${name} (${detail})`); - } - - readCapture(): Array<{ kind: string; prompt: string }> { - if (!existsSync(this.capturePath)) return []; - return readFileSync(this.capturePath, "utf8") - .trim() - .split("\n") - .filter(Boolean) - .map((line) => { - const record = JSON.parse(line) as { kind: string; prompt: string }; - return { - kind: record.kind, - prompt: Buffer.from(record.prompt, "base64").toString("utf8"), - }; - }); - } - - async waitForCaptureCount(count: number): Promise { - await waitFor( - () => this.readCapture().length >= count, - `${count} captures`, - ); - } - - private scrub(value: string): string { - return value - .replaceAll(this.tempRoot, "$E2E_ROOT") - .replaceAll(this.repoRoot, "$REPO"); - } - - private scrubCommand(evidence: CommandEvidence): CommandEvidence { - return { - ...evidence, - command: this.scrub(evidence.command), - stdout: this.scrub(evidence.stdout), - stderr: this.scrub(evidence.stderr), - }; - } - - async finish(error?: unknown): Promise { - await stopProcess(this.host); - await stopProcess(this.daemon); - const hostLogs = [ - ...this.stoppedHostLogs, - `${this.host?.stdout ?? ""}\n${this.host?.stderr ?? ""}`.trim(), - ].filter(Boolean); - rmSync(this.artifactsDir, { recursive: true, force: true }); - mkdirSync(this.artifactsDir, { recursive: true }); - if (existsSync(this.capturePath)) { - copyFileSync(this.capturePath, join(this.artifactsDir, "capture.jsonl")); - } - writeFileSync( - join(this.artifactsDir, "host.log"), - this.scrub(hostLogs.join("\n\n--- host restart ---\n\n")), - ); - writeFileSync( - join(this.artifactsDir, "pty-daemon.log"), - this.scrub( - `${this.daemon?.stdout ?? ""}\n${this.daemon?.stderr ?? ""}`.trim(), - ), - ); - - const commit = spawnSync("git", ["rev-parse", "HEAD"], { - cwd: this.repoRoot, - encoding: "utf8", - }).stdout.trim(); - const metadata = { - result: error ? "FAILED" : "PASSED", - commit, - generatedAt: new Date().toISOString(), - worktree: relative(resolve(this.repoRoot, ".."), this.repoRoot), - runtime: `Bun ${Bun.version}; Electron-as-Node PTY daemon`, - }; - const result = { - metadata, - assertions: this.assertions, - commands: this.commands, - ...(error - ? { error: error instanceof Error ? error.stack : String(error) } - : {}), - }; - writeFileSync( - join(this.artifactsDir, "results.json"), - `${JSON.stringify(result, null, 2)}\n`, - ); - rmSync(this.tempRoot, { recursive: true, force: true }); - } -} diff --git a/packages/cli/test/fixtures/fake-terminal-agent.mjs b/packages/cli/test/fixtures/fake-terminal-agent.mjs deleted file mode 100644 index 72306bc8478..00000000000 --- a/packages/cli/test/fixtures/fake-terminal-agent.mjs +++ /dev/null @@ -1,102 +0,0 @@ -import { createHash } from "node:crypto"; -import { appendFileSync } from "node:fs"; - -const START_PASTE = "\u001b[200~"; -const END_PASTE = "\u001b[201~"; -const capturePath = process.env.SUPERSET_E2E_CAPTURE_PATH; -const initialPrompt = process.argv.at(-1) ?? ""; - -function capture(kind, prompt) { - if (!capturePath) return; - appendFileSync( - capturePath, - `${JSON.stringify({ kind, prompt: Buffer.from(prompt).toString("base64") })}\n`, - ); -} - -async function postHook(eventType) { - const url = process.env.SUPERSET_HOST_AGENT_HOOK_URL; - const terminalId = process.env.SUPERSET_TERMINAL_ID; - if (!url || !terminalId) return; - const response = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - json: { - terminalId, - eventType, - agent: { - agentId: "codex", - sessionId: `fake-${terminalId}`, - definitionId: "custom:e2e", - }, - }, - }), - }); - if (!response.ok) { - throw new Error(`hook ${eventType} failed with HTTP ${response.status}`); - } -} - -async function handlePrompt(prompt) { - capture("follow-up", prompt); - await postHook("UserPromptSubmit"); - process.stdout.write( - `FOLLOWUP bytes=${Buffer.byteLength(prompt)} base64=${Buffer.from(prompt).toString("base64")}\r\n`, - ); - - if (prompt === "PERMISSION") { - await postHook("PermissionRequest"); - return; - } - if (prompt === "WORKING") return; - if (prompt === "FAIL") { - await postHook("StopFailure"); - return; - } - if (prompt === "EXIT") { - process.stdout.write("EXITING\r\n"); - await postHook("SessionEnd"); - process.exit(0); - } - - await new Promise((resolve) => setTimeout(resolve, 30)); - await postHook("Stop"); -} - -capture("initial", initialPrompt); -process.stdout.write( - `READY bytes=${Buffer.byteLength(initialPrompt)} sha256=${createHash("sha256").update(initialPrompt).digest("hex")}\r\n`, -); -await postHook("Stop"); - -process.stdin.setRawMode?.(true); -process.stdin.resume(); -process.stdin.setEncoding("utf8"); - -let pending = ""; -let handling = Promise.resolve(); -process.stdin.on("data", (chunk) => { - pending += chunk; - for (;;) { - const start = pending.indexOf(START_PASTE); - if (start < 0) { - pending = pending.slice(-START_PASTE.length); - return; - } - const end = pending.indexOf(END_PASTE, start + START_PASTE.length); - if (end < 0) { - pending = pending.slice(start); - return; - } - const prompt = pending.slice(start + START_PASTE.length, end); - pending = pending.slice(end + END_PASTE.length); - if (pending.startsWith("\r")) pending = pending.slice(1); - handling = handling - .then(() => handlePrompt(prompt)) - .catch((error) => { - console.error(error); - process.exitCode = 1; - }); - } -}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index af609a39aa1..0b75b4535c0 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -8,6 +8,6 @@ "emitDeclarationOnly": false, "noEmit": true }, - "include": ["src", "scripts", "test/e2e", "cli.config.ts"], + "include": ["src", "scripts", "cli.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/host-service/src/daemon/DaemonSupervisor.test.ts b/packages/host-service/src/daemon/DaemonSupervisor.test.ts index 67130a0c80b..91394fdd577 100644 --- a/packages/host-service/src/daemon/DaemonSupervisor.test.ts +++ b/packages/host-service/src/daemon/DaemonSupervisor.test.ts @@ -20,7 +20,6 @@ import { type ClientMessage, encodeFrame, FrameDecoder, - SUPPORTED_PROTOCOL_VERSIONS, } from "@superset/pty-daemon/protocol"; import { DaemonSupervisor, @@ -78,8 +77,6 @@ interface FakeDaemonOptions { hangUpAfterHello?: boolean; respondWithWrongMessageFirst?: boolean; silent?: boolean; - protocol?: number; - onHello?: (protocols: number[]) => void; } async function startFakeDaemon(opts: FakeDaemonOptions): Promise<{ @@ -99,7 +96,6 @@ async function startFakeDaemon(opts: FakeDaemonOptions): Promise<{ for (const decoded of decoder.drain()) { const msg = decoded.message as ClientMessage; if (msg.type !== "hello") continue; - opts.onHello?.(msg.protocols); if (opts.silent) return; if (opts.hangUpAfterHello) { sock.end(); @@ -123,7 +119,7 @@ async function startFakeDaemon(opts: FakeDaemonOptions): Promise<{ sock.write( encodeFrame({ type: "hello-ack", - protocol: opts.protocol ?? 1, + protocol: 1, daemonVersion: opts.respondWithVersion, daemonPid: opts.daemonPid, }), @@ -154,23 +150,6 @@ describe("probeDaemonVersion", () => { } }); - test("offers legacy protocol support during version probes", async () => { - let offered: number[] | undefined; - const fake = await startFakeDaemon({ - respondWithVersion: "0.2.5", - protocol: 2, - onHello: (protocols) => { - offered = protocols; - }, - }); - try { - expect(await probeDaemonVersion(fake.socketPath, 1500)).toBe("0.2.5"); - expect(offered).toEqual([...SUPPORTED_PROTOCOL_VERSIONS]); - } finally { - await fake.close(); - } - }); - test("returns null when there is no listener on the socket path", async () => { const dead = path.join( os.tmpdir(), diff --git a/packages/host-service/src/daemon/DaemonSupervisor.ts b/packages/host-service/src/daemon/DaemonSupervisor.ts index d5b312fd683..7a9f770a811 100644 --- a/packages/host-service/src/daemon/DaemonSupervisor.ts +++ b/packages/host-service/src/daemon/DaemonSupervisor.ts @@ -24,7 +24,6 @@ import { FrameDecoder, type ServerMessage, type SessionInfo, - SUPPORTED_PROTOCOL_VERSIONS, } from "@superset/pty-daemon/protocol"; import semver from "semver"; import { DaemonClient } from "../terminal/DaemonClient/index.ts"; @@ -1263,7 +1262,7 @@ export async function listDaemonSessions( sock.write( encodeFrame({ type: "hello", - protocols: [...SUPPORTED_PROTOCOL_VERSIONS], + protocols: [CURRENT_PROTOCOL_VERSION], clientVersion: "supervisor-list", }), ); @@ -1434,7 +1433,7 @@ function probeDaemonHello( sock.write( encodeFrame({ type: "hello", - protocols: [...SUPPORTED_PROTOCOL_VERSIONS], + protocols: [CURRENT_PROTOCOL_VERSION], clientVersion: "supervisor-probe", }), ); diff --git a/packages/host-service/src/terminal-agents/index.ts b/packages/host-service/src/terminal-agents/index.ts index 9dd38a2806c..edd354279e7 100644 --- a/packages/host-service/src/terminal-agents/index.ts +++ b/packages/host-service/src/terminal-agents/index.ts @@ -1,6 +1,4 @@ export { SqliteTerminalAgentBindingPersistence } from "./persistence"; -export type { TerminalAgentStatus } from "./status"; -export { terminalAgentStatus } from "./status"; export type { TerminalAgentBindingListFilter, TerminalAgentBindingPersistence, diff --git a/packages/host-service/src/terminal-agents/status.test.ts b/packages/host-service/src/terminal-agents/status.test.ts deleted file mode 100644 index 7a3965f5dc8..00000000000 --- a/packages/host-service/src/terminal-agents/status.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { terminalAgentStatus } from "./status"; - -describe("terminalAgentStatus", () => { - it.each([ - ["Start", "working"], - ["PermissionRequest", "permission"], - ["Stop", "idle"], - ["Attached", "idle"], - ["Failed", "failed"], - ] as const)("maps %s to %s", (lastEventType, expected) => { - expect(terminalAgentStatus({ lastEventType })).toBe(expected); - }); -}); diff --git a/packages/host-service/src/terminal-agents/status.ts b/packages/host-service/src/terminal-agents/status.ts deleted file mode 100644 index 06cd1816b97..00000000000 --- a/packages/host-service/src/terminal-agents/status.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { TerminalAgentBinding } from "./types"; - -export type TerminalAgentStatus = "working" | "permission" | "idle" | "failed"; - -export function terminalAgentStatus( - binding: Pick, -): TerminalAgentStatus { - switch (binding.lastEventType) { - case "Start": - return "working"; - case "PermissionRequest": - return "permission"; - case "Failed": - return "failed"; - default: - return "idle"; - } -} diff --git a/packages/host-service/src/terminal/DaemonClient/DaemonClient.node-test.ts b/packages/host-service/src/terminal/DaemonClient/DaemonClient.node-test.ts index 9511f7d6111..dc185af9422 100644 --- a/packages/host-service/src/terminal/DaemonClient/DaemonClient.node-test.ts +++ b/packages/host-service/src/terminal/DaemonClient/DaemonClient.node-test.ts @@ -3,18 +3,11 @@ // daemon spawns real PTYs via node-pty. import { strict as assert } from "node:assert"; -import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; import { after, before, test } from "node:test"; import { Server } from "@superset/pty-daemon"; -import { - type ClientMessage, - CURRENT_PROTOCOL_VERSION, - encodeFrame, - FrameDecoder, - SUPPORTED_PROTOCOL_VERSIONS, -} from "@superset/pty-daemon/protocol"; +import { CURRENT_PROTOCOL_VERSION } from "@superset/pty-daemon/protocol"; import { DaemonClient } from "./DaemonClient.ts"; const sockPath = path.join( @@ -44,65 +37,6 @@ test("connect + handshake exposes daemon version", async () => { await c.dispose(); }); -test("negotiates v2 for legacy operations and guards snapshots", async () => { - const legacyPath = path.join( - os.tmpdir(), - `host-daemon-client-v2-${process.pid}.sock`, - ); - const legacyServer = net.createServer((socket) => { - const decoder = new FrameDecoder(); - socket.on("data", (chunk) => { - decoder.push(chunk); - for (const frame of decoder.drain()) { - const message = frame.message as ClientMessage; - if (message.type === "hello") { - assert.deepEqual(message.protocols, SUPPORTED_PROTOCOL_VERSIONS); - socket.write( - encodeFrame({ - type: "hello-ack", - protocol: 2, - daemonVersion: "0.2.5", - daemonPid: process.pid, - }), - ); - } else if (message.type === "list") { - socket.write(encodeFrame({ type: "list-reply", sessions: [] })); - } else if (message.type === "prepare-upgrade") { - socket.write( - encodeFrame({ - type: "upgrade-prepared", - result: { ok: true, successorPid: 4242 }, - }), - ); - } - } - }); - socket.on("error", () => {}); - }); - await new Promise((resolve) => - legacyServer.listen(legacyPath, resolve), - ); - - const client = new DaemonClient({ socketPath: legacyPath }); - try { - await client.connect(); - assert.equal(client.protocol, 2); - assert.equal(client.version, "0.2.5"); - assert.deepEqual(await client.list(), []); - assert.deepEqual(await client.prepareUpgrade(), { - ok: true, - successorPid: 4242, - }); - await assert.rejects( - () => client.snapshot("legacy-session"), - /requires protocol 3; connected with protocol 2/, - ); - } finally { - await client.dispose(); - await new Promise((resolve) => legacyServer.close(() => resolve())); - } -}); - test("open + subscribe + receive output + close", async () => { const c = new DaemonClient({ socketPath: sockPath }); await c.connect(); @@ -180,38 +114,6 @@ test("input is forwarded; resize updates dims", async () => { await c.dispose(); }); -test("snapshot reads buffered bytes repeatedly without subscribing", async () => { - const c = new DaemonClient({ socketPath: sockPath }); - await c.connect(); - const id = "host-test-snapshot"; - await c.open(id, { - shell: "/bin/sh", - argv: ["-c", "printf snapshot-marker; sleep 2"], - cols: 80, - rows: 24, - }); - - await waitFor( - async () => (await c.snapshot(id)).data.includes("snapshot-marker"), - 3000, - ); - const first = await c.snapshot(id); - const second = await c.snapshot(id); - assert.ok(first.data.includes("snapshot-marker")); - assert.deepEqual(second, first); - assert.equal(first.truncated, false); - - await c.close(id, "SIGTERM"); - await c.dispose(); -}); - -test("snapshot rejects an unknown terminal", async () => { - const c = new DaemonClient({ socketPath: sockPath }); - await c.connect(); - await assert.rejects(() => c.snapshot("missing-snapshot"), /unknown session/); - await c.dispose(); -}); - test("multiple local subscribers get fanned out from one wire subscription", async () => { const c = new DaemonClient({ socketPath: sockPath }); await c.connect(); @@ -364,12 +266,9 @@ test("adoption flow: client A opens, drops, client B finds + subscribes-with-rep await b.dispose(); }); -async function waitFor( - predicate: () => boolean | Promise, - ms: number, -): Promise { +async function waitFor(predicate: () => boolean, ms: number): Promise { const start = Date.now(); - while (!(await predicate())) { + while (!predicate()) { if (Date.now() - start > ms) throw new Error("waitFor timed out"); await new Promise((r) => setTimeout(r, 25)); } diff --git a/packages/host-service/src/terminal/DaemonClient/DaemonClient.ts b/packages/host-service/src/terminal/DaemonClient/DaemonClient.ts index 5dffe8f27a3..df56991949a 100644 --- a/packages/host-service/src/terminal/DaemonClient/DaemonClient.ts +++ b/packages/host-service/src/terminal/DaemonClient/DaemonClient.ts @@ -23,8 +23,6 @@ import { type ServerMessage, type SessionInfo, type SessionMeta, - SNAPSHOT_PROTOCOL_VERSION, - SUPPORTED_PROTOCOL_VERSIONS, } from "@superset/pty-daemon/protocol"; export interface OpenResult { @@ -37,13 +35,6 @@ export interface ExitInfo { signal: number | null; } -export interface SnapshotResult { - data: Buffer; - cols: number; - rows: number; - truncated: boolean; -} - export type Signal = "SIGINT" | "SIGTERM" | "SIGKILL" | "SIGHUP"; export interface SubscribeCallbacks { @@ -70,7 +61,6 @@ export interface DaemonClientOptions { const OPEN_TIMEOUT_MS = 15_000; const CLOSE_TIMEOUT_MS = 5_000; const LIST_TIMEOUT_MS = 5_000; -const SNAPSHOT_TIMEOUT_MS = 5_000; // Daemon-side handoff has to write a snapshot, spawn a child Node process, // await successor adopt-ack, then reply. The Server uses 5s for the ack // alone; 15s here covers spawn + ack + reply round-trip with margin. @@ -162,57 +152,6 @@ export class DaemonClient { throw new Error(`list: unexpected reply ${reply.type}`); } - /** Read the daemon ring buffer without subscribing or mutating it. */ - async snapshot(id: string): Promise { - if (this.protocol < SNAPSHOT_PROTOCOL_VERSION) { - throw new Error( - `daemon snapshot requires protocol ${SNAPSHOT_PROTOCOL_VERSION}; connected with protocol ${this.protocol}`, - ); - } - return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - off(); - offDisc(); - clearTimeout(timer); - }; - const fail = (error: Error) => { - if (settled) return; - settled = true; - cleanup(); - reject(error); - }; - const off = this.on((message, payload) => { - if (message.type === "error" && message.id === id) { - fail(new Error(`snapshot ${id}: ${message.message}`)); - return; - } - if (message.type !== "snapshot-reply" || message.id !== id) return; - settled = true; - cleanup(); - resolve({ - data: payload ? Buffer.from(payload) : Buffer.alloc(0), - cols: message.cols, - rows: message.rows, - truncated: message.truncated, - }); - }); - const offDisc = this.onDisconnect((error) => - fail(error ?? new Error("daemon disconnected")), - ); - const timer = setTimeout( - () => - fail( - new Error( - `daemon snapshot ${id}: timed out after ${SNAPSHOT_TIMEOUT_MS}ms`, - ), - ), - SNAPSHOT_TIMEOUT_MS, - ); - this.send({ type: "snapshot", id }); - }); - } - /** * Phase 2: ask the daemon to spawn a successor process that inherits PTY * master fds and adopts all live sessions. On success the daemon exits @@ -316,7 +255,7 @@ export class DaemonClient { private async handshake(): Promise { this.send({ type: "hello", - protocols: [...SUPPORTED_PROTOCOL_VERSIONS], + protocols: [CURRENT_PROTOCOL_VERSION], }); const ack = await this.waitForFrame( (m) => m.type === "hello-ack" || m.type === "error", @@ -429,18 +368,14 @@ export class DaemonClient { } /** Register a one-shot listener. Returns an unsubscribe; called for every frame until disposed. */ - private on( - cb: (m: ServerMessage, payload: Uint8Array | null) => void, - ): () => void { + private on(cb: (m: ServerMessage) => void): () => void { this.adhocListeners.add(cb); return () => { this.adhocListeners.delete(cb); }; } - private adhocListeners = new Set< - (m: ServerMessage, payload: Uint8Array | null) => void - >(); + private adhocListeners = new Set<(m: ServerMessage) => void>(); private waitForFrame( predicate: (m: ServerMessage) => boolean, @@ -511,7 +446,7 @@ export class DaemonClient { // Everything else (open-ok, closed, error, hello-ack, list-reply) // goes through the adhoc listener fan-out so request/response // helpers can pick it up. - for (const l of this.adhocListeners) l(msg, frame.payload); + for (const l of this.adhocListeners) l(msg); } } diff --git a/packages/host-service/src/terminal/DaemonClient/index.ts b/packages/host-service/src/terminal/DaemonClient/index.ts index f4f7ba401ed..ed4338ebe70 100644 --- a/packages/host-service/src/terminal/DaemonClient/index.ts +++ b/packages/host-service/src/terminal/DaemonClient/index.ts @@ -3,7 +3,6 @@ export type { ExitInfo, OpenResult, Signal, - SnapshotResult, SubscribeCallbacks, } from "./DaemonClient.ts"; export { DaemonClient } from "./DaemonClient.ts"; diff --git a/packages/host-service/src/terminal/terminal-snapshot.test.ts b/packages/host-service/src/terminal/terminal-snapshot.test.ts deleted file mode 100644 index 7540bd8dd7e..00000000000 --- a/packages/host-service/src/terminal/terminal-snapshot.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { renderTerminalSnapshot } from "./terminal-snapshot"; - -function render(data: string, options: { cols?: number; lines?: number } = {}) { - return renderTerminalSnapshot({ - data: Buffer.from(data), - cols: options.cols ?? 80, - rows: 24, - lines: options.lines ?? 120, - }); -} - -describe("renderTerminalSnapshot", () => { - it("renders ANSI state as plain text and removes unused viewport rows", () => { - expect(render("first\r\n\x1b[31mred\x1b[0m\r\nlast")).toBe( - "first\nred\nlast", - ); - }); - - it("joins wrapped physical rows into logical lines", () => { - expect(render("abcdefgh\r\nlast", { cols: 5 })).toBe("abcdefgh\nlast"); - }); - - it("returns only the requested tail lines", () => { - expect(render("one\r\ntwo\r\nthree", { lines: 2 })).toBe("two\nthree"); - }); - - it("reflects cursor rewrites instead of returning an append-only transcript", () => { - expect(render("progress 1\rprogress 2")).toBe("progress 2"); - }); - - it("enforces the public line cap", () => { - expect(() => - renderTerminalSnapshot({ - data: Buffer.alloc(0), - cols: 80, - rows: 24, - lines: 1001, - }), - ).toThrow("1 to 1000"); - }); -}); diff --git a/packages/host-service/src/terminal/terminal-snapshot.ts b/packages/host-service/src/terminal/terminal-snapshot.ts deleted file mode 100644 index 0249b82ce16..00000000000 --- a/packages/host-service/src/terminal/terminal-snapshot.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const { Terminal: HeadlessTerminal } = - require("@xterm/headless") as typeof import("@xterm/headless"); - -type HeadlessInternals = { - _core?: { _writeBuffer?: { writeSync(data: string | Uint8Array): void } }; -}; - -export interface RenderTerminalSnapshotInput { - data: Uint8Array; - cols: number; - rows: number; - lines: number; -} - -/** Render recent PTY bytes as the terminal screen's last logical text lines. */ -export function renderTerminalSnapshot({ - data, - cols, - rows, - lines, -}: RenderTerminalSnapshotInput): string { - if (!Number.isInteger(lines) || lines < 1 || lines > 1000) { - throw new Error("snapshot lines must be an integer from 1 to 1000"); - } - const terminal = new HeadlessTerminal({ - cols, - rows, - scrollback: 1000, - allowProposedApi: true, - }); - try { - // Terminal.write is async-buffered and has no public synchronous - // equivalent. @xterm/headless is therefore pinned to an exact version in - // this package, and terminal-snapshot.test.ts exercises this compatibility - // boundary so dependency upgrades fail before reaching the read endpoint. - const writeBuffer = (terminal as unknown as HeadlessInternals)._core - ?._writeBuffer; - if (typeof writeBuffer?.writeSync !== "function") { - throw new Error("@xterm/headless synchronous write API is unavailable"); - } - writeBuffer.writeSync(data); - - const buffer = terminal.buffer.active; - const cursorLine = buffer.baseY + buffer.cursorY; - let lastContentLine = -1; - for (let index = buffer.length - 1; index >= 0; index--) { - if (buffer.getLine(index)?.translateToString(true) !== "") { - lastContentLine = index; - break; - } - } - const endLine = Math.max(lastContentLine, cursorLine); - if (endLine < 0) return ""; - - const logicalLines: string[] = []; - for (let index = 0; index <= endLine; index++) { - const line = buffer.getLine(index); - if (!line) continue; - const text = line.translateToString(false); - if (line.isWrapped && logicalLines.length > 0) { - logicalLines[logicalLines.length - 1] += text; - } else { - logicalLines.push(text); - } - } - - return logicalLines - .map((line) => line.trimEnd()) - .slice(-lines) - .join("\n"); - } finally { - terminal.dispose(); - } -} diff --git a/packages/host-service/src/terminal/terminal.adoption.node-test.ts b/packages/host-service/src/terminal/terminal.adoption.node-test.ts index 913777f6740..9a7322b913b 100644 --- a/packages/host-service/src/terminal/terminal.adoption.node-test.ts +++ b/packages/host-service/src/terminal/terminal.adoption.node-test.ts @@ -24,12 +24,6 @@ import { Server } from "@superset/pty-daemon"; import { eq } from "drizzle-orm"; import { createDb, type HostDb } from "../db/index.ts"; import { projects, terminalSessions, workspaces } from "../db/schema.ts"; -import { - cleanupAgentLaunch, - prepareAgentLaunch, - waitForAgentLaunch, -} from "../trpc/router/agents/agent-launch.ts"; -import { buildAttachmentBlock } from "../trpc/router/agents/attachment-prompt.ts"; import { disposeDaemonClient, getDaemonClient, @@ -56,7 +50,6 @@ let workspaceId: string; let otherWorkspaceId: string; let worktreePath: string; let otherWorktreePath: string; -let fakeAgentPath: string; before(async () => { fs.mkdirSync(TEST_HOME, { recursive: true }); @@ -64,32 +57,6 @@ before(async () => { otherWorktreePath = path.join(TEST_HOME, "other-worktree"); fs.mkdirSync(worktreePath, { recursive: true }); fs.mkdirSync(otherWorktreePath, { recursive: true }); - fakeAgentPath = path.join(TEST_HOME, "fake-agent.sh"); - fs.writeFileSync( - fakeAgentPath, - `#!/bin/sh -set -u -mode=$1 -output_path=$2 -pid_path=$3 -shift 3 - -if [ "$mode" = "argv" ]; then - if [ "$1" != "--prompt" ]; then - exit 64 - fi - shift - printf '%s' "$1" > "$output_path" -else - cat > "$output_path" -fi - -printf '%s' "$TEST_LAUNCH_ENV" > "$output_path.env" -printf '%s\n' "$$" > "$pid_path" -sleep 30 -`, - { mode: 0o700 }, - ); server = new Server({ socketPath: SOCK, @@ -146,14 +113,6 @@ after(async () => { }); describe("createTerminalSessionInternal — host-service restart adoption", () => { - test("losslessly launches an argv agent with a large attachment-expanded prompt", async () => { - await assertLosslessAgentLaunch("argv"); - }); - - test("losslessly launches a stdin agent with a large attachment-expanded prompt", async () => { - await assertLosslessAgentLaunch("stdin"); - }); - test("fresh open uses requested initial dimensions", async () => { const terminalId = `e2e-dims-${randomUUID().slice(0, 8)}`; const result = await createTerminalSessionInternal({ @@ -958,69 +917,6 @@ async function waitFor(predicate: () => boolean, ms: number): Promise { } } -async function assertLosslessAgentLaunch( - promptTransport: "argv" | "stdin", -): Promise { - const terminalId = `e2e-agent-launch-${promptTransport}-${randomUUID().slice(0, 8)}`; - const outputPath = path.join(TEST_HOME, `${terminalId}.prompt`); - const pidPath = path.join(TEST_HOME, `${terminalId}.pid`); - const prompt = buildAttachmentBlock( - `${"large multiline prompt 🎉 中文\n".repeat(2048)}closing prompt bytes\n\n`, - Array.from({ length: 300 }, (_, index) => ({ - attachmentId: `attachment-${index}`, - path: `/tmp/attached files/evidence-${index}-é.log`, - })), - ); - const launch = prepareAgentLaunch({ - command: fakeAgentPath, - args: [promptTransport, outputPath, pidPath], - promptArgs: promptTransport === "argv" ? ["--prompt"] : [], - promptTransport, - prompt, - env: { TEST_LAUNCH_ENV: "launch-env-preserved" }, - }); - let terminalCreated = false; - - try { - assert.ok( - Buffer.byteLength(prompt) > 64 * 1024, - "fixture must exceed the old interactive PTY command boundary", - ); - const result = await createTerminalSessionInternal({ - terminalId, - workspaceId, - db, - listed: true, - initialCommand: launch.initialCommand, - }); - if ("error" in result) { - assert.fail(`expected session, got error: ${result.error}`); - } - terminalCreated = true; - - const acknowledgement = await waitForAgentLaunch(launch); - await waitFor( - () => fs.existsSync(outputPath) && fs.existsSync(pidPath), - 5000, - ); - - assert.equal(fs.readFileSync(outputPath, "utf8"), prompt); - assert.equal( - fs.readFileSync(`${outputPath}.env`, "utf8"), - "launch-env-preserved", - ); - const childPid = Number.parseInt(fs.readFileSync(pidPath, "utf8"), 10); - assert.equal(acknowledgement.pid, childPid); - assert.doesNotThrow( - () => process.kill(childPid, 0), - "agent child must still be alive when launch reports success", - ); - } finally { - cleanupAgentLaunch(launch); - if (terminalCreated) await disposeSessionAndWait(terminalId, db); - } -} - async function waitForOutput( pty: { onData: (cb: (d: string) => void) => { dispose(): void } }, marker: string, diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 836431afe11..b8c7463722b 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -519,32 +519,6 @@ export function writeInputToSession({ return { success: true }; } -export async function adoptTerminalSessionInternal({ - terminalId, - workspaceId, - db, - eventBus, - themeType, - replayOnAdoption = false, -}: { - terminalId: string; - workspaceId: string; - db: HostDb; - eventBus?: EventBus; - themeType?: "dark" | "light"; - replayOnAdoption?: boolean; -}): Promise { - return createTerminalSessionInternal({ - terminalId, - workspaceId, - db, - eventBus, - themeType, - adoptOnly: true, - replayOnAdoption, - }); -} - function sendMessage( socket: { send: (data: string) => void; readyState: number }, message: TerminalServerMessage, @@ -1517,12 +1491,13 @@ export function registerWorkspaceTerminalRoute({ // Prefer adoption: if the daemon still owns the PTY across a // host-service restart, we keep the live shell + ring buffer. - const adopted = await adoptTerminalSessionInternal({ + const adopted = await createTerminalSessionInternal({ terminalId, workspaceId: record.originWorkspaceId, themeType, db, eventBus, + adoptOnly: true, // Renderer passes `?replay=0` on reconnect; see replayOnAdoption. replayOnAdoption: c.req.query("replay") !== "0", }); diff --git a/packages/host-service/src/trpc/router/agents/agent-launch.test.ts b/packages/host-service/src/trpc/router/agents/agent-launch.test.ts deleted file mode 100644 index 6e1060e484c..00000000000 --- a/packages/host-service/src/trpc/router/agents/agent-launch.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { - chmodSync, - existsSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - cleanupAgentLaunch, - type PreparedAgentLaunch, - prepareAgentLaunch, - waitForAgentLaunch, - withPreparedAgentLaunch, -} from "./agent-launch"; - -const launches: PreparedAgentLaunch[] = []; -const extraDirectories: string[] = []; - -afterEach(() => { - for (const launch of launches.splice(0)) cleanupAgentLaunch(launch); - for (const directory of extraDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } -}); - -describe("prepareAgentLaunch", () => { - it("keeps a large Unicode prompt out of the interactive PTY command", () => { - const prompt = `${"line with Unicode 🎉 中文\n".repeat(4096)}tail\n\n`; - const launch = prepareAgentLaunch({ - command: "codex", - args: ["--model", "gpt-test"], - promptArgs: ["--prompt"], - promptTransport: "argv", - prompt, - env: { AGENT_SETTING: "value with spaces" }, - }); - launches.push(launch); - - expect(Buffer.byteLength(prompt)).toBeGreaterThan(64 * 1024); - expect(readFileSync(launch.promptPath, "utf8")).toBe(prompt); - expect(launch.initialCommand.length).toBeLessThan(512); - expect(launch.initialCommand).not.toContain("line with Unicode"); - expect(readFileSync(launch.scriptPath, "utf8")).not.toContain(prompt); - expect(statSync(launch.promptPath).mode & 0o777).toBe(0o600); - expect(statSync(launch.scriptPath).mode & 0o777).toBe(0o700); - }); - - it("quotes the short launcher path for interactive shells such as fish", () => { - const baseDir = mkdtempSync(join(tmpdir(), "superset launch's base ")); - extraDirectories.push(baseDir); - chmodSync(baseDir, 0o700); - const launch = prepareAgentLaunch({ - command: "agent", - args: [], - promptArgs: [], - promptTransport: "stdin", - prompt: "prompt", - env: {}, - baseDir, - }); - launches.push(launch); - - expect(launch.initialCommand).toBe( - `'/bin/sh' '${launch.scriptPath.replaceAll("'", "'\\''")}'`, - ); - }); - - it("executes argv agents in the foreground launcher process", () => { - const launch = prepareAgentLaunch({ - command: "codex", - args: ["--model", "gpt-test"], - promptArgs: ["--prompt"], - promptTransport: "argv", - prompt: "prompt", - env: {}, - }); - launches.push(launch); - - const script = readFileSync(launch.scriptPath, "utf8"); - expect(script).toContain("launcher_pid=$$"); - expect(script).toContain("sleep 0.05 2>/dev/null || sleep 1"); - expect(script).toContain( - `exec 'env' 'codex' '--model' 'gpt-test' '--prompt' "$prompt" < /dev/tty`, - ); - expect(script).not.toContain(`"$prompt" < /dev/tty &`); - }); - - it("removes sensitive launch artifacts when scoped work throws", async () => { - let launchDir: string | undefined; - await expect( - withPreparedAgentLaunch( - { - command: "agent", - args: [], - promptArgs: [], - promptTransport: "argv", - prompt: "sensitive prompt", - env: {}, - }, - async (launch) => { - launchDir = launch.launchDir; - expect(existsSync(launch.promptPath)).toBe(true); - throw new Error("session creation failed"); - }, - ), - ).rejects.toThrow("session creation failed"); - - expect(launchDir).toBeDefined(); - expect(existsSync(launchDir as string)).toBe(false); - }); -}); - -describe("waitForAgentLaunch", () => { - it("reports a launcher error instead of a false-success terminal id", async () => { - const launch = prepareAgentLaunch({ - command: "/definitely/missing/superset-agent", - args: [], - promptArgs: [], - promptTransport: "argv", - prompt: "prompt", - env: {}, - }); - launches.push(launch); - - const child = Bun.spawn([launch.scriptPath], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - - await expect( - waitForAgentLaunch(launch, { timeoutMs: 2000 }), - ).rejects.toThrow("exited before launch acknowledgement"); - await child.exited; - }); -}); diff --git a/packages/host-service/src/trpc/router/agents/agent-launch.ts b/packages/host-service/src/trpc/router/agents/agent-launch.ts deleted file mode 100644 index 5c2f0bf3e31..00000000000 --- a/packages/host-service/src/trpc/router/agents/agent-launch.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { - existsSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - buildArgvCommand, - quoteSingleShell, -} from "@superset/shared/agent-prompt-launch"; - -const DEFAULT_LAUNCH_TIMEOUT_MS = 10_000; -const LAUNCH_POLL_INTERVAL_MS = 20; - -export interface PrepareAgentLaunchInput { - command: string; - args: string[]; - promptArgs: string[]; - promptTransport: "argv" | "stdin"; - prompt: string; - env: Record; - baseDir?: string; -} - -export interface PreparedAgentLaunch { - initialCommand: string; - launchDir: string; - promptPath: string; - scriptPath: string; - ackPath: string; - errorPath: string; -} - -function buildStaticArgv(input: PrepareAgentLaunchInput): string[] { - return [ - "env", - ...Object.entries(input.env).map(([key, value]) => `${key}=${value}`), - input.command, - ...input.args, - ...(input.prompt === "" ? [] : input.promptArgs), - ]; -} - -function buildLauncherScript( - input: PrepareAgentLaunchInput, - paths: Omit, -): string { - const staticCommand = buildArgvCommand(buildStaticArgv(input)); - const promptPath = quoteSingleShell(paths.promptPath); - const ackPath = quoteSingleShell(paths.ackPath); - const errorPath = quoteSingleShell(paths.errorPath); - - let preparePrompt = ""; - let launchCommand = `exec ${staticCommand} < /dev/tty`; - if (input.prompt !== "") { - if (input.promptTransport === "argv") { - // Command substitution normally strips trailing newlines. Appending a - // sentinel first and removing exactly that sentinel preserves them. - preparePrompt = `prompt_with_sentinel="$(cat ${promptPath}; printf x)"\nprompt="\${prompt_with_sentinel%x}"\n`; - launchCommand = `exec ${staticCommand} "$prompt" < /dev/tty`; - } else { - launchCommand = `exec ${staticCommand} < ${promptPath}`; - } - } - - return `#!/bin/sh -set -u -${preparePrompt}launcher_pid=$$ - -# The launcher itself is the interactive shell's foreground job. A detached -# watcher verifies that the same pid survives the immediate exec/argument -# failure window; exec then replaces this process with the agent without ever -# moving terminal input into a background process group. -( - # BusyBox sleep rejects fractional values. Preserve the fast path where it - # is supported and fall back to one portable second instead of silently - # collapsing the immediate-failure detection window to zero. - sleep 0.05 2>/dev/null || sleep 1 - if kill -0 "$launcher_pid" 2>/dev/null; then - printf '%s\n' "$launcher_pid" > ${ackPath} - else - printf 'Agent process exited before launch acknowledgement.\n' > ${errorPath} - fi -) >/dev/null 2>&1 & - -${launchCommand} -`; -} - -/** - * Materialize a large prompt outside the PTY input path. The user's shell only - * receives `initialCommand`, a short quoted script path. The POSIX launcher is - * shell-independent (including when the interactive account shell is fish), - * starts the configured agent in the terminal's foreground process group, and - * acknowledges only after that child survives its initial exec window. - */ -export function prepareAgentLaunch( - input: PrepareAgentLaunchInput, -): PreparedAgentLaunch { - const launchDir = mkdtempSync( - join(input.baseDir ?? tmpdir(), "superset-agent-launch-"), - ); - const promptPath = join(launchDir, "prompt.txt"); - const scriptPath = join(launchDir, "launch.sh"); - const ackPath = join(launchDir, "started"); - const errorPath = join(launchDir, "error"); - - try { - writeFileSync(promptPath, input.prompt, { encoding: "utf8", mode: 0o600 }); - const script = buildLauncherScript(input, { - promptPath, - scriptPath, - ackPath, - errorPath, - }); - writeFileSync(scriptPath, script, { encoding: "utf8", mode: 0o700 }); - return { - // Invoke the POSIX shell explicitly so launch also works when the - // system temp directory is mounted noexec. This two-argument command - // remains safe to type into fish and other interactive shells. - initialCommand: buildArgvCommand(["/bin/sh", scriptPath]), - launchDir, - promptPath, - scriptPath, - ackPath, - errorPath, - }; - } catch (error) { - rmSync(launchDir, { recursive: true, force: true }); - throw error; - } -} - -export function cleanupAgentLaunch(launch: PreparedAgentLaunch): void { - rmSync(launch.launchDir, { recursive: true, force: true }); -} - -/** Run work with launch artifacts whose lifetime is scoped to the callback. */ -export async function withPreparedAgentLaunch( - input: PrepareAgentLaunchInput, - run: (launch: PreparedAgentLaunch) => Promise, -): Promise { - const launch = prepareAgentLaunch(input); - try { - return await run(launch); - } finally { - cleanupAgentLaunch(launch); - } -} - -export async function waitForAgentLaunch( - launch: PreparedAgentLaunch, - options: { timeoutMs?: number; signal?: AbortSignal } = {}, -): Promise<{ pid: number }> { - const timeoutMs = options.timeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS; - const deadline = Date.now() + timeoutMs; - - while (Date.now() <= deadline) { - if (options.signal?.aborted) { - throw options.signal.reason ?? new Error("Agent launch aborted"); - } - if (existsSync(launch.errorPath)) { - const detail = readFileSync(launch.errorPath, "utf8").trim(); - throw new Error(detail || "Agent process failed during launch"); - } - if (existsSync(launch.ackPath)) { - const rawPid = readFileSync(launch.ackPath, "utf8").trim(); - const pid = Number.parseInt(rawPid, 10); - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error(`Agent launcher returned an invalid pid: ${rawPid}`); - } - return { pid }; - } - await new Promise((resolve) => - setTimeout(resolve, LAUNCH_POLL_INTERVAL_MS), - ); - } - - throw new Error(`Timed out after ${timeoutMs}ms waiting for agent to start`); -} diff --git a/packages/host-service/src/trpc/router/agents/agents.test.ts b/packages/host-service/src/trpc/router/agents/agents.test.ts index de4cb276765..f218fe0092e 100644 --- a/packages/host-service/src/trpc/router/agents/agents.test.ts +++ b/packages/host-service/src/trpc/router/agents/agents.test.ts @@ -1,23 +1,93 @@ import { describe, expect, it } from "bun:test"; -import { buildAttachmentBlock } from "./attachment-prompt"; - -describe("buildAttachmentBlock", () => { - it("keeps the complete prompt and every resolved attachment path", () => { - const prompt = `${"large prompt 🎉\n".repeat(4096)}tail\n`; - const attachments = Array.from({ length: 300 }, (_, index) => ({ - attachmentId: `attachment-${index}`, - path: `/tmp/attached files/trace-${index}-中文.log`, - })); - - const result = buildAttachmentBlock(prompt, attachments); - - expect(result.startsWith(prompt)).toBe(true); - expect(result).toContain("# Attached files"); - expect(result).toContain("/tmp/attached files/trace-0-中文.log"); - expect(result).toEndWith("- /tmp/attached files/trace-299-中文.log"); +import { buildAgentCommandString } from "./agents"; + +const argvConfig = { + id: "00000000-0000-0000-0000-000000000001", + presetId: "claude", + label: "Claude", + command: "claude", + args: ["--dangerously-skip-permissions"], + promptTransport: "argv" as const, + promptArgs: [], + env: {}, +}; + +const stdinConfig = { + id: "00000000-0000-0000-0000-000000000002", + presetId: "amp", + label: "Amp", + command: "amp", + args: [], + promptTransport: "stdin" as const, + promptArgs: [], + env: {}, +}; + +const RANDOM_ID = "test-1234"; +const DELIMITER = "SUPERSET_PROMPT_test1234"; + +describe("buildAgentCommandString", () => { + it("appends the prompt as a quoted positional (argv transport)", () => { + // Not the shared "$(cat <<…)" form: the command must parse in non-POSIX + // shells like fish, which have no heredocs. + expect( + buildAgentCommandString(argvConfig, "do the thing", [], RANDOM_ID), + ).toBe("'claude' '--dangerously-skip-permissions' 'do the thing'"); + }); + + it("inserts model args between base args and the prompt (argv transport)", () => { + expect( + buildAgentCommandString( + argvConfig, + "do the thing", + ["--model", "sonnet"], + RANDOM_ID, + ), + ).toBe( + "'claude' '--dangerously-skip-permissions' '--model' 'sonnet' 'do the thing'", + ); + }); + + it("inserts model args before the heredoc (stdin transport)", () => { + expect( + buildAgentCommandString( + stdinConfig, + "do the thing", + ["--model", "sonnet"], + RANDOM_ID, + ), + ).toBe( + `'amp' '--model' 'sonnet' <<'${DELIMITER}'\ndo the thing\n${DELIMITER}`, + ); + }); + + it("shell-quotes hostile model and prompt values", () => { + expect( + buildAgentCommandString( + argvConfig, + "p'; rm -rf /", + ["--model", "x'; rm -rf /"], + RANDOM_ID, + ), + ).toBe( + "'claude' '--dangerously-skip-permissions' '--model' 'x'\\''; rm -rf /' 'p'\\''; rm -rf /'", + ); + }); + + it("includes promptArgs before the prompt when a prompt is present", () => { + const config = { ...argvConfig, promptArgs: ["-p"] }; + expect(buildAgentCommandString(config, "p", [], RANDOM_ID)).toBe( + "'claude' '--dangerously-skip-permissions' '-p' 'p'", + ); }); - it("does not alter prompts without attachments", () => { - expect(buildAttachmentBlock("prompt\n", [])).toBe("prompt\n"); + it("drops promptArgs and the prompt payload when the prompt sanitizes to empty", () => { + const config = { ...argvConfig, promptArgs: ["-p"] }; + expect(buildAgentCommandString(config, "\x1b\x07", [], RANDOM_ID)).toBe( + "'claude' '--dangerously-skip-permissions'", + ); + expect(buildAgentCommandString(stdinConfig, "", [], RANDOM_ID)).toBe( + "'amp'", + ); }); }); diff --git a/packages/host-service/src/trpc/router/agents/agents.ts b/packages/host-service/src/trpc/router/agents/agents.ts index 7eb410ff77d..d86d1788c5d 100644 --- a/packages/host-service/src/trpc/router/agents/agents.ts +++ b/packages/host-service/src/trpc/router/agents/agents.ts @@ -4,21 +4,21 @@ import { buildAgentModelArgs, buildAgentModelEnv, } from "@superset/shared/agent-models"; -import { sanitizePromptForPty } from "@superset/shared/agent-prompt-launch"; +import { + buildArgvCommand, + buildPromptCommandString, + envOverlayPrefix, + sanitizePromptForPty, +} from "@superset/shared/agent-prompt-launch"; import { TRPCError } from "@trpc/server"; import { asc, eq } from "drizzle-orm"; import { z } from "zod"; import type { HostDb } from "../../../db"; import { hostAgentConfigs, workspaces } from "../../../db/schema"; -import { - createTerminalSessionInternal, - disposeSessionAndWait, -} from "../../../terminal/terminal"; +import { createTerminalSessionInternal } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; import { resolveAttachmentPath } from "../attachments/storage"; -import { waitForAgentLaunch, withPreparedAgentLaunch } from "./agent-launch"; -import { buildAttachmentBlock } from "./attachment-prompt"; interface ResolvedHostAgentConfig { id: string; @@ -105,6 +105,54 @@ export function resolveHostAgentConfig( return null; } +/** + * Build a shell command string that runs the resolved agent config with the + * given prompt. argv transport appends the prompt as a quoted positional; + * stdin transport delegates heredoc assembly and delimiter collision handling + * to the shared prompt-launch pipeline. + * + * Prompts that sanitize to empty drop `promptArgs` and the prompt payload so + * codex/opencode/copilot don't get stray prompt-mode flags during promptless + * launches — emptiness is only knowable after sanitization, so the check + * lives here rather than in the router's zod schema. + */ +export function buildAgentCommandString( + config: ResolvedHostAgentConfig, + rawPrompt: string, + modelArgs: string[] = [], + randomId: string = crypto.randomUUID(), +): string { + const prompt = sanitizePromptForPty(rawPrompt); + const baseArgv = [config.command, ...config.args, ...modelArgs]; + + if (prompt === "") { + return buildArgvCommand(baseArgv); + } + + if (config.promptTransport === "argv") { + // Plain quoted positional, not the shared "$(cat <<…)" form: the command + // is typed into the user's configured shell, and fish has no heredocs. + return buildArgvCommand([...baseArgv, ...config.promptArgs, prompt]); + } + + return buildPromptCommandString({ + command: buildArgvCommand([...baseArgv, ...config.promptArgs]), + transport: "stdin", + prompt, + randomId, + }); +} + +function buildAttachmentBlock( + prompt: string, + resolved: Array<{ attachmentId: string; path: string }>, +): string { + if (resolved.length === 0) return prompt; + const lines = resolved.map((item) => `- ${item.path}`); + const block = `\n\n# Attached files\n\nThe user attached these files. They are available on this host at:\n\n${lines.join("\n")}`; + return prompt + block; +} + export interface AgentRunInput { workspaceId: string; agent: string; @@ -180,7 +228,7 @@ async function runChatAgent( } async function runTerminalAgent( - ctx: HostServiceContext, + ctx: { db: HostDb; eventBus: import("../../../events").EventBus }, input: AgentRunInput, ): Promise { const config = resolveHostAgentConfig(ctx.db, input.agent); @@ -203,57 +251,37 @@ async function runTerminalAgent( resolvedAttachments.push({ attachmentId, path: resolved.path }); } - const prompt = sanitizePromptForPty( - buildAttachmentBlock(input.prompt, resolvedAttachments), - ); + const prompt = buildAttachmentBlock(input.prompt, resolvedAttachments); const modelArgs = buildAgentModelArgs(config.presetId, input.model); const effortArgs = buildAgentEffortArgs(config.presetId, input.effort); + const command = buildAgentCommandString(config, prompt, [ + ...modelArgs, + ...effortArgs, + ]); const modelEnv = buildAgentModelEnv(config.presetId, input.model); - return withPreparedAgentLaunch( - { - command: config.command, - args: [...config.args, ...modelArgs, ...effortArgs], - promptArgs: config.promptArgs, - promptTransport: config.promptTransport, - prompt, - env: { ...config.env, ...modelEnv }, - }, - async (launch) => { - const terminalId = crypto.randomUUID(); - const result = await createTerminalSessionInternal({ - terminalId, - workspaceId: input.workspaceId, - db: ctx.db, - eventBus: ctx.eventBus, - initialCommand: launch.initialCommand, - }); + const fullCommand = `${envOverlayPrefix({ ...config.env, ...modelEnv })}${command}`; - if ("error" in result) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: result.error, - }); - } + const terminalId = crypto.randomUUID(); + const result = await createTerminalSessionInternal({ + terminalId, + workspaceId: input.workspaceId, + db: ctx.db, + eventBus: ctx.eventBus, + initialCommand: fullCommand, + }); - try { - await waitForAgentLaunch(launch); - } catch (error) { - await disposeSessionAndWait(terminalId, ctx.db).catch(() => undefined); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - error instanceof Error ? error.message : "Agent failed to start", - cause: error, - }); - } + if ("error" in result) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: result.error, + }); + } - return { - kind: "terminal" as const, - sessionId: result.terminalId, - label: config.label, - }; - }, - ); + return { + kind: "terminal", + sessionId: result.terminalId, + label: config.label, + }; } export async function runAgentInWorkspace( diff --git a/packages/host-service/src/trpc/router/agents/attachment-prompt.ts b/packages/host-service/src/trpc/router/agents/attachment-prompt.ts deleted file mode 100644 index ef038c89226..00000000000 --- a/packages/host-service/src/trpc/router/agents/attachment-prompt.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function buildAttachmentBlock( - prompt: string, - resolved: Array<{ attachmentId: string; path: string }>, -): string { - if (resolved.length === 0) return prompt; - const lines = resolved.map((item) => `- ${item.path}`); - const block = `\n\n# Attached files\n\nThe user attached these files. They are available on this host at:\n\n${lines.join("\n")}`; - return prompt + block; -} diff --git a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.test.ts b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.test.ts deleted file mode 100644 index 1e8720a5024..00000000000 --- a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { sanitizePromptForPty } from "@superset/shared/agent-prompt-launch"; -import { encodeAgentPrompt } from "./terminal-agents"; - -describe("encodeAgentPrompt", () => { - it("sends multiline input as one bracketed paste and one submit key", () => { - expect(encodeAgentPrompt("first\nsecond 🎉")).toBe( - "\x1b[200~first\nsecond 🎉\x1b[201~\r", - ); - }); - - it("composes with terminal control-character sanitization", () => { - const prompt = sanitizePromptForPty("first\x1b[31m\n\tsecond\x07"); - expect(encodeAgentPrompt(prompt)).toBe( - "\x1b[200~first\n second\x1b[201~\r", - ); - }); -}); diff --git a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts index 019b1fca518..8dc440f4917 100644 --- a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts +++ b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts @@ -2,22 +2,16 @@ import { type AgentDefinitionId, BUILTIN_AGENT_IDS, } from "@superset/shared/agent-catalog"; -import { sanitizePromptForPty } from "@superset/shared/agent-prompt-launch"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { getDaemonClient } from "../../../terminal/daemon-client-singleton"; import { - adoptTerminalSessionInternal, createTerminalSessionInternal, disposeSessionAndWait, - writeInputToSession, } from "../../../terminal/terminal"; -import { renderTerminalSnapshot } from "../../../terminal/terminal-snapshot"; import type { TerminalAgentBinding, TerminalAgentId, } from "../../../terminal-agents"; -import { terminalAgentStatus } from "../../../terminal-agents"; import { protectedProcedure, router } from "../../index"; type GetOrCreateResult = { @@ -43,119 +37,11 @@ const agentDefinitionIdSchema = z.union([ const GET_OR_CREATE_TIMEOUT_MS = 10_000; -export function encodeAgentPrompt(prompt: string): string { - return `\x1b[200~${prompt}\x1b[201~\r`; -} - -function liveBinding( - bindings: TerminalAgentBinding[], - terminalId: string, -): TerminalAgentBinding { - const binding = bindings.find( - (candidate) => candidate.terminalId === terminalId, - ); - if (!binding) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `No live agent session matching '${terminalId}'.`, - }); - } - return binding; -} - -function withStatus(binding: TerminalAgentBinding) { - return { ...binding, status: terminalAgentStatus(binding) }; -} - export const terminalAgentsRouter = router({ list: protectedProcedure.query(({ ctx }) => { - return ctx.terminalAgentStore.list().map(withStatus); + return ctx.terminalAgentStore.list(); }), - get: protectedProcedure - .input(z.object({ terminalId: z.string().min(1) })) - .query(({ ctx, input }) => { - return withStatus( - liveBinding(ctx.terminalAgentStore.list(), input.terminalId), - ); - }), - - read: protectedProcedure - .input( - z.object({ - terminalId: z.string().min(1), - lines: z.number().int().min(1).max(1000).default(120), - }), - ) - .query(async ({ ctx, input }) => { - const binding = liveBinding( - ctx.terminalAgentStore.list(), - input.terminalId, - ); - try { - const snapshot = await (await getDaemonClient()).snapshot( - binding.terminalId, - ); - return { - terminalId: binding.terminalId, - workspaceId: binding.workspaceId, - status: terminalAgentStatus(binding), - output: renderTerminalSnapshot({ ...snapshot, lines: input.lines }), - truncated: snapshot.truncated, - }; - } catch (error) { - throw new TRPCError({ - code: "NOT_FOUND", - message: - error instanceof Error - ? error.message - : "Unable to read agent session", - cause: error, - }); - } - }), - - send: protectedProcedure - .input( - z.object({ - terminalId: z.string().min(1), - prompt: z.string().min(1), - }), - ) - .mutation(async ({ ctx, input }) => { - const binding = liveBinding( - ctx.terminalAgentStore.list(), - input.terminalId, - ); - const adopted = await adoptTerminalSessionInternal({ - terminalId: binding.terminalId, - workspaceId: binding.workspaceId, - db: ctx.db, - eventBus: ctx.eventBus, - }); - if ("error" in adopted) { - throw new TRPCError({ code: "NOT_FOUND", message: adopted.error }); - } - const prompt = sanitizePromptForPty(input.prompt); - if (prompt === "") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "Prompt is empty after removing terminal control characters.", - }); - } - const sentAt = Date.now(); - const result = writeInputToSession({ - terminalId: binding.terminalId, - workspaceId: binding.workspaceId, - data: encodeAgentPrompt(prompt), - }); - if ("error" in result) { - throw new TRPCError({ code: "NOT_FOUND", message: result.error }); - } - return { accepted: true as const, sentAt }; - }), - listByWorkspace: protectedProcedure .input( z.object({ diff --git a/packages/host-service/test/fixtures/agent-sessions-cli-host.ts b/packages/host-service/test/fixtures/agent-sessions-cli-host.ts deleted file mode 100644 index 17fdbd07065..00000000000 --- a/packages/host-service/test/fixtures/agent-sessions-cli-host.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Database as BunDatabase } from "bun:sqlite"; -import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { createApp } from "../../src/app"; -import type { HostDb } from "../../src/db"; -import * as schema from "../../src/db/schema"; -import { - initTerminalBaseEnv, - resolveTerminalBaseEnv, -} from "../../src/terminal/env"; -import { - createFakeApiClient, - FakeApiAuthProvider, - FakeHostAuthProvider, - FakeModelResolver, - MemoryGitCredentialProvider, -} from "../helpers/fakes"; - -function required(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`${name} is required`); - return value; -} - -const organizationId = required("ORGANIZATION_ID"); -const dbPath = required("HOST_DB_PATH"); -const migrationsFolder = required("HOST_MIGRATIONS_FOLDER"); -const secret = required("HOST_SERVICE_SECRET"); -const workspacePath = required("SUPERSET_E2E_WORKSPACE_PATH"); -const worktreeBasePath = required("SUPERSET_E2E_WORKTREE_BASE_PATH"); -const fakeAgentPath = required("SUPERSET_E2E_AGENT_PATH"); -const capturePath = required("SUPERSET_E2E_CAPTURE_PATH"); -const agentRuntimePath = required("SUPERSET_E2E_AGENT_RUNTIME_PATH"); -const port = Number(required("PORT")); - -const projectId = "20000000-0000-4000-8000-000000000001"; -const workspaceId = "30000000-0000-4000-8000-000000000001"; -const agentConfigId = "40000000-0000-4000-8000-000000000001"; -const missingAgentConfigId = "40000000-0000-4000-8000-000000000002"; - -initTerminalBaseEnv(await resolveTerminalBaseEnv()); - -mkdirSync(dirname(dbPath), { recursive: true }); -const sqlite = new BunDatabase(dbPath, { create: true, readwrite: true }); -sqlite.exec("PRAGMA journal_mode = WAL"); -sqlite.exec("PRAGMA foreign_keys = ON"); -const db = drizzle(sqlite, { schema }); -migrate(db, { migrationsFolder }); - -db.insert(schema.projects) - .values({ - id: projectId, - repoPath: workspacePath, - worktreeBaseDir: worktreeBasePath, - }) - .onConflictDoNothing() - .run(); -db.insert(schema.workspaces) - .values({ - id: workspaceId, - projectId, - worktreePath: workspacePath, - branch: "main", - name: "e2e-agent-sessions", - type: "main", - createdAt: Date.now(), - updatedAt: Date.now(), - }) - .onConflictDoNothing() - .run(); -db.insert(schema.hostAgentConfigs) - .values({ - id: agentConfigId, - presetId: "e2e", - label: "E2E fake agent", - command: agentRuntimePath, - argsJson: JSON.stringify([fakeAgentPath]), - promptTransport: "argv", - promptArgsJson: "[]", - envJson: JSON.stringify({ SUPERSET_E2E_CAPTURE_PATH: capturePath }), - displayOrder: 0, - }) - .onConflictDoUpdate({ - target: schema.hostAgentConfigs.id, - set: { - command: agentRuntimePath, - argsJson: JSON.stringify([fakeAgentPath]), - envJson: JSON.stringify({ SUPERSET_E2E_CAPTURE_PATH: capturePath }), - }, - }) - .run(); -db.insert(schema.hostAgentConfigs) - .values({ - id: missingAgentConfigId, - presetId: "e2e-missing", - label: "E2E missing agent", - command: "/definitely/missing/superset-e2e-agent", - argsJson: "[]", - promptTransport: "argv", - promptArgsJson: "[]", - envJson: "{}", - displayOrder: 1, - }) - .onConflictDoNothing() - .run(); - -const fakeApi = createFakeApiClient({ - "host.ensure.mutate": () => ({ machineId: "e2e-host" }), - "v2Workspace.create.mutate": (input) => { - const row = input as { - id: string; - name: string; - branch: string; - projectId: string; - hostId: string; - type: string; - }; - return { ...row, taskId: null, updatedAt: new Date() }; - }, - "v2Workspace.list.query": () => [], -}); - -const result = createApp({ - config: { - organizationId, - dbPath, - cloudApiUrl: "http://127.0.0.1:9", - migrationsFolder, - allowedOrigins: [], - }, - providers: { - auth: new FakeApiAuthProvider(), - hostAuth: new FakeHostAuthProvider(secret), - credentials: new MemoryGitCredentialProvider(), - modelResolver: new FakeModelResolver(), - }, - db: db as unknown as HostDb, - api: fakeApi.client, - execGh: async () => { - throw new Error("execGh is unavailable in the agent-session E2E fixture"); - }, -}); - -const server = Bun.serve({ - hostname: "127.0.0.1", - port, - fetch: result.app.fetch, -}); -// `PORT=0` asks Bun for an ephemeral port. Terminal env construction happens -// after the server starts and must advertise the resolved port to agent hooks. -process.env.HOST_SERVICE_PORT = String(server.port); - -console.log(`E2E_HOST_READY ${server.url}`); - -let stopping = false; -async function stop(): Promise { - if (stopping) return; - stopping = true; - server.stop(true); - try { - await result.dispose(); - } finally { - sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)"); - sqlite.close(); - } - process.exit(0); -} - -process.on("SIGINT", () => void stop()); -process.on("SIGTERM", () => void stop()); diff --git a/packages/pty-daemon/package.json b/packages/pty-daemon/package.json index a03cb997831..fdae85f5015 100644 --- a/packages/pty-daemon/package.json +++ b/packages/pty-daemon/package.json @@ -1,6 +1,6 @@ { "name": "@superset/pty-daemon", - "version": "0.2.6", + "version": "0.2.7", "private": true, "type": "module", "exports": { diff --git a/packages/pty-daemon/src/Server/Server.ts b/packages/pty-daemon/src/Server/Server.ts index f09ca597dca..019c9dafaf3 100644 --- a/packages/pty-daemon/src/Server/Server.ts +++ b/packages/pty-daemon/src/Server/Server.ts @@ -10,7 +10,6 @@ import { handleList, handleOpen, handleResize, - handleSnapshot, handleSubscribe, handleUnsubscribe, } from "../handlers/index.ts"; @@ -22,7 +21,6 @@ import { type HandoffMessage, type HelloMessage, type ServerMessage, - SNAPSHOT_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, } from "../protocol/index.ts"; import type { HandoffSnapshot, Session } from "../SessionStore/index.ts"; @@ -147,7 +145,6 @@ export class Server { session.buffer = [buf]; session.bufferBytes = buf.byteLength; } - session.bufferTruncated = s.truncated; this.wireSession(session); adopted.push(session); } catch (err) { @@ -463,19 +460,6 @@ export class Server { conn.send(handleList(ctx)); return; } - case "snapshot": { - if (conn.negotiated < SNAPSHOT_PROTOCOL_VERSION) { - conn.send({ - type: "error", - id: msg.id, - message: `snapshot requires protocol ${SNAPSHOT_PROTOCOL_VERSION}`, - code: "EVERSION", - }); - return; - } - handleSnapshot(ctx, conn, msg); - return; - } case "subscribe": { handleSubscribe(ctx, conn, msg); return; diff --git a/packages/pty-daemon/src/SessionStore/SessionStore.test.ts b/packages/pty-daemon/src/SessionStore/SessionStore.test.ts index 51c607f1154..c9e6a33fed0 100644 --- a/packages/pty-daemon/src/SessionStore/SessionStore.test.ts +++ b/packages/pty-daemon/src/SessionStore/SessionStore.test.ts @@ -57,7 +57,6 @@ describe("SessionStore", () => { store.appendOutput(session, Buffer.from(" world")); expect(store.snapshotBuffer(session).toString()).toBe("hello world"); expect(session.bufferBytes).toBe(11); - expect(session.bufferTruncated).toBe(false); }); test("appendOutput evicts oldest chunks when exceeding cap", () => { @@ -69,21 +68,6 @@ describe("SessionStore", () => { const snap = store.snapshotBuffer(session).toString(); expect(snap).toBe("BBBBCCCCCC"); expect(session.bufferBytes).toBe(10); - expect(session.bufferTruncated).toBe(true); - }); - - test("appendOutput retains the exact tail of a chunk larger than the cap", () => { - const store = new SessionStore({ bufferCap: 4 }); - const session = store.add("s0", fakePty({ cols: 80, rows: 24 })); - store.appendOutput( - session, - Buffer.from([0x00, 0x01, 0x02, 0x03, 0xfe, 0xff]), - ); - expect(store.snapshotBuffer(session)).toEqual( - Buffer.from([0x02, 0x03, 0xfe, 0xff]), - ); - expect(session.bufferBytes).toBe(4); - expect(session.bufferTruncated).toBe(true); }); test("appendOutput keeps buffer within cap across many writes", () => { diff --git a/packages/pty-daemon/src/SessionStore/SessionStore.ts b/packages/pty-daemon/src/SessionStore/SessionStore.ts index cd4e3691dbf..40f69d96979 100644 --- a/packages/pty-daemon/src/SessionStore/SessionStore.ts +++ b/packages/pty-daemon/src/SessionStore/SessionStore.ts @@ -10,7 +10,6 @@ export interface Session { buffer: Buffer[]; bufferBytes: number; bufferCap: number; - bufferTruncated: boolean; exited: boolean; exitCode: number | null; exitSignal: number | null; @@ -47,7 +46,6 @@ export class SessionStore { buffer: [], bufferBytes: 0, bufferCap: this.bufferCap, - bufferTruncated: false, exited: false, exitCode: null, exitSignal: null, @@ -90,20 +88,12 @@ export class SessionStore { appendOutput(session: Session, chunk: Buffer): void { session.buffer.push(chunk); session.bufferBytes += chunk.byteLength; - while (session.bufferBytes > session.bufferCap) { - const head = session.buffer[0]; - if (!head) break; - const excess = session.bufferBytes - session.bufferCap; - session.bufferTruncated = true; - if (head.byteLength <= excess) { - session.buffer.shift(); - session.bufferBytes -= head.byteLength; - } else { - // Copy the retained tail so a small ring entry does not keep a much - // larger evicted output chunk's backing allocation alive. - session.buffer[0] = Buffer.from(head.subarray(excess)); - session.bufferBytes -= excess; - } + while ( + session.bufferBytes > session.bufferCap && + session.buffer.length > 0 + ) { + const head = session.buffer.shift(); + if (head) session.bufferBytes -= head.byteLength; } } diff --git a/packages/pty-daemon/src/SessionStore/snapshot.test.ts b/packages/pty-daemon/src/SessionStore/snapshot.test.ts index fba4389273e..3a54ccfaf2d 100644 --- a/packages/pty-daemon/src/SessionStore/snapshot.test.ts +++ b/packages/pty-daemon/src/SessionStore/snapshot.test.ts @@ -76,7 +76,6 @@ describe("handoff snapshot", () => { }); const buf = snapshot.sessions[0]?.buffer ?? new Uint8Array(0); expect(Buffer.from(buf).toString("utf8")).toBe("hello world"); - expect(snapshot.sessions[0]?.truncated).toBe(false); }); test("write + read round-trips", () => { diff --git a/packages/pty-daemon/src/SessionStore/snapshot.ts b/packages/pty-daemon/src/SessionStore/snapshot.ts index f10d116784f..e448c623db4 100644 --- a/packages/pty-daemon/src/SessionStore/snapshot.ts +++ b/packages/pty-daemon/src/SessionStore/snapshot.ts @@ -45,8 +45,6 @@ interface HandoffSessionMessage { * fd was placed. Successor uses this to map sessions → inherited fds. */ fdIndex: number; - /** Optional for compatibility with v1 handoff snapshots. */ - truncated?: boolean; } export interface SerializedSession { @@ -56,7 +54,6 @@ export interface SerializedSession { fdIndex: number; /** Live ring buffer bytes — empty Uint8Array when there's no replay. */ buffer: Uint8Array; - truncated: boolean; } export interface HandoffSnapshot { @@ -91,7 +88,6 @@ export function serializeSessions(opts: SerializeOptions): HandoffSnapshot { meta: s.pty.meta, fdIndex, buffer: Buffer.concat(s.buffer), - truncated: s.bufferTruncated, }); } return { @@ -121,7 +117,6 @@ export function writeSnapshot(path: string, snapshot: HandoffSnapshot): void { pid: s.pid, meta: s.meta, fdIndex: s.fdIndex, - truncated: s.truncated, }; parts.push( encodeFrame(msg, s.buffer.byteLength > 0 ? s.buffer : undefined), @@ -186,7 +181,6 @@ export function readSnapshot(path: string): HandoffSnapshot { meta: m.meta as SessionMeta, fdIndex: m.fdIndex, buffer: frame.payload ?? new Uint8Array(0), - truncated: m.truncated === true, }); } diff --git a/packages/pty-daemon/src/handlers/handlers.test.ts b/packages/pty-daemon/src/handlers/handlers.test.ts index 94f84b29e1a..bb8fff5ae22 100644 --- a/packages/pty-daemon/src/handlers/handlers.test.ts +++ b/packages/pty-daemon/src/handlers/handlers.test.ts @@ -9,7 +9,6 @@ import { handleList, handleOpen, handleResize, - handleSnapshot, handleSubscribe, handleUnsubscribe, } from "./handlers.ts"; @@ -200,59 +199,6 @@ describe("handlers", () => { expect(reply.sessions).toHaveLength(2); }); - test("snapshot returns bytes repeatedly without subscribing or clearing", () => { - const ctx = makeCtx(); - handleOpen(ctx, { - type: "open", - id: "s0", - meta: { shell: "/bin/sh", argv: [], cols: 80, rows: 24 }, - }); - const session = ctx.store.get("s0"); - if (!session) throw new Error("no session"); - ctx.store.appendOutput(session, Buffer.from([0x00, 0xff, 0x41])); - const conn = makeConn(); - - handleSnapshot(ctx, conn, { type: "snapshot", id: "s0" }); - handleSnapshot(ctx, conn, { type: "snapshot", id: "s0" }); - - expect(conn.subscriptions.size).toBe(0); - expect(conn.sent).toHaveLength(2); - for (const frame of conn.sent) { - expect(frame.message).toEqual({ - type: "snapshot-reply", - id: "s0", - cols: 80, - rows: 24, - truncated: false, - }); - expect(Buffer.from(frame.payload ?? [])).toEqual( - Buffer.from([0x00, 0xff, 0x41]), - ); - } - }); - - test("snapshot reports truncation and unknown sessions", () => { - const ctx = makeCtx(); - handleOpen(ctx, { - type: "open", - id: "s0", - meta: { shell: "/bin/sh", argv: [], cols: 80, rows: 24 }, - }); - const session = ctx.store.get("s0"); - if (!session) throw new Error("no session"); - session.bufferTruncated = true; - const conn = makeConn(); - - handleSnapshot(ctx, conn, { type: "snapshot", id: "s0" }); - handleSnapshot(ctx, conn, { type: "snapshot", id: "missing" }); - - expect(conn.sent[0]?.message).toMatchObject({ truncated: true }); - expect(conn.sent[1]?.message).toMatchObject({ - type: "error", - code: "ENOENT", - }); - }); - test("subscribe with replay sends buffered output", () => { const ctx = makeCtx(); handleOpen(ctx, { diff --git a/packages/pty-daemon/src/handlers/handlers.ts b/packages/pty-daemon/src/handlers/handlers.ts index 315a1b4c647..bf002ba7131 100644 --- a/packages/pty-daemon/src/handlers/handlers.ts +++ b/packages/pty-daemon/src/handlers/handlers.ts @@ -12,8 +12,6 @@ import type { OutputMessage, ResizeMessage, ServerMessage, - SnapshotMessage, - SnapshotReplyMessage, SubscribeMessage, UnsubscribeMessage, } from "../protocol/index.ts"; @@ -166,27 +164,6 @@ export function handleList(ctx: HandlerCtx): ListReplyMessage { return { type: "list-reply", sessions: ctx.store.list() }; } -/** Return current ring bytes without subscribing or mutating session state. */ -export function handleSnapshot( - ctx: HandlerCtx, - conn: Conn, - msg: SnapshotMessage, -): void { - const session = ctx.store.get(msg.id); - if (!session) { - conn.send(errorFor(msg.id, `unknown session: ${msg.id}`, "ENOENT")); - return; - } - const reply: SnapshotReplyMessage = { - type: "snapshot-reply", - id: msg.id, - cols: session.pty.meta.cols, - rows: session.pty.meta.rows, - truncated: session.bufferTruncated, - }; - conn.send(reply, ctx.store.snapshotBuffer(session)); -} - /** * Subscribe the connection to a session. If `replay` is true, immediately * send an `output` frame whose binary tail is the buffered bytes — before diff --git a/packages/pty-daemon/src/handlers/index.ts b/packages/pty-daemon/src/handlers/index.ts index b6dcb9719b4..97b7935d62a 100644 --- a/packages/pty-daemon/src/handlers/index.ts +++ b/packages/pty-daemon/src/handlers/index.ts @@ -5,7 +5,6 @@ export { handleList, handleOpen, handleResize, - handleSnapshot, handleSubscribe, handleUnsubscribe, } from "./handlers.ts"; diff --git a/packages/pty-daemon/src/protocol/index.ts b/packages/pty-daemon/src/protocol/index.ts index 777bd9ab7de..f7915e36fca 100644 --- a/packages/pty-daemon/src/protocol/index.ts +++ b/packages/pty-daemon/src/protocol/index.ts @@ -23,14 +23,11 @@ export type { ServerMessage, SessionInfo, SessionMeta, - SnapshotMessage, - SnapshotReplyMessage, SubscribeMessage, UnsubscribeMessage, UpgradePreparedMessage, } from "./messages.ts"; export { CURRENT_PROTOCOL_VERSION, - SNAPSHOT_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, } from "./version.ts"; diff --git a/packages/pty-daemon/src/protocol/messages.ts b/packages/pty-daemon/src/protocol/messages.ts index d899cef9c51..269b3bac1dc 100644 --- a/packages/pty-daemon/src/protocol/messages.ts +++ b/packages/pty-daemon/src/protocol/messages.ts @@ -74,11 +74,6 @@ export interface ListMessage { type: "list"; } -export interface SnapshotMessage { - type: "snapshot"; - id: string; -} - export interface SubscribeMessage { type: "subscribe"; id: string; @@ -135,16 +130,6 @@ export interface ListReplyMessage { sessions: SessionInfo[]; } -/** Buffered PTY bytes ride in the frame's binary tail. */ -export interface SnapshotReplyMessage { - type: "snapshot-reply"; - id: string; - cols: number; - rows: number; - /** True when bytes older than this snapshot were evicted from the ring. */ - truncated: boolean; -} - export interface ErrorMessage { type: "error"; id?: string; @@ -171,7 +156,6 @@ export type ClientMessage = | ResizeMessage | CloseMessage | ListMessage - | SnapshotMessage | SubscribeMessage | UnsubscribeMessage | PrepareUpgradeMessage; @@ -183,6 +167,5 @@ export type ServerMessage = | ExitMessage | ClosedMessage | ListReplyMessage - | SnapshotReplyMessage | ErrorMessage | UpgradePreparedMessage; diff --git a/packages/pty-daemon/src/protocol/version.ts b/packages/pty-daemon/src/protocol/version.ts index 5da8db9b188..0c33230954b 100644 --- a/packages/pty-daemon/src/protocol/version.ts +++ b/packages/pty-daemon/src/protocol/version.ts @@ -5,15 +5,9 @@ // v2: framing is [u32 totalLen][u32 jsonLen][JSON][optional payload bytes]; // OutputMessage and InputMessage drop their `data` field and carry // bytes via the payload tail. (See framing.ts.) -// v3: adds non-destructive snapshot/snapshot-reply messages. Snapshot bytes -// use the binary payload tail and report whether older ring bytes evicted. // -// We don't keep v1 around. v2 and v3 share framing and all pre-snapshot -// messages, so keeping v2 negotiable lets a new host-service reach an existing -// daemon and perform the fd-handoff upgrade without dropping live sessions. -export const CURRENT_PROTOCOL_VERSION = 3 as const; -export const SNAPSHOT_PROTOCOL_VERSION = 3 as const; -export const SUPPORTED_PROTOCOL_VERSIONS: readonly number[] = [ - 2, - CURRENT_PROTOCOL_VERSION, -]; +// We don't keep v1 around. Phase 2 auto-update converges daemon to current +// on host-service start, so the version-skew window is bounded; any +// session lost to that one upgrade is recoverable. +export const CURRENT_PROTOCOL_VERSION = 2 as const; +export const SUPPORTED_PROTOCOL_VERSIONS: readonly number[] = [2]; diff --git a/packages/pty-daemon/src/protocol/wire-shape.test.ts b/packages/pty-daemon/src/protocol/wire-shape.test.ts index 485efe7a85c..bd0000e7772 100644 --- a/packages/pty-daemon/src/protocol/wire-shape.test.ts +++ b/packages/pty-daemon/src/protocol/wire-shape.test.ts @@ -8,7 +8,7 @@ import { decodeFrame, encodeFrame } from "./framing.ts"; const HEADER = 4; const INNER = 4; -describe("binary-tail wire shape", () => { +describe("v2 wire shape", () => { test("output frames carry bytes in the binary tail, not the JSON header", () => { // Pre-v2 shape was `{ type: "output", id, data: "" }`. If // anyone reintroduces that, the JSON portion will contain "data". @@ -85,28 +85,4 @@ describe("binary-tail wire shape", () => { // 8-char "aGVsbG8=" instead of the 5-byte "hello". expect(tail.toString("utf8")).not.toBe("aGVsbG8="); }); - - test("snapshot replies carry raw bytes and truncation only in metadata", () => { - const bytes = Buffer.from([0x00, 0xff, 0x41, 0x42]); - const frame = encodeFrame( - { - type: "snapshot-reply", - id: "s0", - cols: 80, - rows: 24, - truncated: true, - }, - bytes, - ); - const decoded = decodeFrame(frame); - - expect(decoded.message).toEqual({ - type: "snapshot-reply", - id: "s0", - cols: 80, - rows: 24, - truncated: true, - }); - expect(Buffer.from(decoded.payload ?? [])).toEqual(bytes); - }); }); diff --git a/packages/pty-daemon/test/control-plane.test.ts b/packages/pty-daemon/test/control-plane.test.ts index ecede4423ce..fed43e37234 100644 --- a/packages/pty-daemon/test/control-plane.test.ts +++ b/packages/pty-daemon/test/control-plane.test.ts @@ -12,10 +12,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { after, before, describe, test } from "node:test"; -import { - CURRENT_PROTOCOL_VERSION, - encodeFrame, -} from "../src/protocol/index.ts"; +import { encodeFrame } from "../src/protocol/index.ts"; import { Server } from "../src/Server/index.ts"; import { accumulatedOutputAsString, @@ -109,37 +106,15 @@ describe("handshake", () => { test("picks highest mutual when multiple offered", async () => { const c = await connect(sockPath); - c.send({ type: "hello", protocols: [2, CURRENT_PROTOCOL_VERSION, 99] }); - const ack = await c.waitFor((m) => m.type === "hello-ack"); - if (ack.type === "hello-ack") { - assert.equal(ack.protocol, CURRENT_PROTOCOL_VERSION); - } - await c.close(); - }); - - test("keeps v2 operations reachable while rejecting v3-only snapshots", async () => { - const c = await connect(sockPath); - c.send({ type: "hello", protocols: [2] }); + c.send({ type: "hello", protocols: [2, 99] }); const ack = await c.waitFor((m) => m.type === "hello-ack"); if (ack.type === "hello-ack") assert.equal(ack.protocol, 2); - - c.send({ type: "list" }); - await c.waitFor((m) => m.type === "list-reply"); - - c.send({ type: "snapshot", id: "legacy-snapshot" }); - const error = await c.waitFor( - (m) => m.type === "error" && m.id === "legacy-snapshot", - ); - if (error.type === "error") { - assert.equal(error.code, "EVERSION"); - assert.match(error.message, /requires protocol 3/); - } await c.close(); }); test("rejects duplicate hello", async () => { const c = await connectAndHello(sockPath); - c.send({ type: "hello", protocols: [CURRENT_PROTOCOL_VERSION] }); + c.send({ type: "hello", protocols: [2] }); const err = await c.waitFor((m) => m.type === "error", 1000); if (err.type === "error") { assert.match(err.message, /duplicate hello/); @@ -1021,10 +996,7 @@ describe("server shutdown", () => { describe("framing on the wire", () => { test("server tolerates split frames across multiple TCP chunks", async () => { const c = await connect(sockPath); - const hello = encodeFrame({ - type: "hello", - protocols: [CURRENT_PROTOCOL_VERSION], - }); + const hello = encodeFrame({ type: "hello", protocols: [2] }); // Send the hello in 3-byte chunks to force the decoder to buffer. for (let i = 0; i < hello.length; i += 3) { c.sendRaw(hello.subarray(i, Math.min(i + 3, hello.length))); diff --git a/packages/pty-daemon/test/helpers/client.ts b/packages/pty-daemon/test/helpers/client.ts index 1abe536166f..f3c553e83b8 100644 --- a/packages/pty-daemon/test/helpers/client.ts +++ b/packages/pty-daemon/test/helpers/client.ts @@ -1,9 +1,8 @@ // Reusable test client for pty-daemon integration tests. -// Speaks the daemon's current wire protocol over a Unix socket. +// Speaks the daemon's wire protocol (v2) over a Unix socket. import * as net from "node:net"; import { - CURRENT_PROTOCOL_VERSION, encodeFrame, FrameDecoder, type ServerMessage, @@ -226,7 +225,7 @@ export async function connectAndHello( socketPath: string, ): Promise { const c = await connect(socketPath); - c.send({ type: "hello", protocols: [CURRENT_PROTOCOL_VERSION] }); + c.send({ type: "hello", protocols: [2] }); await c.waitFor((m) => m.type === "hello-ack"); return c; } diff --git a/packages/pty-daemon/test/integration.test.ts b/packages/pty-daemon/test/integration.test.ts index da178b91556..0201469b7b3 100644 --- a/packages/pty-daemon/test/integration.test.ts +++ b/packages/pty-daemon/test/integration.test.ts @@ -9,7 +9,6 @@ import * as os from "node:os"; import * as path from "node:path"; import { after, before, test } from "node:test"; import { adoptFromFd, spawn as spawnPty } from "../src/Pty/Pty.ts"; -import { CURRENT_PROTOCOL_VERSION } from "../src/protocol/index.ts"; import { Server } from "../src/Server/index.ts"; import { connect, connectAndHello, payloadAsString } from "./helpers/client.ts"; @@ -27,11 +26,11 @@ after(async () => { test("handshake: hello → hello-ack", async () => { const c = await connect(sockPath); - c.send({ type: "hello", protocols: [CURRENT_PROTOCOL_VERSION] }); + c.send({ type: "hello", protocols: [2] }); const ack = await c.waitFor((m) => m.type === "hello-ack"); assert.equal(ack.type, "hello-ack"); if (ack.type === "hello-ack") { - assert.equal(ack.protocol, CURRENT_PROTOCOL_VERSION); + assert.equal(ack.protocol, 2); assert.equal(ack.daemonVersion, "0.0.0-test"); } await c.close(); diff --git a/skills/superset/SKILL.md b/skills/superset/SKILL.md index 598b6688c27..9d01bec511c 100644 --- a/skills/superset/SKILL.md +++ b/skills/superset/SKILL.md @@ -42,16 +42,13 @@ Optionally act on the new workspace as soon as it's materialized: ```bash superset workspaces create --project --local --name "..." --branch --agent claude --prompt "fix the build" -superset workspaces create --project --local --name "..." --branch --agent codex --prompt "review the context" --attachment ./context.md superset workspaces create --project --local --name "..." --branch --command "bun install && bun test" ``` -- `--agent`/`--prompt` launch an agent in the workspace (both required together) — the inline form of `agents create`. Repeatable local `--attachment ` values are uploaded first. +- `--agent`/`--prompt` launch an agent in the workspace (both required together) — the inline form of `agents create`. - `--command ` runs a one-off shell command in the worktree — the inline form of `terminals create`. The two are independent — pass either or both. -If the workspace is created but its inline agent fails to start, the CLI exits -non-zero and prints the retained workspace ID plus an `agents create` retry. ## Agents @@ -63,24 +60,7 @@ superset agents create --workspace --agent claude --prompt "..." `--agent` accepts a preset id (e.g. `claude`, `codex`), a HostAgentConfig instance UUID, or `superset` for a built-in Superset chat session. Pass `--attachment-id ` once per attachment. Use `agents list` first if you don't already know which agents are installed on the target host. -`agents create --json` returns `{ kind, sessionId, label }`. `kind` is `chat` (the `superset` agent) or `terminal` (e.g. `claude`, `codex`) — you need it to build a session deep link (see [Opening sessions in the desktop app](#opening-sessions-in-the-desktop-app)). - -Continue a live terminal agent headlessly with its returned `sessionId`: - -```bash -superset agents sessions list [--host | --local] [--workspace ] -superset agents sessions read --lines 120 -superset agents sessions send "Now fix the failing test" -superset agents sessions send --file follow-up.md --wait --timeout 5m -superset agents sessions wait --for idle --timeout 5m -``` - -`read` is a bounded recent terminal snapshot, not a durable transcript. `send` -accepts exactly one source from positional text, `--file`, or piped stdin. -`send --wait` waits for a post-send idle, permission, failure, or exit event and -returns a fresh snapshot when the process remains live. These commands work -while the desktop renderer is closed and control terminal agents only; exited -process resume and Superset chat sessions are not part of this interface. +`agents run --json` returns `{ kind, sessionId, label }`. `kind` is `chat` (the `superset` agent) or `terminal` (e.g. `claude`, `codex`) — you need it to build a session deep link (see [Opening sessions in the desktop app](#opening-sessions-in-the-desktop-app)). ## Opening sessions in the desktop app @@ -91,9 +71,9 @@ superset workspaces open superset workspaces open --print ``` -A session you start with `agents create` syncs to the desktop app but has **no pane** in the workspace view until you navigate to it. Recover live terminal IDs with `agents sessions list`. `workspaces open` targets only the workspace and cannot focus a session, so build the deep link yourself and append a query param chosen by the session `kind`: +A session you start with `agents run` syncs to the desktop app but has **no pane** in the workspace view until you navigate to it, and there is no session list — so a freshly created session is effectively invisible until opened. `workspaces open` targets only the workspace and cannot focus a session, so build the deep link yourself and append a query param chosen by the session `kind`: -| `kind` (from `agents create --json`) | Agents | Deep-link param | +| `kind` (from `agents run --json`) | Agents | Deep-link param | | --- | --- | --- | | `chat` | `superset` | `?chatSessionId=` | | `terminal` | `claude`, `codex` | `?terminalId=` |