mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
964f69f349 | ||
|
|
25a00e547e | ||
|
|
8f95e9ac9d |
No files matched your search
@@ -10,6 +10,7 @@ import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
|
||||
import type { OpenViewResult } from "./commands/terminal.js";
|
||||
|
||||
interface Pane { seat: string; label: string; paneCommand: string }
|
||||
interface HerdrTab { workspace_id: string; tab_id: string; label: string }
|
||||
interface Preview {
|
||||
planId: string;
|
||||
status: { launch?: { socketPath: string; session?: string } };
|
||||
@@ -144,6 +145,55 @@ function failure(provider: string, error: string): OpenViewResult {
|
||||
return { provider, ok: false, opened: [], absent: [], degraded: [], pages: 0, error, code: "terminal_window_failed" };
|
||||
}
|
||||
|
||||
/** Saved Herdr labels survive reboot; only current clients prove a local attachment. */
|
||||
async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: Pane[][], herdrArgs: string[], deps: WindowDeps): Promise<boolean> {
|
||||
try {
|
||||
const tmux = (await deps.exec("/bin/sh", ["-c", "command -v tmux"])).trim();
|
||||
if (!path.isAbsolute(tmux)) return false;
|
||||
const listed = JSON.parse(await deps.exec("/usr/bin/env", [...herdrArgs, "pane", "list", "--workspace", existing.workspace_id]));
|
||||
const panes = listed?.result?.panes as Array<{ workspace_id: string; tab_id: string; pane_id: string }> | undefined;
|
||||
if (!Array.isArray(panes) || !panes.every(pane => pane && pane.workspace_id === existing.workspace_id && typeof pane.tab_id === "string" && typeof pane.pane_id === "string" && pane.pane_id)) return false;
|
||||
const clients = new Map<string, string[]>();
|
||||
// Match view-composer's serialization (these strings are compared, never executed).
|
||||
const quote = (value: string) => "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
const inventory = await deps.exec(tmux, ["list-clients", "-F", "#{client_pid}\t#{session_name}\t#{client_readonly}"]);
|
||||
for (const line of inventory.trim().split("\n")) {
|
||||
const [pid, session, readOnly] = line.split("\t");
|
||||
if (!pid || !/^\d+$/.test(pid) || !session || !["0", "1"].includes(readOnly ?? "")) continue;
|
||||
// Compare the composer's exact command, including session aliases and read-only mode.
|
||||
// SSH/custom commands cannot be confirmed from this local client inventory.
|
||||
clients.set(pid, ["tmux", quote(tmux)].map(binary => `${binary} attach ${readOnly === "1" ? "-r " : ""}-t ${quote(session)}`));
|
||||
}
|
||||
const used = new Set<string>();
|
||||
for (const [index, page] of pages.entries()) {
|
||||
const label = pages.length > 1 ? `${existing.label.slice(0, -2)}/${index + 1}` : existing.label;
|
||||
const matches = tabs.filter(tab => tab.workspace_id === existing.workspace_id && tab.label === label);
|
||||
if (matches.length !== 1) return false;
|
||||
const remaining = page.map(pane => pane.paneCommand);
|
||||
for (const pane of panes.filter(pane => pane.tab_id === matches[0]!.tab_id)) {
|
||||
if (remaining.length === 0) break;
|
||||
const result = JSON.parse(await deps.exec("/usr/bin/env", [...herdrArgs, "pane", "process-info", "--pane", pane.pane_id]));
|
||||
const info = result?.result?.process_info;
|
||||
if (info?.pane_id !== pane.pane_id || (info.foreground_processes !== undefined && !Array.isArray(info.foreground_processes))) return false;
|
||||
for (const process of info.foreground_processes ?? []) {
|
||||
const pid = String(process?.pid);
|
||||
if (used.has(pid)) continue;
|
||||
const found = remaining.findIndex(command => clients.get(pid)?.includes(command));
|
||||
if (found < 0) continue;
|
||||
remaining.splice(found, 1);
|
||||
used.add(pid);
|
||||
break; // One Herdr pane can satisfy only one expected tile.
|
||||
}
|
||||
}
|
||||
if (remaining.length) return false;
|
||||
}
|
||||
return pages.length > 0;
|
||||
} catch {
|
||||
// Read failures prevent reuse, not opening. Never type into or close an unverified pane.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the daemon's existing composition; never rediscover/relaunch kernel seats here. */
|
||||
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps(), expectedPlan?: string): Promise<OpenViewResult> {
|
||||
let provider = requestedProvider ?? "herdr";
|
||||
@@ -211,7 +261,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
// Inventory is read-only: a failed read must not prevent the first open.
|
||||
// Scope each tab list to its workspace, on the daemon's exact endpoint.
|
||||
const herdrArgs = ["-u", "TMUX", "-u", "HERDR_SESSION", "-u", "HERDR_SOCKET_PATH", `HERDR_SOCKET_PATH=${endpoint.socketPath}`, herdr];
|
||||
let tabs: Array<{ workspace_id: string; tab_id: string; label: string }> = [];
|
||||
let tabs: HerdrTab[] = [];
|
||||
try {
|
||||
const listed = JSON.parse(await deps.exec("/usr/bin/env", [...herdrArgs, "workspace", "list"]));
|
||||
const workspaces = listed?.result?.workspaces as Array<{ workspace_id: string }> | undefined;
|
||||
@@ -233,7 +283,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
const viewMarker = `openrig:${composed.id}`;
|
||||
const planMarker = `${viewMarker}#${planId.slice(0, 16)}`;
|
||||
const marker = (label: string) => label.includes("#") ? label.slice(0, label.lastIndexOf("#")) : "";
|
||||
const existing = tabs.find(tab => marker(tab.label) === planMarker && (composed.pages.length <= 1 || tab.label.endsWith("/1")));
|
||||
const candidates = tabs.filter(tab => marker(tab.label) === planMarker && (composed.pages.length <= 1 || tab.label.endsWith("/1")));
|
||||
const stale = tabs.filter(tab => {
|
||||
const value = marker(tab.label);
|
||||
return value !== planMarker && (value === viewMarker ||
|
||||
@@ -242,6 +292,11 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
for (const id of new Set(stale.map(tab => tab.workspace_id))) {
|
||||
windowNotes.push(`Workspace ${id} has an older or different plan for this view and was kept. After inspecting it, close it if no longer needed: herdr workspace close ${shellQuote(id)}`);
|
||||
}
|
||||
let existing: HerdrTab | undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (await hasLiveAttachments(candidate, tabs, composed.pages, herdrArgs, deps)) { existing = candidate; break; }
|
||||
windowNotes.push(`Could not confirm live attachments in workspace ${candidate.workspace_id}. Its contents were kept. After inspecting it, close it if no longer needed: herdr workspace close ${shellQuote(candidate.workspace_id)}`);
|
||||
}
|
||||
if (existing) {
|
||||
const focused = JSON.parse(await deps.exec("/usr/bin/env", [...herdrArgs, "tab", "focus", existing.tab_id])) as {
|
||||
result?: { tab?: { tab_id?: string; workspace_id?: string } };
|
||||
@@ -252,7 +307,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
return {
|
||||
provider, ok: true, opened: [], absent: composed.absent, degraded: composed.degraded, pages: 0, window,
|
||||
reusedWorkspace: { id: existing.workspace_id, tabId: existing.tab_id, view: composed.id },
|
||||
notes: [...windowNotes, "Existing workspace contents were kept; no layout refresh or new tiles were requested. Check the new terminal shows the intended view."],
|
||||
notes: [...windowNotes, "Existing local tmux attachments were confirmed and workspace contents were kept; no layout refresh or new tiles were requested. Check the new terminal shows the intended view."],
|
||||
};
|
||||
}
|
||||
const result = await client.post<OpenViewResult>("/api/terminal/open", { view, provider: "herdr", expectedPlan: planId }, { timeoutMs: 45_000 });
|
||||
|
||||
@@ -13,7 +13,7 @@ const planId = "0123456789abcdef".repeat(4);
|
||||
const label = (id = "kernel", token = "l1") => `openrig:${id}#${planId.slice(0, 16)}#${token}`;
|
||||
interface Tab { workspace_id: string; tab_id: string; label: string }
|
||||
function fixture(tabs: Tab[] = [], id = "kernel") {
|
||||
const panes = ["tui", "advisor", "operator"].map(seat => ({ seat, label: seat, paneCommand: `tmux attach-session -t '=fixture-${seat}'` }));
|
||||
const panes = ["tui", "advisor", "operator"].map(seat => ({ seat, label: seat, paneCommand: `tmux attach -t 'fixture-${seat}'` }));
|
||||
const composed = { id, opened: panes, pages: [panes], absent: [] as Array<{ seat: string; host: string | null; reason: string }>, degraded: [] as Array<{ seat: string; host: string; reason: string }> };
|
||||
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("preview") ? {
|
||||
planId, composed,
|
||||
@@ -26,10 +26,21 @@ function fixture(tabs: Tab[] = [], id = "kernel") {
|
||||
const workspaces = vi.fn(async () => JSON.stringify({ result: { workspaces: [...new Set(tabs.map(tab => tab.workspace_id))].map(workspace_id => ({ workspace_id })) } }));
|
||||
const list = vi.fn(async (workspace: string) => JSON.stringify({ id: "cli:tab:list", result: { tabs: tabs.filter(tab => tab.workspace_id === workspace) } }));
|
||||
const focus = vi.fn(async (tabId: string) => JSON.stringify({ id: "cli:tab:focus", result: { tab: tabs.find(tab => tab.tab_id === tabId) } }));
|
||||
const paneList = vi.fn(async (workspace: string) => JSON.stringify({ result: { panes: tabs
|
||||
.filter(tab => tab.workspace_id === workspace)
|
||||
.flatMap(tab => (composed.pages.length > 1 ? composed.pages[Number(tab.label.split("/").at(-1)) - 1] ?? [] : composed.opened)
|
||||
.map(pane => ({ workspace_id: workspace, tab_id: tab.tab_id, pane_id: `${tab.tab_id}-${pane.seat}` }))) } }));
|
||||
const processInfo = vi.fn(async (paneId: string) => JSON.stringify({ result: { process_info: {
|
||||
pane_id: paneId, foreground_processes: [{ pid: 101 + panes.findIndex(pane => paneId.endsWith(`-${pane.seat}`)) }],
|
||||
} } }));
|
||||
const clients = vi.fn(async () => panes.map((pane, i) => `${101 + i}\tfixture-${pane.seat}\t0`).join("\n"));
|
||||
const exec = vi.fn(async (file: string, args: string[]) => {
|
||||
if (file === "/usr/bin/osascript") return "window-sized";
|
||||
if (args.includes("--version")) return "herdr 0.9.3";
|
||||
if (file === "/bin/sh") return "/fixture/bin/herdr";
|
||||
if (file === "/bin/sh") return args[1] === "command -v tmux" ? "/fixture/bin/tmux" : "/fixture/bin/herdr";
|
||||
if (file === "/fixture/bin/tmux") return clients();
|
||||
if (args.includes("process-info")) return processInfo(args.at(-1)!);
|
||||
if (args.includes("pane")) return paneList(args.at(-1)!);
|
||||
if (file === "/usr/bin/env" && args.at(-2) === "workspace" && args.at(-1) === "list") return workspaces();
|
||||
if (file === "/usr/bin/env" && args.at(-3) === "list" && args.at(-2) === "--workspace") return list(args.at(-1)!);
|
||||
if (file === "/usr/bin/env" && args.at(-2) === "focus") return focus(args.at(-1)!);
|
||||
@@ -43,7 +54,7 @@ function fixture(tabs: Tab[] = [], id = "kernel") {
|
||||
lifecycleDeps: {} as TerminalDeps["lifecycleDeps"], windowDeps,
|
||||
clientFactory: () => ({ baseUrl: "http://localhost:7433", get, post }) as unknown as ReturnType<TerminalDeps["clientFactory"]>,
|
||||
};
|
||||
return { tabs, composed, workspaces, list, focus, exec, post, run: (args: string[] = ["--json"]) => new Command().addCommand(terminalCommand(deps)).parseAsync(["terminal", "open", `saved:${id}`, ...args], { from: "user" }) };
|
||||
return { tabs, composed, workspaces, list, focus, paneList, processInfo, clients, exec, post, run: (args: string[] = ["--json"]) => new Command().addCommand(terminalCommand(deps)).parseAsync(["terminal", "open", `saved:${id}`, ...args], { from: "user" }) };
|
||||
}
|
||||
|
||||
describe("reopening a Herdr view in a desktop window", () => {
|
||||
@@ -57,6 +68,92 @@ describe("reopening a Herdr view in a desktop window", () => {
|
||||
});
|
||||
afterEach(() => { process.exitCode = originalExit; vi.restoreAllMocks(); });
|
||||
|
||||
|
||||
it.each(["plain shells", "detached clients", "wrong session", "read-only mismatch"])("recomposes a matching saved plan with %s", async state => {
|
||||
const old = { workspace_id: "restored", tab_id: "old-tab", label: label() };
|
||||
const f = fixture([old]);
|
||||
if (state === "plain shells") f.processInfo.mockImplementation(async paneId => JSON.stringify({ result: { process_info: { pane_id: paneId, foreground_processes: [{ pid: 999, name: "zsh" }] } } }));
|
||||
if (state === "detached clients") f.clients.mockResolvedValue("");
|
||||
if (state === "wrong session") f.clients.mockResolvedValue("101\tunrelated\t0\n102\tfixture-advisor\t0\n103\tfixture-operator\t0");
|
||||
if (state === "read-only mismatch") f.clients.mockResolvedValue("101\tfixture-tui\t1\n102\tfixture-advisor\t0\n103\tfixture-operator\t0");
|
||||
await f.run();
|
||||
expect(f.focus).not.toHaveBeenCalled();
|
||||
expect(f.post).toHaveBeenCalledExactlyOnceWith("/api/terminal/open", { view: "saved:kernel", provider: "herdr", expectedPlan: planId }, { timeoutMs: 45_000 });
|
||||
expect(f.tabs[0]).toEqual(old);
|
||||
expect(JSON.parse(logs[0]!)).toMatchObject({ ok: true, notes: expect.arrayContaining([expect.stringContaining("Could not confirm live attachments in workspace restored")]) });
|
||||
});
|
||||
|
||||
it.each(["missing page", "dead second page"])("checks all pages before reuse: %s", async state => {
|
||||
const f = fixture([
|
||||
{ workspace_id: "old", tab_id: "first", label: label("kernel", "l1/1") },
|
||||
{ workspace_id: "old", tab_id: "second", label: label("kernel", "l1/2") },
|
||||
{ workspace_id: "old", tab_id: "third", label: label("kernel", "l1/3") },
|
||||
]);
|
||||
f.composed.pages = f.composed.opened.map(pane => [pane]);
|
||||
if (state === "missing page") f.tabs.splice(1, 1);
|
||||
else f.processInfo.mockImplementation(async paneId => JSON.stringify({ result: { process_info: { pane_id: paneId, foreground_processes: [{ pid: paneId.startsWith("second-") ? 999 : paneId.startsWith("first-") ? 101 : 103 }] } } }));
|
||||
await f.run();
|
||||
expect(f.post).toHaveBeenCalledTimes(1);
|
||||
expect(f.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["missing panes", "wrong workspace", "wrong process pane", "lost process reply", "malformed process reply"])("keeps old contents and creates once when attachment inventory is unconfirmed: %s", async state => {
|
||||
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }]);
|
||||
if (state === "missing panes") f.paneList.mockResolvedValue(JSON.stringify({ result: { panes: [] } }));
|
||||
if (state === "wrong workspace") f.paneList.mockResolvedValue(JSON.stringify({ result: { panes: [{ workspace_id: "other", tab_id: "first", pane_id: "first-tui" }] } }));
|
||||
if (state === "wrong process pane") f.processInfo.mockResolvedValue(JSON.stringify({ result: { process_info: { pane_id: "other", foreground_processes: [{ pid: 101 }] } } }));
|
||||
if (state === "lost process reply") f.processInfo.mockRejectedValue(new Error("reply lost"));
|
||||
if (state === "malformed process reply") f.processInfo.mockResolvedValue("{");
|
||||
await f.run();
|
||||
expect(f.post).toHaveBeenCalledTimes(1);
|
||||
expect(f.focus).not.toHaveBeenCalled();
|
||||
expect(f.exec.mock.calls.some(([, args]) => args.includes("close") || args.includes("send-text"))).toBe(false);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a live matching copy after a stale one instead of creating repeatedly", async () => {
|
||||
const f = fixture([
|
||||
{ workspace_id: "restored", tab_id: "stale", label: label("kernel", "old") },
|
||||
{ workspace_id: "live", tab_id: "fresh", label: label("kernel", "new") },
|
||||
]);
|
||||
f.processInfo.mockImplementation(async paneId => JSON.stringify({ result: { process_info: { pane_id: paneId,
|
||||
foreground_processes: [{ pid: paneId.startsWith("stale-") ? 999 : 101 + ["tui", "advisor", "operator"].findIndex(seat => paneId.endsWith(seat)) }],
|
||||
} } }));
|
||||
await f.run();
|
||||
expect(f.focus).toHaveBeenCalledExactlyOnceWith("fresh");
|
||||
expect(f.post).not.toHaveBeenCalled();
|
||||
expect(f.tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("confirms an absolute tmux command, quoted session alias and read-only attachment", async () => {
|
||||
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }]);
|
||||
f.composed.opened[0]!.paneCommand = "'/fixture/bin/tmux' attach -r -t 'alias'\"'\"'s session'";
|
||||
f.clients.mockResolvedValue("101\talias's session\t1\n102\tfixture-advisor\t0\n103\tfixture-operator\t0");
|
||||
await f.run();
|
||||
expect(f.focus).toHaveBeenCalledExactlyOnceWith("first");
|
||||
expect(f.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores an inert filler once the expected tiles have live attachments", async () => {
|
||||
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }]);
|
||||
f.paneList.mockResolvedValue(JSON.stringify({ result: { panes: ["blank", "tui", "advisor", "operator"].map(seat => ({ workspace_id: "old", tab_id: "first", pane_id: `first-${seat}` })) } }));
|
||||
f.processInfo.mockImplementation(async paneId => JSON.stringify({ result: { process_info: { pane_id: paneId,
|
||||
...(paneId.endsWith("blank") ? {} : { foreground_processes: [{ pid: 101 + ["tui", "advisor", "operator"].findIndex(seat => paneId.endsWith(seat)) }] }),
|
||||
} } }));
|
||||
await f.run();
|
||||
expect(f.focus).toHaveBeenCalledExactlyOnceWith("first");
|
||||
expect(f.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a fresh SSH view when local attachment inventory cannot prove it", async () => {
|
||||
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }]);
|
||||
f.composed.opened[0]!.paneCommand = "ssh 'remote' 'tmux attach -t remote-seat'";
|
||||
await f.run();
|
||||
expect(f.focus).not.toHaveBeenCalled();
|
||||
expect(f.post).toHaveBeenCalledTimes(1);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates the first space, then opens another window on it without adding tabs or spaces", async () => {
|
||||
const f = fixture();
|
||||
await f.run();
|
||||
|
||||
Reference in new issue
Block a user