Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/amico-run/schemas/doctor-report.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"$id": "https://harmoniqs.github.io/amicode/schemas/doctor-report.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": true,
"description": "The `amico doctor --json` machine contract (doctor v2, #525): the surface inventory the settings panel and watchdog consume. Canonical form: deep-sorted keys, 2-space indent, trailing newline.",
"properties": {
"surfaces": {
"additionalProperties": false,
"items": {
"additionalProperties": true,
"properties": {
"evidence": {
"items": {
"minLength": 1,
"type": "string"
},
"minItems": 1,
"type": "array"
},
"source_version": {
"type": [
"string",
"null"
]
},
"surface": {
"enum": [
"server-binary",
"extension",
"vendored-binary",
"staged-skills",
"agent-cards-global",
"agent-cards-staging"
]
},
"verdict": {
"enum": [
"current",
"stale",
"integrity-failure",
"unknown"
]
},
"version": {
"type": [
"string",
"null"
]
}
},
"required": [
"surface",
"version",
"verdict",
"evidence"
],
"type": "object"
},
"minItems": 6,
"type": "array"
}
},
"required": [
"surfaces"
],
"title": "doctor --json report",
"type": "object"
}
14 changes: 9 additions & 5 deletions packages/amico-run/src/amico.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function usage(): string {
["resolve --platform <p> --kind <k> --size <n>", "tier resolution → JSON (amico-run subcommand)"],
["sandbox <workspace-dir> --packages A,B,…", "generate a per-problem Julia env (amico-run subcommand)"],
["estimate <script.jl> | --spec <s.json>", "v0 size estimate → JSON suggestion signal, never a route (Δ10 #34)"],
["doctor", "validate the studio binding — paths, mounts, drift (#402)"],
["doctor [--json] [--root-…]", "studio binding + fleet surface inventory — six records, verdicts (#402, #525)"],
[
"pasqal devices | submit --device <d> --artifact <p> [--confirm <h>]",
"Pasqal device path — list/select + gated submit (#160)",
Expand Down Expand Up @@ -77,11 +77,15 @@ export async function main(argv: string[]): Promise<number> {
return launch(["estimate", ...rest]);

case "doctor": {
// The studio binding's health check (#402): the manifest's world —
// paths exist, mounts readable, exactly one rw personal, drift flagged.
// The studio binding's health check (#402) + the fleet surface inventory
// (#525): six records, each with version + verdict + evidence. `--json`
// emits the machine contract (canonical JSON, surfaces only); the human
// table derives from the same records. Root flags make every probe
// injectable — the fixture suite never touches the real fleet surfaces.
const { doctorReport } = await import("./doctor.js");
const report = await doctorReport();
console.log(report.rendered);
const report = await doctorReport(rest);
if (report.json !== null) console.log(report.json);
else console.log(report.rendered);
return report.exit;
}

Expand Down
88 changes: 81 additions & 7 deletions packages/amico-run/src/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
// doctor.ts — `amico doctor` (#402): validate the studio BINDING — the world,
// not just the schema. The schema checks structure; this checks existence,
// mount health, the exactly-one-rw-personal rule, and flags the KNOWN legacy
// drift as warnings (the relocation slices' to-do list — drift is not breakage).
// doctor.ts — `amico doctor`: v1 (#402) validates the studio BINDING — the
// world, not just the schema; v2 (#525) adds the FLEET SURFACE INVENTORY
// (surfaces.ts): five physical surfaces, six records, verdicts by version
// string or content digest (never mtime). v1's checks are preserved verbatim
// — the report GAINS a surfaces section; nothing v1 consumers rely on breaks.
import type { StudioPaths } from "@amicode/schema";
import { studioPathsOrLegacy } from "@amicode/schema";
import {
surfaceInventory,
renderSurfacesTable,
canonicalJson,
type SurfaceContext,
type SurfacesReport,
} from "./surfaces.js";

export interface Diagnosis {
ok: boolean; // no ERRORS (warnings don't fail the doctor)
Expand Down Expand Up @@ -71,8 +79,62 @@ export async function diagnoseStudio(paths: StudioPaths, exists: Exists): Promis
return { ok: errors.length === 0, errors, warnings, checks };
}

/** The CLI entry: diagnose THIS machine's binding and print the table. */
export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered: string; exit: number }> {
/** The CLI entry: diagnose THIS machine's binding and print the table.
* v2 (#525): accepts the doctor flags — `--json` (the machine contract) and
* the injectable roots (`--root-vscext`, `--root-config`, `--root-server`,
* `--root-repo-amicode`, `--root-repo-fork`, `--root-staging`, plus
* `--running-binary <path>` to stub the running-process evidence). With no
* args it behaves exactly as v1 plus the appended surfaces table. */
export interface DoctorArgs {
json: boolean;
roots: Partial<SurfaceContext>;
runningBinary: string | null;
}

export function parseDoctorArgs(argv: string[]): { ok: true; args: DoctorArgs } | { ok: false; message: string } {
const args: DoctorArgs = { json: false, roots: {}, runningBinary: null };
const rootFlags: Record<string, keyof SurfaceContext> = {
"--root-vscext": "rootVscext",
"--root-config": "rootConfig",
"--root-server": "rootServer",
"--root-repo-amicode": "rootRepoAmicode",
"--root-repo-fork": "rootRepoFork",
"--root-staging": "rootStaging",
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--json") {
args.json = true;
} else if (a === "--running-binary") {
const v = argv[++i];
if (!v) return { ok: false, message: "--running-binary requires a path" };
args.runningBinary = v;
} else if (rootFlags[a]) {
const v = argv[++i];
if (!v) return { ok: false, message: `${a} requires a path` };
(args.roots as Record<string, unknown>)[rootFlags[a]] = v;
} else {
return { ok: false, message: `unknown doctor flag: ${a}` };
}
}
if (args.runningBinary !== null) args.roots.runningBinary = args.runningBinary;
return { ok: true, args };
}

export async function doctorReport(
argv: string[] = [],
): Promise<{ diagnosis: Diagnosis; surfaces: SurfacesReport; rendered: string; json: string | null; exit: number }> {
const parsed = parseDoctorArgs(argv);
if (!parsed.ok) {
const message = `doctor: ${parsed.message}`;
return {
diagnosis: { ok: false, errors: [message], warnings: [], checks: [] },
surfaces: { surfaces: [] },
rendered: message,
json: null,
exit: 64,
};
}
let paths = studioPathsOrLegacy();
try {
paths = studioPathsOrLegacy();
Expand All @@ -89,6 +151,7 @@ export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered:
}
};
const diagnosis = await diagnoseStudio(paths, stat);
const surfaces = await surfaceInventory(parsed.args.roots);
const width = Math.max(...diagnosis.checks.map((c) => c.name.length));
const lines = diagnosis.checks.map((c) => {
const mark = c.status === "ok" ? "ok " : c.status === "warn" ? "warn" : "ERR ";
Expand All @@ -97,5 +160,16 @@ export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered:
const summary = diagnosis.ok
? `studio binding healthy${diagnosis.warnings.length ? ` (${diagnosis.warnings.length} warning${diagnosis.warnings.length > 1 ? "s" : ""})` : ""}`
: `studio binding has ${diagnosis.errors.length} error${diagnosis.errors.length > 1 ? "s" : ""}`;
return { diagnosis, rendered: `${summary}\n${lines.join("\n")}`, exit: diagnosis.ok ? 0 : 1 };
const rendered = `${summary}\n${lines.join("\n")}\n\n${renderSurfacesTable(surfaces.surfaces)}`;
// the machine contract (panel + watchdog): canonical JSON, surfaces only —
// deep-sorted keys, 2-space indent, trailing newline (the vault-card form)
const json = parsed.args.json ? canonicalJson({ surfaces: surfaces.surfaces }) : null;
return {
diagnosis,
surfaces,
rendered,
json,
// surfaces never fail the report (they degrade individually) — exit stays v1's
exit: diagnosis.ok ? 0 : 1,
};
}
118 changes: 118 additions & 0 deletions packages/amico-run/src/doctor_schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// doctor_schema.ts — validate `amico doctor --json` reports against the
// COMMITTED JSON Schema (schemas/doctor-report.schema.json, #525). A minimal
// JSON-Schema-subset engine covering exactly the keywords that schema uses
// (type incl. type arrays, enum, required, properties, items, minItems,
// minLength, additionalProperties) — the same zero-dependency approach as
// the extension's vault_card_validator. Errors name the violated path.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

export type SchemaError = { path: string; message: string };
export type SchemaValidation = { ok: boolean; errors: SchemaError[] };

type JsonSchema = Record<string, unknown>;

/** Load the committed doctor-report schema. Resolved from this module's
* location (src/ → ../schemas/); when running from the esbuild bundle
* (dist/), the package root is one level up from there too. */
export function loadDoctorSchema(): JsonSchema {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(here, "..", "schemas", "doctor-report.schema.json"), // src/ or dist/
join(here, "schemas", "doctor-report.schema.json"), // package root
];
for (const p of candidates) {
try {
return JSON.parse(readFileSync(p, "utf8")) as JsonSchema;
} catch {
// try next candidate
}
}
throw new Error(`doctor-report.schema.json not found near ${here}`);
}

function checkType(value: unknown, type: string): boolean {
switch (type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && Number.isFinite(value);
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean":
return typeof value === "boolean";
case "array":
return Array.isArray(value);
case "object":
return typeof value === "object" && value !== null && !Array.isArray(value);
case "null":
return value === null;
default:
return true;
}
}

export function validateAgainstSchema(value: unknown, schema: JsonSchema, at = "$"): SchemaError[] {
const errors: SchemaError[] = [];
const push = (p: string, message: string) => errors.push({ path: p, message });

if (schema.const !== undefined && value !== schema.const) {
push(at, `must equal ${JSON.stringify(schema.const)}`);
}
if (Array.isArray(schema.enum) && !(schema.enum as unknown[]).includes(value)) {
push(at, `must be one of ${(schema.enum as unknown[]).map((v) => JSON.stringify(v)).join(", ")}`);
}

const type = schema.type;
if (typeof type === "string" && !checkType(value, type)) {
push(at, `must be ${type}`);
return errors;
}
if (Array.isArray(type) && !(type as string[]).some((t) => checkType(value, t))) {
push(at, `must be one of ${(type as string[]).join(" | ")}`);
return errors;
}

if (Array.isArray(value)) {
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
push(at, `must have at least ${schema.minItems} items (found ${value.length})`);
}
if (schema.items && typeof schema.items === "object") {
value.forEach((v, i) => errors.push(...validateAgainstSchema(v, schema.items as JsonSchema, `${at}[${i}]`)));
}
return errors;
}

if (value !== null && typeof value === "object") {
const obj = value as Record<string, unknown>;
if (Array.isArray(schema.required)) {
for (const key of schema.required as string[]) {
if (!(key in obj)) push(at, `missing required field "${key}"`);
}
}
if (schema.properties && typeof schema.properties === "object") {
for (const [key, sub] of Object.entries(schema.properties as Record<string, JsonSchema>)) {
if (key in obj) errors.push(...validateAgainstSchema(obj[key], sub, `${at}.${key}`));
}
}
if (schema.additionalProperties === false && schema.properties && typeof schema.properties === "object") {
const allowed = new Set(Object.keys(schema.properties as Record<string, unknown>));
for (const key of Object.keys(obj)) {
if (!allowed.has(key)) push(`${at}.${key}`, `additional property not allowed here`);
}
}
return errors;
}

if (typeof value === "string" && typeof schema.minLength === "number" && value.length < schema.minLength) {
push(at, `must be at least ${schema.minLength} characters`);
}
return errors;
}

/** Validate a doctor --json report object against the committed schema. */
export function validateDoctorReport(value: unknown, schema: JsonSchema = loadDoctorSchema()): SchemaValidation {
const errors = validateAgainstSchema(value, schema);
return { ok: errors.length === 0, errors };
}
Loading
Loading