mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
Multi-host + Living Notes. Multi-host foundation: shareable whole-topology staged spin-up brings a rig topology up across multiple hosts in stages; VPS product-factory multi-host hardening ships with rig host verbs (add / list / doctor, capped at three; one built-in product-factory-vps posture, three- valued per item); the transport posture is documented (ssh for pane ops, http-bearer for daemon REST, no cross-transport fallback); the For-You feed aggregates activity across every registered host; and rig file moves files across the topology's registered hosts. Living Notes: durable INTENT / PLAN / DELIVERED signal layer at mission and slice altitude with agent authorship + timestamps; cheap composer surfaces make it the first-class place agents record decisions, plans, and delivered work; the one-structure review contract reads left-to-right as a single vertical stack (INTENT above, PLAN + mockup in the middle, DELIVERED with paired proof at the bottom); a plan change deletes the old plan and writes a new one - never demotes or stacks multiple competing plans. Operationalize the SDLC control plane: conventions SSOT ships in source at docs/reference/sdlc-conventions.md (Living Notes UI section names, proof-contract format + plannedRef mockup pairing, staged- approval locks, C1 proof header + closed sets, three role contracts, curation rule, elastic-middle doctrine, advisory fail-open audit posture); rig scope slice create scaffolds the convention sections + proof + PROOF.md + an IMPLEMENTATION-PRD.md skeleton for every template kind; rig scope audit + rig workspace doctor gain sdlc- convention advisories (records-and-advises, gates on HIGH only); the mission-slice-sop skill ships in canonical source with byte-parity guard; rig proof / rig scope slice create / rig scope slice approve help + cli-reference teach the SDLC flow. BREAKING: rig ps default flips to consolidated all-active-rigs compact projection (the v0.4.0 current-rig-only default is retired - it hid running rigs from the operator's field of view). --json is scope-not-shape (still a bare array with existing per-entry keys; scope widens to all non-archived rigs including stopped ones; one-line migration for the old fleet firehose: rig ps --nodes -A --full). -A / --all-rigs keeps exactly ONE meaning (the --nodes fleet widener; bare -A is a structured teaching error). --nodes names its scope everywhere (session default local-only; --host <id> --nodes requires --rig or -A; multi-host fan-out is rollup-only by default; the explicit ladder --all-hosts --nodes -A --full fans out per-node with hostId-stamped projected rows). --all-hosts / --hosts --json emits the shared AggregatedPayload (items + hosts with closed-enum statuses: ok | unreachable | unsupported-transport | auth-failed). Operator UX: agent altitude coordination panel scopes multi-agent state to the right altitude (workspace / mission / slice) so the coordination surface reads without per-seat drowning; composes with the workspace observability tabs from 0.4.1. Docs closeout catches docs/as-built/architecture.md, docs/as-built/cli-reference.md, and the codemaps up to shipped state. Migrations: additive only. Existing v0.4.3 databases upgrade by running rig daemon start. Behavior change (rig ps default view): the compact all-active-rigs projection replaces the current-rig-only default. If your call sites relied on the v0.4.3 default shape, add --full for the rich per-node projection or --rig <name> for the prior scope. Public-safety scrub applied inline to packages/ui/twin/corrective/ fixtures-corrective.ts (one demo-fixture role identifier neutralized to the demo-namespace baseline established at 0.4.1). See CHANGELOG.md and docs/releases/v0.4.4.md.
126 lines
5.3 KiB
TypeScript
126 lines
5.3 KiB
TypeScript
// OPR.0.4.4.18 — `rig file copy` CLI wiring: rejection surfaces render the
|
|
// core's structured errors verbatim (fail-closed, spawn NEVER invoked on any
|
|
// rejection — the spawn-spy negative), dry-run reports the planned transfer,
|
|
// JSON mode is agent-consumable and exposes the closed builder's argv.
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { fileCommand } from "../src/commands/file.js";
|
|
import type { FileCopyResult } from "../src/lib/file-transfer.js";
|
|
import type { SshHostEntry } from "../src/host-registry.js";
|
|
|
|
const VPS: SshHostEntry = { id: "vps-a", transport: "ssh", target: "vps-a.tail.net", user: "openrig" };
|
|
const registryLoader = (() => ({ ok: true as const, registry: { hosts: [VPS] } })) as never;
|
|
|
|
let logs: string[];
|
|
let errs: string[];
|
|
|
|
beforeEach(() => {
|
|
logs = [];
|
|
errs = [];
|
|
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => {
|
|
logs.push(a.join(" "));
|
|
});
|
|
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => {
|
|
errs.push(a.join(" "));
|
|
});
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
function okResult(partial: Partial<FileCopyResult> = {}): FileCopyResult {
|
|
return { ok: true, failedStep: "none", exitCode: 0, stdout: "", stderr: "", ...partial };
|
|
}
|
|
|
|
async function runCopy(args: string[], run?: (plan: unknown) => Promise<FileCopyResult>) {
|
|
const runs: unknown[] = [];
|
|
const cmd = fileCommand({
|
|
registryLoader,
|
|
run: (async (plan: unknown) => {
|
|
runs.push(plan);
|
|
return run ? run(plan) : okResult({ bytesTransferred: 128, filesTransferred: 1 });
|
|
}) as never,
|
|
});
|
|
await cmd.parseAsync(["copy", ...args], { from: "user" });
|
|
return { runs };
|
|
}
|
|
|
|
describe("rig file copy — rejection surfaces (spawn NEVER invoked)", () => {
|
|
it("remote:remote rejects with the pull-then-push text; exit 1; no run", async () => {
|
|
const { runs } = await runCopy(["vps-a:/a.md", "vps-a:/b.md"]);
|
|
expect(errs.join("\n")).toContain("remote-to-remote is not in v0; pull then push");
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("unknown host (the N18-1 colon-file case) fails loudly; no run", async () => {
|
|
const { runs } = await runCopy(["notes:v2.md", "./out.md"]);
|
|
expect(errs.join("\n")).toContain("unknown host id 'notes'");
|
|
expect(runs).toHaveLength(0);
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("denied location + charset violations reject before any transfer; JSON mode carries {ok:false, code}", async () => {
|
|
await runCopy(["--json", "~/.ssh/id_ed25519", "vps-a:/tmp/k"]);
|
|
const payload = JSON.parse(logs[0]!) as Record<string, unknown>;
|
|
expect(payload["ok"]).toBe(false);
|
|
expect(payload["code"]).toBe("denied_path");
|
|
expect(process.exitCode).toBe(1);
|
|
|
|
logs = [];
|
|
const { runs } = await runCopy(["--json", "./a.md", "vps-a:/srv/a b.md"]);
|
|
const payload2 = JSON.parse(logs[0]!) as Record<string, unknown>;
|
|
expect(payload2["code"]).toBe("denied_path");
|
|
expect(String(payload2["error"])).toContain("a space");
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("leading-dash operand refused, teaching the ./ escape; no run", async () => {
|
|
const { runs } = await runCopy(["--", "--delete-after", "vps-a:/srv/x"]);
|
|
expect(errs.join("\n")).toContain("./--delete-after");
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("rig file copy — success + dry-run rendering", () => {
|
|
it("success prints src → dst + bytes", async () => {
|
|
await runCopy(["./a.md", "vps-a:/srv/briefs/a.md"]);
|
|
expect(logs.join("\n")).toMatch(/Copied .*a\.md → vps-a:\/srv\/briefs\/a\.md \(128 bytes\)/);
|
|
expect(process.exitCode).toBe(0);
|
|
});
|
|
|
|
it("dry-run prints the planned transfer and moves nothing (native rsync --dry-run rides the plan)", async () => {
|
|
const { runs } = await runCopy(
|
|
["--dry-run", "./a.md", "vps-a:/srv/a.md"],
|
|
async () => okResult({ stdout: ">f+++++++++ a.md\nNumber of regular files transferred: 1\nTotal transferred file size: 128 bytes\n", bytesTransferred: 128, filesTransferred: 1 }),
|
|
);
|
|
expect((runs[0] as { dryRun: boolean }).dryRun).toBe(true);
|
|
const out = logs.join("\n");
|
|
expect(out).toContain("DRY RUN — nothing moved");
|
|
expect(out).toContain("→ vps-a:/srv/a.md");
|
|
expect(out).toContain("files: 1, bytes: 128");
|
|
});
|
|
|
|
it("JSON success exposes the closed builder's argv (transparency; '--' pin visible)", async () => {
|
|
await runCopy(["--json", "./a.md", "vps-a:/srv/a.md"]);
|
|
const payload = JSON.parse(logs[0]!) as { ok: boolean; rsyncArgv: string[]; src: string; dst: string };
|
|
expect(payload.ok).toBe(true);
|
|
expect(payload.dst).toBe("vps-a:/srv/a.md");
|
|
expect(payload.rsyncArgv.indexOf("--")).toBe(payload.rsyncArgv.length - 3);
|
|
});
|
|
|
|
it("transfer failure renders [failedStep] + stderr + hint; exit 1", async () => {
|
|
await runCopy(["./a.md", "vps-a:/srv/a.md"], async () =>
|
|
({ ok: false, failedStep: "permission-gate", exitCode: 255, stdout: "", stderr: "Permission denied (publickey).", hint: "See the keychain field note." }) as FileCopyResult,
|
|
);
|
|
const err = errs.join("\n");
|
|
expect(err).toContain("[permission-gate]");
|
|
expect(err).toContain("Permission denied");
|
|
expect(err).toContain("Hint:");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
});
|