mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf54f1a62e | ||
|
|
7beb4d876f | ||
|
|
2aff5ea81d | ||
|
|
af6dc5ac02 |
No files matched your search
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDefaultOpenRigPath } from "../../openrig-compat.js";
|
||||
import { HerdrViewer, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
|
||||
import { HerdrViewer, ViewerCreateNotDispatchedError, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
|
||||
type HerdrViewerRun, type InspectViewerSeat, type ViewerKey, type ViewerMapping, type ViewerPane } from "./herdr-viewer.js";
|
||||
import { DEFAULT_FOLLOW_POLICY, planFollowingTiles, type FollowControls, type FollowPolicy, type FollowTile, type FollowingStatus } from "./terminal-follow-policy.js";
|
||||
import type { ComposedPane, ComposedView, OpenViewResult } from "./terminal-provider.js";
|
||||
@@ -150,7 +150,7 @@ export class HerdrFollowView {
|
||||
state.tiles = state.tiles.filter(t => t.key !== state!.closing); delete state.closing; save();
|
||||
}
|
||||
const observations = new Map<string, { focused: boolean; input: "empty" | "text" | "unknown" }>();
|
||||
// Validate ALL old identities before any change. One failed read retains
|
||||
// Validate old identities before planning. One failed read retains
|
||||
// the whole view, rather than presenting an untrusted partial inventory.
|
||||
for (const tile of state.tiles) {
|
||||
const currentMember = view.task.members.find(m => keyOf(m.key.hostId, m.key.nodeId) === tile.key);
|
||||
@@ -161,9 +161,16 @@ export class HerdrFollowView {
|
||||
throw new Error(`${tile.seat}: Core's current attachment differs from the retained viewer; keeping the previous view unconfirmed.`);
|
||||
}
|
||||
}
|
||||
const current = await currentPane(tile.viewer);
|
||||
const verified = await viewers.lookup(tile.member, tile.target);
|
||||
if (!verified.ok || JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error("A retained viewer is unconfirmed; keeping the whole view.");
|
||||
let current: ViewerPane;
|
||||
try { current = await currentPane(tile.viewer); }
|
||||
catch (error) { throw new Error(`${tile.seat}: unknown: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
let verified = await viewers.lookup(tile.member, tile.target);
|
||||
const retainedPhase = !verified.ok ? verified.phase : undefined;
|
||||
if (!verified.ok && verified.phase === "created" && currentMember?.availability === "available") {
|
||||
verified = await viewers.ensure(tile.member, tile.target, stillCurrent, undefined, tile.viewer);
|
||||
}
|
||||
if (!verified.ok) throw new Error(`${tile.seat}: ${verified.phase ?? retainedPhase ?? "unknown"}: ${verified.error} Keeping the whole view.`);
|
||||
if (JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error(`${tile.seat}: Viewer mapping changed; keeping the whole view.`);
|
||||
observations.set(tile.key, { focused: current.focused!, input: await this.deps.input?.(tile.target) ?? "unknown" });
|
||||
}
|
||||
const plan = planFollowingTiles(state.tiles, members, true, observations, policy, now());
|
||||
@@ -174,19 +181,24 @@ export class HerdrFollowView {
|
||||
if (!target || !member.key.hostId || !member.key.nodeId) { notice(`${join.seat}: ${member.reason ?? "Terminal is unavailable; the member remains in this task."}`); continue; }
|
||||
const memberKey = { hostId: member.key.hostId, nodeId: member.key.nodeId };
|
||||
let verified = await viewers.ensure(memberKey, target, stillCurrent, async (cwd) => {
|
||||
await currentPane(state!.pane!);
|
||||
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
|
||||
const anchor = state!.tiles.at(-1)?.viewer;
|
||||
if (anchor) await currentPane(anchor);
|
||||
try {
|
||||
await currentPane(state!.pane!);
|
||||
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
|
||||
if (anchor) await currentPane(anchor);
|
||||
} catch (error) {
|
||||
throw new ViewerCreateNotDispatchedError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const split = body(await this.deps.run(["pane", "split", "--pane", anchor?.paneId ?? state!.pane!.pane_id,
|
||||
"--direction", anchor && state!.tiles.length % 2 ? "right" : "down", "--no-focus", "--cwd", cwd]));
|
||||
return JSON.stringify({ root_pane: split.pane });
|
||||
});
|
||||
const originalFailure = !verified.ok ? `${verified.phase ?? "unknown"}: ${verified.error}` : undefined;
|
||||
for (let attempt = 0; !verified.ok && attempt < 5; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
verified = await viewers.lookup(memberKey, target); // read-only: never repeats a create/run.
|
||||
}
|
||||
if (!verified.ok) { notice(`${join.seat}: ${verified.error}`); continue; }
|
||||
if (!verified.ok) { notice(`${join.seat}: ${originalFailure}; confirmation: ${verified.phase ?? "unknown"}: ${verified.error}`); continue; }
|
||||
state.tiles.push({ key: join.key, seat: join.seat, target, member: { hostId: member.key.hostId, nodeId: member.key.nodeId }, viewer: verified.viewer });
|
||||
notice(`${join.seat}: ${join.reason}`); save();
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { ComposedPane } from "./terminal-provider.js";
|
||||
export interface ViewerKey { hostId: string; nodeId: string }
|
||||
export interface ViewerMapping { workspaceId: string; paneId: string; terminalId: string; readOnly: boolean }
|
||||
export type ViewerResult = { ok: true; viewer: ViewerMapping; created: boolean }
|
||||
| { ok: false; code: string; error: string };
|
||||
| { ok: false; code: string; error: string; phase?: Entry["phase"] };
|
||||
export type InspectViewerSeat = (argv: string[]) => Promise<{ sessionId: string; nodeId: string }>;
|
||||
export type HerdrViewerRun = (argv: string[]) => Promise<string>;
|
||||
/** Only the caller which has not dispatched a create may assert this. */
|
||||
export class ViewerCreateNotDispatchedError extends Error {}
|
||||
export type CreateViewerPane = (cwd: string, label: string) => Promise<string>;
|
||||
export interface ViewerPane { workspace_id: string; pane_id: string; terminal_id: string; cwd: string; focused?: boolean }
|
||||
type Pane = ViewerPane;
|
||||
@@ -21,7 +23,6 @@ interface Entry {
|
||||
phase: "creating" | "created" | "starting"; pane?: Pane;
|
||||
}
|
||||
interface Journal { version: 1; entries: Record<string, Entry> }
|
||||
const unavailable = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error });
|
||||
const runFile = promisify(execFile);
|
||||
|
||||
/** The supported CLI, never guessed socket RPC method parity. Its reported
|
||||
@@ -89,9 +90,9 @@ export class HerdrViewer {
|
||||
private readonly platform = process.platform, private readonly root = getDefaultOpenRigPath("private/terminal-viewers"),
|
||||
private readonly inspect: InspectViewerSeat = inspectSeat) {}
|
||||
|
||||
ensure(key: ViewerKey, target: ComposedPane, stillCurrent: () => Promise<boolean> = async () => true, createPane?: CreateViewerPane): Promise<ViewerResult> {
|
||||
ensure(key: ViewerKey, target: ComposedPane, stillCurrent: () => Promise<boolean> = async () => true, createPane?: CreateViewerPane, createdOnly?: ViewerMapping): Promise<ViewerResult> {
|
||||
// ponytail: one endpoint queue; per-seat locks if creation throughput needs it.
|
||||
const result = this.pending.then(() => this.ensureOnce(key, target, true, stillCurrent, createPane));
|
||||
const result = this.pending.then(() => this.ensureOnce(key, target, true, stillCurrent, createPane, createdOnly));
|
||||
this.pending = result.then(() => {}, () => {});
|
||||
return result;
|
||||
}
|
||||
@@ -102,17 +103,19 @@ export class HerdrViewer {
|
||||
private async ownedPanes(entry: Entry): Promise<Pane[]> {
|
||||
return findAttributedViewerPanes(this.run, entry.cwd);
|
||||
}
|
||||
private async ensureOnce(key: ViewerKey, target: ComposedPane, provision = true, stillCurrent: () => Promise<boolean> = async () => true, createPane?: CreateViewerPane): Promise<ViewerResult> {
|
||||
private async ensureOnce(key: ViewerKey, target: ComposedPane, provision = true, stillCurrent: () => Promise<boolean> = async () => true, createPane?: CreateViewerPane, createdOnly?: ViewerMapping): Promise<ViewerResult> {
|
||||
let entry: Entry | undefined;
|
||||
const unconfirmed = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error, ...(entry ? { phase: entry.phase } : {}) });
|
||||
try {
|
||||
if (this.platform !== "linux") return { ok: false, code: "viewer_unsupported", error: "Ensure-viewer is qualified on Linux Herdr 0.9.5/protocol 22 only." };
|
||||
const status = body(await this.run(["status", "server", "--json"]));
|
||||
if (status.version !== "0.9.5" || status.protocol !== 22 || status.running !== true || status.socket !== this.socketPath) {
|
||||
return unavailable("The qualified Herdr version/protocol or selected socket was not confirmed.");
|
||||
return unconfirmed("The qualified Herdr version/protocol or selected socket was not confirmed.");
|
||||
}
|
||||
if (provision && !await stillCurrent()) return unavailable("Task membership changed while waiting to provision; refresh its preview.");
|
||||
if (!target.attachArgv) return unavailable("Only verified local tmux bindings support ensure-viewer.");
|
||||
if (provision && !await stillCurrent()) return unconfirmed("Task membership changed while waiting to provision; refresh its preview.");
|
||||
if (!target.attachArgv) return unconfirmed("Only verified local tmux bindings support ensure-viewer.");
|
||||
const binding = await this.inspect(target.attachArgv);
|
||||
if (binding.nodeId !== key.nodeId) return unavailable("The current tmux binding does not match this member’s node identity.");
|
||||
if (binding.nodeId !== key.nodeId) return unconfirmed("The current tmux binding does not match this member’s node identity.");
|
||||
if (provision) mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
||||
const endpoint = createHash("sha256").update(this.socketPath).digest("hex");
|
||||
const file = path.join(this.root, `${endpoint}.json`);
|
||||
@@ -122,36 +125,53 @@ export class HerdrViewer {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
journal = { version: 1, entries: {} };
|
||||
}
|
||||
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unavailable("Viewer provenance is unreadable; no provider mutation was attempted.");
|
||||
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unconfirmed("Viewer provenance is unreadable; no provider mutation was attempted.");
|
||||
const save = () => {
|
||||
const tmp = `${file}.${randomUUID()}.tmp`;
|
||||
writeFileSync(tmp, `${JSON.stringify(journal)}\n`, { mode: 0o600 }); renameSync(tmp, file);
|
||||
};
|
||||
const id = JSON.stringify([key.hostId, key.nodeId]);
|
||||
let entry = journal.entries[id];
|
||||
entry = journal.entries[id];
|
||||
let created = false;
|
||||
if (createdOnly && (!entry || entry.phase !== "created" || !entry.pane
|
||||
|| entry.pane.workspace_id !== createdOnly.workspaceId || entry.pane.pane_id !== createdOnly.paneId
|
||||
|| entry.pane.terminal_id !== createdOnly.terminalId || entry.readOnly !== createdOnly.readOnly)) {
|
||||
return unconfirmed("The retained created viewer changed; no create or attach was dispatched.");
|
||||
}
|
||||
if (entry && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
|
||||
return unavailable("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
|
||||
return unconfirmed("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
|
||||
}
|
||||
if (!entry) {
|
||||
if (!provision) return { ok: false, code: "viewer_not_provisioned", error: "No owned viewer has been provisioned for this member." };
|
||||
if (Object.keys(journal.entries).length >= 256) return unavailable("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
|
||||
if (Object.keys(journal.entries).length >= 256) return unconfirmed("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
|
||||
const token = randomUUID();
|
||||
const cwd = path.join(this.root, endpoint, token); mkdirSync(cwd, { recursive: true, mode: 0o700 });
|
||||
entry = { cwd, label: `openrig-viewer-${token}`, command: target.paneCommand, argv: target.attachArgv, readOnly: target.readOnly, phase: "creating" };
|
||||
journal.entries[id] = entry; save();
|
||||
// No retry here. A timeout is reconciled by enumeration on the next request.
|
||||
const reply = body(await (createPane ? createPane(cwd, entry.label)
|
||||
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"])));
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await (createPane ? createPane(cwd, entry.label)
|
||||
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"]));
|
||||
} catch (error) {
|
||||
if (createPane && error instanceof ViewerCreateNotDispatchedError) {
|
||||
// This invocation positively refused BEFORE dispatch, not an empty
|
||||
// enumeration or a timeout after a possible provider effect.
|
||||
rmdirSync(cwd); delete journal.entries[id]; save();
|
||||
return unconfirmed(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const reply = body(raw);
|
||||
const made = pane(reply.root_pane);
|
||||
if (!made || made.cwd !== cwd) return unavailable("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
|
||||
if (!made || made.cwd !== cwd) return unconfirmed("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
|
||||
entry.pane = made; entry.phase = "created"; save(); created = true;
|
||||
}
|
||||
if (!entry.pane) {
|
||||
// A unique private cwd attributes a previously initiated create, not a
|
||||
// caller ID or mutable display label. Empty/unreadable is still unknown.
|
||||
const found = await this.ownedPanes(entry);
|
||||
if (found.length !== 1) return unavailable("Uncertain viewer creation has no unique private-cwd mapping.");
|
||||
if (found.length !== 1) return unconfirmed("Uncertain viewer creation has no unique private-cwd mapping.");
|
||||
entry.pane = found[0]!; entry.phase = "created"; if (provision) save();
|
||||
}
|
||||
let current: Pane | null;
|
||||
@@ -159,14 +179,14 @@ export class HerdrViewer {
|
||||
catch { current = null; } // A missing ID alone never proves absence.
|
||||
if (!current || current.pane_id !== entry.pane.pane_id || current.terminal_id !== entry.pane.terminal_id
|
||||
|| current.workspace_id !== entry.pane.workspace_id || current.cwd !== entry.cwd) {
|
||||
if (!provision || (await this.ownedPanes(entry)).length !== 0) {
|
||||
return unavailable("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
|
||||
if (createdOnly || !provision || (await this.ownedPanes(entry)).length !== 0) {
|
||||
return unconfirmed("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
|
||||
}
|
||||
// Only a complete empty readback retires provenance. Remove our empty
|
||||
// attribution directory, never provider panes or arbitrary journal paths.
|
||||
if (!/^openrig-viewer-[0-9a-f-]{36}$/.test(entry.label)
|
||||
|| entry.cwd !== path.join(this.root, endpoint, entry.label.slice("openrig-viewer-".length))) {
|
||||
return unavailable("Absent viewer provenance has no owned attribution directory.");
|
||||
return unconfirmed("Absent viewer provenance has no owned attribution directory.");
|
||||
}
|
||||
try { rmdirSync(entry.cwd); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
@@ -174,8 +194,10 @@ export class HerdrViewer {
|
||||
return { ok: false, code: "viewer_not_provisioned", error: "The old viewer is confirmed absent; ensure again to create a verified replacement." };
|
||||
}
|
||||
if (entry.phase === "created") {
|
||||
if (!provision) return unavailable("The created viewer process is not yet confirmed.");
|
||||
if (!await stillCurrent()) return unavailable("Task membership changed before the viewer process started.");
|
||||
if (!provision) return unconfirmed("The created viewer process is not yet confirmed.");
|
||||
if (!await stillCurrent()) return unconfirmed("Task membership changed before the viewer process started.");
|
||||
const beforeRun = await this.inspect(target.attachArgv);
|
||||
if (beforeRun.nodeId !== key.nodeId || beforeRun.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed before the viewer process started.");
|
||||
entry.phase = "starting"; save();
|
||||
// pane run rc0/empty stdout is not proof. It is sent ONCE then checked.
|
||||
await this.run(["pane", "run", current.pane_id, entry.command]);
|
||||
@@ -195,11 +217,11 @@ export class HerdrViewer {
|
||||
&& (actual[expected.length] === `=${targetName}` || actual[expected.length] === binding.sessionId);
|
||||
});
|
||||
const afterBinding = await this.inspect(target.attachArgv);
|
||||
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unavailable("The seat binding changed while provisioning its viewer.");
|
||||
if (!verified) return unavailable("The viewer process is not confirmed; no pane run was repeated.");
|
||||
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed while provisioning its viewer.");
|
||||
if (!verified) return unconfirmed("The viewer process is not confirmed; no pane run was repeated.");
|
||||
return { ok: true, viewer: { workspaceId: current.workspace_id, paneId: current.pane_id, terminalId: current.terminal_id, readOnly: entry.readOnly }, created };
|
||||
} catch (error) {
|
||||
return unavailable(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return unconfirmed(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { TerminalService } from "../src/domain/terminal/terminal-service.js";
|
||||
@@ -30,7 +30,7 @@ function fixture() {
|
||||
const frames = new Map<string, ComposerSnapshot | null>();
|
||||
let transform = (view: ComposedView) => view;
|
||||
let lateFocus: { seat: string; reads: number } | undefined;
|
||||
let clock = 100, lostCreate = false, lostClose = false, readsFailed = false;
|
||||
let clock = 100, lostCreate = false, lostClose = false, readsFailed = false, hideViewers = false;
|
||||
const run = async (args: string[]) => {
|
||||
calls.push(args);
|
||||
const command = args.slice(0, 2).join(" ");
|
||||
@@ -48,7 +48,7 @@ function fixture() {
|
||||
if (args.length !== 2) throw new Error("usage: herdr workspace list");
|
||||
return JSON.stringify({ result: { workspaces: [{ workspace_id: "w1" }] } });
|
||||
}
|
||||
if (command === "pane list") return JSON.stringify({ result: { panes: [...panes.values()] } });
|
||||
if (command === "pane list") return JSON.stringify({ result: { panes: [...panes.values()].filter(p => !hideViewers || !p.cwd.includes("/viewers/")) } });
|
||||
if (command === "pane get") {
|
||||
const selected = panes.get(args[2]!); if (!selected) throw new Error("missing pane");
|
||||
return JSON.stringify({ result: { pane: selected } });
|
||||
@@ -89,8 +89,14 @@ function fixture() {
|
||||
return { type: "pane_focused" };
|
||||
} } });
|
||||
const provider = create();
|
||||
const follow = (seats = ["a", "b", "c"], controls = {}, complete = true) => provider.follow("qitem:task", transform(task(seats, complete)), { enabled: true, ...controls }, async () => true);
|
||||
return { follow, create, panes, calls, rpc, inputs, frames, advance: (ms: number) => { clock += ms; },
|
||||
const follow = (seats = ["a", "b", "c"], controls = {}, complete = true, current = async () => true) => provider.follow("qitem:task", transform(task(seats, complete)), { enabled: true, ...controls }, current);
|
||||
const journal = () => {
|
||||
const directory = path.join(root, readdirSync(root)[0]!, "viewers");
|
||||
return path.join(directory, readdirSync(directory).find(name => name.endsWith(".json"))!);
|
||||
};
|
||||
const exitViewer = (seat: string) => { [...panes.values()].find(p => p.argv.at(-1) === `$${seat}`)!.argv = ["/bin/bash"]; };
|
||||
return { follow, create, panes, calls, rpc, inputs, frames, exitViewer, journal,
|
||||
hideViewers: () => { hideViewers = true; }, advance: (ms: number) => { clock += ms; },
|
||||
loseCreate: () => { lostCreate = true; }, loseClose: () => { lostClose = true; }, failReads: () => { readsFailed = true; },
|
||||
changeTarget: () => { transform = view => ({ ...view, opened: view.opened.map(target => target.seat === "a"
|
||||
? { ...target, paneCommand: `${target.paneCommand} --changed`, attachArgv: [...target.attachArgv!.slice(0, 4), "replacement-a", ...target.attachArgv!.slice(5)] } : target) }); },
|
||||
@@ -99,6 +105,75 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("stable task-following workspace", () => {
|
||||
it("resumes a join paused before attach dispatch through its existing journal without changing drafts or focus", async () => {
|
||||
const f = fixture(); await f.follow(); f.inputs.set("b", "text"); f.focusSeat("b");
|
||||
const before = [...f.panes.values()].map(p => [p.pane_id, p.terminal_id]); const count = f.calls.length; let reads = 0;
|
||||
const paused = await f.follow(["a", "b", "c", "d"], {}, true, async () => ++reads !== 4);
|
||||
expect(paused.opened).toEqual(["a", "b", "c"]);
|
||||
const entry = JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])];
|
||||
expect(entry.phase).toBe("created"); expect(entry.pane).toBeTruthy();
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(0);
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true });
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(1);
|
||||
expect([...f.panes.values()].slice(0, 4).map(p => [p.pane_id, p.terminal_id])).toEqual(before);
|
||||
expect([...f.panes.values()].find(p => p.focused)?.argv.at(-1)).toBe("$b");
|
||||
expect(f.inputs.get("b")).toBe("text"); expect(f.rpc).toHaveLength(0);
|
||||
});
|
||||
it("an unconfirmed older viewer holds the whole view before a new join", async () => {
|
||||
const f = fixture(); await f.follow(); f.exitViewer("c"); const count = f.calls.length;
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, opened: ["a", "b", "c"], following: { state: "retained" } });
|
||||
expect(f.calls.slice(count).some(c => ["run", "close", "split", "create"].includes(c[1]!))).toBe(false);
|
||||
});
|
||||
it("keeps an already dispatched attach unconfirmed with its original seat, phase and reason", async () => {
|
||||
const f = fixture(); await f.follow(); f.exitViewer("a"); const count = f.calls.length;
|
||||
const held = await f.follow();
|
||||
expect(held).toMatchObject({ ok: false, opened: ["a", "b", "c"] });
|
||||
expect(held.error).toContain("a:"); expect(held.error).toContain("starting");
|
||||
expect(held.error).toContain("The viewer process is not confirmed");
|
||||
await f.follow(); expect(f.calls.slice(count).some(c => ["run", "close", "split"].includes(c[1]!))).toBe(false);
|
||||
});
|
||||
it("a known refusal before split preserves its reason and allows a later join", async () => {
|
||||
const f = fixture(); await f.follow(); const count = f.calls.length; let reads = 0;
|
||||
const held = await f.follow(["a", "b", "c", "d"], {}, true, async () => ++reads !== 3);
|
||||
expect(held.opened).toEqual(["a", "b", "c"]);
|
||||
expect(held.following!.events.join("\n")).toContain("d: creating: Membership changed before the split.");
|
||||
expect(f.calls.slice(count).some(c => ["split", "run", "close"].includes(c[1]!))).toBe(false);
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
|
||||
});
|
||||
it("a known pre-split refusal retires its unused intent even when directory cleanup fails", async () => {
|
||||
const f = fixture(); await f.follow(); const count = f.calls.length; let reads = 0, retainedFile = "";
|
||||
const held = await f.follow(["a", "b", "c", "d"], {}, true, async () => {
|
||||
if (++reads !== 3) return true;
|
||||
const entry = JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])];
|
||||
retainedFile = path.join(entry.cwd, "retained-note"); writeFileSync(retainedFile, "keep this file");
|
||||
return false;
|
||||
});
|
||||
expect(held.following!.events.join("\n")).toContain("d: creating: Membership changed before the split.");
|
||||
expect(JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])]).toBeUndefined();
|
||||
expect(readFileSync(retainedFile, "utf8")).toBe("keep this file");
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
|
||||
});
|
||||
it("an uncertain dispatched split keeps its intent even when enumeration is empty", async () => {
|
||||
const f = fixture(); await f.follow(); f.loseCreate(); f.hideViewers(); const count = f.calls.length;
|
||||
const held = await f.follow(["a", "b", "c", "d"]);
|
||||
expect(held.following!.events.join("\n")).toContain("d: creating: Viewer effect is unconfirmed: lost create response");
|
||||
const before = readFileSync(f.journal(), "utf8");
|
||||
await f.follow(["a", "b", "c", "d"]);
|
||||
expect(readFileSync(f.journal(), "utf8")).toBe(before);
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(0);
|
||||
});
|
||||
it("a changed terminal identity holds the whole view before joining another member", async () => {
|
||||
const f = fixture(); await f.follow();
|
||||
[...f.panes.values()].find(p => p.cwd.includes("/viewers/"))!.terminal_id = "other-generation";
|
||||
const count = f.calls.length;
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, following: { state: "retained" } });
|
||||
expect(f.calls.slice(count).some(c => ["run", "split", "close"].includes(c[1]!))).toBe(false);
|
||||
});
|
||||
it.each(["", "\x1b[2mAsk Codex to do anything\x1b[22m"])("closes only a departed viewer when the real reader confirms its two-footer Codex composer is empty (%s)", async body => {
|
||||
const f = fixture(); f.frames.set("c", codexTwoRowComposer(body));
|
||||
await f.follow();
|
||||
|
||||
Reference in new issue
Block a user