feat(slice-21 FR-5c): daemon route POST /api/workspace/doctor + runWorkspaceDoctor orchestrator (OPR.0.3.2.21.FR-5)

Third commit of the FR-5 chain. Wires the 7 readiness checks into a
single orchestrator function + adds the daemon HTTP route that exposes
the structured DoctorReport.

runWorkspaceDoctor orchestrator (workspace-doctor.ts):
- Takes RunDoctorInput with: workspaceRoot + source, slicesRoot,
  allowlistValue + source, daemonResolvedWorkspaceRoot,
  configFilePath, daemonStartTime.
- Calls all 7 check helpers in fixed order; aggregates status counts
  into summary {ok, warn, fail}; emits daemonResolvedAt as ISO
  timestamp.
- Pure function (filesystem effects bounded to the individual
  check helpers); the route adapter is the only side that touches
  SettingsStore + process.uptime().

POST /api/workspace/doctor route (routes/workspace.ts):
- Wired alongside the slice-01 POST /validate via the same
  workspaceRoutes() Hono factory; mounted at /api/workspace per
  server.ts:509 (no server.ts changes).
- Reads inputs from c.get("settingsStore") matching the established
  pattern at routes/config.ts (503 on settingsStore unavailable).
- Honors optional body.workspaceRoot for CLI --workspace override;
  caller-supplied root is treated as "env" for fix-hint purposes
  (explicit operator choice), while daemon-resolved
  workspaceRoot/source still feeds check #4 comparison so the
  doctor catches daemon-vs-caller workspace divergence.
- Slices root derived from body when overridden (default
  `<workspaceRoot>/missions`) or from SettingsStore
  workspace.slices_root when not overridden.
- daemonStartTime computed inline as `new Date(Date.now() -
  process.uptime() * 1000)` per the FR-5b fix-up comment hint.
- 5xx on internal failure via the same try/catch shape as
  /validate; clean 503 vs 500 distinction (503 = wiring missing,
  500 = check pipeline crashed).

Tests added (12 total):
- workspace-doctor.test.ts (+4 orchestrator tests):
  - 7-check report shape on healthy workspace (summary ok=7)
  - WARN aggregation when MISSION_NOTES absent
  - FAIL aggregation when workspace root unreachable
  - Stable check ordering across repeated runs
- workspace-routes.test.ts (+8 — new describe block for /doctor):
  - 200 with 7-check DoctorReport on healthy workspace via stub
    SettingsStore wired through Hono middleware (matching the
    server.ts:430 c.set pattern)
  - body.workspaceRoot honored for workspace-under-check;
    daemon_points_at_this_workspace turns FAIL on caller-supplied
    different root (the load-bearing discriminator for the
    --workspace flag)
  - 503 when SettingsStore middleware is absent (the bare-app
    discriminator)
  - empty-body POST tolerated via the json().catch shape

Stub SettingsStore in route tests mirrors the resolveOne value/
source/defaultValue shape; only the surface the doctor route uses
(resolveOne + configPath) is implemented, keeping the test setup
bounded and the production SettingsStore wiring contract explicit.

Verification:
- packages/daemon tsc EXIT=0
- packages/daemon workspace-doctor.test.ts + workspace-routes.test.ts:
  43/43 PASS (35 doctor + 8 routes; 31 doctor were from prior commits,
  4 new orchestrator + 8 new route)
- Pre-commit staged-diff grep for forbidden-attribution pattern:
  ZERO matches
- Pre-commit FULL-CONTENT grep on every touched file (touch-event-
  scope rule): ZERO matches
- Commit body verification grep clean per banked discipline
- git diff --cached --stat: 4 files (+405 / -1)

Branch: slice-21-fr-5-rig-workspace-doctor off LOCAL main e154a5ce.
Chain: a4b4b67d (FR-5a, CLEAR) → e5d61983 (FR-5b, BLOCKED) →
bc5ec05f (FR-5b fix-up, CLEAR) → this commit (FR-5c daemon route).
LOCAL only, NO push.

Files:
- packages/daemon/src/domain/workspace/workspace-doctor.ts (+79)
- packages/daemon/src/routes/workspace.ts (+79 / -1)
- packages/daemon/test/workspace-doctor.test.ts (+116)
- packages/daemon/test/workspace-routes.test.ts (+132)
This commit is contained in:
mvschwarz committed 2026-06-01 21:24:04 -07:00
1 parent bc5ec05fce
commit 6b21ce3c86
4 files changed
+405 -1

No files matched your search

@@ -514,3 +514,82 @@ export function checkMissionNotesPresence(opts: CheckMissionNotesInput): DoctorC
evidence: { missionsRoot, missing },
};
}
export interface RunDoctorInput {
/** Workspace root under check (caller's --workspace override or
* daemon-resolved default). */
workspaceRoot: string;
workspaceRootSource: WorkspaceRootSource;
/** Resolved missions folder per ConfigStore workspace.slices_root,
* or `<workspaceRoot>/missions` when caller overrode --workspace. */
slicesRoot: string;
/** Raw files.allowlist value from SettingsStore (will be decoded
* via canonical decodeAllowlist). */
allowlistValue: string;
allowlistSource: WorkspaceRootSource;
/** Daemon-resolved workspace.root (for check #4 comparison against
* the workspace under check). */
daemonResolvedWorkspaceRoot: string;
/** Daemon's ConfigStore configPath on disk (for check #5 staleness
* comparison). */
configFilePath: string;
/** Daemon process start time (for check #5). */
daemonStartTime: Date;
}
export interface DoctorReport {
/** Workspace under check (echoes input.workspaceRoot for clarity). */
workspaceRoot: string;
checks: DoctorCheck[];
summary: { ok: number; warn: number; fail: number };
/** ISO timestamp of when the daemon ran the report. */
daemonResolvedAt: string;
}
/**
* Orchestrator — runs all 7 checks in fixed order and returns a
* structured DoctorReport with aggregate counts. Pure function;
* filesystem effects are bounded to the individual check helpers
* (statSync / readdirSync / existsSync). Used by the daemon route
* POST /api/workspace/doctor (FR-5c) and by the CLI's --json
* formatter (FR-5d).
*/
export function runWorkspaceDoctor(input: RunDoctorInput): DoctorReport {
const checks: DoctorCheck[] = [
checkWorkspaceRootReachable({
workspaceRoot: input.workspaceRoot,
source: input.workspaceRootSource,
}),
checkMissionsFolder({
workspaceRoot: input.workspaceRoot,
slicesRoot: input.slicesRoot,
}),
checkFileAllowlist({
workspaceRoot: input.workspaceRoot,
allowlistValue: input.allowlistValue,
allowlistSource: input.allowlistSource,
}),
checkDaemonWorkspace({
daemonResolvedRoot: input.daemonResolvedWorkspaceRoot,
expectedRoot: input.workspaceRoot,
}),
checkDaemonReload({
configFilePath: input.configFilePath,
daemonStartTime: input.daemonStartTime,
}),
checkOptionalSliceDocs({
missionsRoot: input.slicesRoot,
}),
checkMissionNotesPresence({
missionsRoot: input.slicesRoot,
}),
];
const summary = { ok: 0, warn: 0, fail: 0 };
for (const c of checks) summary[c.status]++;
return {
workspaceRoot: input.workspaceRoot,
checks,
summary,
daemonResolvedAt: new Date().toISOString(),
};
}
+78 -1
View File
@@ -1,6 +1,6 @@
// PL-007 Workspace Primitive v0 — workspace HTTP routes.
//
// One read-only endpoint at v0:
// Read-only endpoints:
//
// POST /api/workspace/validate
// body: { root: string; workspaceKind?: WorkspaceKind;
@@ -8,17 +8,27 @@
// maxFiles?: number }
// response: FrontmatterValidationReport
//
// POST /api/workspace/doctor (slice-21 FR-5)
// body (all optional): { workspaceRoot?: string }
// response: DoctorReport (7-check workspace-readiness report)
//
// No filesystem mutation. Operator picks the root + kind per invocation.
//
// Whoami / node-inventory surface workspace data through their existing
// routes; no separate /api/workspace/whoami.
import { Hono } from "hono";
import * as path from "node:path";
import {
validateWorkspaceFrontmatter,
type ValidateOpts,
} from "../domain/workspace/frontmatter-validator.js";
import { WORKSPACE_KINDS, type WorkspaceKind } from "../domain/types.js";
import {
runWorkspaceDoctor,
type WorkspaceRootSource,
} from "../domain/workspace/workspace-doctor.js";
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
export function workspaceRoutes(): Hono {
const app = new Hono();
@@ -62,5 +72,72 @@ export function workspaceRoutes(): Hono {
}
});
// Slice-21 FR-5 — workspace doctor.
//
// Resolves all inputs from the daemon's SettingsStore (matching
// /api/config), runs the 7-check orchestrator, and returns the
// DoctorReport. Caller can override the workspace under check via
// body.workspaceRoot — useful for `rig workspace doctor --workspace
// <path>` to probe an alternate workspace without restarting the
// daemon. When overridden, the daemon-side workspace.root is still
// resolved for check #4 (daemon-points-at-this-workspace).
app.post("/doctor", async (c) => {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const body = await c.req.json<{ workspaceRoot?: string }>().catch(
() => ({} as { workspaceRoot?: string }),
);
try {
const daemonResolved = store.resolveOne("workspace.root");
const workspaceUnderCheck =
typeof body.workspaceRoot === "string" && body.workspaceRoot.length > 0
? body.workspaceRoot
: (daemonResolved.value as string);
// Caller-supplied workspaceRoot is treated as an explicit
// operator choice for fix-hint purposes (akin to env). When
// not supplied, source is the daemon's actual resolution
// channel (env / file / default).
const workspaceRootSource: WorkspaceRootSource =
typeof body.workspaceRoot === "string" && body.workspaceRoot.length > 0
? "env"
: (daemonResolved.source as WorkspaceRootSource);
// When caller overrode workspace, slicesRoot defaults to
// `<workspaceRoot>/missions` (the same derivation ConfigStore
// would apply for an unset workspace.slices_root). When not
// overridden, honor the daemon's resolved slicesRoot.
const slicesRoot =
typeof body.workspaceRoot === "string" && body.workspaceRoot.length > 0
? path.join(body.workspaceRoot, "missions")
: (store.resolveOne("workspace.slices_root").value as string);
const allowlistResolved = store.resolveOne("files.allowlist");
// Daemon start time captured from process.uptime() at request
// time. Per banked discipline (cited in workspace-doctor.ts:
// CheckDaemonReloadInput.daemonStartTime JSDoc), this is a
// pragmatic approximation — sufficient for "did config change
// since startup" but does not account for explicit clock skew.
const daemonStartTime = new Date(Date.now() - process.uptime() * 1000);
const report = runWorkspaceDoctor({
workspaceRoot: workspaceUnderCheck,
workspaceRootSource,
slicesRoot,
allowlistValue: allowlistResolved.value as string,
allowlistSource: allowlistResolved.source as WorkspaceRootSource,
daemonResolvedWorkspaceRoot: daemonResolved.value as string,
configFilePath: store.configPath,
daemonStartTime,
});
return c.json(report);
} catch (err) {
const message = err instanceof Error ? err.message : "internal error";
return c.json({ error: "doctor_failed", message }, 500);
}
});
return app;
}
@@ -16,6 +16,7 @@ import {
checkDaemonReload,
checkOptionalSliceDocs,
checkMissionNotesPresence,
runWorkspaceDoctor,
type DoctorCheck,
} from "../src/domain/workspace/workspace-doctor.js";
@@ -426,3 +427,118 @@ describe("FR-5 check #7 — MISSION_NOTES presence", () => {
expect(result.evidence?.errorCode).toBe("ENOENT");
});
});
describe("FR-5 runWorkspaceDoctor — orchestrator", () => {
function scaffoldHealthyWorkspace(root: string): { missions: string; configPath: string } {
const missions = path.join(root, "missions");
fs.mkdirSync(missions);
fs.mkdirSync(path.join(missions, "getting-started"));
fs.writeFileSync(path.join(missions, "getting-started", "MISSION_NOTES.md"), "");
fs.mkdirSync(path.join(missions, "getting-started", "slices", "s1"), { recursive: true });
fs.writeFileSync(path.join(missions, "getting-started", "slices", "s1", "README.md"), "");
const configPath = path.join(root, ".test-config.json");
fs.writeFileSync(configPath, "{}");
const oldMtime = new Date(Date.now() - 60_000);
fs.utimesSync(configPath, oldMtime, oldMtime);
return { missions, configPath };
}
it("returns a 7-check report with summary counts on a healthy workspace", () => {
const { missions, configPath } = scaffoldHealthyWorkspace(dir);
const report = runWorkspaceDoctor({
workspaceRoot: dir,
workspaceRootSource: "env",
slicesRoot: missions,
allowlistValue: `workspace:${fs.realpathSync(dir)}`,
allowlistSource: "default",
daemonResolvedWorkspaceRoot: dir,
configFilePath: configPath,
daemonStartTime: new Date(Date.now() - 5_000),
});
expect(report.workspaceRoot).toBe(dir);
expect(report.checks).toHaveLength(7);
expect(report.checks.map((c) => c.check)).toEqual([
"workspace_root_reachable",
"missions_folder_present",
"file_allowlist_sane",
"daemon_points_at_this_workspace",
"daemon_reload_needed",
"optional_slice_docs",
"mission_notes_presence",
]);
expect(report.summary.ok).toBe(7);
expect(report.summary.warn).toBe(0);
expect(report.summary.fail).toBe(0);
expect(typeof report.daemonResolvedAt).toBe("string");
expect(report.daemonResolvedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
// Discriminator-flip: the orchestrator must aggregate WARN status
// when at least one check warns. Without the for-loop summary
// tally, this would stay 0/0/0.
it("aggregates warn count when a single check warns (mission notes missing)", () => {
const missions = path.join(dir, "missions");
fs.mkdirSync(path.join(missions, "m1"), { recursive: true });
// intentionally no MISSION_NOTES.md
const configPath = path.join(dir, ".test-config.json");
fs.writeFileSync(configPath, "{}");
fs.utimesSync(configPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
const report = runWorkspaceDoctor({
workspaceRoot: dir,
workspaceRootSource: "env",
slicesRoot: missions,
allowlistValue: `workspace:${fs.realpathSync(dir)}`,
allowlistSource: "default",
daemonResolvedWorkspaceRoot: dir,
configFilePath: configPath,
daemonStartTime: new Date(Date.now() - 5_000),
});
expect(report.summary.warn).toBeGreaterThanOrEqual(1);
const notes = report.checks.find((c) => c.check === "mission_notes_presence");
expect(notes?.status).toBe("warn");
});
// Discriminator-flip: orchestrator must aggregate FAIL status when
// at least one check fails. Routes check #1 fails when workspace
// root is bogus.
it("aggregates fail count when workspace root is unreachable", () => {
const bogus = path.join(dir, "does-not-exist");
const configPath = path.join(dir, ".test-config.json");
fs.writeFileSync(configPath, "{}");
fs.utimesSync(configPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
const report = runWorkspaceDoctor({
workspaceRoot: bogus,
workspaceRootSource: "env",
slicesRoot: path.join(bogus, "missions"),
allowlistValue: `workspace:${fs.realpathSync(dir)}`,
allowlistSource: "default",
daemonResolvedWorkspaceRoot: bogus,
configFilePath: configPath,
daemonStartTime: new Date(Date.now() - 5_000),
});
expect(report.summary.fail).toBeGreaterThanOrEqual(1);
const reachable = report.checks.find((c) => c.check === "workspace_root_reachable");
expect(reachable?.status).toBe("fail");
});
// Discriminator-flip: check ordering is stable. The CLI human
// formatter (FR-5d) groups by category in this order; a reordered
// checks[] would break the formatter's category assumption.
it("emits checks in the documented fixed order regardless of input timing", () => {
const { missions, configPath } = scaffoldHealthyWorkspace(dir);
const reports = Array.from({ length: 3 }, () =>
runWorkspaceDoctor({
workspaceRoot: dir,
workspaceRootSource: "env",
slicesRoot: missions,
allowlistValue: `workspace:${fs.realpathSync(dir)}`,
allowlistSource: "default",
daemonResolvedWorkspaceRoot: dir,
configFilePath: configPath,
daemonStartTime: new Date(Date.now() - 5_000),
}),
);
const fingerprints = reports.map((r) => r.checks.map((c) => c.check).join(","));
expect(new Set(fingerprints).size).toBe(1);
});
});
@@ -74,3 +74,135 @@ describe("workspace HTTP routes (PL-007)", () => {
expect(body.workspaceKind).toBeNull();
});
});
// Slice-21 FR-5 — POST /api/workspace/doctor route tests.
//
// Wires a stub SettingsStore via Hono middleware (matching production
// pattern at server.ts:430 where `c.set("settingsStore", ...)`).
// SettingsStore is constructed with a temp config-file path so the
// suite doesn't touch ~/.openrig.
describe("workspace doctor HTTP route (slice-21 FR-5)", () => {
let doctorDir: string;
let doctorApp: Hono;
let configPath: string;
beforeEach(async () => {
doctorDir = fs.mkdtempSync(path.join(os.tmpdir(), "fr5-route-"));
// Build a healthy workspace shape under doctorDir.
fs.mkdirSync(path.join(doctorDir, "missions", "getting-started"), { recursive: true });
fs.writeFileSync(path.join(doctorDir, "missions", "getting-started", "MISSION_NOTES.md"), "");
fs.mkdirSync(path.join(doctorDir, "missions", "getting-started", "slices", "s1"), { recursive: true });
fs.writeFileSync(path.join(doctorDir, "missions", "getting-started", "slices", "s1", "README.md"), "");
// Stub SettingsStore-shaped object — we only need the surface the
// doctor route uses: resolveOne + configPath. SettingsStore's
// public surface is large; the stub mirrors the resolveOne return
// shape (value/source/defaultValue).
configPath = path.join(doctorDir, ".test-config.json");
fs.writeFileSync(configPath, "{}");
const oldMtime = new Date(Date.now() - 60_000);
fs.utimesSync(configPath, oldMtime, oldMtime);
const stubStore = {
configPath,
resolveOne(key: string) {
switch (key) {
case "workspace.root":
return { value: doctorDir, source: "env", defaultValue: doctorDir };
case "workspace.slices_root":
return { value: path.join(doctorDir, "missions"), source: "default", defaultValue: path.join(doctorDir, "missions") };
case "files.allowlist":
return { value: `workspace:${fs.realpathSync(doctorDir)}`, source: "default", defaultValue: `workspace:${doctorDir}` };
default:
return { value: "", source: "default", defaultValue: "" };
}
},
};
doctorApp = new Hono();
doctorApp.use("*", async (c, next) => {
c.set("settingsStore" as never, stubStore as never);
await next();
});
doctorApp.route("/api/workspace", workspaceRoutes());
});
afterEach(() => {
try { fs.rmSync(doctorDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it("POST /doctor returns 200 with a 7-check DoctorReport on a healthy workspace", async () => {
const res = await doctorApp.request("/api/workspace/doctor", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json() as {
workspaceRoot: string;
checks: Array<{ check: string; status: string }>;
summary: { ok: number; warn: number; fail: number };
daemonResolvedAt: string;
};
expect(body.workspaceRoot).toBe(doctorDir);
expect(body.checks).toHaveLength(7);
expect(body.summary.ok).toBe(7);
expect(body.summary.warn).toBe(0);
expect(body.summary.fail).toBe(0);
expect(body.daemonResolvedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
// Discriminator-flip: caller-supplied workspaceRoot must be honored.
// Without the body.workspaceRoot branch the route would always check
// the daemon-resolved workspace.
it("POST /doctor honors body.workspaceRoot for the workspace under check", async () => {
const altRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fr5-route-alt-"));
try {
const res = await doctorApp.request("/api/workspace/doctor", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workspaceRoot: altRoot }),
});
expect(res.status).toBe(200);
const body = await res.json() as {
workspaceRoot: string;
checks: Array<{ check: string; status: string; evidence?: Record<string, unknown> }>;
summary: { ok: number; warn: number; fail: number };
};
expect(body.workspaceRoot).toBe(altRoot);
// Check #4 (daemon_points_at_this_workspace) must FAIL because
// daemon's resolved root differs from the caller-supplied one.
const daemonCheck = body.checks.find((c) => c.check === "daemon_points_at_this_workspace");
expect(daemonCheck?.status).toBe("fail");
} finally {
fs.rmSync(altRoot, { recursive: true, force: true });
}
});
// Discriminator-flip: 503 when SettingsStore is missing. Without
// the `if (!store) return 503` guard the route would crash.
it("POST /doctor returns 503 when settingsStore is unavailable", async () => {
const bareApp = new Hono();
bareApp.route("/api/workspace", workspaceRoutes());
const res = await bareApp.request("/api/workspace/doctor", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(503);
const body = await res.json() as { error: string };
expect(body.error).toBe("settings_unavailable");
});
// Discriminator-flip: empty body must be tolerated. Without the
// .catch(() => ({})) guard the JSON parse would throw and 500
// would land instead of the report.
it("POST /doctor tolerates empty body (no Content-Type, no JSON)", async () => {
const res = await doctorApp.request("/api/workspace/doctor", {
method: "POST",
});
expect(res.status).toBe(200);
const body = await res.json() as { workspaceRoot: string };
expect(body.workspaceRoot).toBe(doctorDir);
});
});