diff --git a/packages/extension/package.json b/packages/extension/package.json index 993a5d15..400d28b9 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -99,6 +99,11 @@ "type": "string", "default": "", "description": "Runs root the inspector watches. Empty = ~/.amico/runs/default (must match where amico-run writes)." + }, + "amicode.labToml": { + "type": "string", + "default": "", + "description": "Path to the lab.toml hardware profile, validated on load. Empty = ~/.amico/lab.toml (where install.sh writes the starter)." } } } diff --git a/packages/extension/scripts/lab.toml.example b/packages/extension/scripts/lab.toml.example index df482a22..da264532 100644 --- a/packages/extension/scripts/lab.toml.example +++ b/packages/extension/scripts/lab.toml.example @@ -1,5 +1,9 @@ # Amicode starter lab config (one lab). Representative single-transmon params; -# the agent / template may read these. (lab.toml schema lands in Phase 2.) +# the agent / template reads these. Validated on extension load against the shared +# @amicode/schema lab schema — a malformed value surfaces a field-precise error +# rather than silently solving against the wrong hardware. +schema_version = "1" + [lab] name = "demo-lab" diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6cf97b10..a10f0c71 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -9,6 +9,7 @@ import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "./opencode_config"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; +import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; import { RunsRootWatcher } from "./file_watcher"; import { stageDemoRun } from "./demo_replay"; @@ -50,6 +51,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { watcher.start(); ctx.subscriptions.push(watcher); + // Validate lab.toml on load (0.1b / S17). A malformed hardware profile would + // otherwise silently solve against the wrong hardware or fail opaquely mid-solve. + // Field-precise: the error names the offending key + path. Non-fatal — the rest + // of the extension still activates; a partner can fix the config and reload. + const labPath = resolveLabTomlPath(vscode.workspace.getConfiguration("amicode").get("labToml", "")); + const lab = checkLabToml(labPath); + if (lab.state === "invalid") { + runsChannel.appendLine(`[lab] ${lab.path} is INVALID:`); + for (const e of lab.errors) runsChannel.appendLine(` ${e}`); + void vscode.window.showErrorMessage( + `Amicode: lab.toml is invalid — ${lab.errors[0]}` + + (lab.errors.length > 1 ? ` (+${lab.errors.length - 1} more; see "Amicode — runs" output)` : ""), + ); + } else if (lab.state === "valid") { + runsChannel.appendLine(`[lab] validated ${lab.path}`); + } + // 3. opencode project bootstrap const amicoRunBinDir = resolveAmicoRunBinDir(ctx.extensionPath); const opencodeProject = prepareOpencodeProject({ diff --git a/packages/extension/src/lab_config.ts b/packages/extension/src/lab_config.ts new file mode 100644 index 00000000..efb3efbf --- /dev/null +++ b/packages/extension/src/lab_config.ts @@ -0,0 +1,38 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { validateFile, type Validation } from "@amicode/schema"; + +// ============================================================================ +// lab.toml load-time validation (0.1b / S17). A partner lab's lab.toml is the +// only place hardware params enter a solve; validating it on extension load +// turns a malformed/mistyped config into a field-precise error (offending key + +// path) instead of a silent solve against the wrong hardware or an opaque +// mid-solve failure. The schema itself lives in the shared @amicode/schema +// package (0.1a) — this is only the resolve-path + load + surface seam. +// ============================================================================ + +/** Resolve the lab.toml the extension validates on load. Empty config → + * ~/.amico/lab.toml (where install.sh writes the starter). A leading ~ is + * expanded, mirroring resolveRunsRoot / resolveJuliaProject. */ +export function resolveLabTomlPath(configValue: string): string { + const v = (configValue ?? "").trim(); + if (v === "") return path.join(os.homedir(), ".amico", "lab.toml"); + if (v === "~") return os.homedir(); + if (v.startsWith("~/")) return path.join(os.homedir(), v.slice(2)); + return v; +} + +export type LabCheck = + | { state: "absent"; path: string } + | { state: "valid"; path: string } + | { state: "invalid"; path: string; errors: string[] }; + +/** Validate the lab.toml at `labPath` against the shared lab schema. A missing + * file is `absent` (not an error — a lab may be provisioned later); a present + * file is validated field-precise via the single @amicode/schema validator. */ +export function checkLabToml(labPath: string): LabCheck { + if (!fs.existsSync(labPath)) return { state: "absent", path: labPath }; + const v: Validation = validateFile(labPath, "lab"); + return v.ok ? { state: "valid", path: labPath } : { state: "invalid", path: labPath, errors: v.errors }; +} diff --git a/packages/extension/test/lab_config.test.ts b/packages/extension/test/lab_config.test.ts new file mode 100644 index 00000000..176d4f47 --- /dev/null +++ b/packages/extension/test/lab_config.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { validateFile } from "@amicode/schema"; +import { resolveLabTomlPath, checkLabToml } from "../src/lab_config"; + +const VALID = + 'schema_version = "1"\n[lab]\nname = "demo-lab"\n' + + "[transmon]\nomega_GHz = 5.0\ndelta_GHz = 0.2\nlevels = 3\ndrive_max_GHz = 0.2\n"; + +function writeLab(content: string): string { + const p = join(mkdtempSync(join(tmpdir(), "lab-")), "lab.toml"); + writeFileSync(p, content); + return p; +} +function errs(content: string): string[] { + const c = checkLabToml(writeLab(content)); + return c.state === "invalid" ? c.errors : []; +} +const has = (es: string[], needle: string) => es.some((e) => e.includes(needle)); + +describe("resolveLabTomlPath", () => { + it("defaults to ~/.amico/lab.toml", () => + expect(resolveLabTomlPath("")).toBe(join(homedir(), ".amico", "lab.toml"))); + it("expands a leading ~", () => { + expect(resolveLabTomlPath("~")).toBe(homedir()); + expect(resolveLabTomlPath("~/x/lab.toml")).toBe(join(homedir(), "x", "lab.toml")); + }); + it("uses an explicit path, trimmed", () => + expect(resolveLabTomlPath(" /a/lab.toml ")).toBe("/a/lab.toml")); +}); + +describe("checkLabToml", () => { + it("a missing file is `absent`, not an error (a lab may be provisioned later)", () => + expect(checkLabToml(join(tmpdir(), "definitely-absent-lab-dir", "lab.toml")).state).toBe("absent")); + it("a conforming lab.toml is `valid`", () => + expect(checkLabToml(writeLab(VALID)).state).toBe("valid")); + + // field-precise negative matrix (#16 ACs / S17) + it("missing required key → names the absent key + path", () => + expect(has(errs(VALID.replace("drive_max_GHz = 0.2\n", "")), 'missing required key "drive_max_GHz"')).toBe(true)); + it("wrong type → names the offending key", () => + expect(has(errs(VALID.replace("levels = 3", 'levels = "three"')), "/transmon/levels: must be integer")).toBe(true)); + it("out-of-range → names the offending key (distinct from wrong-type)", () => + expect(has(errs(VALID.replace("levels = 3", "levels = 99")), "/transmon/levels: must be <= 10")).toBe(true)); + it("unknown / misspelled key → names that key", () => + expect(has(errs(VALID + "rogue = 1\n"), 'unknown key "rogue"')).toBe(true)); + it("absent schema_version → field-precise required error", () => + expect(has(errs(VALID.replace('schema_version = "1"\n', "")), 'missing required key "schema_version"')).toBe(true)); + it("unrecognized schema_version → version-specific error", () => + expect(has(errs(VALID.replace('schema_version = "1"', 'schema_version = "9"')), "/schema_version: unrecognized version")).toBe(true)); + + it("hardware range bounds are field-precise (#29: omega/drive_max/delta) + name minLength", () => { + expect(has(errs(VALID.replace("omega_GHz = 5.0", "omega_GHz = 999")), "/transmon/omega_GHz: must be <= 100")).toBe(true); + expect(has(errs(VALID.replace("drive_max_GHz = 0.2", "drive_max_GHz = 50")), "/transmon/drive_max_GHz: must be <= 10")).toBe(true); + expect(has(errs(VALID.replace("delta_GHz = 0.2", "delta_GHz = 25")), "/transmon/delta_GHz: must be <= 2")).toBe(true); // garbage anharmonicity + expect(has(errs(VALID.replace('name = "demo-lab"', 'name = ""')), "/lab/name")).toBe(true); // minLength + }); + + it("parity over a corpus: checkLabToml === @amicode/schema.validateFile on every input (no second path) [#16]", () => { + const corpus = [ + VALID, // valid + VALID.replace("drive_max_GHz = 0.2\n", ""), // missing required + VALID.replace("levels = 3", 'levels = "three"'), // wrong type + VALID.replace("levels = 3", "levels = 99"), // out of range + VALID.replace("delta_GHz = 0.2", "delta_GHz = 25"), // out of range (delta) + VALID + "rogue = 1\n", // unknown key + VALID.replace('schema_version = "1"\n', ""), // absent version + VALID.replace('schema_version = "1"', 'schema_version = "9"'), // unrecognized version + ]; + for (const content of corpus) { + const p = writeLab(content); + const c = checkLabToml(p); + const direct = validateFile(p, "lab"); + expect(c.state === "valid" ? [] : (c as { errors: string[] }).errors).toEqual(direct.errors); + } + }); +}); + +describe("valid lab profiles conform (demo + Schuster)", () => { + it("scripts/lab.toml.example (the shipped starter) validates clean", () => + expect(validateFile(join(__dirname, "..", "scripts", "lab.toml.example"), "lab").errors).toEqual([])); + it("a Schuster-profile lab (negative-convention δ, 4 levels) validates clean", () => { + // Distinct from demo-lab: negative anharmonicity convention + a 4-level model, + // exercising the schema's range tolerance on a second real-shaped profile. + const schuster = 'schema_version = "1"\n[lab]\nname = "schuster-transmon"\n' + + "[transmon]\nomega_GHz = 4.8\ndelta_GHz = -0.33\nlevels = 4\ndrive_max_GHz = 0.1\n"; + expect(checkLabToml(writeLab(schuster)).state).toBe("valid"); + }); +});