diff --git a/README.md b/README.md index e7ee21a0..ac06307d 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,83 @@ +
+ + + + Amicode + + # Amicode -A VS Code extension for agentic quantum-control pulse optimization — natural-language chat → LLM-authored Julia solve (Piccolo/Piccolissimo) → live run inspector → per-lab pulse catalog. Deployed onto partner-lab machines; a vendored [opencode](https://github.com/sst/opencode) binary provides the chat/LLM harness. +### Quantum optimal control, driven by conversation. + +Describe the gate you want in plain language. Amicode designs the pulse, runs the +solve, and shows you the result — without leaving your editor. + +A VS Code extension · built on [Piccolo.jl](https://github.com/harmoniqs/Piccolo.jl) · chat harness vendored from [opencode](https://github.com/sst/opencode) + +
+ +--- + +Amicode turns a natural-language description of a control problem into an +LLM-authored Julia optimization, runs it, and streams the result back into native +editor panels. The physics, the solver idioms, and your lab's accumulated +knowledge all ride along as context — so the script it writes is correct by +construction, not by luck. + +## What it does -> ## ⚠️ Source of truth = the design docs, not this code -> -> This repository's **authoritative design lives in the `harmoniqs/amico` vault**, in this order of authority (architecture → context → diagrams → interfaces → planning → plans → specs). When code and docs disagree, **the docs win.** Scope, architecture, and interface changes happen in the vault docs first, then flow to code and issues. +**Conversational solves.** Ask for a gate or a state preparation; Amicode writes a +self-contained Piccolo script, runs the Ipopt solve, and captures the result. No +boilerplate, no parameter-guessing. -| # | Authority | Location in `harmoniqs/amico` | -|---|-----------|------------------------------| -| 1 | **Architecture + diagrams + interfaces** (container, solve lifecycle, chat→solve→inspector sequence, run-dir contract, provisioning flow, module decomposition §5, dependency graph §7) | [`vault/specs/spec-20260529-amicode-architecture.md`](https://github.com/harmoniqs/amico/blob/main/vault/specs/spec-20260529-amicode-architecture.md) | -| 2 | **Context / requirements** (problem, solution, user stories S1–S38 with acceptance criteria, stable interface contracts, risks) | [`vault/specs/spec-20260529-amicode-prd.md`](https://github.com/harmoniqs/amico/blob/main/vault/specs/spec-20260529-amicode-prd.md) | -| 3 | **Decisions** (decision log D1–D10; 157 resolved/deferred open questions) | [`vault/specs/spec-20260529-amicode-open-questions.md`](https://github.com/harmoniqs/amico/blob/main/vault/specs/spec-20260529-amicode-open-questions.md) | -| 4 | **Project-management plan** (phases β→4, tasks β.1–4.2, per-phase Definition of Done, dependency-ordered parallel-engineer schedule, ~56-pd estimate) | [`vault/plans/plan-20260603-124231-amicode-phased-build.md`](https://github.com/harmoniqs/amico/blob/main/vault/plans/plan-20260603-124231-amicode-phased-build.md) | -| 5 | **Review / QA** (60-agent swarm review; consolidated findings) | [`vault/reviews/review-20260603-amicode-plan-swarm.md`](https://github.com/harmoniqs/amico/blob/main/vault/reviews/review-20260603-amicode-plan-swarm.md) | +**Physics that ships with the tool.** Platform references for neutral-atom Rydberg, +transmon, fluxonium, trapped-ion, and bosonic systems load on demand — the +Hamiltonians, drive conventions, and construction patterns are inlined into each +script so it stands on its own. -Access requires the `harmoniqs/amico` vault repo. (We chose reference-only over copying the docs in, to keep a single source of truth and avoid drift.) +**Your knowledge, mounted.** Amicode reads your **Armonia** — the stack of vaults +you mount (personal, team, public). Notes, specs, experiment history, and your +pulse catalog become first-class context the assistant plans against. -## Status of the code in this repo +**A live run inspector.** Watch a solve converge in real time: overlaid pulse +plots, fidelity and constraint-violation traces, per-run metrics. Every run is +captured and revisitable. -`src/` is the **v2 spike** — a working chat→solve→inspector prototype (CLI-direct, after the pivot away from MCP + callback-HTTP). It is a **starting point, not the authority.** Per the design audit (vault decision log D9/D10), the following are explicitly in flux or superseded — do not treat them as canonical: +**A pulse catalog.** A versioned, warm-startable library of your best pulses — +retrieve the incumbent for a `(platform, gate)`, warm-start from it, and promote a +new best when you beat it. -- **`bin/amico-run`** — being re-architected into the **D9 thin orchestrator** (spawns `julia diff --git a/packages/extension/src/device_inspector.ts b/packages/extension/src/device_inspector.ts new file mode 100644 index 00000000..1840419e --- /dev/null +++ b/packages/extension/src/device_inspector.ts @@ -0,0 +1,164 @@ +import * as vscode from "vscode"; +import { inspectorResourceRootDirs } from "./opencode_paths"; +import type { DeviceStatus, NextAction } from "./device_status"; + +// ============================================================================ +// Device Inspector — a panel webview showing a device-focused dashboard (Spec A +// §3): drive lines online, per-qubit rollup, latest T1/T2/fidelities with +// staleness, calibration params, and the ranked action list (qilc items locked +// when unentitled). +// +// SIBLING to Raghav's Run Inspector (run_inspector.ts) — NOT a fork SolidJS +// surface, NOT an edit to run_inspector.ts. Same idioms: a WebviewViewProvider + +// registerDeviceInspector(ctx), a typed DEVICE-keyed postMessage protocol, and a +// per-device replay-on-reopen buffer so a reopened panel rebuilds every pane. +// +// The pure projection/action logic lives in device_status.ts (Task 4) over the +// DeviceRegistry state (Task 3) + the qick_client queue (Task 5); this class is +// only the vscode plumbing + the webview shell (CSP + nonce; theme via VS Code +// CSS vars). The message payloads are DeviceStatus / NextAction[] verbatim. +// ============================================================================ + +let DEVICE_INSPECTOR: DeviceInspectorView | undefined; + +/** Everything replayable about one device's pane — kept current whether or not + * the webview exists, so resolveWebviewView can rebuild the pane on reopen. */ +interface DeviceBuffer { + device: string; + status?: DeviceStatus; + actions?: NextAction[]; +} + +class DeviceInspectorView implements vscode.WebviewViewProvider { + private view?: vscode.WebviewView; + private readonly panes = new Map(); + private activeDevice?: string; + + constructor(private readonly ctx: vscode.ExtensionContext) {} + + private paneFor(device: string): DeviceBuffer { + let p = this.panes.get(device); + if (!p) { + p = { device }; + this.panes.set(device, p); + } + return p; + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { + enableScripts: true, + // Extension assets only — the view renders from message data. + localResourceRoots: inspectorResourceRootDirs(this.ctx.extensionUri.fsPath).map((d) => vscode.Uri.file(d)), + }; + view.webview.html = this.renderHtml(view.webview); + + const msgSub = view.webview.onDidReceiveMessage((msg: { type?: string; action?: string }) => { + if (msg?.type !== "control") return; + if (msg.action === "refresh") void vscode.commands.executeCommand("amicode.device.refresh"); + }); + view.onDidDispose(() => { + this.view = undefined; + msgSub.dispose(); + }); + + // Replay EVERY device pane from its buffer (status then actions), then pick + // the visible pane last (activate is idempotent + last, so it wins). + for (const p of this.panes.values()) this.replayPane(view, p); + if (this.activeDevice) view.webview.postMessage({ type: "activate", device: this.activeDevice }); + } + + private replayPane(view: vscode.WebviewView, p: DeviceBuffer): void { + if (p.status) view.webview.postMessage({ type: "device-status", device: p.device, status: p.status }); + if (p.actions) view.webview.postMessage({ type: "actions", device: p.device, actions: p.actions }); + } + + // -------- public surface used by the poll loop (all device-keyed) -------- + + postDeviceStatus(device: string, status: DeviceStatus): void { + this.paneFor(device).status = status; + if (this.view) this.view.webview.postMessage({ type: "device-status", device, status }); + } + + postActions(device: string, actions: NextAction[]): void { + this.paneFor(device).actions = actions; + if (this.view) this.view.webview.postMessage({ type: "actions", device, actions }); + } + + /** Make `device` the visible pane. Buffered until the webview materializes. */ + activate(device: string): void { + this.paneFor(device); + this.activeDevice = device; + if (this.view) this.view.webview.postMessage({ type: "activate", device }); + } + + reveal(): void { + void revealDeviceInspector(); + } + + private renderHtml(webview: vscode.Webview): string { + const uri = (...parts: string[]) => webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, ...parts)); + const nonce = newNonce(); + // Same security shell as the Run Inspector: CSP + nonce, brand/layout + // stylesheets, and the TS-composed view bundle. Theme rides VS Code CSS vars + // under webview.cspSource (brand.css) — NOT the chat iframe's ?colorScheme=. + // style-src keeps 'unsafe-inline' for runtime element .style / static attrs. + return /* html */ ` + + + + + + + + + + +`; + } +} + +function newNonce(): string { + let s = ""; + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) s += chars[Math.floor(Math.random() * chars.length)]; + return s; +} + +/** Context key gating the Device Inspector view (package.json `when`). It starts + * false on every window load and is NOT persisted, so VS Code never restores + * the panel on its own — the view only materializes once a reveal path flips + * it. That is what makes the device inspector strictly button-press-only. + * Mirrors the Run Inspector's INSPECTOR_CONTEXT_KEY (run_inspector.ts). */ +export const DEVICE_INSPECTOR_CONTEXT_KEY = "amicode.deviceInspectorRevealed"; + +/** The one way the Device Inspector is ever shown: flip the gate context key on + * (so the gated view is allowed to appear), then focus it. Every reveal path — + * the open command and the (unused) reveal() shim — funnels through here, so the + * panel is only ever shown deliberately and never by VS Code's own view-state + * restoration. Structurally identical to revealInspector (run_inspector.ts). */ +export async function revealDeviceInspector(): Promise { + await vscode.commands.executeCommand("setContext", DEVICE_INSPECTOR_CONTEXT_KEY, true); + await vscode.commands.executeCommand("amicode.deviceInspector.focus"); +} + +export function registerDeviceInspector(ctx: vscode.ExtensionContext): DeviceInspectorView { + DEVICE_INSPECTOR = new DeviceInspectorView(ctx); + ctx.subscriptions.push( + vscode.window.registerWebviewViewProvider("amicode.deviceInspector", DEVICE_INSPECTOR, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + return DEVICE_INSPECTOR; +} + +export function getDeviceInspector(): DeviceInspectorView | undefined { + return DEVICE_INSPECTOR; +} + +export type { DeviceInspectorView }; diff --git a/packages/extension/src/device_inspector_webview.ts b/packages/extension/src/device_inspector_webview.ts new file mode 100644 index 00000000..57155e0a --- /dev/null +++ b/packages/extension/src/device_inspector_webview.ts @@ -0,0 +1,20 @@ +// Device Inspector webview entry — mounts the TS-composed view (media/ui/views/ +// device_inspector.ts). No static markup: the view builds its own DOM from +// atoms/components; brand.css + layout.css are linked by the shell +// (device_inspector.ts). Mirrors inspector_webview.ts. + +import { applyBrandAccent } from "../media/ui/brand_accent"; +import { createDeviceInspectorView } from "../media/ui/views/device_inspector"; + +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + +declare function acquireVsCodeApi(): { + postMessage(msg: unknown): void; +}; + +const vscodeApi = acquireVsCodeApi(); +const view = createDeviceInspectorView((msg) => vscodeApi.postMessage(msg)); +document.body.append(view.el); +window.addEventListener("message", (e) => view.onMessage(e.data)); + +vscodeApi.postMessage({ type: "log", text: "device_inspector_webview booted" }); diff --git a/packages/extension/src/device_registry.ts b/packages/extension/src/device_registry.ts new file mode 100644 index 00000000..1be0027e --- /dev/null +++ b/packages/extension/src/device_registry.ts @@ -0,0 +1,150 @@ +import type { NodeState, NodeStatus } from "./calibration_graph"; + +// ============================================================================ +// Device calibration registry — Spec A §4.2 + corrections C6/C7. +// +// On disk (rolling OPS state, NOT vault — §4.2): `state.json` (a JSON map, the +// LATEST value per node) + `history.jsonl` (the append log of every calibration +// result). Node DEFINITIONS + thresholds are durable knowledge and live in the +// vault `graph.toml` (calibration_graph.ts); latest values + their history are +// churning ops state and live here. +// +// This module is PURE + in-memory (the run_registry.ts precedent): parse/ +// serialize helpers + an idempotent registry keyed by node. The FILE I/O lives +// in the poll loop / a manager (the real run_registry ↔ runs_manager split); +// per §2.4 the Amicode TS queue client — never the QILC loop — writes state.json. +// ============================================================================ + +/** A finished calibration job's result event → one `history.jsonl` line and the + * new latest `state.json` entry for its node. */ +export interface CalibrationEvent { + node: string; + value?: Record; + ts: string; // ISO8601 + status: NodeStatus; + job_id: string; + config_version?: string; +} + +const NODE_STATUSES: NodeStatus[] = ["calibrated", "stale", "suspect", "failed", "uncharacterized"]; + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +function toNodeState(o: Record): NodeState { + const st: NodeState = {}; + if (isRecord(o.value)) st.value = o.value; + if (typeof o.ts === "string") st.ts = o.ts; + if (typeof o.status === "string" && (NODE_STATUSES as string[]).includes(o.status)) st.status = o.status as NodeStatus; + if (typeof o.job_id === "string") st.job_id = o.job_id; + if (typeof o.config_version === "string") st.config_version = o.config_version; + return st; +} + +/** Parse a `state.json` body → { node → NodeState }. Never throws: junk, a + * non-object, or a missing file all degrade to {}. */ +export function parseStateJson(text: string | unknown): Record { + let parsed: unknown; + if (typeof text === "string") { + try { + parsed = JSON.parse(text); + } catch { + return {}; + } + } else { + parsed = text; + } + if (!isRecord(parsed)) return {}; + const out: Record = {}; + for (const [node, raw] of Object.entries(parsed)) if (isRecord(raw)) out[node] = toNodeState(raw); + return out; +} + +/** Serialize a node-state map → deterministic `state.json` text (sorted keys so + * a replay produces a byte-identical file — §6 crit 6). */ +export function serializeStateJson(state: Record): string { + const sorted: Record = {}; + for (const k of Object.keys(state).sort()) sorted[k] = state[k]; + return JSON.stringify(sorted, null, 2); +} + +/** Serialize one calibration event → a single `history.jsonl` line. */ +export function historyLine(ev: CalibrationEvent): string { + return JSON.stringify(ev); +} + +/** Parse one `history.jsonl` line → CalibrationEvent, or undefined. Never throws + * (a blank/torn final line heals on the next drain — the run_registry.ts rule). */ +export function parseHistoryLine(line: string): CalibrationEvent | undefined { + if (!line || !line.trim()) return undefined; + let o: unknown; + try { + o = JSON.parse(line); + } catch { + return undefined; + } + if (!isRecord(o)) return undefined; + if (typeof o.node !== "string" || typeof o.job_id !== "string" || typeof o.ts !== "string") return undefined; + const status = typeof o.status === "string" && (NODE_STATUSES as string[]).includes(o.status) ? (o.status as NodeStatus) : "calibrated"; + return { + node: o.node, + job_id: o.job_id, + ts: o.ts, + status, + value: isRecord(o.value) ? o.value : undefined, + config_version: typeof o.config_version === "string" ? o.config_version : undefined, + }; +} + +/** In-memory calibration state, keyed by node. Idempotent by job_id (§6 crit 6): + * replaying a finished-job event is a no-op. */ +export class DeviceRegistry { + private readonly map = new Map(); + /** Applied job_ids — the idempotency key (§6 crit 6, "keyed on job_id"). */ + private readonly seen = new Set(); + + /** Hydrate from a parsed `state.json` map (poll loop reads the file, hands it + * here). The carried job_id primes the dedup set so a replay after reload is + * still a no-op. */ + constructor(initial?: Record) { + if (!initial) return; + for (const [node, st] of Object.entries(initial)) { + this.map.set(node, { ...st }); + if (st.job_id) this.seen.add(st.job_id); + } + } + + /** Apply a finished-job event. Returns true if state changed, false if this + * job_id was already applied (idempotent replay). */ + record(ev: CalibrationEvent): boolean { + if (ev.job_id && this.seen.has(ev.job_id)) return false; + if (ev.job_id) this.seen.add(ev.job_id); + this.map.set(ev.node, { + value: ev.value, + ts: ev.ts, + status: ev.status, + job_id: ev.job_id, + config_version: ev.config_version, + }); + return true; + } + + latest(node: string): NodeState | undefined { + const st = this.map.get(node); + return st ? { ...st } : undefined; + } + + /** The `Record` evaluate() consumes — a deep-ish copy so + * callers can't mutate registry state (the run_registry.ts all()-copies rule). */ + toStateMap(): Record { + const out: Record = {}; + for (const [node, st] of this.map) out[node] = { ...st }; + return out; + } + + /** Serialized `state.json` — byte-stable across replays (§6 crit 6). */ + snapshot(): string { + return serializeStateJson(this.toStateMap()); + } +} diff --git a/packages/extension/src/device_status.ts b/packages/extension/src/device_status.ts new file mode 100644 index 00000000..3037ad04 --- /dev/null +++ b/packages/extension/src/device_status.ts @@ -0,0 +1,194 @@ +import { + evaluate, + worseStatus, + type CalibrationGraph, + type NodeState, + type NodeStatus, + type NodeVerdict, + type RecommendedAction, +} from "./calibration_graph"; +import type { ConfigVersion, QueueView } from "./qick_job_server"; + +// ============================================================================ +// Device-status projection (Spec A §3.2) + queue-aware next-actions + the +// entitlement seam (§5). All PURE — no vscode, no fetch. The device view renders +// these objects; the §6 tests assert the objects directly (the Kobalte-SSR +// untestability lesson). Entitlement here is a RESOLVED boolean passed in — +// the authoritative package-resolution predicate lives in qick_client.ts +// (isQilcEntitled), reconciled per correction C10 so there is no duplicate. +// ============================================================================ + +export interface DriveLine { + id: string; + target?: string; + kind?: string; +} + +export interface DriveLineStatus extends DriveLine { + online: boolean; +} + +export interface QubitRollup { + qubit: string; + /** Worst-status-wins over that qubit's nodes (§3.2). uncharacterized if none. */ + status: NodeStatus; + nodeCount: number; +} + +export interface MetricReading { + value: number; + ts?: string; + ageSeconds: number; + status: NodeStatus; + node: string; +} + +/** The object the device view renders (§3.2) — derived on demand, never git-churned. */ +export interface DeviceStatus { + driveLines: DriveLineStatus[]; + qubits: QubitRollup[]; + /** Latest numeric produced params (T1/T2/fidelity/…) with age + status. Only + * present for MEASURED nodes — never a fabricated number (§3.2 honesty rule). */ + metrics: Record; + /** main_config values ∪ per-node produced params. */ + calibrationParams: Record; + /** The full ranked verdict set (§4.3) — feeds the view's node list. */ + nodes: NodeVerdict[]; +} + +export interface BuildDeviceStatusArgs { + graph: CalibrationGraph; + state: Record; + now: number; + driveLines: DriveLine[]; + /** Device qubits (from the card) — a qubit with no graph node rolls up + * uncharacterized (honesty). Defaults to the qubits named on graph nodes. */ + qubits?: string[]; + /** Channels the server's health() reports online. Absent/empty → all offline + * (a dead server degrades honestly, §6 crit 5 projection side). */ + onlineChannels?: string[]; + mainConfig?: ConfigVersion; +} + +export function buildDeviceStatus(args: BuildDeviceStatusArgs): DeviceStatus { + const { graph, state, now, driveLines, onlineChannels, mainConfig } = args; + const verdicts = evaluate(graph, state, now); + const byNode = new Map(verdicts.map((v) => [v.node, v])); + + // drive lines online + const online = new Set(onlineChannels ?? []); + const driveLineStatus: DriveLineStatus[] = driveLines.map((d) => ({ ...d, online: online.has(d.id) })); + + // per-qubit rollup (worst status wins) + const qubitSet = args.qubits ?? [...new Set([...graph.nodes.values()].map((n) => n.qubit).filter((q): q is string => !!q))]; + const qubits: QubitRollup[] = qubitSet.map((qubit) => { + const nodeVerdicts = verdicts.filter((v) => v.qubit === qubit); + let status: NodeStatus = "uncharacterized"; // no nodes → honest gap + if (nodeVerdicts.length > 0) { + status = nodeVerdicts.reduce((acc, v) => worseStatus(acc, v.status), "calibrated"); + } + return { qubit, status, nodeCount: nodeVerdicts.length }; + }); + + // latest metrics + produced params (only for measured nodes) + const metrics: Record = {}; + const producedParams: Record = {}; + for (const [name, node] of graph.nodes) { + const st = state[name]; + if (!st || !st.value) continue; + const v = byNode.get(name)!; + for (const key of node.produces) { + const val = st.value[key]; + if (val === undefined) continue; + producedParams[key] = val; + if (typeof val === "number" && Number.isFinite(val)) { + metrics[key] = { value: val, ts: st.ts, ageSeconds: v.ageSeconds, status: v.status, node: name }; + } + } + } + + const mainPayload = mainConfig && mainConfig.payload && typeof mainConfig.payload === "object" ? (mainConfig.payload as Record) : {}; + const calibrationParams: Record = { ...mainPayload, ...producedParams }; + + return { driveLines: driveLineStatus, qubits, metrics, calibrationParams, nodes: verdicts }; +} + +// -------------------------------------------------------------------------- +// Queue-awareness + entitlement (§5). +// -------------------------------------------------------------------------- + +export interface NextAction { + /** The graph node this action pertains to. */ + node: string; + /** The node to actually run — the fallback when a qilc node is locked (§5.2). */ + recommendedNode: string; + status: NodeStatus; + action: RecommendedAction; + impl: "standard" | "qilc"; + /** Premium + unentitled → locked, and carries the funnel (below). Never auto-runs + * the premium action; the view shows the upsell rather than a dead grey-out. */ + locked: boolean; + /** The funnel shown on a locked premium node (Aaron 2026-07-07): name the product + * + capability + invite. User-facing copy names "Intonatissimo" / "closed-loop + * calibration" — NEVER the private method acronym. Absent on unlocked nodes. */ + premium?: { package: string; capability: string; invite: string }; + reason: string; +} + +export interface NextActionsResult { + /** Idle ⟺ no running job AND no pending job for this device (§5.1). */ + idle: boolean; + ranked_actions: NextAction[]; +} + +export function nextActions( + graph: CalibrationGraph, + state: Record, + queue: QueueView, + now: number, + opts: { entitled: boolean }, +): NextActionsResult { + const verdicts = evaluate(graph, state, now); + const idle = queue.running === undefined && queue.pending.length === 0; + + const ranked: NextAction[] = []; + for (const v of verdicts) { + if (v.recommended_action === "none") continue; // calibrated nodes need no action + const base: NextAction = { + node: v.node, + recommendedNode: v.node, + status: v.status, + action: v.recommended_action, + impl: v.impl, + locked: false, + reason: v.reason, + }; + if (v.impl === "qilc" && !opts.entitled) { + base.locked = true; // §5.2 access control — but a FUNNEL, not a dead grey-out + base.premium = { + package: "Intonatissimo", + capability: "closed-loop calibration", + invite: "Closed-loop calibration here is handled by Intonatissimo — contact Harmoniqs to enable it on this device.", + }; + if (v.fallback) { + // deterministic path still falls back to the standard node... + base.recommendedNode = v.fallback; + base.action = "calibrate"; + // ...while the user-facing copy advertises the premium package (funnel). + base.reason = `Closed-loop calibration via Intonatissimo (premium) — falling back to '${v.fallback}' until enabled`; + } else { + base.action = "redesign"; + base.reason = "Closed-loop calibration via Intonatissimo (premium) not enabled, no fallback → redesign the pulse"; + } + } + ranked.push(base); + } + return { idle, ranked_actions: ranked }; +} + +/** Advisory-only capability hint from the job server's health() flags (§5.2). + * This is NOT the entitlement authority — the run-time truth is whether the + * private package resolves (qick_client.isQilcEntitled). */ +export function capabilityHint(feature: string, capabilities: string[] | undefined): boolean { + return capabilities?.includes(feature) ?? false; +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index c26d260a..586721bc 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -26,6 +26,13 @@ import { amicodeOpsDir } from "./substrate/vault_store"; import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; import * as os from "node:os"; import { readTomlSafe } from "./run_dir_reader"; +import { parse as parseYaml } from "yaml"; +import { registerDeviceInspector, getDeviceInspector, revealDeviceInspector } from "./device_inspector"; +import { loadGraph } from "./calibration_graph"; +import { parseStateJson } from "./device_registry"; +import { buildDeviceStatus, nextActions, capabilityHint, type DriveLine } from "./device_status"; +import { SchusterJobServer } from "./qick_client"; +import type { QueueView } from "./qick_job_server"; // ============================================================================ // Extension entry point. Boot order on activate: @@ -44,6 +51,105 @@ let opencodeReadyUrl: URL | undefined; /** Set once the binary + vault are known; the watcher's onRunFinished closure * and the distillNow command read it lazily (undefined = distiller disabled). */ let distillerSetup: DistillerSetup | undefined; +/** Device Inspector poll timer (Spec A §5.1) — cleared on deactivate. */ +let devicePollTimer: ReturnType | undefined; + +const DEVICE_POLL_MS = 2500; // mirror the RunsManager cadence + +/** Drive-line + qubit list from a device card's YAML frontmatter (§3.1). The + * card is durable knowledge (vault); this only READS it. Never throws. */ +function readDeviceCard(cardPath: string): { driveLines: DriveLine[]; qubits: string[] } | undefined { + try { + const md = fs.readFileSync(cardPath, "utf8"); + const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!m) return undefined; + const fm = parseYaml(m[1]) as Record; + const dlRaw = Array.isArray(fm.drive_lines) ? (fm.drive_lines as Record[]) : []; + const driveLines: DriveLine[] = dlRaw + .filter((d) => d && typeof d === "object" && typeof d.id === "string") + .map((d) => ({ id: String(d.id), target: typeof d.target === "string" ? d.target : undefined, kind: typeof d.kind === "string" ? d.kind : undefined })); + const qubits = + typeof fm.qubits === "number" + ? Array.from({ length: fm.qubits }, (_v, i) => `Q${i + 1}`) + : Array.isArray(fm.qubits) + ? (fm.qubits as unknown[]).map(String) + : [...new Set(driveLines.map((d) => d.target).filter((t): t is string => !!t))]; + return { driveLines, qubits }; + } catch { + return undefined; + } +} + +/** Poll tick (Spec A §3, §5.1). Dormant until `amicode.device.name` + + * `amicode.device.graph` are set. All I/O is never-reject: a dead endpoint / + * missing file degrades the projection to uncharacterized/offline, never + * crashes the session. The heavy package-resolution entitlement authority + * (qick_client.isQilcEntitled) runs at session prep, not this hot path — here + * the fast health() capability flag is the advisory hint (§5.2). */ +async function refreshDeviceInspector(channel: vscode.OutputChannel): Promise { + const inspector = getDeviceInspector(); + if (!inspector) return; + const cfg = vscode.workspace.getConfiguration("amicode"); + const deviceName = (cfg.get("device.name", "") || "").trim(); + const graphPath = (cfg.get("device.graph", "") || "").trim(); + if (!deviceName || !graphPath) return; // dormant until a device is configured + + try { + const loaded = loadGraph(fs.readFileSync(graphPath, "utf8")); + if (!loaded.ok) { + channel.appendLine(`[device] graph ${graphPath} failed to load: ${loaded.error}`); + return; + } + // rolling ops state (§4.2): ~/.amico/amicode/devices//state.json + const stateFile = path.join(amicodeOpsDir(), "devices", deviceName, "state.json"); + let state = {}; + try { + state = parseStateJson(fs.readFileSync(stateFile, "utf8")); + } catch { + /* no state yet → everything reads uncharacterized (honest) */ + } + const card = readDeviceCard((cfg.get("device.card", "") || "").trim()); + + // queue + health from the configured job-server endpoint (never-reject); no + // endpoint → empty queue (idle) + no online channels (all drive lines offline). + let queue: QueueView = { running: undefined, pending: [] }; + let onlineChannels: string[] | undefined; + let capabilities: string[] | undefined; + let mainConfig; + const endpoint = (cfg.get("device.endpoint", "") || "").trim(); + if (endpoint) { + const client = new SchusterJobServer({ baseUrl: endpoint }); + const q = await client.queue(); + if (q.ok) queue = q.value; + const h = await client.health(); + if (h.ok) { + onlineChannels = h.value.channels; + capabilities = h.value.capabilities; + } + const mc = await client.mainConfig("hw"); + if (mc.ok) mainConfig = mc.value ?? undefined; + } + + const now = Date.now(); + const status = buildDeviceStatus({ + graph: loaded.graph, + state, + now, + driveLines: card?.driveLines ?? [], + qubits: card?.qubits, + onlineChannels, + mainConfig, + }); + const entitled = capabilityHint("qilc", capabilities); // advisory hint; authority = package resolution (prep) + const { ranked_actions } = nextActions(loaded.graph, state, queue, now, { entitled }); + + inspector.postDeviceStatus(deviceName, status); + inspector.postActions(deviceName, ranked_actions); + inspector.activate(deviceName); + } catch (e) { + channel.appendLine(`[device] refresh error: ${(e as Error).message}`); + } +} /** Run dirs with a cooperative stop in flight (escalation timer armed) — a * second Stop click must not stack a second dialog. */ @@ -52,7 +158,8 @@ const pendingStops = new Set(); export async function activate(ctx: vscode.ExtensionContext): Promise { const opencodeChannel = vscode.window.createOutputChannel("Amicode — opencode"); const runsChannel = vscode.window.createOutputChannel("Amicode — runs"); - ctx.subscriptions.push(opencodeChannel, runsChannel); + const devicesChannel = vscode.window.createOutputChannel("Amicode — devices"); + ctx.subscriptions.push(opencodeChannel, runsChannel, devicesChannel); // Runs root (resolved early — the inspector needs it for its CSP resource roots). const runsRoot = resolveRunsRoot(vscode.workspace.getConfiguration("amicode").get("runsRoot", "")); @@ -60,6 +167,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 1. UI surfaces const trees = registerTrees(ctx); registerRunInspector(ctx); + registerDeviceInspector(ctx); // Spec A §3 — device dashboard, sibling to the Run Inspector registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow ctx.subscriptions.push( // #47 session catalog: record the save (workspaceState + tree), then open @@ -164,7 +272,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), - platformSkills: cfgArr("platformSkills"), skillLibraryRoots: cfgArr("skillLibraryRoots"), // User-memory substrate (spec-20260705-002847): "" in the setting keeps the // auto-resolve (kind=personal marker scan); a path pins the vault explicitly. @@ -173,6 +280,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); opencodeChannel.appendLine(`[boot] template: ${opencodeProject.templatePath}`); + opencodeChannel.appendLine( + `[boot] armonia mounts: ${opencodeProject.mounts.length} (${opencodeProject.mounts.map((m) => m.name).join(", ")})`, + ); // 4. Spawn opencode — the VENDORED binary by default (spec §4; S35, kills // Assumption 4). Config override is a dev-only escape hatch. On a missing @@ -234,10 +344,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeProject.skillPaths, opencodeProject.skillsStageDir, opencodeProject.vaultDir, - // Model pin (fallback-only, resolveModelPin): without it, default - // resolution gambles on provider ordering — with Google creds it - // picked a preview model that hung every headless/agent turn. - resolveModelPin(), + // Armonia mount stack (spec-20260707-002846 C1): per-mount read grants. + opencodeProject.mounts, + // Model pin. ONLY an explicit `amicode.defaultModel` pins config.model + // (which is authoritative — it outranks the user's recent pick). Empty + // → resolveModelPin() is undefined (NO forced pin), so opencode uses + // the user's recent selection, else the provider default. A hardcoded + // fallback here used to override the user's own choice. The in-chat + // picker still overrides per session. + vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), ), }, channel: opencodeChannel, @@ -266,7 +381,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.workspace.getConfiguration("amicode").get("juliaProject", ""), ), skillRoots: cfgArr("skillRoots"), - platformSkills: cfgArr("platformSkills"), skillLibraryRoots: cfgArr("skillLibraryRoots"), vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, }); @@ -286,7 +400,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { project2.skillPaths, project2.skillsStageDir, project2.vaultDir, - resolveModelPin(), + // Armonia mount stack (spec-20260707-002846 C1): per-mount read grants. + project2.mounts, + // Same pin rule as boot: only an explicit amicode.defaultModel pins. + vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), ), }, channel: opencodeChannel, @@ -595,10 +712,36 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), ); + // Device Inspector commands + poll loop (Spec A §3, §5.1). + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.openDeviceInspector", async () => { + await revealDeviceInspector(); + void refreshDeviceInspector(devicesChannel); + }), + vscode.commands.registerCommand("amicode.device.refresh", () => { + void refreshDeviceInspector(devicesChannel); + }), + ); + // Fixed-cadence poll; DORMANT (early-return) until a device is configured, so + // this is always safe to run. Cleared on deactivate. + devicePollTimer = setInterval(() => void refreshDeviceInspector(devicesChannel), DEVICE_POLL_MS); + ctx.subscriptions.push({ + dispose: () => { + if (devicePollTimer) { + clearInterval(devicePollTimer); + devicePollTimer = undefined; + } + }, + }); + opencodeChannel.appendLine(`[boot] activated; runsRoot=${runsRoot}; amicoRunBinDir=${amicoRunBinDir ?? "(none)"}`); } export function deactivate(): void { + if (devicePollTimer) { + clearInterval(devicePollTimer); + devicePollTimer = undefined; + } // Distill trigger 2 (session close): queue-only — a drain must not delay // shutdown; the next activation or trigger drains the queue. if (distillerSetup) triggerSweep(distillerSetup, false); diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 05564b0d..040d12b0 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -9,21 +9,28 @@ import { compileScore, spliceIntoAgentsMd, compileChainedScore, chainManifest } import { resolveLibrarySkills, resolvePackageSkills, + isProductSkillEntitled, buildSkillIndexSection, stageOpencodeSkills, type SkillIndexEntry, } from "./scores/package_skills"; import { readSolverModeState } from "./solver_mode"; import { - resolvePersonalVault, - defaultVaultsRoot, readProfileMd, readKnowledgeLines, readDemoLines, + readMemoryIndexLines, hasOnboardingCompleted, onboardingDir, } from "./substrate/vault_store"; -import { buildAboutUserSection, buildRecentProblemsSection, buildReferenceDemosSection } from "./substrate/user_splice"; +import { resolveMountStack, personalMount, type Mount, type MountStack } from "./substrate/mount_store"; +import { + buildAboutUserSection, + buildRecentProblemsSection, + buildReferenceDemosSection, + buildMountStackSection, + buildMemoryIndexSection, +} from "./substrate/user_splice"; // ============================================================================ // Prepare a per-session opencode project directory. @@ -133,11 +140,28 @@ const DEFAULT_PLUGIN_PATH = path.resolve(__dirname, "..", "opencode-plugin", "am export const DEFAULT_SCORES_ROOT = path.resolve(__dirname, "..", "scores"); /** Skill-index roots (spec-20260704-113005 §3). Package skills are co-located - * in the workspace package repos; platform skills are the configured names in - * the central amico-plugin library. Overridable via settings (Task 6). */ + * in the workspace package repos; library (product) skills are discovered by + * `surface: product` tag from the central amico-plugin library. Overridable + * via settings (Task 6). */ export const DEFAULT_SKILL_ROOTS = [path.join(os.homedir(), "harmoniqs", "packages")]; -export const DEFAULT_PLATFORM_SKILLS = ["atoms", "transmon", "fluxonium", "ions", "bosonic"]; export const DEFAULT_LIBRARY_ROOTS = [path.join(os.homedir(), "harmoniqs", "amico-plugin", "skills")]; +/** The canonical `surface: product` skill set (spec-20260708-112732 §4.5) — a + * documentation/reference anchor for what tag-based discovery is expected to + * surface, NOT a selection input (selection is now purely by frontmatter tag, + * see resolveLibrarySkills). Kept as the golden expectation the discovery test + * asserts against the real library root. */ +export const DEFAULT_PLATFORM_SKILLS = [ + "atoms", + "bosonic", + "fluxonium", + "ions", + "transmon", + "setup", + "solve", + "plot", + "objectives", + "demo", +]; /** Bundled spec-C authoring assets (absolute), resolved relative to this module. * At runtime __dirname is the extension's dist/src dir; the assets ship one @@ -241,28 +265,6 @@ export function writeAuthoringConfig( } } -/** Default model pin for the generated config. Without one, opencode's - * default-resolution gambles on provider ordering and (with Google creds) - * lands on preview variants — gemini-3.1-pro-preview-customtools rejected or - * HUNG every turn. Preference: Anthropic if the user has creds for it, else - * the boring-but-available GA Gemini flash (the newest flash is capacity-throttled at peak; 2.5 answered in 1.4s while 3.5 returned overloaded). The app's - * model picker still overrides per session; undefined leaves opencode's own - * default (no creds yet — nothing sane to pin). */ -export function preferredModel( - authPath: string = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"), -): string | undefined { - try { - const providers = Object.keys(JSON.parse(fs.readFileSync(authPath, "utf8")) as Record); - if (providers.includes("anthropic")) return "anthropic/claude-sonnet-5"; - } catch { - /* no auth.json — the free default below still works */ - } - // Creds-free default: the zen free tier rides no user quota — Gemini keys - // kept hitting capacity throttles ("model overloaded" → failed turns render - // as "model undefined" stubs), while this answered a tool-bearing turn in ~3s. - return "opencode/deepseek-v4-flash-free"; -} - /** Solver-mode section for AGENTS.md (rchari/solver-wire): in HP mode the * agent authors with the Piccolissimo stack by default — the entitlement * gate (issimo) has already admitted the packages by the time this renders. */ @@ -278,24 +280,21 @@ function solverModeSection(): string { ); } -/** The model pin to inject, or undefined. FALLBACK-only: a model in the user's - * global opencode config wins (our injected config would override it in the - * merge — the 1.17.3 preserve-user-model contract), so we pin nothing then. */ -export function resolveModelPin( - globalConfigPath: string = path.join( - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), - "opencode", - "opencode.json", - ), - authPath?: string, -): string | undefined { - try { - const cfg = JSON.parse(fs.readFileSync(globalConfigPath, "utf8")) as { model?: unknown }; - if (typeof cfg.model === "string" && cfg.model) return undefined; // user chose — never override - } catch { - /* no global config — fall through to the creds-based pin */ - } - return authPath === undefined ? preferredModel() : preferredModel(authPath); +/** The model pin to inject into the generated config, or undefined. + * + * We deliberately DO NOT force a fallback model here. `config.model` is the + * authoritative default in opencode's resolution — it outranks the user's + * recent-model pick (local.tsx: `configuredModel() ?? recentModel() ?? …`). + * A hardcoded fallback in that slot therefore OVERRODE the user's own + * selection permanently (report: "set to qwen but defaults to deepseek"). + * + * So: the only explicit default is `amicode.defaultModel` (handled by the + * caller, which passes it ahead of this). With no explicit default we return + * undefined and let opencode resolve it — the user's recent pick, else the + * provider default. A user who wants a fixed default sets amicode.defaultModel + * (or a model in their global opencode.json, which opencode reads natively). */ +export function resolveModelPin(): string | undefined { + return undefined; } export function buildOpencodeConfigContent( @@ -307,6 +306,7 @@ export function buildOpencodeConfigContent( skillPaths: string[] = [], skillsStageDir: string = "", vaultDir: string = "", + mounts: Mount[] = [], modelPin?: string, ): string { const templatesDir = path.dirname(templatePath); @@ -349,6 +349,12 @@ export function buildOpencodeConfigContent( [`${problemsRoot()}/**`]: "allow", // amicode_* problem workspaces the agent reads back [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads ...skillGrants, // per-indexed-skill dirs (spec §3, least-privilege) + // Armonia mount stack (spec-20260707-002846 C1): a READ grant per mount + // so the agent can read cards/notes on demand across the WHOLE stack. + // The permission surface has no read/write split, so even a read-only + // mount gets a grant here (read posture); write discipline stays + // distiller-side (its own config), same contract as the vault grant below. + ...Object.fromEntries(mounts.map((m) => [`${m.path}/**`, "allow"])), // User-memory substrate (spec-20260705-002847 §6): the interview reads // problem/environment cards on demand. Read-only BY CONTRACT — vault // writes are distiller-only (its own config); the permission surface @@ -374,13 +380,15 @@ export interface OpencodeConfigOptions { entitlementsDir?: string; /** Roots to search for co-located package skills (spec §3). Default: DEFAULT_SKILL_ROOTS. */ skillRoots?: string[]; - /** Configured platform-skill names to index from the library (spec §3). Default: DEFAULT_PLATFORM_SKILLS. */ - platformSkills?: string[]; - /** Roots for the central platform-skill library (spec §3). Default: DEFAULT_LIBRARY_ROOTS. */ + /** Roots for the central library, scanned for `surface: product` skills + * (spec-20260708-112732 §4.5). Default: DEFAULT_LIBRARY_ROOTS. */ skillLibraryRoots?: string[]; - /** Personal vault dir for the user-memory substrate (spec-20260705-002847). - * undefined → auto-resolve (kind=personal marker scan under ~/.amico/vaults); - * "" → personalization disabled; a path → used as-is. */ + /** Personal vault dir for the user-memory substrate (spec-20260705-002847), + * three-state (spec-20260707-002846 C1): + * undefined → auto-resolve the full Armonia mount stack under + * ~/.amico/vaults; vaultDir = the personal mount ("" if none); + * "" → personalization disabled (empty stack, no grants, no splice); + * a path → a single forced personal mount at that path (dev escape hatch). */ vaultDir?: string; } @@ -395,8 +403,13 @@ export interface OpencodeProject { * buildOpencodeConfigContent as `skills.paths`. "" if none staged. */ skillsStageDir: string; /** Resolved personal vault ("" when personalization is off) — thread into - * buildOpencodeConfigContent for the read grant. */ + * buildOpencodeConfigContent for the read grant. Equals `personalMount(mounts)` + * path (unchanged behavior for the distiller + onboarding consumers). */ vaultDir: string; + /** The resolved Armonia mount stack (spec-20260707-002846 C1) — thread into + * buildOpencodeConfigContent for the per-mount read grants. [] when + * personalization is disabled ("" vaultDir). */ + mounts: Mount[]; } export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodeProject { @@ -418,10 +431,27 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro // transport for the Bun-side plugin. FALLBACK: any failure leaves the substituted // AGENTS.md exactly as before — the hardcoded section IS the fallback content; // score trouble must never brick the boot. - // User-memory substrate (spec-20260705-002847): resolve the personal vault - // ONCE, up front — the routing predicate (§3) and the splice (§6) both need - // it. undefined → auto-resolve (kind=personal marker scan); "" → off. - const vaultDir = opts.vaultDir !== undefined ? opts.vaultDir : resolvePersonalVault(defaultVaultsRoot(), ""); + // Armonia mount stack (spec-20260707-002846 C1) + user-memory substrate + // (spec-20260705-002847): resolve ONCE, up front — the routing predicate (§3), + // the per-mount read grants, and the splice (§6, C3/C4) all need it. The + // three-state opts.vaultDir contract is preserved EXACTLY: + // undefined → auto-resolve the FULL stack (personal mount → vaultDir); + // "" → personalization OFF (empty stack, no grants, no splice); + // a path → a single forced personal mount at that path (dev escape hatch). + let stack: MountStack; + if (opts.vaultDir === undefined) { + stack = resolveMountStack(); + } else if (opts.vaultDir === "") { + stack = { mounts: [], warnings: [] }; + } else { + stack = { + mounts: [{ name: path.basename(opts.vaultDir), kind: "personal", path: opts.vaultDir, writable: true }], + warnings: [], + }; + } + // vaultDir === the personal mount path (unchanged behavior for the distiller + + // onboarding predicate consumers); "" when there is no personal mount. + const vaultDir = personalMount(stack)?.path ?? ""; let finalContent = filled; try { @@ -483,11 +513,15 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro try { const entsDir = opts.entitlementsDir ?? path.join(os.homedir(), ".amico", "amicode"); const scoresRoot = opts.scoresRoot ?? DEFAULT_SCORES_ROOT; - const allow = packageAllowlist(entitlementsTablePath(scoresRoot), readLocalEntitlements(entsDir).entitlements); + const ents = readLocalEntitlements(entsDir).entitlements; + const allow = packageAllowlist(entitlementsTablePath(scoresRoot), ents); skillEntries = [ - ...resolveLibrarySkills( - opts.platformSkills ?? DEFAULT_PLATFORM_SKILLS, - opts.skillLibraryRoots ?? DEFAULT_LIBRARY_ROOTS, + // Library (product) skills by `surface: product` tag (spec-20260708-112732 + // §4.5). The entitlement seam (§7.1) is wired but a no-op today — every + // product skill is public, so all stage; a future GATED_PRODUCT_SKILLS row + // gates without touching this call. + ...resolveLibrarySkills(opts.skillLibraryRoots ?? DEFAULT_LIBRARY_ROOTS, (name) => + isProductSkillEntitled(name, ents), ), ...resolvePackageSkills(allow, opts.skillRoots ?? DEFAULT_SKILL_ROOTS), ]; @@ -533,6 +567,23 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro } } + // Mount-stack + memory-index splice (spec-20260707-002846 C3/C4 read side): + // its OWN try/catch — mount-parity trouble must never brick the boot. The + // mount-stack section renders whenever the stack has mounts (mounts can exist + // without a personal vault — e.g. a team-only stack); the typed-memory index + // is read from the personal mount, so it is gated on vaultDir. Empty stack + // ("" vaultDir) → both builders return "" → nothing is spliced. + try { + const mountSection = buildMountStackSection(stack); + if (mountSection) finalContent = finalContent + "\n\n" + mountSection; + if (vaultDir) { + const memorySection = buildMemoryIndexSection(readMemoryIndexLines(vaultDir)); + if (memorySection) finalContent = finalContent + "\n\n" + memorySection; + } + } catch (e) { + console.warn(`amicode: mount-stack/memory-index splice failed (session continues): ${e}`); + } + fs.writeFileSync(agentsPath, finalContent + solverModeSection(), "utf8"); // The agent reads the template from its bundled absolute path (the session @@ -544,5 +595,6 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro skillPaths: skillEntries.map((e) => e.path), skillsStageDir, vaultDir, + mounts: stack.mounts, }; } diff --git a/packages/extension/src/qick_client.ts b/packages/extension/src/qick_client.ts new file mode 100644 index 00000000..dc20983f --- /dev/null +++ b/packages/extension/src/qick_client.ts @@ -0,0 +1,266 @@ +import { + parseQueue, + parseHistory, + parseConfigVersions, + parseConfigVersion, + parseJob, + type AbstractJobServer, + type ConfigVersion, + type Health, + type HistoryFilters, + type Job, + type QueueView, + type Result, + type SubmitRequest, +} from "./qick_job_server"; + +// ============================================================================ +// QICK job-server HTTP/MCP clients — Spec A §2.3 (adapters) + §5.2 (entitlement). +// +// SchusterJobServer — impl #1, Node `fetch` → the multimode `job_server` +// FastAPI (submit/queue/history/status/cancel + +// config-versioning + health). Endpoint from the +// environment card's keyed endpoints[role=job_server] +// pointer (never credentials — §3.1 / L0 §2.5). +// SnowbirdMcpJobServer — impl #2, the same verbs onto Snowbird's QICK MCP +// tool calls (experiment.payload.tool). +// +// Both are NEVER-REJECT (§2.3): every call returns Result — a dead tunnel / +// 500 / timeout / malformed body degrades the view, never crashes the session. +// vscode-free (only Node fetch / an injected runner) so it is unit-testable. +// ============================================================================ + +/** Minimal `fetch` surface we use — injectable so tests drive a stub. */ +export type FetchLike = ( + url: string, + init?: { method?: string; headers?: Record; body?: string }, +) => Promise<{ ok: boolean; status: number; json(): Promise; text(): Promise }>; + +export interface SchusterOptions { + /** Base URL resolved from the environment card's endpoints[role=job_server].ptr. */ + baseUrl: string; + /** Injectable for tests; defaults to the global fetch. */ + fetchImpl?: FetchLike; +} + +function ok(value: T): Result { + return { ok: true, value }; +} +function err(error: string): Result { + return { ok: false, error }; +} + +/** Never-throw health parser (kept local — the health shape is client-specific). */ +function parseHealth(v: unknown): Health { + const o = v && typeof v === "object" ? (v as Record) : {}; + const stats = o.stats && typeof o.stats === "object" ? (o.stats as Record) : {}; + return { + ok: o.ok !== false, + stats: { + pending: typeof stats.pending === "number" ? stats.pending : 0, + running: typeof stats.running === "number" ? stats.running : 0, + }, + capabilities: Array.isArray(o.capabilities) ? o.capabilities.filter((c): c is string => typeof c === "string") : undefined, + channels: Array.isArray(o.channels) ? o.channels.filter((c): c is string => typeof c === "string") : undefined, + }; +} + +export class SchusterJobServer implements AbstractJobServer { + constructor(private readonly opts: SchusterOptions) {} + + private get fetchImpl(): FetchLike { + return this.opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + } + + /** One never-reject round trip: fetch → status check → JSON → parse. Any + * failure (network throw, non-2xx, malformed body) → {ok:false, error}. */ + private async req( + method: string, + path: string, + parse: (json: unknown) => T, + body?: unknown, + ): Promise> { + try { + const res = await this.fetchImpl(this.opts.baseUrl + path, { + method, + headers: body !== undefined ? { "content-type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) return err(`http_${res.status}: ${method} ${path}`); + let json: unknown; + try { + json = await res.json(); + } catch (e) { + return err(`parse_error: ${method} ${path}: ${(e as Error).message}`); + } + return ok(parse(json)); + } catch (e) { + return err(`network_error: ${method} ${path}: ${(e as Error).message}`); + } + } + + async submit(reqBody: SubmitRequest): Promise> { + return this.req("POST", "/jobs/submit", (j) => { + const id = j && typeof j === "object" ? (j as Record).job_id : undefined; + return { job_id: typeof id === "string" ? id : "" }; + }, reqBody); + } + + async queue(): Promise> { + return this.req("GET", "/jobs/queue", parseQueue); + } + + async history(filters: HistoryFilters): Promise> { + const qs = new URLSearchParams(); + if (filters.user) qs.set("user", filters.user); + if (filters.status) qs.set("status", filters.status); + if (filters.limit !== undefined) qs.set("limit", String(filters.limit)); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return this.req("GET", `/jobs/history${suffix}`, parseHistory); + } + + async status(jobId: string): Promise> { + const r = await this.req("GET", `/jobs/${encodeURIComponent(jobId)}`, (j) => parseJob(j)); + if (!r.ok) return r; + if (!r.value) return err(`parse_error: malformed job ${jobId}`); + return ok(r.value); + } + + async cancel(jobId: string): Promise> { + return this.req("DELETE", `/jobs/${encodeURIComponent(jobId)}`, () => undefined); + } + + async configVersions(type: string): Promise> { + return this.req("GET", `/config/versions?type=${encodeURIComponent(type)}`, parseConfigVersions); + } + + async mainConfig(type: string): Promise> { + return this.req("GET", `/config/main?type=${encodeURIComponent(type)}`, parseConfigVersion); + } + + async pushConfig(type: string, payload: unknown): Promise> { + const r = await this.req("POST", "/config/push", (j) => parseConfigVersion(j), { type, payload }); + if (!r.ok) return r; + if (!r.value) return err("parse_error: push_config returned no version"); + return ok(r.value); + } + + async setMain(type: string, versionId: string): Promise> { + return this.req("POST", "/config/main", () => undefined, { type, version_id: versionId }); + } + + async health(): Promise> { + return this.req("GET", "/health", parseHealth); + } +} + +// -------------------------------------------------------------------------- +// SnowbirdMcpJobServer — the contract verbs onto Snowbird's QICK MCP tool calls. +// Snowbird's MCP surface is a set of SYNCHRONOUS named measurement tools, so +// there is no persistent job queue: submit dispatches the tool named in the +// experiment payload; queue/history are empty (idle-safe); the job store verbs +// are unsupported but degrade gracefully (never-reject). +// -------------------------------------------------------------------------- + +export type McpToolCaller = (tool: string, args: unknown) => Promise; + +export interface SnowbirdMcpOptions { + callTool: McpToolCaller; + capabilities?: string[]; + channels?: string[]; +} + +export class SnowbirdMcpJobServer implements AbstractJobServer { + private jobCounter = 0; + constructor(private readonly opts: SnowbirdMcpOptions) {} + + async submit(reqBody: SubmitRequest): Promise> { + const payload = reqBody.experiment.payload as Record | undefined; + const tool = payload && typeof payload.tool === "string" ? payload.tool : undefined; + if (!tool) return err("bad_request: mcp experiment payload has no `tool`"); + try { + await this.opts.callTool(tool, payload); + return ok({ job_id: `MCP-${++this.jobCounter}` }); + } catch (e) { + return err(`mcp_error: ${tool}: ${(e as Error).message}`); + } + } + + async queue(): Promise> { + // MCP tools are synchronous — no persistent queue → always idle-safe. + return ok({ running: undefined, pending: [] }); + } + + async history(): Promise> { + return ok([]); + } + + async status(jobId: string): Promise> { + return err(`unsupported: mcp adapter has no job store (${jobId})`); + } + + async cancel(): Promise> { + return err("unsupported: mcp tool calls are synchronous, nothing to cancel"); + } + + async configVersions(): Promise> { + return ok([]); + } + + async mainConfig(): Promise> { + return ok(undefined); + } + + async pushConfig(): Promise> { + return err("unsupported: mcp config write-back is a Spec B deliverable"); + } + + async setMain(): Promise> { + return err("unsupported: mcp config write-back is a Spec B deliverable"); + } + + async health(): Promise> { + return ok({ + ok: true, + stats: { pending: 0, running: 0 }, + capabilities: this.opts.capabilities, + channels: this.opts.channels, + }); + } +} + +// -------------------------------------------------------------------------- +// Entitlement predicate (§5.2) — the AUTHORITATIVE gate is package resolution: +// the private Intonatissimo package resolving in the target Julia environment +// (i.e. the qilc strategy can actually run). The job server's health() +// capabilities flag is only an advisory hint (device_status.capabilityHint). +// Reconciled with the scores-tier entitlement axis per correction C10: this is +// a DISTINCT, named predicate (package resolution ≠ scores tier), not a +// duplicate `isEntitled`. +// -------------------------------------------------------------------------- + +/** Injectable command runner — defaults to child_process.execFile. Returns the + * process exit code; never throws in the default impl (spawn errors → code 1). */ +export type CommandRunner = (cmd: string, args: string[]) => Promise<{ code: number }>; + +const defaultRunner: CommandRunner = (cmd, args) => + new Promise((resolve) => { + // Lazy require so the browser/webview bundles never pull node:child_process. + import("node:child_process") + .then(({ execFile }) => { + execFile(cmd, args, (error) => resolve({ code: error ? (typeof error.code === "number" ? error.code : 1) : 0 })); + }) + .catch(() => resolve({ code: 1 })); + }); + +/** True ⟺ the private Intonatissimo package resolves in `juliaProject` (the qilc + * strategy can run). Never throws — any failure (no julia, spawn error) → false. */ +export async function isQilcEntitled(juliaProject: string, run: CommandRunner = defaultRunner): Promise { + const script = `using Pkg; exit(haskey(Pkg.project().dependencies, "Intonatissimo") ? 0 : 1)`; + try { + const { code } = await run("julia", [`--project=${juliaProject}`, "-e", script]); + return code === 0; + } catch { + return false; + } +} diff --git a/packages/extension/src/qick_job_server.ts b/packages/extension/src/qick_job_server.ts new file mode 100644 index 00000000..edba811e --- /dev/null +++ b/packages/extension/src/qick_job_server.ts @@ -0,0 +1,342 @@ +// ============================================================================ +// QICK job-server (QUEUE) contract — Spec A §2. +// +// A minimal queue-layer contract any QICK job server can satisfy (Schuster's +// multimode `job_server` is impl #1). This is the QUEUE contract (submit / +// queue / history / status / cancel / config-versioning / health) consumed by +// the device view + calibration graph. It is DELIBERATELY distinct from: +// - the 3-verb MEASUREMENT contract (upload_pulse!/trigger!/readout → +// expt_service) the QILC inner loop speaks (Spec B), and +// - Raghav's internal Amicode Scheduler/RunsManager (which queues pulse-design +// *solves* off the runs/index). +// Do not conflate them (§2 reviewer flag). +// +// vscode-free + no Date.now/Math.random anywhere — so vitest runs the whole +// contract headless and deterministically (the run_registry.ts precedent). +// ============================================================================ + +export type JobStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; + +/** Per-adapter OPAQUE experiment blob (§4.1). The `schuster` adapter reads the + * payload as a `job_server` job spec (class/module/config); the `mcp` adapter + * reads it as a named Snowbird MCP tool call. The queue verbs stay uniform; + * only the payload shape is adapter-specific. */ +export interface ExperimentBlob { + adapter: string; + payload: unknown; +} + +export interface SubmitRequest { + user: string; + experiment: ExperimentBlob; + priority?: number; + station_config?: unknown; + config_version_ids?: Record; +} + +/** Job shape — adopted from Schuster verbatim (§2.2), not imposed. */ +export interface Job { + job_id: string; + user: string; + experiment: ExperimentBlob; + status: JobStatus; + priority: number; + created_at?: string; + started_at?: string; + completed_at?: string; + data_file_path?: string; + error_message?: string; + /** Canned/observed result payload (the node's produced params live here). */ + result?: Record; + config_version_ids?: Record; +} + +/** Live queue view — drives idle-detection (§5.1). */ +export interface QueueView { + running?: Job; + pending: Job[]; +} + +/** An immutable calibration snapshot from the config-versioning ledger (§2.1). */ +export interface ConfigVersion { + version_id: string; + type: string; + payload?: unknown; + created_at?: string; + is_main?: boolean; +} + +export interface HealthStats { + pending: number; + running: number; +} + +export interface Health { + ok: boolean; + stats: HealthStats; + /** Advisory entitlement hint (§5.2) — the RUN-TIME truth is package resolution. */ + capabilities?: string[]; + /** Drive-line channels the server reports online (§3.2 drive-lines-online). */ + channels?: string[]; +} + +export interface HistoryFilters { + user?: string; + status?: JobStatus; + limit?: number; +} + +/** Never-reject result envelope (§2.3): every adapter call returns this — a + * dead tunnel or a 500 degrades the view, never crashes the session. The + * in-memory MockJobServer returns DIRECT values (it cannot fail); the HTTP + * adapters (qick_client.ts) return `Result`. */ +export type Result = { ok: true; value: T } | { ok: false; error: string }; + +/** The adapter interface the HTTP clients implement (SchusterJobServer, + * SnowbirdMcpJobServer — qick_client.ts). Every verb is never-reject. */ +export interface AbstractJobServer { + submit(req: SubmitRequest): Promise>; + queue(): Promise>; + history(filters: HistoryFilters): Promise>; + status(jobId: string): Promise>; + cancel(jobId: string): Promise>; + configVersions(type: string): Promise>; + mainConfig(type: string): Promise>; + pushConfig(type: string, payload: unknown): Promise>; + setMain(type: string, versionId: string): Promise>; + health(): Promise>; +} + +// -------------------------------------------------------------------------- +// Never-throw parsers — the HTTP adapters route raw JSON through these so a +// malformed/partial payload degrades to an empty view instead of throwing. +// -------------------------------------------------------------------------- + +function asString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} +function asNumber(v: unknown, dflt: number): number { + return typeof v === "number" && Number.isFinite(v) ? v : dflt; +} +function asRecord(v: unknown): Record | undefined { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : undefined; +} + +const JOB_STATUSES: JobStatus[] = ["pending", "running", "completed", "failed", "cancelled"]; + +/** Parse one loosely-typed job object → Job, or undefined if it lacks a job_id. + * Never throws. */ +export function parseJob(v: unknown): Job | undefined { + const o = asRecord(v); + if (!o) return undefined; + const job_id = asString(o.job_id); + if (!job_id) return undefined; + const expRec = asRecord(o.experiment); + const experiment: ExperimentBlob = { + adapter: asString(expRec?.adapter) ?? asString(o.adapter) ?? "unknown", + payload: expRec?.payload ?? o.expt_config ?? {}, + }; + const status = (JOB_STATUSES as string[]).includes(String(o.status)) ? (o.status as JobStatus) : "pending"; + return { + job_id, + user: asString(o.user) ?? "unknown", + experiment, + status, + priority: asNumber(o.priority, 0), + created_at: asString(o.created_at), + started_at: asString(o.started_at), + completed_at: asString(o.completed_at), + data_file_path: asString(o.data_file_path), + error_message: asString(o.error_message), + result: asRecord(o.result), + config_version_ids: asRecord(o.config_version_ids) as Record | undefined, + }; +} + +/** Parse a `GET /jobs/queue` body → {running, pending[]}. Never throws. */ +export function parseQueue(v: unknown): QueueView { + const o = asRecord(v); + if (!o) return { running: undefined, pending: [] }; + const running = parseJob(o.running); + const pendingRaw = Array.isArray(o.pending) ? o.pending : []; + const pending = pendingRaw.map(parseJob).filter((j): j is Job => j !== undefined); + return { running, pending }; +} + +/** Parse a `GET /jobs/history` body (array) → Job[]. Never throws. */ +export function parseHistory(v: unknown): Job[] { + const arr = Array.isArray(v) ? v : asRecord(v)?.jobs; + if (!Array.isArray(arr)) return []; + return arr.map(parseJob).filter((j): j is Job => j !== undefined); +} + +/** Parse a config-versions body (array) → ConfigVersion[]. Never throws. */ +export function parseConfigVersions(v: unknown): ConfigVersion[] { + const arr = Array.isArray(v) ? v : asRecord(v)?.versions; + if (!Array.isArray(arr)) return []; + const out: ConfigVersion[] = []; + for (const item of arr) { + const o = asRecord(item); + const version_id = asString(o?.version_id); + if (!o || !version_id) continue; + out.push({ + version_id, + type: asString(o.type) ?? "", + payload: o.payload, + created_at: asString(o.created_at), + is_main: o.is_main === true, + }); + } + return out; +} + +/** Parse a single config-version object → ConfigVersion, or undefined. */ +export function parseConfigVersion(v: unknown): ConfigVersion | undefined { + const o = asRecord(v); + const version_id = asString(o?.version_id); + if (!o || !version_id) return undefined; + return { + version_id, + type: asString(o.type) ?? "", + payload: o.payload, + created_at: asString(o.created_at), + is_main: o.is_main === true, + }; +} + +// -------------------------------------------------------------------------- +// MockJobServer — in-memory queue + canned results + a settable capability set. +// All §6 acceptance tests run against it, no hardware. Deterministic: ids come +// from monotonic counters (NO Date.now / Math.random). Methods return DIRECT +// values (it cannot fail) — the never-reject Result envelope is the HTTP +// adapters' concern (qick_client.ts). +// -------------------------------------------------------------------------- + +export interface MockJobServerOptions { + capabilities?: string[]; + channels?: string[]; +} + +interface PendingEntry { + job: Job; + seq: number; +} + +export class MockJobServer { + private jobCounter = 0; + private cfgCounter = 0; + private seqCounter = 0; + private pendingEntries: PendingEntry[] = []; + private runningJob?: Job; + private readonly done: Job[] = []; + private readonly configs = new Map(); + private readonly mains = new Map(); + private readonly capabilities?: string[]; + private readonly channels?: string[]; + + constructor(opts: MockJobServerOptions = {}) { + this.capabilities = opts.capabilities; + this.channels = opts.channels; + } + + /** Pending sorted the way it would run: priority desc, then FIFO (seq asc). */ + private sortedPending(): PendingEntry[] { + return [...this.pendingEntries].sort((a, b) => b.job.priority - a.job.priority || a.seq - b.seq); + } + + async submit(req: SubmitRequest): Promise { + const job: Job = { + job_id: `JOB-${++this.jobCounter}`, + user: req.user, + experiment: req.experiment, + status: "pending", + priority: req.priority ?? 0, + config_version_ids: req.config_version_ids, + }; + this.pendingEntries.push({ job, seq: ++this.seqCounter }); + return { ...job }; + } + + async queue(): Promise { + return { + running: this.runningJob ? { ...this.runningJob } : undefined, + pending: this.sortedPending().map((e) => ({ ...e.job })), + }; + } + + async history(filters: HistoryFilters): Promise { + let jobs = this.done; + if (filters.user !== undefined) jobs = jobs.filter((j) => j.user === filters.user); + if (filters.status !== undefined) jobs = jobs.filter((j) => j.status === filters.status); + const out = jobs.map((j) => ({ ...j })); + return filters.limit !== undefined ? out.slice(-filters.limit) : out; + } + + async status(jobId: string): Promise { + if (this.runningJob?.job_id === jobId) return { ...this.runningJob }; + const pend = this.pendingEntries.find((e) => e.job.job_id === jobId); + if (pend) return { ...pend.job }; + const fin = this.done.find((j) => j.job_id === jobId); + return fin ? { ...fin } : undefined; + } + + async cancel(jobId: string): Promise { + const idx = this.pendingEntries.findIndex((e) => e.job.job_id === jobId); + if (idx === -1) return false; + const [entry] = this.pendingEntries.splice(idx, 1); + this.done.push({ ...entry.job, status: "cancelled" }); + return true; + } + + /** TEST HELPER (not a contract verb): promote the highest-priority pending job + * to running, then finish it with the given outcome (default: completed with + * the supplied result). Returns the finished job, or undefined if idle. */ + async runNext(outcome: { result?: Record; status?: JobStatus; error?: string } = {}): Promise { + const ordered = this.sortedPending(); + if (ordered.length === 0) return undefined; + const head = ordered[0]; + this.pendingEntries = this.pendingEntries.filter((e) => e !== head); + const status: JobStatus = outcome.status ?? (outcome.error ? "failed" : "completed"); + const finished: Job = { + ...head.job, + status, + result: outcome.result, + error_message: outcome.error, + }; + this.runningJob = undefined; + this.done.push(finished); + return { ...finished }; + } + + async configVersions(type: string): Promise { + return (this.configs.get(type) ?? []).map((v) => ({ ...v, is_main: this.mains.get(type) === v.version_id })); + } + + async mainConfig(type: string): Promise { + const mainId = this.mains.get(type); + if (!mainId) return undefined; + const found = (this.configs.get(type) ?? []).find((v) => v.version_id === mainId); + return found ? { ...found, is_main: true } : undefined; + } + + async pushConfig(type: string, payload: unknown): Promise { + const ver: ConfigVersion = { version_id: `CFG-${type}-${++this.cfgCounter}`, type, payload }; + const list = this.configs.get(type) ?? []; + list.push(ver); + this.configs.set(type, list); + return { ...ver }; + } + + async setMain(type: string, versionId: string): Promise { + this.mains.set(type, versionId); + } + + async health(): Promise { + return { + ok: true, + stats: { pending: this.pendingEntries.length, running: this.runningJob ? 1 : 0 }, + capabilities: this.capabilities, + channels: this.channels, + }; + } +} diff --git a/packages/extension/src/scores/package_skills.ts b/packages/extension/src/scores/package_skills.ts index db5c88d6..20de8d7d 100644 --- a/packages/extension/src/scores/package_skills.ts +++ b/packages/extension/src/scores/package_skills.ts @@ -5,9 +5,12 @@ import { parse as parseYaml } from "yaml"; // same parser as scores/loader.ts // Dual-source skill index (spec-20260704-113005 §1/§3). Two skill TYPES: // - PACKAGE skills: co-located at packages/

.jl/skills//SKILL.md, // discovered ONLY for entitlement-allowlisted packages (gated). -// - PLATFORM skills: cross-package physics refs in the central amico-plugin -// library, discovered by an explicit CONFIGURED NAME LIST (public), never a -// whole-dir scan — the library holds ~50 process skills that must not leak. +// - LIBRARY (product) skills: cross-package refs in the central amico-plugin +// library, discovered by SURFACE TAG (spec-20260708-112732 §4.5/§7.1): the +// library dir is scanned, but ONLY skills whose frontmatter carries +// `surface: product` are staged. `internal` and untagged skills — the ~44 +// process skills in the same library — MUST NOT leak into Amicode; the tag +// IS the least-privilege guard (superseding the old hardcoded name list). // Content is read on demand by the agent — never baked into the prompt or the // .vsix. Errors mirror the entitlements philosophy: skip + warn, never throw. export interface SkillIndexEntry { @@ -24,15 +27,22 @@ function expandHome(p: string): string { return p; } -/** Parse a SKILL.md's frontmatter; throw on anything malformed (caller skips). */ -function readFrontmatter(skillPath: string): { name: string; description: string } { +/** Parse a SKILL.md's frontmatter; throw on anything malformed (caller skips). + * `surface` (spec-20260708-112732 §4.5) is optional — a string tag + * (`product` | `internal`) or undefined when the skill is untagged. It drives + * library-skill staging (see resolveLibrarySkills). */ +function readFrontmatter(skillPath: string): { name: string; description: string; surface?: string } { const raw = fs.readFileSync(skillPath, "utf8"); const m = raw.match(/^---\n([\s\S]*?)\n---/); if (!m) throw new Error("missing frontmatter"); - const fm = parseYaml(m[1]) as { name?: string; description?: string }; + const fm = parseYaml(m[1]) as { name?: string; description?: string; surface?: string }; if (typeof fm.name !== "string" || typeof fm.description !== "string") throw new Error("frontmatter needs name + description"); - return { name: fm.name, description: fm.description }; + return { + name: fm.name, + description: fm.description, + surface: typeof fm.surface === "string" ? fm.surface : undefined, + }; } /** Package skills for allowlisted packages (gated). First root containing @@ -70,21 +80,64 @@ export function resolvePackageSkills(allowlist: string[], roots: string[]): Skil return out; } -/** Platform skills from the central library (spec §3, Rev 2). PUBLIC by - * construction — NO entitlement input. Only the CONFIGURED names are looked - * up: the library dir also holds ~50 process skills that must never leak into - * the Amicode prompt (explicit list is the guard; marker-based discovery is a - * recorded follow-up). First root containing `/SKILL.md` wins. */ -export function resolveLibrarySkills(names: string[], roots: string[]): SkillIndexEntry[] { +/** Library (product) skills gated behind an entitlement — the future-gated- + * product seam (spec-20260708-112732 §7.1). EMPTY today: every `surface: + * product` skill is public, so the entitlement filter is a no-op and all + * product skills stage. Maps skill name → the entitlement code required to + * stage it; adding one row here gates that skill WITHOUT touching discovery. */ +export const GATED_PRODUCT_SKILLS: Readonly> = {}; + +/** Entitlement predicate for library (product) skill staging (§7.1 seam). A + * product skill absent from GATED_PRODUCT_SKILLS is public (always staged); a + * gated one stages only when its required entitlement is held. Wired at the + * call site even while the map is empty, so gating later is a one-row edit. */ +export function isProductSkillEntitled(name: string, entitlements: readonly string[] = []): boolean { + const required = GATED_PRODUCT_SKILLS[name]; + return required === undefined || entitlements.includes(required); +} + +/** Library skills from the central amico-plugin library, discovered by SURFACE + * TAG (spec-20260708-112732 §4.5/§7.1). The library root is SCANNED, but ONLY + * skills whose frontmatter carries `surface: product` are returned — `internal` + * and untagged skills (the ~44 process skills) are the leak hazard and are + * DROPPED. The tag is the least-privilege guard that the old hardcoded name + * list used to be; staging (stageOpencodeSkills) still copies only THIS + * selected set to the per-session stage dir — `skills.paths` never points at + * the library root itself. First root holding a given `/SKILL.md` wins. + * + * `isEntitled` is the entitlement seam: a product skill is included only when + * the predicate admits its name. The default admits every product skill (the + * public-today behaviour); production wires isProductSkillEntitled so a future + * GATED_PRODUCT_SKILLS row gates without a code change. */ +export function resolveLibrarySkills( + roots: string[], + isEntitled: (name: string) => boolean = () => true, +): SkillIndexEntry[] { const out: SkillIndexEntry[] = []; - for (const name of names) { - const skillPath = roots.map((r) => path.join(expandHome(r), name, "SKILL.md")).find((p) => fs.existsSync(p)); - if (!skillPath) continue; // configured-but-absent — silently skipped + const seen = new Set(); // first-root-wins, keyed by dir name + for (const r of roots) { + const root = expandHome(r); + let names: string[] = []; try { - const fm = readFrontmatter(skillPath); + names = fs.readdirSync(root); + } catch { + continue; // missing library root — silently skipped (session proceeds) + } + for (const name of names.sort()) { + if (seen.has(name)) continue; + const skillPath = path.join(root, name, "SKILL.md"); + if (!fs.existsSync(skillPath)) continue; + let fm: { name: string; description: string; surface?: string }; + try { + fm = readFrontmatter(skillPath); + } catch (e) { + console.warn(`amicode: skipping malformed library skill ${skillPath}: ${e}`); + continue; + } + if (fm.surface !== "product") continue; // THE GUARD: internal/untagged never stage + seen.add(name); // this dir is the authoritative product skill (earlier root wins) + if (!isEntitled(fm.name)) continue; // §7.1 entitlement seam (no-op today) out.push({ source: "library", name: fm.name, description: fm.description, path: skillPath }); - } catch (e) { - console.warn(`amicode: skipping malformed library skill ${skillPath}: ${e}`); } } return out; diff --git a/packages/extension/src/scores/router.ts b/packages/extension/src/scores/router.ts index acc98fcc..1c0c6ffe 100644 --- a/packages/extension/src/scores/router.ts +++ b/packages/extension/src/scores/router.ts @@ -11,8 +11,8 @@ export function buildRouterSection(visible: Score[]): string { "## Onset router", "", "When a session opens without a specific request, after your one-line Amico", - 'intro ask exactly one question — "What do you want to do today?" — via', - "`amicode_ask` when available, with these options:", + 'intro ask exactly one question — "What do you want to do today?" — via the', + "native `question` tool, with these options:", "", ]; if (cards.length > 0) { diff --git a/packages/extension/src/substrate/mount_store.ts b/packages/extension/src/substrate/mount_store.ts new file mode 100644 index 00000000..760d093a --- /dev/null +++ b/packages/extension/src/substrate/mount_store.ts @@ -0,0 +1,239 @@ +/** Armonia mount-stack discovery + precedence (spec-20260707-002846 Component 1 + * — "bootstrap parity", read side). + * + * A TypeScript port of the amico-plugin session-start hook's mount discovery + * (the PARITY ORACLE: ~/harmoniqs/amico-plugin-vault-cli/hooks/session-start, + * branch feat/amico-vault-mounts-toml / PR #27, lines 53–232). Same ranks, same + * skip/rescue semantics, same unlisted-append behavior. + * + * Canonical kind ranks follow the APPROVED vault-CLI spec + * (spec-20260703-053956), NOT the Ombra draft's table: the draft swapped + * team/restricted; we keep restricted(3) < team(4) (spec correction — see the + * parity PR body). Ranks: + * personal 0 · engagement 1 · project 2 · restricted 3 · team 4 · public 5 · other 6 + * Writable-by-default: personal/engagement/project = rw; the rest = ro. + * + * Everything here is read-only and failure-tolerant: a missing vaults root, + * unreadable marker, or unparseable manifest yields the empty/degraded value + * and a warning — never a throw (the session must boot regardless). + * + * TWIN / UNIFY-LATER: a second copy of this resolver lives in + * amico-run (packages/amico-run/src/mounts.ts) with the same API + semantics + * plus an $AMICO_VAULTS_ROOT/$AMICO_MOUNTS_TOML env seam (its verb tests cross a + * child-process boundary; this in-process vitest twin needs no env seam). The + * duplication is deliberate short-term (Ombra spec chose extension-resident + * mount discovery; depth-1 §7.3 wants the CLI to own it long-term). Unify-later + * follow-up: fold both onto the amico-run implementation once the CLI is the + * single retrieval spine. */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse as parseToml } from "smol-toml"; + +export interface Mount { + /** Resolved mount name (marker `name`, else dir basename). */ + name: string; + /** Mount kind (marker `kind`, overridable by a matching manifest entry). */ + kind: string; + /** Absolute path to the vault dir (the dir holding `.amico-vault.toml`). */ + path: string; + /** Writable-by-default posture (rw when true, ro when false). */ + writable: boolean; +} + +export interface MountStack { + /** Mounts in read precedence (top = highest precedence). */ + mounts: Mount[]; + /** Non-fatal skip/degrade notices (mirror the oracle's `⚠ skipped:` lines). */ + warnings: string[]; +} + +export function defaultVaultsRoot(): string { + return path.join(os.homedir(), ".amico", "vaults"); +} + +export function defaultMountsTomlPath(): string { + return path.join(os.homedir(), ".amico", "mounts.toml"); +} + +/** Canonical kind order (vault-CLI spec-20260703-053956). Unknown → 6. */ +function kindRank(kind: string): number { + switch (kind) { + case "personal": + return 0; + case "engagement": + return 1; + case "project": + return 2; + case "restricted": + return 3; + case "team": + return 4; + case "public": + return 5; + default: + return 6; + } +} + +/** Writability default by kind (oracle lines 142–145). */ +function writableByKind(kind: string): boolean { + return kind === "personal" || kind === "project" || kind === "engagement"; +} + +interface ManifestEntry { + id?: string; + kind?: string; + path?: string; + writable?: boolean | string; +} + +/** Parse `~/.amico/mounts.toml`'s `[[mount]]` array. Missing file → []; a parse + * failure is tolerated (→ [] + warning) so a garbled manifest degrades to + * kind-rank ordering rather than bricking discovery. */ +function loadManifest(mountsTomlPath: string, warnings: string[]): ManifestEntry[] { + let text: string; + try { + text = fs.readFileSync(mountsTomlPath, "utf8"); + } catch { + return []; // absent manifest is the common case, not a warning + } + try { + const parsed = parseToml(text) as { mount?: ManifestEntry[] }; + return Array.isArray(parsed.mount) ? parsed.mount : []; + } catch { + warnings.push(`mounts.toml unparseable at ${mountsTomlPath} — falling back to kind-rank ordering`); + return []; + } +} + +/** The manifest match key for a mount entry: its `id`, else its `path` basename + * (oracle ordering match, hook lines 162–166 / 173). */ +function manifestKey(entry: ManifestEntry): string | undefined { + if (typeof entry.id === "string" && entry.id !== "") return entry.id; + if (typeof entry.path === "string" && entry.path !== "") return path.basename(entry.path); + return undefined; +} + +function manifestWritable(entry: ManifestEntry): boolean | undefined { + if (entry.writable === true || entry.writable === "true") return true; + if (entry.writable === false || entry.writable === "false") return false; + return undefined; +} + +/** Discover + order the Armonia mount stack. + * + * Discovery: every dir under `vaultsRoot` with an `.amico-vault.toml` marker. + * `name` defaults to the dir basename. Kind resolution order (oracle lines + * 125–133): the manifest `kind` override applies BEFORE the missing-kind skip — + * a marker with no `kind` but a matching manifest entry is RESCUED with the + * manifest kind; only a mount with no kind from either source is skipped. + * Duplicate resolved name → the later discovery is skipped + warned. + * + * Ordering: with a manifest present, its array order governs (each entry + * matched by id-or-path-basename against a discovered mount); unlisted mounts + * append in DISCOVERY order (NOT kind-rank — oracle lines 181–187). Absent + * manifest → kind-rank then name. */ +export function resolveMountStack( + vaultsRoot: string = defaultVaultsRoot(), + mountsTomlPath: string = defaultMountsTomlPath(), +): MountStack { + const warnings: string[] = []; + + let entries: string[]; + try { + entries = fs.readdirSync(vaultsRoot).sort(); // sorted = deterministic discovery order (matches the glob) + } catch { + return { mounts: [], warnings }; // missing/unreadable root → empty stack, no throw + } + + const manifest = loadManifest(mountsTomlPath, warnings); + // Kind/writable override is keyed on id === name (oracle manifest_field, strict id). + const overrideById = new Map(); + for (const e of manifest) { + if (typeof e.id === "string" && e.id !== "") overrideById.set(e.id, e); + } + + const discovered: Mount[] = []; + const seen = new Set(); + for (const base of entries) { + const dir = path.join(vaultsRoot, base); + const marker = path.join(dir, ".amico-vault.toml"); + let markerText: string; + try { + markerText = fs.readFileSync(marker, "utf8"); + } catch { + warnings.push(`skipped '${base}': no .amico-vault.toml marker`); + continue; + } + let kind = ""; + let name = base; + try { + const m = parseToml(markerText) as { kind?: unknown; name?: unknown }; + if (typeof m.kind === "string") kind = m.kind; + if (typeof m.name === "string" && m.name !== "") name = m.name; + } catch { + warnings.push(`skipped '${base}': .amico-vault.toml is unparseable`); + continue; + } + // Manifest kind override BEFORE the missing-kind skip (oracle rescue rule). + const override = overrideById.get(name); + if (override && typeof override.kind === "string" && override.kind !== "") kind = override.kind; + if (kind === "") { + warnings.push(`skipped '${base}': marker missing 'kind'`); + continue; + } + if (seen.has(name)) { + warnings.push(`skipped '${base}': duplicate id '${name}'`); + continue; + } + seen.add(name); + let writable = writableByKind(kind); + const w = override ? manifestWritable(override) : undefined; + if (w !== undefined) writable = w; + discovered.push({ name, kind, path: dir, writable }); + } + + const ordered = manifest.length > 0 ? orderByManifest(discovered, manifest) : orderByKindRank(discovered); + return { mounts: ordered, warnings }; +} + +/** Manifest array order, then unlisted mounts in discovery order (oracle 157–188). */ +function orderByManifest(discovered: Mount[], manifest: ManifestEntry[]): Mount[] { + const ordered: Mount[] = []; + const emitted = new Set(); + for (const entry of manifest) { + const key = manifestKey(entry); + if (key === undefined) continue; + for (const m of discovered) { + if (emitted.has(m.name)) continue; + if (m.name === key || path.basename(m.path) === key) { + ordered.push(m); + emitted.add(m.name); + } + } + } + for (const m of discovered) { + if (!emitted.has(m.name)) { + ordered.push(m); + emitted.add(m.name); + } + } + return ordered; +} + +/** Kind rank, then name (oracle `sort -k1,1n -k2,2`). */ +function orderByKindRank(discovered: Mount[]): Mount[] { + return [...discovered].sort((a, b) => { + const ra = kindRank(a.kind); + const rb = kindRank(b.kind); + if (ra !== rb) return ra - rb; + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); +} + +/** The personal mount (first `kind === "personal"` in stack order), or + * undefined. This is what the config funnel maps to the legacy `vaultDir`. */ +export function personalMount(stack: MountStack): Mount | undefined { + return stack.mounts.find((m) => m.kind === "personal"); +} diff --git a/packages/extension/src/substrate/user_splice.ts b/packages/extension/src/substrate/user_splice.ts index 069a3a46..a38484ca 100644 --- a/packages/extension/src/substrate/user_splice.ts +++ b/packages/extension/src/substrate/user_splice.ts @@ -1,7 +1,11 @@ /** The personalized splice (spec-20260705-002847 §6): two lean sections built * from the vault's user-memory files. Both are ≤~3 KB by construction (profile * capped at ~30 lines by convention, knowledge lines capped at 50 by the - * reader); the agent reads full cards on demand from the granted vault path. */ + * reader); the agent reads full cards on demand from the granted vault path. + * + * The mount-stack + memory-index sections (spec-20260707-002846 C3/C4 read + * side) live here too — same "build a lean section, splice on demand" shape. */ +import type { MountStack } from "./mount_store"; export function buildAboutUserSection(profileMd: string): string { if (!profileMd) return ""; @@ -29,6 +33,52 @@ export function buildReferenceDemosSection(demoLines: string[]): string { ].join("\n"); } +/** The Armonia mount stack, top→bottom in read precedence, plus a condensed + * static block mirroring the amico-vault skill's "Mounts & resolution" (so the + * agent knows how reads union and how writes route without loading the skill). + * Empty stack → "" (no mounts discovered ⇒ nothing to say). Parity oracle: the + * session-start hook's rendered "Mount stack" block. */ +export function buildMountStackSection(stack: MountStack): string { + if (stack.mounts.length === 0) return ""; + const mountLines = stack.mounts.map( + (m) => `- ${m.name} · kind=${m.kind} · ${m.writable ? "rw" : "ro"} · ${m.path}`, + ); + const warnLines = stack.warnings.map((w) => `- ⚠ ${w}`); + return [ + "## Mount stack (Armonia — read precedence top→bottom)", + "", + ...mountLines, + ...warnLines, + "", + "Resolution & write-routing (condensed from the amico-vault skill):", + "- Reads union across all mounts; on the same relative path the first hit", + " top→bottom wins (higher-precedence mount shadows lower).", + "- Writes route by intent to the first WRITABLE mount of that kind:", + " personal→personal, engagement→engagement, project→project,", + " restricted/team/public→their own kind.", + "- If the target mount is absent or read-only, write to the personal mount", + " and stamp `route_intent: ` in the note frontmatter — never silently", + " drop a write, never write a ro mount.", + "- Ambiguous intent: ask once, else default to personal.", + ].join("\n"); +} + +/** The typed-memory index (spec-20260707-002846 C4 read side): the one-line + * pointers from `amicode/memory/MEMORY.md`. Only the index is spliced; the full + * typed cards load on demand from the granted vault path. No lines → "". */ +export function buildMemoryIndexSection(memoryIndexLines: string[]): string { + if (memoryIndexLines.length === 0) return ""; + return [ + "## Memory index", + "", + ...memoryIndexLines, + "", + "These are one-line pointers. The full typed-memory cards (user / feedback /", + "project / reference) load on demand from the granted vault path under", + "`amicode/memory/` — read a card only when its hook is relevant to the turn.", + ].join("\n"); +} + export function buildRecentProblemsSection(knowledgeLines: string[]): string { if (knowledgeLines.length === 0) return ""; return [ diff --git a/packages/extension/src/substrate/vault_store.ts b/packages/extension/src/substrate/vault_store.ts index 429b236e..a9a02ef2 100644 --- a/packages/extension/src/substrate/vault_store.ts +++ b/packages/extension/src/substrate/vault_store.ts @@ -9,6 +9,7 @@ import * as os from "node:os"; import * as path from "node:path"; export const KNOWLEDGE_LINE_CAP = 50; +export const MEMORY_INDEX_LINE_CAP = 50; export function defaultVaultsRoot(): string { return path.join(os.homedir(), ".amico", "vaults"); @@ -79,6 +80,14 @@ export function readDemoLines(vaultDir: string, cap = 30): string[] { return readIndexLines(vaultDir, "DEMOS.md", cap); } +/** Typed-memory index list lines (spec-20260707-002846 C4). The distiller writes + * durable facts as typed cards under `/amicode/memory/` and maintains a + * one-line index at `memory/MEMORY.md`; only that index is spliced (the cards + * load on demand). Subdir-capable reuse of the readIndexLines pattern. */ +export function readMemoryIndexLines(vaultDir: string, cap: number = MEMORY_INDEX_LINE_CAP): string[] { + return readIndexLines(vaultDir, path.join("memory", "MEMORY.md"), cap); +} + /** Second disjunct of the routing predicate (§3): completed marker in the * onboarding stream. Malformed lines are skipped. */ export function hasOnboardingCompleted(onboardingStreamDir: string): boolean { diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index 5adfc850..9462b27d 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -61,6 +61,14 @@ describe("AGENTS.md teaches the D9/D10 script-authoring workflow", () => { expect(AGENTS).not.toMatch(/--system\b/); expect(AGENTS).not.toMatch(/load_pulse/); }); + it("teaches the Formulation → Piccolo authoring map (typed facets)", () => { + expect(AGENTS).toMatch(/Formulation authoring map/); + expect(AGENTS).toMatch(/MinimumTimeProblem/); + expect(AGENTS).toMatch(/SamplingProblem/); + expect(AGENTS).toMatch(/trajectory_type/); + expect(AGENTS).toMatch(/free_phase = true/); + expect(AGENTS).toMatch(/primary infidelity objective is derived/i); + }); }); describe("AGENTS.md pulse-designer interview (Layer 0)", () => { diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts index c0c16f50..4fb14957 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -23,6 +23,14 @@ import { validateSystem, validateFormulation, updateSystem, + validateCompositeSystem, + compositeSystemWarnings, + normalizeSystem, + updateCompositeSystem, + compositeSystemToml, + expandTopology, + replicateHomogeneous, + type CompositeSystem, canonicalJson, deriveSlug, entityDiff, @@ -41,10 +49,15 @@ const SYS: SystemEntity = { }; const FORM: FormulationEntity = { - problem: "gate_synthesis", + trajectory_type: "gate", + time_mode: "fixed", + parameterization: "smooth", + robustness: { kind: "none", params: {} }, + free_phase: false, + leakage: false, target: "X", - objective: "unitary infidelity", - constraints: ["amplitude bound (drive_max)", "smoothness"], + objectives: [], + constraints: [{ kind: "bounds", params: {}, label: "amplitude bound (drive_max)" }], }; describe("systemToml", () => { @@ -85,26 +98,21 @@ describe("systemToml", () => { }); describe("formulationToml", () => { - it("round-trips problem/target/objective/constraints under [formulation]", () => { + it("round-trips the structured facets under [formulation]", () => { const doc = parse(formulationToml(FORM)) as any; - expect(doc.formulation.problem).toBe("gate_synthesis"); + expect(doc.formulation.trajectory_type).toBe("gate"); expect(doc.formulation.target).toBe("X"); - expect(doc.formulation.objective).toBe("unitary infidelity"); - expect(doc.formulation.constraints).toEqual(FORM.constraints); + expect(doc.formulation.robustness).toEqual({ kind: "none", params: {} }); + expect(doc.formulation.constraints[0].kind).toBe("bounds"); expect(Number.isNaN(Date.parse(doc.formulation.recorded))).toBe(false); }); it("escapes quotes, backslashes, and newlines in string values (round-trip exact)", () => { const nasty = 'say "hi" \\ then\nnewline\ttab'; - const doc = parse(formulationToml({ ...FORM, target: nasty, constraints: [nasty] })) as any; + const doc = parse(formulationToml({ ...FORM, target: nasty })) as any; expect(doc.formulation.target).toBe(nasty); - expect(doc.formulation.constraints).toEqual([nasty]); }); - it("rejects an empty or whitespace-only target", () => { - expect(() => formulationToml({ ...FORM, target: "" })).toThrow(/target/); - expect(() => formulationToml({ ...FORM, target: " " })).toThrow(/target/); - }); - it("rejects an empty problem", () => { - expect(() => formulationToml({ ...FORM, problem: "" })).toThrow(/problem/); + it("rejects an unknown enum value", () => { + expect(() => formulationToml({ ...FORM, trajectory_type: "bogus" as any })).toThrow(/trajectory_type/); }); }); @@ -116,7 +124,7 @@ describe("validateSystem / validateFormulation", () => { it("name the offending field in each problem message", () => { expect(validateSystem({ ...SYS, platform: "" as any }).join(" ")).toMatch(/platform/); expect(validateSystem({ ...SYS, levels: 1 }).join(" ")).toMatch(/levels/); - expect(validateFormulation({ ...FORM, target: "" }).join(" ")).toMatch(/target/); + expect(validateFormulation({ ...FORM, time_mode: "nope" as any }).join(" ")).toMatch(/time_mode/); }); }); @@ -254,10 +262,15 @@ describe("opened entity model (spec A)", () => { }); it("round-trips formulation.solve through TOML", () => { const f: FormulationEntity = { - problem: "min_time", + trajectory_type: "gate", + time_mode: "min_time", + parameterization: "smooth", + robustness: { kind: "none", params: {} }, + free_phase: false, + leakage: false, target: "CZ", - objective: "unitary infidelity", - constraints: ["amplitude bound"], + objectives: [], + constraints: [{ kind: "dt_bounds", params: {} }], solve: { T: 10, N: 50, max_iter: 60, integrator: "MagnusGL4" }, }; const parsed = parse(formulationToml(f)) as any; @@ -321,3 +334,222 @@ describe("problem + run-ref serializers", () => { expect((parse(t) as any).runs[0].tier).toBe("vetted"); }); }); + +describe("composite system schema + validation (spec-20260709)", () => { + const COMP: CompositeSystem = { + platform: "transmon", + components: [ + { id: "q1", role: "qubit", levels: 3, params: { omega: 4.8, delta: -0.2 } }, + { id: "q2", role: "qubit", levels: 3, params: { omega: 4.9, delta: -0.2 } }, + ], + couplings: [{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }], + topology: "single-pair", + drive: { arch: "per-component" }, + }; + + it("accepts a valid composite", () => { + expect(validateCompositeSystem(COMP)).toEqual([]); + }); + + it("N=1 (degenerate single-qubit) is valid with empty couplings", () => { + expect( + validateCompositeSystem({ + platform: "transmon", + components: [{ id: "q1", role: "qubit", levels: 3, params: {} }], + couplings: [], + drive: { arch: "per-component" }, + }), + ).toEqual([]); + }); + + it("accepts an arbitrary (open) platform string; rejects empty", () => { + expect(validateCompositeSystem({ ...COMP, platform: "fluxonium-xyz" })).toEqual([]); + expect(validateCompositeSystem({ ...COMP, platform: "" }).join(" ")).toMatch(/platform/); + }); + + it("rejects unknown role / kind / topology / drive.arch (closed sets)", () => { + expect( + validateCompositeSystem({ ...COMP, components: [{ id: "q1", role: "spin" as any, params: {} }] }).join(" "), + ).toMatch(/role/); + expect( + validateCompositeSystem({ + ...COMP, + couplings: [{ between: ["q1", "q2"], kind: "banana" as any, params: {} }], + }).join(" "), + ).toMatch(/kind/); + expect(validateCompositeSystem({ ...COMP, topology: "grid" as any }).join(" ")).toMatch(/topology/); + expect(validateCompositeSystem({ ...COMP, drive: { arch: "telepathy" as any } }).join(" ")).toMatch(/drive/); + }); + + it("rejects a coupling referencing an unknown component id", () => { + expect( + validateCompositeSystem({ + ...COMP, + couplings: [{ between: ["q1", "q9"], kind: "cross-resonance", params: {} }], + }).join(" "), + ).toMatch(/unknown component/); + }); + + it("rejects duplicate component ids", () => { + expect( + validateCompositeSystem({ ...COMP, components: [COMP.components[0], COMP.components[0]] }).join(" "), + ).toMatch(/duplicate/); + }); + + it("mode-mediated requires exactly one mode/resonator member (ion + motional mode)", () => { + const ok: CompositeSystem = { + platform: "ion", + components: [ + { id: "i1", role: "atom", params: {} }, + { id: "i2", role: "atom", params: {} }, + { id: "m1", role: "mode", levels: 8, params: {} }, + ], + couplings: [{ between: ["i1", "i2", "m1"], kind: "mode-mediated", params: { eta: 0.1 } }], + drive: { arch: "global" }, + }; + expect(validateCompositeSystem(ok)).toEqual([]); + const bad = { ...ok, couplings: [{ between: ["i1", "i2"], kind: "mode-mediated" as const, params: {} }] }; + expect(validateCompositeSystem(bad).join(" ")).toMatch(/mode-mediated/); + }); + + it("rejects non-integer / <2 component levels", () => { + expect( + validateCompositeSystem({ ...COMP, components: [{ id: "q1", role: "qubit", levels: 1, params: {} }] }).join(" "), + ).toMatch(/levels/); + expect( + validateCompositeSystem({ ...COMP, components: [{ id: "q1", role: "qubit", levels: 3.5, params: {} }] }).join(" "), + ).toMatch(/levels/); + }); + + it("heterogeneous cavity+qubit (bosonic) is native", () => { + const bosonic: CompositeSystem = { + platform: "bosonic", + components: [ + { id: "q1", role: "qubit", levels: 2, params: {} }, + { id: "cav", role: "cavity", levels: 12, params: { kerr: -0.001 } }, + ], + couplings: [{ between: ["q1", "cav"], kind: "dispersive-chi", params: { chi: 0.002 } }], + drive: { arch: "per-component" }, + }; + expect(validateCompositeSystem(bosonic)).toEqual([]); + }); + + it("compositeSystemWarnings are soft (never a rejection)", () => { + const lowCav: CompositeSystem = { + platform: "bosonic", + components: [{ id: "cav", role: "cavity", levels: 2, params: {} }], + couplings: [], + drive: { arch: "per-component" }, + }; + expect(validateCompositeSystem(lowCav)).toEqual([]); // valid... + expect(compositeSystemWarnings(lowCav).join(" ")).toMatch(/Fock/); // ...but warned + expect(compositeSystemWarnings(COMP)).toEqual([]); // clean 3-level qubits, no warning + }); +}); + +describe("normalizeSystem + composite merge/toml/hash (spec-20260709)", () => { + const COMPOSITE: CompositeSystem = { + platform: "transmon", + components: [ + { id: "q1", role: "qubit", levels: 3, params: { omega: 4.8, delta: -0.2 } }, + { id: "q2", role: "qubit", levels: 3, params: { omega: 4.9, delta: -0.2 } }, + ], + couplings: [{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }], + topology: "single-pair", + drive: { arch: "per-component" }, + }; + + it("flat → N=1 composite (levels→components[0], notes carried, role/arch from platform)", () => { + const c = normalizeSystem({ platform: "transmon", levels: 3, params: { omega: 4.8 }, notes: "prose" }); + expect(c.components).toHaveLength(1); + expect(c.components[0]).toMatchObject({ id: "q1", role: "qubit", levels: 3, params: { omega: 4.8 } }); + expect(c.couplings).toEqual([]); + expect(c.drive.arch).toBe("per-component"); + expect(c.notes).toBe("prose"); + expect(validateCompositeSystem(c)).toEqual([]); + }); + + it("rydberg→atom/global; unknown→qubit/per-component; absent flat levels stays absent", () => { + const r = normalizeSystem({ platform: "rydberg", levels: 3, params: {} }); + expect(r.components[0].role).toBe("atom"); + expect(r.drive.arch).toBe("global"); + const u = normalizeSystem({ platform: "fluxonium", params: {} }); + expect(u.components[0].role).toBe("qubit"); + expect(u.components[0].levels).toBeUndefined(); + expect(u.drive.arch).toBe("per-component"); + }); + + it("is idempotent on an already-composite entity", () => { + expect(normalizeSystem(COMPOSITE)).toEqual(COMPOSITE); + }); + + it("updateCompositeSystem tolerates a FLAT existing (F1) + merges a composite patch", () => { + const merged = updateCompositeSystem( + { platform: "transmon", levels: 3, params: { omega: 4.8 } }, + { + components: [{ id: "q2", role: "qubit", levels: 3, params: { omega: 4.9 } }], + couplings: [{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }], + topology: "single-pair", + drive: { arch: "per-component" }, + }, + ); + expect(merged.components.map((c) => c.id).sort()).toEqual(["q1", "q2"]); + expect(merged.couplings).toHaveLength(1); + expect(merged.topology).toBe("single-pair"); + expect(validateCompositeSystem(merged)).toEqual([]); + }); + + it("compositeSystemToml round-trips through smol-toml (AoT + inline params)", () => { + const doc = parse(compositeSystemToml(COMPOSITE)) as any; + expect(doc.system.platform).toBe("transmon"); + expect(doc.system.topology).toBe("single-pair"); + expect(doc.system.drive.arch).toBe("per-component"); + expect(doc.system.components).toHaveLength(2); + expect(doc.system.components[0].id).toBe("q1"); + expect(doc.system.components[0].params.omega).toBe(4.8); + expect(doc.system.couplings[0].kind).toBe("cross-resonance"); + expect(doc.system.couplings[0].between).toEqual(["q1", "q2"]); + }); + + it("canonicalJson drops notes → composites differing only in notes hash-equal", () => { + const a = normalizeSystem({ platform: "transmon", levels: 3, params: { omega: 4.8 }, notes: "x" }); + const b = normalizeSystem({ platform: "transmon", levels: 3, params: { omega: 4.8 }, notes: "DIFFERENT" }); + expect(canonicalJson(a)).toBe(canonicalJson(b)); + }); +}); + +describe("topology expansion + homogeneous replicate (spec-20260709)", () => { + it("single-pair → 1 edge over [q1,q2]", () => { + const edges = expandTopology("single-pair", ["q1", "q2"], "cross-resonance", { g: 0.005 }); + expect(edges).toEqual([{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }]); + }); + it("linear-chain(N) → N-1 edges in canonical order", () => { + const edges = expandTopology("linear-chain", ["q1", "q2", "q3", "q4"], "exchange"); + expect(edges.map((e) => e.between)).toEqual([ + ["q1", "q2"], + ["q2", "q3"], + ["q3", "q4"], + ]); + expect(edges.every((e) => e.kind === "exchange")).toBe(true); + }); + it("custom → [] (edges authored directly)", () => { + expect(expandTopology("custom", ["q1", "q2"], "ZZ")).toEqual([]); + }); + it("single-pair with wrong arity throws", () => { + expect(() => expandTopology("single-pair", ["q1", "q2", "q3"], "ZZ")).toThrow(/single-pair/); + }); + it("deferred presets (e.g. ring) throw §9", () => { + expect(() => expandTopology("ring" as any, ["q1", "q2"], "ZZ")).toThrow(/deferred/); + }); + it("replicateHomogeneous → N components identical except id (q1..qN)", () => { + const comps = replicateHomogeneous({ role: "qubit", levels: 3, params: { omega: 4.8 } }, 3); + expect(comps.map((c) => c.id)).toEqual(["q1", "q2", "q3"]); + expect(comps.every((c) => c.role === "qubit" && c.levels === 3 && c.params.omega === 4.8)).toBe(true); + // mutating one component's params must not alias the others + comps[0].params.omega = 9; + expect(comps[1].params.omega).toBe(4.8); + }); + it("replicateHomogeneous rejects n < 1", () => { + expect(() => replicateHomogeneous({ role: "qubit", params: {} }, 0)).toThrow(/n >= 1/); + }); +}); diff --git a/packages/extension/test/calibration_graph.test.ts b/packages/extension/test/calibration_graph.test.ts new file mode 100644 index 00000000..dd37eba3 --- /dev/null +++ b/packages/extension/test/calibration_graph.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadGraph, evaluate, type CalibrationGraph, type NodeState } from "../src/calibration_graph"; + +// Spec A §4 — the deterministic calibration graph (Kelly et al. "Optimus" DAG). +// Cycle rejection at load (§4.1); pure + total evaluate() (§4.3): own-status, +// suspect-propagation over the topo order, verdict + ranked action list. All +// exercised on the Snowbird fixture DAG (§4.4). Fixed NOW → no wall-clock. + +const NOW = Date.parse("2026-07-07T00:00:00Z"); // fixed epoch ms — deterministic +const FIXTURE = readFileSync(join(__dirname, "corpus", "snowbird-graph.toml"), "utf8"); + +function loadOk(): CalibrationGraph { + const r = loadGraph(FIXTURE); + if (!r.ok) throw new Error(`fixture failed to load: ${r.error}`); + return r.graph; +} + +/** Seed every node with a fresh (recent) result → all calibrated. */ +function freshState(g: CalibrationGraph): Record { + const fresh = new Date(NOW - 60_000).toISOString(); // 1 min ago + const state: Record = {}; + for (const name of g.nodes.keys()) state[name] = { value: {}, ts: fresh, status: "calibrated", job_id: `JOB-${name}` }; + return state; +} + +describe("calibration graph — loadGraph (acyclic)", () => { + it("loads the Snowbird fixture and orders dependencies before dependents", () => { + const g = loadOk(); + expect(g.nodes.has("cz_gate")).toBe(true); + expect(g.nodes.get("cz_gate")?.impl).toBe("qilc"); + expect(g.nodes.get("cz_gate")?.fallback).toBe("cz_gate_standard"); + // topo order: every dependency precedes the node that depends on it. + const pos = new Map(g.topoOrder.map((n, i) => [n, i])); + for (const node of g.nodes.values()) + for (const dep of node.depends_on) expect(pos.get(dep)!).toBeLessThan(pos.get(node.name)!); + // roots have depth 0; a leaf is deeper than its parents. + expect(g.depth("resonator_spec")).toBe(0); + expect(g.depth("qubit_spec")).toBe(1); + expect(g.depth("chevron")).toBeGreaterThan(g.depth("readout")); + }); + + it("rejects a graph with a dependency cycle with a typed error (§6 crit 7)", () => { + const cyclic = ` +[node.a] +depends_on = ["c"] +produces = ["x"] +impl = "standard" +[node.b] +depends_on = ["a"] +produces = ["y"] +impl = "standard" +[node.c] +depends_on = ["b"] +produces = ["z"] +impl = "standard" +`; + const r = loadGraph(cyclic); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.toLowerCase()).toContain("cycle"); + }); +}); + +describe("calibration graph — evaluate() (pure + total)", () => { + it("all-fresh state → every node calibrated / action none", () => { + const g = loadOk(); + const verdicts = evaluate(g, freshState(g), NOW); + expect(verdicts.every((v) => v.status === "calibrated")).toBe(true); + expect(verdicts.every((v) => v.recommended_action === "none")).toBe(true); + expect(verdicts.find((v) => v.node === "pi_amp")?.status).toBe("calibrated"); + }); + + it("a stale parent marks its full descendant closure suspect; the parent ranks first (§6 crit 2)", () => { + const g = loadOk(); + const state = freshState(g); + // qubit_spec measured well before its 12h ttl → stale. + state.qubit_spec = { value: {}, ts: new Date(NOW - 100 * 3600_000).toISOString(), status: "calibrated", job_id: "JOB-old" }; + const verdicts = evaluate(g, state, NOW); + const byNode = new Map(verdicts.map((v) => [v.node, v])); + + expect(byNode.get("qubit_spec")?.status).toBe("stale"); + expect(byNode.get("qubit_spec")?.recommended_action).toBe("check"); + // full descendant closure is suspect (§4.3 step 4) + for (const desc of ["pi_amp", "pi_len", "T1", "T2_ramsey", "T2_echo", "readout", "chevron", "cz_gate", "cz_gate_standard"]) + expect(byNode.get(desc)?.status, `${desc} should be suspect`).toBe("suspect"); + // the ancestor above the stale node is untouched + expect(byNode.get("resonator_spec")?.status).toBe("calibrated"); + + // ranked action list: qubit_spec (the moved parent) ranks before its children + const actionable = verdicts.filter((v) => v.recommended_action !== "none").map((v) => v.node); + expect(actionable.indexOf("qubit_spec")).toBeLessThan(actionable.indexOf("pi_amp")); + expect(actionable.indexOf("qubit_spec")).toBeLessThan(actionable.indexOf("chevron")); + }); + + it("empty state → uncharacterized / +∞ age / calibrate; ranked roots-first, name tie-broken (§6 crit 2)", () => { + const g = loadOk(); + const verdicts = evaluate(g, {}, NOW); + expect(verdicts.every((v) => v.status === "uncharacterized")).toBe(true); + expect(verdicts.every((v) => v.recommended_action === "calibrate")).toBe(true); + expect(verdicts.every((v) => v.ageSeconds === Infinity)).toBe(true); + + // roots-first: resonator_spec (depth 0) is the single most-urgent root. + expect(verdicts[0].node).toBe("resonator_spec"); + // deterministic name tie-break among equal (depth, +∞ age): pi_amp before pi_len. + const order = verdicts.map((v) => v.node); + expect(order.indexOf("pi_amp")).toBeLessThan(order.indexOf("pi_len")); + // a leaf never precedes its parent. + expect(order.indexOf("readout")).toBeLessThan(order.indexOf("chevron")); + }); + + it("an explicit failed status → failed / calibrate, and evaluate stays total on unknown nodes in state", () => { + const g = loadOk(); + const state = freshState(g); + state.readout = { value: { readout_fidelity: 0.5 }, ts: new Date(NOW - 60_000).toISOString(), status: "failed", job_id: "JOB-x" }; + state["ghost_node_not_in_graph"] = { value: {}, ts: new Date(NOW).toISOString(), status: "calibrated" }; + const verdicts = evaluate(g, state, NOW); + const byNode = new Map(verdicts.map((v) => [v.node, v])); + expect(byNode.get("readout")?.status).toBe("failed"); + expect(byNode.get("readout")?.recommended_action).toBe("calibrate"); + // chevron descends from readout → suspect; a stray state key is ignored (no throw, no verdict) + expect(byNode.get("chevron")?.status).toBe("suspect"); + expect(byNode.has("ghost_node_not_in_graph")).toBe(false); + }); +}); diff --git a/packages/extension/test/composite_skeletons.test.ts b/packages/extension/test/composite_skeletons.test.ts new file mode 100644 index 00000000..a09bc9a0 --- /dev/null +++ b/packages/extension/test/composite_skeletons.test.ts @@ -0,0 +1,36 @@ +// Golden-skeleton SNAPSHOT check (spec-20260709 §5 / §7.7, plan F3). +// +// This is a STRUCTURAL PRESENCE check of the documented example solve.jl skeletons — +// it verifies the intended authoring output (right constructor + per-component +// subsystem_levels + free_phase = N for entanglers), NOT a composite→constructor +// mapping function (deliberately NOT built — that's the §9 load-bearing non-goal). +// Real mapping verification is the Task 10 live run. +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +const read = (name: string) => + readFileSync(new URL(`./fixtures/composite-skeletons/${name}`, import.meta.url), "utf8"); + +describe("composite → solve.jl golden skeletons (snapshot presence)", () => { + it("2-transmon CZ → MultiTransmonSystem, subsystem_levels [3, 3], free_phase", () => { + const s = read("cz-2transmon.jl"); + expect(s).toContain("MultiTransmonSystem"); + expect(s).toContain("subsystem_levels = [3, 3]"); + expect(s).toContain("EmbeddedOperator"); + expect(s).toContain("free_phase = true"); + }); + + it("Rydberg CZ (global) → GlobalRydbergSystem, 3-level per atom, free_phase", () => { + const s = read("cz-rydberg-global.jl"); + expect(s).toContain("GlobalRydbergSystem"); + expect(s).toContain("subsystem_levels = [3, 3]"); + expect(s).toContain("free_phase = true"); + }); + + it("heterogeneous cavity+qubit → cavity system with a Fock-truncated cavity level", () => { + const s = read("cavity-qubit.jl"); + expect(s).toContain("subsystem_levels = [2, 12]"); // qubit 2, cavity Fock cutoff 12 + expect(s).toContain("dispersive-chi"); + expect(s).toContain("free_phase = true"); + }); +}); diff --git a/packages/extension/test/corpus/snowbird-graph.toml b/packages/extension/test/corpus/snowbird-graph.toml new file mode 100644 index 00000000..4fdc5cfa --- /dev/null +++ b/packages/extension/test/corpus/snowbird-graph.toml @@ -0,0 +1,108 @@ +# Snowbird calibration graph (Spec A §4.4) — the worked example DAG. +# resonator_spec → qubit_spec → {pi_amp, pi_len} +# → {T1, T2_ramsey, T2_echo, readout} → chevron +# cz_gate (impl=qilc, fallback=cz_gate_standard) depends on {T2_ramsey, readout} +# +# Snowbird is driven by its QICK MCP server, so every node's `experiment` is an +# `adapter = "mcp"` blob naming a measurement tool (§4.1 per-adapter opaque blob). +# This fixture backs the §6 evaluation acceptance tests — MOCK-only, no hardware. + +[node.resonator_spec] +depends_on = [] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "resonator_spectroscopy" } } +produces = ["resonator_freq"] +ttl_seconds = 86400 +impl = "standard" +[node.resonator_spec.thresholds] +check = { metric = "resonator_freq_drift", max = 0.5 } +calibrate = { metric = "resonator_snr", min = 5.0 } + +[node.qubit_spec] +depends_on = ["resonator_spec"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "qubit_spectroscopy" } } +produces = ["qubit_freq"] +ttl_seconds = 43200 +impl = "standard" +[node.qubit_spec.thresholds] +check = { metric = "qubit_freq_drift", max = 0.2 } +calibrate = { metric = "qubit_snr", min = 4.0 } + +[node.pi_amp] +depends_on = ["qubit_spec"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "amplitude_rabi" } } +produces = ["pi_amp"] +ttl_seconds = 43200 +impl = "standard" +[node.pi_amp.thresholds] +check = { metric = "pi_amp_drift", max = 0.02 } +calibrate = { metric = "rabi_contrast", min = 0.8 } + +[node.pi_len] +depends_on = ["qubit_spec"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "length_rabi" } } +produces = ["pi_len"] +ttl_seconds = 43200 +impl = "standard" + +[node.T1] +depends_on = ["pi_amp", "pi_len"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "t1" } } +produces = ["T1"] +ttl_seconds = 21600 +impl = "standard" + +[node.T2_ramsey] +depends_on = ["pi_amp", "pi_len"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "t2_ramsey" } } +produces = ["T2_ramsey"] +ttl_seconds = 21600 +impl = "standard" + +[node.T2_echo] +depends_on = ["pi_amp", "pi_len"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "t2_echo" } } +produces = ["T2_echo"] +ttl_seconds = 21600 +impl = "standard" + +[node.readout] +depends_on = ["pi_amp", "pi_len"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "readout_optimization" } } +produces = ["readout_fidelity"] +ttl_seconds = 21600 +impl = "standard" + +[node.chevron] +depends_on = ["readout"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "chevron" } } +produces = ["chevron_map"] +ttl_seconds = 21600 +impl = "standard" + +# A qilc (PREMIUM, §5.2) node names a standard fallback so an UNentitled user +# still gets a computable recommendation. +[node.cz_gate] +depends_on = ["T2_ramsey", "readout"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "cz_qilc" } } +produces = ["cz_fidelity"] +ttl_seconds = 21600 +impl = "qilc" +fallback = "cz_gate_standard" + +[node.cz_gate_standard] +depends_on = ["T2_ramsey", "readout"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "cz_standard" } } +produces = ["cz_fidelity"] +ttl_seconds = 21600 +impl = "standard" diff --git a/packages/extension/test/device_acceptance.test.ts b/packages/extension/test/device_acceptance.test.ts new file mode 100644 index 00000000..c6cc4cb2 --- /dev/null +++ b/packages/extension/test/device_acceptance.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadGraph } from "../src/calibration_graph"; +import { DeviceRegistry, type CalibrationEvent } from "../src/device_registry"; +import { buildDeviceStatus, nextActions } from "../src/device_status"; +import { MockJobServer } from "../src/qick_job_server"; +import { SchusterJobServer } from "../src/qick_client"; + +// Spec A §6 acceptance sweep — the whole flow END-TO-END on MockJobServer + the +// Snowbird fixture DAG, no hardware. Each `it` names the criterion it pins; the +// finer-grained unit coverage lives in the per-module tests. This is the +// integration proof that the mock substrate backs every §6 criterion. + +const NOW = Date.parse("2026-07-07T00:00:00Z"); +const FIXTURE = readFileSync(join(__dirname, "corpus", "snowbird-graph.toml"), "utf8"); +function graph() { + const r = loadGraph(FIXTURE); + if (!r.ok) throw new Error(r.error); + return r.graph; +} +const DRIVE_LINES = [ + { id: "ch0", target: "Q1", kind: "drive" }, + { id: "ch1", target: "Q1", kind: "flux" }, +]; + +describe("Spec A §6 acceptance sweep (MockJobServer + Snowbird fixture)", () => { + it("crit 3 + crit 1: an idle mock queue yields a non-empty ranked list over an honest projection", async () => { + const g = graph(); + const js = new MockJobServer({ channels: ["ch0", "ch1"], capabilities: [] }); + const health = await js.health(); + const status = buildDeviceStatus({ + graph: g, + state: {}, // nothing measured yet + now: NOW, + driveLines: DRIVE_LINES, + qubits: ["Q1"], + onlineChannels: health.channels, + }); + // honesty: empty state → uncharacterized qubit, no fabricated metrics + expect(status.qubits[0].status).toBe("uncharacterized"); + expect(Object.keys(status.metrics)).toHaveLength(0); + expect(status.driveLines.every((d) => d.online)).toBe(true); // both channels reported + + const r = nextActions(g, {}, await js.queue(), NOW, { entitled: true }); + expect(r.idle).toBe(true); + expect(r.ranked_actions.length).toBeGreaterThan(0); + // roots-first: resonator_spec (the only depth-0 node) is the first action + expect(r.ranked_actions[0].node).toBe("resonator_spec"); + }); + + it("crit 3: a running job for the device flips idle=false", async () => { + const g = graph(); + const js = new MockJobServer(); + await js.submit({ user: "amico", experiment: { adapter: "mock", payload: {} } }); + expect(nextActions(g, {}, await js.queue(), NOW, { entitled: true }).idle).toBe(false); + }); + + it("crit 6: replaying a finished-job event → zero change to state.json (through the registry)", async () => { + const js = new MockJobServer(); + await js.submit({ user: "amico", experiment: { adapter: "mock", payload: { tool: "amplitude_rabi" } } }); + const finished = await js.runNext({ result: { pi_amp: 0.031 } }); + expect(finished?.status).toBe("completed"); + + // the TS QUEUE CLIENT (never the loop, §2.4) turns the finished job into a + // state event and records it — idempotently. + const reg = new DeviceRegistry(); + const ev: CalibrationEvent = { node: "pi_amp", job_id: finished!.job_id, ts: "2026-07-06T21:00:00Z", status: "calibrated", value: finished!.result }; + expect(reg.record(ev)).toBe(true); + const snap = reg.snapshot(); + expect(reg.record(ev)).toBe(false); // replay of the same finished job + expect(reg.snapshot()).toBe(snap); // state.json byte-identical + }); + + it("crit 4: entitlement gates the qilc node — off → fallback, on → itself", () => { + const g = graph(); + const emptyQueue = { running: undefined, pending: [] }; + const off = nextActions(g, {}, emptyQueue, NOW, { entitled: false }).ranked_actions.find((a) => a.node === "cz_gate")!; + expect(off.locked).toBe(true); + expect(off.recommendedNode).toBe("cz_gate_standard"); + const on = nextActions(g, {}, emptyQueue, NOW, { entitled: true }).ranked_actions.find((a) => a.node === "cz_gate")!; + expect(on.locked).toBe(false); + expect(on.recommendedNode).toBe("cz_gate"); + }); + + it("crit 5 + crit 7: a dead client degrades to an honest offline projection; a cyclic graph is rejected", async () => { + // dead client — a 500 never throws; the caller treats it as empty/offline. + const dead = new SchusterJobServer({ baseUrl: "http://dead", fetchImpl: async () => ({ ok: false, status: 500, json: async () => ({}), text: async () => "" }) }); + const q = await dead.queue(); + const h = await dead.health(); + expect(q.ok).toBe(false); + expect(h.ok).toBe(false); + const g = graph(); + const status = buildDeviceStatus({ + graph: g, + state: {}, + now: NOW, + driveLines: DRIVE_LINES, + qubits: ["Q1"], + onlineChannels: h.ok ? h.value.channels : undefined, // dead → undefined → all offline + }); + expect(status.driveLines.every((d) => !d.online)).toBe(true); + expect(status.qubits[0].status).toBe("uncharacterized"); + + // cycle rejection at load — evaluate() is never called on a cyclic graph. + const cyclic = loadGraph(` +[node.a] +depends_on = ["b"] +produces = ["x"] +impl = "standard" +[node.b] +depends_on = ["a"] +produces = ["y"] +impl = "standard" +`); + expect(cyclic.ok).toBe(false); + }); +}); diff --git a/packages/extension/test/device_inspector_view.test.ts b/packages/extension/test/device_inspector_view.test.ts new file mode 100644 index 00000000..941ee723 --- /dev/null +++ b/packages/extension/test/device_inspector_view.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import * as vscode from "vscode"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createDeviceInspectorView } from "../media/ui/views/device_inspector"; +import { registerDeviceInspector, revealDeviceInspector, DEVICE_INSPECTOR_CONTEXT_KEY } from "../src/device_inspector"; +import type { DeviceStatus, NextAction } from "../src/device_status"; + +// C4/C5 — the device view IS unit-tested here (happy-dom, mirroring +// inspector_webview_view.test.ts + inspector_view_contract.test.ts): the +// device-keyed pane router, locked-qilc rendering, the shell⇄view CSP seam, and +// host buffering / replay-on-reopen. + +const status = (rollup: DeviceStatus["qubits"]): DeviceStatus => ({ + driveLines: [ + { id: "ch0", target: "Q1", kind: "drive", online: true }, + { id: "ch2", target: "Q2", kind: "drive", online: false }, + ], + qubits: rollup, + metrics: { T1: { value: 55.2, ts: "2026-07-06T21:00:00Z", ageSeconds: 3600, status: "calibrated", node: "T1" } }, + calibrationParams: { pi_amp: 0.031 }, + nodes: [], +}); + +const lockedAction: NextAction = { + node: "cz_gate", + recommendedNode: "cz_gate_standard", + status: "uncharacterized", + action: "calibrate", + impl: "qilc", + locked: true, + reason: "qilc calibration locked (unentitled) → fall back to 'cz_gate_standard'", +}; +const openAction: NextAction = { + node: "pi_amp", + recommendedNode: "pi_amp", + status: "stale", + action: "check", + impl: "standard", + locked: false, + reason: "stale", +}; + +const panes = (v: { el: HTMLElement }) => [...v.el.querySelectorAll(".device-pane")]; +const activePane = (v: { el: HTMLElement }) => v.el.querySelector(".device-pane.active"); +const nameOf = (pane: Element | null | undefined) => pane?.querySelector(".device-name")?.textContent; + +describe("Device Inspector view router (device-keyed panes)", () => { + it("activate shows exactly one pane and hides the empty-state hint", () => { + const v = createDeviceInspectorView(() => {}); + const emptyHint = v.el.firstElementChild as HTMLElement; + expect(emptyHint.style.display).not.toBe("none"); + + v.onMessage({ type: "device-status", device: "snowbird", status: status([{ qubit: "Q1", status: "suspect", nodeCount: 3 }]) }); + v.onMessage({ type: "device-status", device: "multimode", status: status([{ qubit: "Q1", status: "calibrated", nodeCount: 2 }]) }); + expect(panes(v)).toHaveLength(2); + expect(v.el.querySelectorAll(".device-pane.active")).toHaveLength(0); + + v.onMessage({ type: "activate", device: "snowbird" }); + expect(v.el.querySelectorAll(".device-pane.active")).toHaveLength(1); + expect(nameOf(activePane(v))).toContain("snowbird"); + expect(emptyHint.style.display).toBe("none"); + }); + + it("a background device's status never mutates the active pane (no cross-talk)", () => { + const v = createDeviceInspectorView(() => {}); + v.onMessage({ type: "activate", device: "snowbird" }); + v.onMessage({ type: "device-status", device: "snowbird", status: status([{ qubit: "Q1", status: "suspect", nodeCount: 3 }]) }); + const active = activePane(v)!; + expect(nameOf(active)).toContain("snowbird"); + // multimode arrives in the background — its own pane, not snowbird's. + v.onMessage({ type: "device-status", device: "multimode", status: status([{ qubit: "Q1", status: "calibrated", nodeCount: 2 }]) }); + expect(nameOf(activePane(v))).toContain("snowbird"); + expect(panes(v)).toHaveLength(2); + }); + + it("renders drive-line online/offline chips and a locked qilc action greyed", () => { + const v = createDeviceInspectorView(() => {}); + v.onMessage({ type: "activate", device: "snowbird" }); + v.onMessage({ type: "device-status", device: "snowbird", status: status([{ qubit: "Q1", status: "suspect", nodeCount: 3 }]) }); + v.onMessage({ type: "actions", device: "snowbird", actions: [openAction, lockedAction] }); + const pane = activePane(v)!; + // drive-line online state is visible + expect(pane.querySelectorAll(".drive-line.online").length).toBe(1); + expect(pane.querySelectorAll(".drive-line.offline").length).toBe(1); + // the qilc action is rendered locked/greyed; the standard one is not + expect(pane.querySelectorAll(".action.locked").length).toBe(1); + expect(pane.querySelector(".action.locked")?.textContent).toContain("cz_gate"); + // honesty: T1 metric present with its value + expect(pane.textContent).toContain("55.2"); + }); +}); + +// --- Host shell contract + buffering (mirrors inspector_view_contract.test.ts) --- + +const PKG_ROOT = join(__dirname, ".."); + +function makeView() { + const posted: Array> = []; + let disposeCb: () => void = () => undefined; + let capturedHtml = ""; + const view = { + webview: { + options: {}, + cspSource: "vscode-webview://unit", + asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), + postMessage: (m: Record) => { posted.push(m); }, + onDidReceiveMessage: () => ({ dispose() {} }), + set html(v: string) { capturedHtml = v; }, + get html() { return capturedHtml; }, + }, + onDidDispose: (cb: () => void) => { disposeCb = cb; return { dispose() {} }; }, + }; + return { view, posted, dispose: () => disposeCb(), html: () => capturedHtml }; +} + +function harness() { + const ctx = { extensionUri: { fsPath: PKG_ROOT }, subscriptions: [] as unknown[] }; + return registerDeviceInspector(ctx as never); +} + +describe("Device Inspector shell contract (plumbing ⇄ TS-composed view)", () => { + it("links brand.css + layout.css + the device view bundle under a nonce'd CSP", () => { + const inspector = harness(); + const v = makeView(); + inspector.resolveWebviewView(v.view as never); + const html = v.html(); + expect(html).toMatch(/]+href="vscode-webview:\/\/unit\/[^"]*brand\.css"/); + expect(html).toMatch(/]+href="vscode-webview:\/\/unit\/[^"]*layout\.css"/); + expect(html).toMatch(/