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
5 changes: 5 additions & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/extension/scripts/lab.toml.example
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
18 changes: 18 additions & 0 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -50,6 +51,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
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<string>("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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] this showErrorMessage + output-channel path is the one surface the unit tests can't reach — no VS Code host in CI, so checkLabToml is covered but the toast/channel wiring isn't. I'll verify it by hand on the packaged VSIX (bad ~/.amico/lab.toml → reload → toast + "Amicode — runs" list). Non-blocking — but it's the seam Phase 1's UI work compounds on, so it wants a host-level test approach soon.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the toast/channel wiring stays unit-untestable (no VS Code host in CI). Noted as a Phase-1 prerequisite (a host-level/integration-test approach before the inspector/catalog UI compounds on this seam); not adding host-test infra in this slice. checkLabToml itself is covered.

`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({
Expand Down
38 changes: 38 additions & 0 deletions packages/extension/src/lab_config.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
91 changes: 91 additions & 0 deletions packages/extension/test/lab_config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading