mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
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:
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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user