Files
openrig/packages/cli/test/workspace-doctor-cli.test.ts
mvschwarz ac75777a3c Release 0.4.4
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.
2026-07-06 01:20:09 -07:00

385 lines
15 KiB
TypeScript

// Slice-21 FR-5 — `rig workspace doctor` CLI subcommand tests.
//
// Covers:
// - default behavior posts to /api/workspace/doctor with empty body
// - --workspace passes body.workspaceRoot
// - --json passes through the structured report
// - human output groups by category (workspace / missions / daemon)
// - exit-code semantics for fail / warn-with-strict / warn-without-strict / all-ok
//
// Mirrors the queue.test.ts pattern: vi.mock daemon-lifecycle to skip
// the live status probe; clientFactory delivers stub HTTP responses.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
workspaceCommand,
renderHumanDoctorReport,
type WorkspaceDeps,
} from "../src/commands/workspace.js";
import type { LifecycleDeps } from "../src/daemon-lifecycle.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 DoctorReportFixture {
workspaceRoot: string;
checks: Array<{
check: string;
status: "ok" | "warn" | "fail";
message: string;
fixHint?: string;
}>;
summary: { ok: number; warn: number; fail: number };
daemonResolvedAt: string;
}
function healthyReport(workspaceRoot = "/ws"): DoctorReportFixture {
return {
workspaceRoot,
checks: [
{ check: "workspace_root_reachable", status: "ok", message: `workspace root '${workspaceRoot}' is a reachable directory` },
{ check: "missions_folder_present", status: "ok", message: `missions folder '${workspaceRoot}/missions' is present` },
{ check: "file_allowlist_sane", status: "ok", message: "files.allowlist has 1 usable entry covering workspace root" },
{ check: "daemon_points_at_this_workspace", status: "ok", message: "daemon and caller agree on workspace root" },
{ check: "daemon_reload_needed", status: "ok", message: "config file mtime is older than daemon start" },
{ check: "optional_slice_docs", status: "ok", message: "every slice has a README" },
{ check: "mission_notes_presence", status: "ok", message: "every mission has MISSION_NOTES.md" },
{ check: "sdlc_convention_sections", status: "ok", message: "every slice README carries the SDLC convention sections" },
],
summary: { ok: 8, warn: 0, fail: 0 },
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
};
}
function makeDeps(response: { status: number; data: unknown }): {
deps: WorkspaceDeps;
post: ReturnType<typeof vi.fn>;
} {
const post = vi.fn(async (_path: string, _body: unknown) => response);
return {
post,
deps: {
lifecycleDeps: {} as LifecycleDeps,
clientFactory: () => ({
post,
get: vi.fn(),
getText: vi.fn(),
postText: vi.fn(),
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
},
};
}
function captureStdout(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
};
return { logs, restore: () => { console.log = original; } };
}
let originalExitCode: number | string | undefined;
beforeEach(() => {
originalExitCode = process.exitCode;
process.exitCode = 0;
});
afterEach(() => {
process.exitCode = originalExitCode;
});
describe("rig workspace doctor — request shape", () => {
it("POSTs to /api/workspace/doctor with empty body when no --workspace", async () => {
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", {});
});
// Discriminator-flip: when --workspace is passed, body.workspaceRoot
// must carry the resolved absolute path. Without path.resolve(), a
// relative input like "./ws" would land as "./ws" on the wire and
// the daemon check #4 would compare against an un-normalized value.
it("POSTs body.workspaceRoot (absolute) when --workspace is supplied", async () => {
const { deps, post } = makeDeps({ status: 200, data: healthyReport("/ws-override") });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync([
"node", "rig", "doctor", "--workspace", "/ws-override",
]);
} finally {
out.restore();
}
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", { workspaceRoot: "/ws-override" });
});
it("resolves relative --workspace to absolute path before posting", async () => {
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync([
"node", "rig", "doctor", "--workspace", "./local-ws",
]);
} finally {
out.restore();
}
const [, body] = post.mock.calls[0]!;
const sent = body as { workspaceRoot: string };
expect(sent.workspaceRoot.startsWith("/")).toBe(true);
expect(sent.workspaceRoot.endsWith("/local-ws")).toBe(true);
});
// GUARD/QA BLOCKING-A2 discriminator: CLI-side
// OPENRIG_FILES_ALLOWLIST must propagate to the daemon as
// body.filesAllowlistOverride. Without this, an operator setting
// the env in their CLI shell sees no doctor behavior change because
// the daemon's env-resolution is decoupled from the CLI process.
it("forwards OPENRIG_FILES_ALLOWLIST from process.env as body.filesAllowlistOverride", async () => {
const original = process.env.OPENRIG_FILES_ALLOWLIST;
process.env.OPENRIG_FILES_ALLOWLIST = "workspace:.";
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
else process.env.OPENRIG_FILES_ALLOWLIST = original;
}
const [, body] = post.mock.calls[0]!;
const sent = body as { workspaceRoot?: string; filesAllowlistOverride?: string };
expect(sent.filesAllowlistOverride).toBe("workspace:.");
});
// Discriminator-flip: an unset OPENRIG_FILES_ALLOWLIST must NOT
// attach an override (the daemon should use SettingsStore as the
// source of truth, not silently apply an empty-string).
it("omits filesAllowlistOverride from body when OPENRIG_FILES_ALLOWLIST is unset", async () => {
const original = process.env.OPENRIG_FILES_ALLOWLIST;
delete process.env.OPENRIG_FILES_ALLOWLIST;
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
else process.env.OPENRIG_FILES_ALLOWLIST = original;
}
const [, body] = post.mock.calls[0]!;
const sent = body as { filesAllowlistOverride?: string };
expect(sent.filesAllowlistOverride).toBeUndefined();
});
// Discriminator-flip: empty-string env value is treated as unset
// (no override sent). Without the length > 0 check, an empty value
// would land as filesAllowlistOverride="" and the daemon route
// would treat that as an explicit override.
it("treats empty-string OPENRIG_FILES_ALLOWLIST as unset (no override sent)", async () => {
const original = process.env.OPENRIG_FILES_ALLOWLIST;
process.env.OPENRIG_FILES_ALLOWLIST = "";
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
else process.env.OPENRIG_FILES_ALLOWLIST = original;
}
const [, body] = post.mock.calls[0]!;
const sent = body as { filesAllowlistOverride?: string };
expect(sent.filesAllowlistOverride).toBeUndefined();
});
});
describe("rig workspace doctor — output formatters", () => {
it("--json prints the report as a single-line JSON document", async () => {
const report = healthyReport();
const { deps } = makeDeps({ status: 200, data: report });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--json"]);
} finally {
out.restore();
}
expect(out.logs).toHaveLength(1);
const parsed = JSON.parse(out.logs[0]!) as DoctorReportFixture;
expect(parsed.summary).toEqual({ ok: 8, warn: 0, fail: 0 });
expect(parsed.checks).toHaveLength(8);
});
it("default human output groups checks under workspace / missions / daemon categories", async () => {
const { deps } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
const joined = out.logs.join("\n");
expect(joined).toContain("workspace doctor — /ws");
expect(joined).toContain("summary: 8 ok, 0 warn, 0 fail");
expect(joined).toContain("workspace:");
expect(joined).toContain("missions:");
expect(joined).toContain("daemon:");
// Discriminator: each check name must appear under the correct
// group section. We assert the category labels appear BEFORE the
// check names that belong to them.
const wsIdx = joined.indexOf("workspace:");
const wsCheckIdx = joined.indexOf("workspace_root_reachable");
const missionsIdx = joined.indexOf("missions:");
const missionsCheckIdx = joined.indexOf("missions_folder_present");
const daemonIdx = joined.indexOf("daemon:");
const daemonCheckIdx = joined.indexOf("daemon_points_at_this_workspace");
expect(wsIdx).toBeLessThan(wsCheckIdx);
expect(missionsIdx).toBeLessThan(missionsCheckIdx);
expect(daemonIdx).toBeLessThan(daemonCheckIdx);
});
// Discriminator-flip: fixHint MUST render below the failing check.
// Without `if (c.fixHint) console.log(...)` operators don't see how
// to fix the failure.
it("human output renders fixHint under failing/warning checks", () => {
const report: DoctorReportFixture = {
workspaceRoot: "/ws",
checks: [
{ check: "workspace_root_reachable", status: "fail", message: "workspace root '/ws' does not exist", fixHint: "run `rig config init-workspace`" },
{ check: "missions_folder_present", status: "ok", message: "ok" },
{ check: "file_allowlist_sane", status: "ok", message: "ok" },
{ check: "daemon_points_at_this_workspace", status: "ok", message: "ok" },
{ check: "daemon_reload_needed", status: "ok", message: "ok" },
{ check: "optional_slice_docs", status: "ok", message: "ok" },
{ check: "mission_notes_presence", status: "ok", message: "ok" },
],
summary: { ok: 6, warn: 0, fail: 1 },
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
};
const out = captureStdout();
try {
renderHumanDoctorReport(report);
} finally {
out.restore();
}
const joined = out.logs.join("\n");
expect(joined).toContain("[FAIL] workspace_root_reachable");
expect(joined).toContain("Fix: run `rig config init-workspace`");
});
// Discriminator-flip: a check absent from DOCTOR_CHECK_GROUPS falls
// into "other:" rather than vanishing. Protects against silent
// future-check drop-off.
it("human output renders ungrouped checks under 'other:'", () => {
const report: DoctorReportFixture = {
workspaceRoot: "/ws",
checks: [
{ check: "future_unknown_check", status: "warn", message: "new check the formatter didn't know about" },
],
summary: { ok: 0, warn: 1, fail: 0 },
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
};
const out = captureStdout();
try {
renderHumanDoctorReport(report);
} finally {
out.restore();
}
const joined = out.logs.join("\n");
expect(joined).toContain("other:");
expect(joined).toContain("future_unknown_check");
});
});
describe("rig workspace doctor — exit-code semantics", () => {
it("exits 0 when summary is all-ok (default mode)", async () => {
const { deps } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
expect(process.exitCode).toBe(0);
});
// Discriminator-flip: any fail must trip exit code 1 even without
// --strict.
it("exits 1 when summary has at least one fail (default mode)", async () => {
const failing = healthyReport();
failing.checks[0]!.status = "fail";
failing.summary = { ok: 6, warn: 0, fail: 1 };
const { deps } = makeDeps({ status: 200, data: failing });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
expect(process.exitCode).toBe(1);
});
// Discriminator-flip: warns are NOT failures in default mode.
it("exits 0 when summary has warns but no fails (default mode)", async () => {
const warning = healthyReport();
warning.checks[6]!.status = "warn";
warning.summary = { ok: 6, warn: 1, fail: 0 };
const { deps } = makeDeps({ status: 200, data: warning });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
expect(process.exitCode).toBe(0);
});
// Discriminator-flip: --strict turns warns into exit-1.
it("exits 1 when summary has warns and --strict is set", async () => {
const warning = healthyReport();
warning.checks[6]!.status = "warn";
warning.summary = { ok: 6, warn: 1, fail: 0 };
const { deps } = makeDeps({ status: 200, data: warning });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--strict"]);
} finally {
out.restore();
}
expect(process.exitCode).toBe(1);
});
// Discriminator-flip: HTTP 5xx → exit 1.
it("exits 1 when daemon returns 5xx", async () => {
const { deps } = makeDeps({ status: 503, data: { error: "settings_unavailable" } });
const out = captureStdout();
const stderrOrig = process.stderr.write;
process.stderr.write = ((_chunk: unknown) => true) as typeof process.stderr.write;
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
process.stderr.write = stderrOrig;
}
expect(process.exitCode).toBe(1);
});
});