mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
OpenRig's managed seats can now run on a tmux server of their own instead of sharing the person's default server. `rig config set terminal.tmux_server isolated` keeps them on a persistent server derived from the canonical OpenRig home, so they stop cluttering the person's own `tmux ls`. `inherit` remains the default and keeps the existing native server selection and attachment behaviour; both modes keep normal tmux configuration. The selection is made before reconciliation, and a change waits while this instance's registered seats still run on the previous server, or while that server can't be inspected; it never moves or kills a live pane. Launch, capture, inventory, terminal views and cleanup use the effective selection. Ordinary inheritance tolerates missing or invalid advisory records and gains no new identity prerequisite; strict record and identity checks apply to isolation. Fixed isolated and migration selections carry a durable pin, and an opt-out records it before removing the marker, so startup and offline cleanup agree after an interrupted update. SSH tiles follow the viewing instance's effective mode: inherited views keep `ssh -t … tmux attach`, and isolated views use the remote `rig terminal attach` and never fall back to a same-named session on the default server. The reference includes a two-instance recipe with separate homes, databases and ports.
306 lines
17 KiB
TypeScript
306 lines
17 KiB
TypeScript
// OPR.0.4.6.02 C3 — the `rig terminal` CLI family. Pins arg parsing, the daemon
|
|
// path/body each subcommand hits, the --json shape, and the open exit
|
|
// semantics for explicit cmux (partial-with-names = exit 0; zero-pane = non-zero).
|
|
// The default desktop path is exercised in terminal-open-window-default.test.ts.
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { TerminalDeps } from "../src/commands/terminal.js";
|
|
import { createProgram } from "../src/index.js";
|
|
|
|
vi.mock("../src/daemon-lifecycle.js", async () => {
|
|
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
|
return {
|
|
...actual,
|
|
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
|
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
|
};
|
|
});
|
|
|
|
interface StubResponse { status: number; data: unknown }
|
|
|
|
function makeDeps(opts?: { routes?: Record<string, StubResponse> }): {
|
|
deps: TerminalDeps;
|
|
calls: Array<{ method: string; path: string; body?: unknown }>;
|
|
} {
|
|
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
|
const routes = opts?.routes ?? {};
|
|
return {
|
|
calls,
|
|
deps: {
|
|
lifecycleDeps: {} as TerminalDeps["lifecycleDeps"],
|
|
clientFactory: () => ({
|
|
isLocalInstance: vi.fn(async () => true),
|
|
get: vi.fn(async (path: string) => {
|
|
calls.push({ method: "GET", path });
|
|
return routes[`GET ${path}`] ?? { status: 200, data: {} };
|
|
}),
|
|
getText: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: "" }; }),
|
|
post: vi.fn(async (path: string, body: unknown) => {
|
|
calls.push({ method: "POST", path, body });
|
|
return routes[`POST ${path}`] ?? { status: 200, data: {} };
|
|
}),
|
|
delete: vi.fn(async (path: string) => { calls.push({ method: "DELETE", path }); return { status: 204, data: null }; }),
|
|
postText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
|
|
postExpectText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
|
|
}) as unknown as ReturnType<TerminalDeps["clientFactory"]>,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("task-following controls", () => {
|
|
beforeEach(() => { process.exitCode = 0; vi.spyOn(console, "log").mockImplementation(() => {}); });
|
|
const body = { provider: "herdr", ok: true, opened: [], absent: [], degraded: [], pages: 1,
|
|
following: { id: "stable", enabled: true, state: "empty", maxTiles: 4, tiles: [], events: [], overflow: [] } };
|
|
it("posts one owner operation with explicit follow controls and accepts a complete empty view", async () => {
|
|
const { deps, calls } = makeDeps({ routes: { "POST /api/terminal/follow": { status: 200, data: body } } });
|
|
const program = createProgram({ terminalDeps: deps }); program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "qitem:task", "--provider", "herdr", "--follow", "--max-tiles", "4", "--pin", "a@team", "--json"]);
|
|
expect(calls).toEqual([{ method: "POST", path: "/api/terminal/follow", body: { view: "qitem:task", provider: "herdr", enabled: true,
|
|
maxTiles: 4, pin: ["a@team"], unpin: undefined, suppress: undefined, unsuppress: undefined, suppressionMs: undefined, graceMs: undefined, focusRetentionMs: undefined } }]);
|
|
expect(process.exitCode).toBe(0);
|
|
});
|
|
it("disables without opening a desktop or creating a replacement workspace", async () => {
|
|
const { deps, calls } = makeDeps({ routes: { "POST /api/terminal/follow": { status: 200, data: { ...body, following: { ...body.following, enabled: false, state: "disabled" } } } } });
|
|
const program = createProgram({ terminalDeps: deps }); program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "qitem:task", "--no-follow", "--json"]);
|
|
expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ path: "/api/terminal/follow", body: { enabled: false } });
|
|
});
|
|
});
|
|
|
|
const opened = (seats: string[], extra: Record<string, unknown> = {}) => ({
|
|
provider: "herdr", ok: seats.length > 0, opened: seats, absent: [], degraded: [], pages: 1, ...extra,
|
|
});
|
|
|
|
describe("rig terminal CLI", () => {
|
|
let logs: string[];
|
|
beforeEach(() => {
|
|
logs = [];
|
|
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
it("registers open/views/status/attach subcommands", () => {
|
|
const { deps } = makeDeps();
|
|
const program = createProgram({ terminalDeps: deps });
|
|
const cmd = program.commands.find((c) => c.name() === "terminal");
|
|
expect(cmd).toBeDefined();
|
|
expect(cmd!.commands.map((c) => c.name()).sort()).toEqual(["attach", "open", "status", "views"]);
|
|
});
|
|
|
|
it.each([false, true])("attaches using the daemon's effective server, preserving read-only=%s", async readOnly => {
|
|
const serverArgs = ["-S", "/private/selected server.sock"];
|
|
const { deps, calls } = makeDeps({ routes: { "GET /api/terminal/status": {
|
|
status: 200, data: { tmux: { mode: "inherit", requestedMode: "isolated", migrationPending: true, serverArgs } },
|
|
} } });
|
|
deps.attachTmux = vi.fn(async () => 7);
|
|
await createProgram({ terminalDeps: deps }).parseAsync([
|
|
"node", "rig", "terminal", "attach", ...(readOnly ? ["--read-only"] : []), "--", "seat'quoted",
|
|
]);
|
|
expect(deps.attachTmux).toHaveBeenCalledWith({ session: "seat'quoted", serverArgs, readOnly });
|
|
expect(calls).toEqual([{ method: "GET", path: "/api/terminal/status" }]);
|
|
expect(process.exitCode).toBe(7);
|
|
});
|
|
|
|
it.each([
|
|
{ status: 503, data: {}, message: "HTTP 503" },
|
|
{ status: 200, data: {}, message: "cannot report its tmux server" },
|
|
{ status: 200, data: { tmux: { serverArgs: ["kill-server"] } }, message: "invalid tmux server options" },
|
|
])("reports unreadable or invalid server metadata without attaching: $message", async response => {
|
|
const { deps } = makeDeps({ routes: { "GET /api/terminal/status": response } });
|
|
deps.attachTmux = vi.fn(async () => 0);
|
|
await createProgram({ terminalDeps: deps }).parseAsync(["node", "rig", "terminal", "attach", "seat"]);
|
|
expect(deps.attachTmux).not.toHaveBeenCalled();
|
|
expect(console.error).toHaveBeenCalledWith(expect.stringContaining(response.message));
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("does not attach a local namesake for an HTTP remote daemon", async () => {
|
|
const { deps, calls } = makeDeps();
|
|
const factory = deps.clientFactory;
|
|
deps.clientFactory = url => Object.assign(factory(url), { baseUrl: "http://remote:7433", isLocalInstance: vi.fn(async () => false) });
|
|
deps.attachTmux = vi.fn(async () => 0);
|
|
await createProgram({ terminalDeps: deps }).parseAsync(["node", "rig", "terminal", "attach", "seat"]);
|
|
expect(calls).toEqual([]);
|
|
expect(deps.attachTmux).not.toHaveBeenCalled();
|
|
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("local tmux"));
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("--window uses local desktop handling and reports a headless refusal without a provider POST", async () => {
|
|
const previewPath = "/api/terminal/preview?view=saved%3Akernel&provider=herdr";
|
|
const pane = { seat: "operator", label: "operator", paneCommand: "tmux attach-session -t '=operator'" };
|
|
const { deps, calls } = makeDeps({ routes: { [`GET ${previewPath}`]: { status: 200, data: {
|
|
planId: "headless-plan", status: {}, composed: { opened: [pane], pages: [[pane]], absent: [], degraded: [] },
|
|
} } } });
|
|
const factory = deps.clientFactory;
|
|
deps.clientFactory = url => Object.assign(factory(url), { baseUrl: "http://localhost:7433" });
|
|
deps.windowDeps = { platform: "linux", env: {}, exists: () => false, exec: vi.fn(async () => { throw new Error("not installed"); }), launch: vi.fn(), sleep: vi.fn(), id: () => "unused" };
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "saved:kernel", "--window", "--json"]);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(JSON.parse(logs[0]!)).toMatchObject({ ok: false, opened: [], code: "terminal_window_failed", windowAttempted: false });
|
|
expect(JSON.parse(logs[0]!).error).toContain("run: env -u TMUX tmux attach-session -t '=operator'");
|
|
expect(calls).toEqual([{ method: "GET", path: previewPath }]);
|
|
expect(calls.some(call => call.method === "POST")).toBe(false);
|
|
expect(deps.windowDeps.launch).not.toHaveBeenCalled();
|
|
expect(vi.mocked(deps.windowDeps.exec).mock.calls.some(([file]) => file === "/usr/bin/osascript" || file === "/usr/bin/open")).toBe(false);
|
|
});
|
|
|
|
it("open POSTs /api/terminal/open with view + provider", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/terminal/open": { status: 200, data: opened(["a-seat"]) } },
|
|
});
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux", "--json"]);
|
|
const call = calls.find((c) => c.path === "/api/terminal/open");
|
|
expect(call!.body).toEqual({ view: "acme-build", provider: "cmux" });
|
|
});
|
|
|
|
it("open --provider rex POSTs the rex provider and names the Rex session it opened", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/terminal/open": { status: 200, data: opened(["a-seat"], { provider: "rex", space: { id: "session:1", label: "acme-build" } }) } },
|
|
});
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "rex"]);
|
|
expect(calls.find((c) => c.path === "/api/terminal/open")!.body).toEqual({ view: "acme-build", provider: "rex" });
|
|
expect(logs[0]).toContain("Prepared 1 tile(s) in rex.");
|
|
expect(logs[0]).toContain('rex session: "acme-build" (session:1).');
|
|
expect(process.exitCode).toBeUndefined();
|
|
});
|
|
|
|
it("open with a partial (some opened, some named) is a SUCCESS — exit 0", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: {
|
|
"POST /api/terminal/open": {
|
|
status: 200,
|
|
data: opened(["a-seat"], { absent: [{ seat: "b", host: null, reason: "not alive" }] }),
|
|
},
|
|
},
|
|
});
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux"]);
|
|
expect(process.exitCode).toBeUndefined(); // exit 0 (disclosure, not failure)
|
|
});
|
|
|
|
it("open prints the provider's notes (OPR.0.6.0.8: suffixed name, refused focus)", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: { "POST /api/terminal/open": { status: 200, data: { ...opened(["a-seat"]), notes: ['A workspace named "acme-build" already exists, so this one is "acme-build (2)".'] } } },
|
|
});
|
|
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
try {
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux"]);
|
|
expect(log.mock.calls.map((c) => String(c[0])).join("\n")).toContain(' note: A workspace named "acme-build" already exists, so this one is "acme-build (2)".');
|
|
} finally { log.mockRestore(); }
|
|
});
|
|
|
|
it.each([false, true])("names unconfirmed panes without claiming none opened (json=%s)", async json => {
|
|
const result = opened([], { degraded: [{ seat: "a-seat", host: "herdr", code: "pane_unconfirmed", reason: "Inspect the terminal before retrying." }] });
|
|
const { deps, calls } = makeDeps({ routes: { "POST /api/terminal/open": { status: 200, data: result } } });
|
|
const program = createProgram({ terminalDeps: deps }); program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux", ...(json ? ["--json"] : [])]);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(calls.filter(c => c.method === "POST")).toHaveLength(1);
|
|
if (json) expect(JSON.parse(logs[0]!)).toEqual(result);
|
|
else {
|
|
expect(logs[0]).toContain("No tiles confirmed in herdr.");
|
|
expect(logs[0]).toContain("a-seat — unconfirmed (herdr): Inspect");
|
|
expect(logs[0]).not.toMatch(/No tiles opened|skipped|Prepared/);
|
|
}
|
|
});
|
|
|
|
it("counts a confirmed seat while naming an unconfirmed sibling", async () => {
|
|
const { deps } = makeDeps({ routes: { "POST /api/terminal/open": { status: 200, data: opened(["a-seat"], {
|
|
degraded: [{ seat: "b-seat", host: "herdr", code: "pane_unconfirmed", reason: "pane list unreadable" }],
|
|
}) } } });
|
|
const program = createProgram({ terminalDeps: deps }); program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux"]);
|
|
expect(process.exitCode).toBeUndefined();
|
|
expect(logs[0]).toContain("Prepared 1 tile(s)");
|
|
expect(logs[0]).toContain("b-seat — unconfirmed (herdr): pane list unreadable");
|
|
});
|
|
|
|
it("open with ZERO panes opened is a failure — non-zero exit", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: { "POST /api/terminal/open": { status: 200, data: opened([], { ok: false, code: "herdr_unavailable", error: "no binary" }) } },
|
|
});
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux"]);
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it.each([false, true])("preserves fallback attach commands on a failed open (json=%s)", async json => {
|
|
const command = "operator.agent: env -u TMUX tmux attach -t 'operator-bound'";
|
|
const result = opened([], { code: "herdr_unavailable", error: "no binary", notes: [command] });
|
|
const { deps } = makeDeps({ routes: { "POST /api/terminal/open": { status: 200, data: result } } });
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "saved:kernel", "--provider", "cmux", ...(json ? ["--json"] : [])]);
|
|
expect(process.exitCode).toBe(1);
|
|
if (json) expect(JSON.parse(logs[0]!)).toEqual(result);
|
|
else expect(logs.join("\n")).toContain(command);
|
|
});
|
|
|
|
it("open of an unknown view (404) exits non-zero and surfaces the reason", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: { "POST /api/terminal/open": { status: 404, data: { provider: "herdr", ok: false, opened: [], absent: [], degraded: [], pages: 0, code: "view_not_found", error: "unknown view 'nope'" } } },
|
|
});
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "nope", "--provider", "cmux", "--json"]);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(logs.join("\n")).toContain("view_not_found");
|
|
});
|
|
|
|
it.each([false, true])("open reports a bare HTTP503 service error (json=%s)", async (json) => {
|
|
const { deps } = makeDeps({ routes: { "POST /api/terminal/open": { status: 503, data: { error: "terminal_service_unavailable" } } } });
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await expect(program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux", ...(json ? ["--json"] : [])])).resolves.toBeDefined();
|
|
expect(process.exitCode).toBe(2);
|
|
const output = [...logs, ...vi.mocked(console.error).mock.calls.map(args => args.join(" "))].join("\n");
|
|
expect(output).toContain("terminal_service_unavailable");
|
|
if (json) expect(JSON.parse(logs[0]!)).toEqual({ error: "terminal_service_unavailable" });
|
|
});
|
|
|
|
it.each([404, 409])("keeps structured %s open errors human-readable", async (status) => {
|
|
const { deps } = makeDeps({
|
|
routes: { "POST /api/terminal/open": { status, data: opened([], { code: "view_not_found", error: "unknown view 'nope'" }) } },
|
|
});
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "open", "nope", "--provider", "cmux"]);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(logs.join("\n")).toContain("provider: unknown view 'nope' (view_not_found)");
|
|
expect(logs.join("\n")).not.toMatch(/^\s*\{/);
|
|
});
|
|
|
|
it("views GETs /api/terminal/views", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "GET /api/terminal/views": { status: 200, data: { saved: [], rigs: ["acme-build"] } } },
|
|
});
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "views", "--json"]);
|
|
expect(calls.find((c) => c.path === "/api/terminal/views")).toBeDefined();
|
|
});
|
|
|
|
it("status GETs /api/terminal/status with the provider query", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "GET /api/terminal/status?provider=herdr": { status: 200, data: { providers: [] } } },
|
|
});
|
|
const program = createProgram({ terminalDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "terminal", "status", "--provider", "herdr", "--json"]);
|
|
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/terminal/status"));
|
|
expect(call!.path).toContain("provider=herdr");
|
|
});
|
|
});
|