mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
feat(slice-21 FR-5a): workspace-doctor check #1 — workspace root reachable (OPR.0.3.2.21.FR-5)
First commit of the FR-5 chain — establishes the per-check shape +
ships check #1 (the simplest gate). Checks #2-#7, daemon route, and
CLI subcommand land in follow-on commits on this same branch.
Per-check shape: `{check, status: "ok" | "warn" | "fail", message,
fixHint?, evidence?}` per the FR-5 IMPL-PRD §76-78. Orch-marshal-
accepted decision on the SHAPE-DIVERGENCE: the install-health
`rig doctor` (packages/cli/src/commands/doctor.ts) uses a different
shape (status: pass|warn|fail|skipped, reason/fix); `rig workspace
doctor` is intentionally a separate surface with a separate schema
because the concerns diverge (workspace-readiness vs install-health),
fixHint reads more honestly than fix, evidence is a structured
object that JSON-pipe consumers can introspect, and ok is state-
language vs pass test-language. Documented in the file header.
Check #1 — workspace root reachable:
- Verifies the resolved workspace root exists as a directory
- Propagates the resolution source (env / file / default) through
the fix-hint so the operator gets the right remediation channel
(unset OPENRIG_WORKSPACE_ROOT vs edit config.json vs run
rig config init-workspace)
- Fail cases: ENOENT (root does not exist) and not-a-directory
(root exists but is a regular file or other non-dir entry)
- Evidence object carries the workspaceRoot + source + errorCode/
kind so JSON consumers can branch on the specific failure mode
Source-aware fix-hints land verbatim from the shipped ConfigStore
precedence at packages/cli/src/config-store.ts (env >
file > default) so the doctor's recommendations match the actual
resolution chain that produced the value being checked.
Tests (5 passing) include 4 discriminator-flip negative cases per
banked feedback_handoff_body_claims_need_discriminator_verification:
- ok path (existing directory)
- ENOENT (non-existent path)
- not-a-directory (regular file at the path)
- source-aware fix-hint (env vs file vs default)
- non-ENOENT IO error (EACCES via chmod 0o000 on parent; skips on
Windows / root user where chmod is non-blocking)
The unit test imports the pure check function directly; no daemon /
HTTP / CLI wiring touched in this commit. That wiring lands in
follow-on commits (#2: checks #2-#7 core logic; #3: daemon route
POST /api/workspace/doctor; #4: CLI subcommand + formatters).
Verification:
- packages/daemon tsc EXIT=0 (clean; no errors)
- packages/daemon workspace-doctor.test.ts: 5/5 PASS
- Pre-commit staged-diff grep for forbidden-attribution pattern:
ZERO matches
- Pre-commit FULL-CONTENT grep on every touched file (per the
touch-event-scope rule I authored at FR-4): ZERO matches
- git diff --cached --stat: 2 files (+192 / -0), both net-new
Branch: slice-21-fr-5-rig-workspace-doctor off LOCAL main e154a5ce.
Chain: this commit (FR-5a check #1 core logic + types). LOCAL only,
NO push.
Files:
- packages/daemon/src/domain/workspace/workspace-doctor.ts (+96 new)
- packages/daemon/test/workspace-doctor.test.ts (+96 new)
This commit is contained in:
1 parent
e154a5cef1
commit
a4b4b67dae
2 files changed
+192
No files matched your search
@@ -0,0 +1,96 @@
|
||||
// Slice-21 FR-5 — workspace doctor readiness checks for `rig workspace doctor`.
|
||||
//
|
||||
// 7 checks (this commit ships #1; #2-#7 land in follow-on commits in
|
||||
// this same chain):
|
||||
// 1. Workspace root reachable (env > file > default precedence)
|
||||
// 2. Missions folder present
|
||||
// 3. File allowlist sane
|
||||
// 4. Daemon points at this workspace
|
||||
// 5. Daemon reload needed
|
||||
// 6. Optional slice docs (warn-only)
|
||||
// 7. MISSION_NOTES presence
|
||||
//
|
||||
// Per-check return shape is `{check, status: "ok"|"warn"|"fail",
|
||||
// message, fixHint?, evidence?}` per FR-5 IMPL-PRD §76-78. The shape
|
||||
// diverges intentionally from the install-health `rig doctor`'s
|
||||
// DoctorCheck shape (pass|warn|fail|skipped, reason/fix) — different
|
||||
// concerns warrant different schemas (workspace-readiness vs install-
|
||||
// health); the divergence is orch-marshal-accepted per cont.43-followup.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
|
||||
export type CheckStatus = "ok" | "warn" | "fail";
|
||||
|
||||
export interface DoctorCheck {
|
||||
check: string;
|
||||
status: CheckStatus;
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
evidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type WorkspaceRootSource = "env" | "file" | "default";
|
||||
|
||||
export interface CheckWorkspaceRootInput {
|
||||
workspaceRoot: string;
|
||||
source: WorkspaceRootSource;
|
||||
}
|
||||
|
||||
const ENV_FIX_HINT =
|
||||
"unset OPENRIG_WORKSPACE_ROOT or set it to an existing directory; run `rig config init-workspace` to scaffold a fresh one";
|
||||
const FILE_FIX_HINT =
|
||||
"update workspace.root in config.json to an existing directory; run `rig config init-workspace` to scaffold a fresh one";
|
||||
const DEFAULT_FIX_HINT =
|
||||
"run `rig config init-workspace` to scaffold the default workspace at the configured root";
|
||||
|
||||
function fixHintForSource(source: WorkspaceRootSource): string {
|
||||
switch (source) {
|
||||
case "env":
|
||||
return ENV_FIX_HINT;
|
||||
case "file":
|
||||
return FILE_FIX_HINT;
|
||||
case "default":
|
||||
return DEFAULT_FIX_HINT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check #1 — workspace root reachable.
|
||||
*
|
||||
* Verifies the resolved workspace root exists as a directory. The
|
||||
* `source` (env / file / default) is propagated into the fix-hint so
|
||||
* the operator gets the right remediation channel.
|
||||
*/
|
||||
export function checkWorkspaceRootReachable(opts: CheckWorkspaceRootInput): DoctorCheck {
|
||||
const { workspaceRoot, source } = opts;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(workspaceRoot);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
check: "workspace_root_reachable",
|
||||
status: "fail",
|
||||
message: code === "ENOENT"
|
||||
? `workspace root '${workspaceRoot}' does not exist (resolved from ${source})`
|
||||
: `workspace root '${workspaceRoot}' is not reachable: ${(err as Error).message}`,
|
||||
fixHint: fixHintForSource(source),
|
||||
evidence: { workspaceRoot, source, errorCode: code ?? "unknown" },
|
||||
};
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
check: "workspace_root_reachable",
|
||||
status: "fail",
|
||||
message: `workspace root '${workspaceRoot}' exists but is not a directory (resolved from ${source})`,
|
||||
fixHint: fixHintForSource(source),
|
||||
evidence: { workspaceRoot, source, kind: "not_a_directory" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "workspace_root_reachable",
|
||||
status: "ok",
|
||||
message: `workspace root '${workspaceRoot}' is a reachable directory`,
|
||||
evidence: { workspaceRoot, source },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Slice-21 FR-5 — workspace doctor check unit tests.
|
||||
//
|
||||
// Per banked feedback_handoff_body_claims_need_discriminator_verification,
|
||||
// each check ships with a discriminator-flip negative test: if the
|
||||
// production code were wrong, the assertion would catch it.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
checkWorkspaceRootReachable,
|
||||
type DoctorCheck,
|
||||
} from "../src/domain/workspace/workspace-doctor.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "fr5-doctor-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe("FR-5 check #1 — workspace root reachable", () => {
|
||||
it("returns ok when workspace root is an existing directory", () => {
|
||||
const result: DoctorCheck = checkWorkspaceRootReachable({ workspaceRoot: dir, source: "default" });
|
||||
expect(result.check).toBe("workspace_root_reachable");
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.message).toContain(dir);
|
||||
expect(result.fixHint).toBeUndefined();
|
||||
expect(result.evidence).toEqual({ workspaceRoot: dir, source: "default" });
|
||||
});
|
||||
|
||||
// Discriminator-flip: ENOENT path MUST be a fail, not silently pass.
|
||||
it("returns fail with ENOENT evidence when workspace root does not exist", () => {
|
||||
const missing = path.join(dir, "definitely-not-here");
|
||||
const result = checkWorkspaceRootReachable({ workspaceRoot: missing, source: "env" });
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.message).toContain("does not exist");
|
||||
expect(result.message).toContain("env");
|
||||
expect(result.fixHint).toContain("OPENRIG_WORKSPACE_ROOT");
|
||||
expect(result.evidence?.errorCode).toBe("ENOENT");
|
||||
});
|
||||
|
||||
// Discriminator-flip: a file (not directory) at the root MUST fail, not pass.
|
||||
// Without isDirectory() check, statSync would succeed and the check
|
||||
// would return ok — this test catches that regression.
|
||||
it("returns fail when workspace root is a regular file, not a directory", () => {
|
||||
const filePath = path.join(dir, "not-a-dir");
|
||||
fs.writeFileSync(filePath, "");
|
||||
const result = checkWorkspaceRootReachable({ workspaceRoot: filePath, source: "file" });
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.message).toContain("not a directory");
|
||||
expect(result.fixHint).toContain("config.json");
|
||||
expect(result.evidence?.kind).toBe("not_a_directory");
|
||||
});
|
||||
|
||||
// Source-aware fix-hint discriminator. If fix-hint resolution were
|
||||
// hard-coded to one source, this would catch it.
|
||||
it("emits source-aware fix-hints (env vs file vs default)", () => {
|
||||
const missing = path.join(dir, "missing");
|
||||
const envResult = checkWorkspaceRootReachable({ workspaceRoot: missing, source: "env" });
|
||||
const fileResult = checkWorkspaceRootReachable({ workspaceRoot: missing, source: "file" });
|
||||
const defaultResult = checkWorkspaceRootReachable({ workspaceRoot: missing, source: "default" });
|
||||
expect(envResult.fixHint).toContain("OPENRIG_WORKSPACE_ROOT");
|
||||
expect(envResult.fixHint).not.toContain("workspace.root in config.json");
|
||||
expect(fileResult.fixHint).toContain("workspace.root in config.json");
|
||||
expect(fileResult.fixHint).not.toContain("OPENRIG_WORKSPACE_ROOT");
|
||||
expect(defaultResult.fixHint).toContain("rig config init-workspace");
|
||||
});
|
||||
|
||||
// Discriminator-flip: non-ENOENT IO error must still fail with a
|
||||
// useful message + evidence.errorCode (e.g., EACCES on permission
|
||||
// denied). Simulate by chmod 000 on a created subdir.
|
||||
it("returns fail with errorCode evidence on non-ENOENT stat error", () => {
|
||||
// Skip on platforms where chmod doesn't restrict stat (e.g. Windows,
|
||||
// root user). The check is the discriminator-flip we want, but it
|
||||
// must be robust under varied test environments.
|
||||
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
||||
const lockedParent = path.join(dir, "locked-parent");
|
||||
fs.mkdirSync(lockedParent);
|
||||
const inside = path.join(lockedParent, "inside");
|
||||
fs.mkdirSync(inside);
|
||||
try {
|
||||
fs.chmodSync(lockedParent, 0o000);
|
||||
const result = checkWorkspaceRootReachable({ workspaceRoot: inside, source: "env" });
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.evidence?.errorCode).toBeDefined();
|
||||
expect(result.evidence?.errorCode).not.toBe("unknown");
|
||||
} finally {
|
||||
fs.chmodSync(lockedParent, 0o700);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user