diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..4627c288 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe3cb610..8a67ebc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,11 @@ jobs: # self-skipping — the skip was the #25 CI-level false-green (an injection or # config-merge regression would pass CI because the only test for it skipped). - run: pnpm --filter amicode-v2 fetch:opencode + env: + # gh release download pulls the vendored binary from the PRIVATE + # harmoniqs/opencode release; the default GITHUB_TOKEN is scoped to + # this repo only, so gh needs a cross-repo token (repo secret). + GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} - run: pnpm -r run test # amico-run suite (incl. S31 grep rule) + extension unit suite (incl. the opencode inject/merge integration) + @amicode/schema conformance - name: amico-validate — shipped configs conform + linked bin works (0.1c gate) run: | @@ -70,7 +75,11 @@ jobs: - uses: actions/setup-node@v4 with: { node-version: 20, cache: pnpm } - run: pnpm install --frozen-lockfile - - run: pnpm --filter amicode-v2 package # amico-run build + ext build + fetch:opencode + vsce + - run: pnpm -r run build # amico-run + extension (same as fast — ensures dist/ is in place before vsce) + - run: pnpm --filter amicode-v2 fetch:opencode # vendor the opencode binary (explicit step, matches fast/boot-smoke) + env: + GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} # private-fork release fetch (see fast) + - run: pnpm --filter amicode-v2 exec vsce package --no-dependencies --allow-missing-repository -o amicode.vsix - run: AMICODE_REQUIRE_VSIX=1 pnpm --filter amicode-v2 exec vitest run test/packaging.test.ts boot-smoke: strategy: @@ -84,4 +93,6 @@ jobs: with: { node-version: 20, cache: pnpm } - run: pnpm install --frozen-lockfile - run: pnpm --filter amicode-v2 fetch:opencode + env: + GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} # private-fork release fetch (see fast); one step covers both matrix legs - run: pnpm --filter amicode-v2 test:smoke diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..c01914d8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +# generated / vendored — never hand-formatted +node_modules +dist +packages/extension/vendor +packages/extension/exemplars +*.vsix +pnpm-lock.yaml +CHANGELOG.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..6622177b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 120, + "semi": true +} diff --git a/packages/.DS_Store b/packages/.DS_Store new file mode 100644 index 00000000..46610f70 Binary files /dev/null and b/packages/.DS_Store differ diff --git a/packages/amico-run/.DS_Store b/packages/amico-run/.DS_Store new file mode 100644 index 00000000..294803c0 Binary files /dev/null and b/packages/amico-run/.DS_Store differ diff --git a/packages/amico-run/esbuild.config.mjs b/packages/amico-run/esbuild.config.mjs index eb12aaa7..69a8d9b7 100644 --- a/packages/amico-run/esbuild.config.mjs +++ b/packages/amico-run/esbuild.config.mjs @@ -1,17 +1,17 @@ -import { build } from 'esbuild' -import { chmodSync } from 'node:fs' +import { build } from "esbuild"; +import { chmodSync } from "node:fs"; await build({ - entryPoints: ['src/cli.ts'], + entryPoints: ["src/cli.ts"], bundle: true, - platform: 'node', - target: 'node20', + platform: "node", + target: "node20", // ESM, not CJS: the package is "type": "module", so node executes dist/amico-run.js // as ESM — a CJS bundle would die on `require is not defined in ES module scope`. - format: 'esm', - outfile: 'dist/amico-run.js', - banner: { js: '#!/usr/bin/env node' }, + format: "esm", + outfile: "dist/amico-run.js", + banner: { js: "#!/usr/bin/env node" }, sourcemap: true, - logLevel: 'info', -}) -chmodSync('dist/amico-run.js', 0o755) + logLevel: "info", +}); +chmodSync("dist/amico-run.js", 0o755); diff --git a/packages/amico-run/src/authoring.ts b/packages/amico-run/src/authoring.ts index 30a9e5c8..788046eb 100644 --- a/packages/amico-run/src/authoring.ts +++ b/packages/amico-run/src/authoring.ts @@ -4,9 +4,9 @@ // assets); the gate reads it here. Absent file → conservative built-in // defaults (public base ∪ support set) so a bare-but-spec'd dev invocation // still gates sanely. $AMICO_AUTHORING_FILE overrides the path (tests). -import { existsSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; // NOTE (spec-20260704-113005 §3): session prep ALSO writes an additive // `skills: [{source: "library"|"package", package?, name, description, path}]` @@ -14,54 +14,54 @@ import { join } from 'node:path' // record for provenance/UI; amico-run does not consume it (unknown fields are // ignored here), so it is intentionally NOT in this interface. export interface AuthoringConfig { - allowlist: string[] // entitlement-resolved Harmoniqs packages - support_set: string[] // fixed support packages the run-dir contract itself needs - registry?: string // abs path to templates/registry.toml - exemplars?: string // abs path to exemplars/index.json - verify_harness?: string // abs path to julia/verify_rollout.jl - verify_tolerance: number // tier-3 re-rollout agreement (absolute) + allowlist: string[]; // entitlement-resolved Harmoniqs packages + support_set: string[]; // fixed support packages the run-dir contract itself needs + registry?: string; // abs path to templates/registry.toml + exemplars?: string; // abs path to exemplars/index.json + verify_harness?: string; // abs path to julia/verify_rollout.jl + verify_tolerance: number; // tier-3 re-rollout agreement (absolute) } -export const DEFAULT_ALLOWLIST = ['Piccolo', 'Legato', 'Intonato', 'NamedTrajectories', 'DirectTrajOpt'] -export const DEFAULT_SUPPORT = ['JLD2', 'CairoMakie', 'Makie', 'TOML', 'Printf'] -const DEFAULT_TOLERANCE = 0.001 +export const DEFAULT_ALLOWLIST = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]; +export const DEFAULT_SUPPORT = ["JLD2", "CairoMakie", "Makie", "TOML", "Printf"]; +const DEFAULT_TOLERANCE = 0.001; function defaults(): AuthoringConfig { return { allowlist: [...DEFAULT_ALLOWLIST], support_set: [...DEFAULT_SUPPORT], verify_tolerance: DEFAULT_TOLERANCE, - } + }; } export function authoringFile(): string { - const env = process.env.AMICO_AUTHORING_FILE - if (env && env.trim() !== '') return env - return join(homedir(), '.amico', 'authoring', 'authoring.json') + const env = process.env.AMICO_AUTHORING_FILE; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "authoring", "authoring.json"); } export function readAuthoring(): { config: AuthoringConfig; warning?: string } { - const file = authoringFile() - if (!existsSync(file)) return { config: defaults() } - let raw: unknown + const file = authoringFile(); + if (!existsSync(file)) return { config: defaults() }; + let raw: unknown; try { - raw = JSON.parse(readFileSync(file, 'utf8')) + raw = JSON.parse(readFileSync(file, "utf8")); } catch { - return { config: defaults(), warning: `malformed authoring.json at ${file} — using built-in defaults` } + return { config: defaults(), warning: `malformed authoring.json at ${file} — using built-in defaults` }; } - if (typeof raw !== 'object' || raw === null) - return { config: defaults(), warning: `authoring.json at ${file} is not an object — using built-in defaults` } - const data = raw as Record + if (typeof raw !== "object" || raw === null) + return { config: defaults(), warning: `authoring.json at ${file} is not an object — using built-in defaults` }; + const data = raw as Record; const strings = (v: unknown): string[] | undefined => - Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : undefined + Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : undefined; return { config: { allowlist: strings(data.allowlist) ?? [...DEFAULT_ALLOWLIST], support_set: strings(data.support_set) ?? [...DEFAULT_SUPPORT], - registry: typeof data.registry === 'string' ? data.registry : undefined, - exemplars: typeof data.exemplars === 'string' ? data.exemplars : undefined, - verify_harness: typeof data.verify_harness === 'string' ? data.verify_harness : undefined, - verify_tolerance: typeof data.verify_tolerance === 'number' ? data.verify_tolerance : DEFAULT_TOLERANCE, + registry: typeof data.registry === "string" ? data.registry : undefined, + exemplars: typeof data.exemplars === "string" ? data.exemplars : undefined, + verify_harness: typeof data.verify_harness === "string" ? data.verify_harness : undefined, + verify_tolerance: typeof data.verify_tolerance === "number" ? data.verify_tolerance : DEFAULT_TOLERANCE, }, - } + }; } diff --git a/packages/amico-run/src/baseline.ts b/packages/amico-run/src/baseline.ts index 2bc10f8e..e3f59ab1 100644 --- a/packages/amico-run/src/baseline.ts +++ b/packages/amico-run/src/baseline.ts @@ -6,32 +6,37 @@ // convention's `# ── FILL IN` / `# ─────` pair; an index entry may override // with fill_begin/fill_end regex sources. Unterminated blocks mask to EOF // (conservative: an attacker deleting the end marker can't unmask anything). -import { createHash } from 'node:crypto' +import { createHash } from "node:crypto"; -const DEFAULT_BEGIN = '^# ── FILL IN' -const DEFAULT_END = '^# ─────' +const DEFAULT_BEGIN = "^# ── FILL IN"; +const DEFAULT_END = "^# ─────"; export function maskFillPoints(text: string, beginSource?: string, endSource?: string): string { - const begin = new RegExp(beginSource ?? DEFAULT_BEGIN) - const end = new RegExp(endSource ?? DEFAULT_END) - const out: string[] = [] - let inside = false - for (const line of text.split('\n')) { + const begin = new RegExp(beginSource ?? DEFAULT_BEGIN); + const end = new RegExp(endSource ?? DEFAULT_END); + const out: string[] = []; + let inside = false; + for (const line of text.split("\n")) { if (!inside && begin.test(line)) { - inside = true - out.push(line) - continue + inside = true; + out.push(line); + continue; } if (inside && end.test(line)) { - inside = false - out.push(line) - continue + inside = false; + out.push(line); + continue; } - out.push(inside ? '#MASKED' : line) + out.push(inside ? "#MASKED" : line); } - return out.join('\n') + return out.join("\n"); } export function maskedHash(text: string, beginSource?: string, endSource?: string): string { - return 'sha256:' + createHash('sha256').update(maskFillPoints(text, beginSource, endSource)).digest('hex') + return ( + "sha256:" + + createHash("sha256") + .update(maskFillPoints(text, beginSource, endSource)) + .digest("hex") + ); } diff --git a/packages/amico-run/src/catalog.ts b/packages/amico-run/src/catalog.ts index 1c47fbeb..855f0a50 100644 --- a/packages/amico-run/src/catalog.ts +++ b/packages/amico-run/src/catalog.ts @@ -5,131 +5,131 @@ // exemplars index (exemplars/index.json, built by build_exemplars.mjs) is // tier 2, with build-time masked baseline_hash per entry. Loaders never // throw: a missing/corrupt catalog degrades to tier 3, not a crash. -import { existsSync, readFileSync } from 'node:fs' -import { parse as parseToml } from 'smol-toml' -import { JULIA_STDLIBS } from './import_scan.js' +import { existsSync, readFileSync } from "node:fs"; +import { parse as parseToml } from "smol-toml"; +import { JULIA_STDLIBS } from "./import_scan.js"; export interface TemplateEntry { - id: string - platform: string - kind: string - size: number - path: string - packages: string[] - status: string // "vetted" | "experimental" | … - entitlement?: string // required entitlement id, when gated - fill_begin?: string - fill_end?: string + id: string; + platform: string; + kind: string; + size: number; + path: string; + packages: string[]; + status: string; // "vetted" | "experimental" | … + entitlement?: string; // required entitlement id, when gated + fill_begin?: string; + fill_end?: string; } export interface ExemplarEntry { - id: string - platform: string - kind: string - size: number - path: string - packages: string[] - baseline_hash: string - notes?: string - fill_begin?: string - fill_end?: string + id: string; + platform: string; + kind: string; + size: number; + path: string; + packages: string[]; + baseline_hash: string; + notes?: string; + fill_begin?: string; + fill_end?: string; } export interface Registry { - templates: TemplateEntry[] - support: string[] - uuids: Record - verifyTolerance: number + templates: TemplateEntry[]; + support: string[]; + uuids: Record; + verifyTolerance: number; } export interface ExemplarsIndex { - exemplars: ExemplarEntry[] + exemplars: ExemplarEntry[]; } export interface Shape { - platform: string - kind: string - size: number + platform: string; + kind: string; + size: number; } export interface ShapeMatch { - tier: 'vetted' | 'composed' | 'free' - template?: TemplateEntry - exemplar?: ExemplarEntry - blockedHigher?: { tier: 'vetted' | 'composed'; requires: string } + tier: "vetted" | "composed" | "free"; + template?: TemplateEntry; + exemplar?: ExemplarEntry; + blockedHigher?: { tier: "vetted" | "composed"; requires: string }; } -const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 } +const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 }; function strings(v: unknown): string[] { - return Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : [] + return Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; } export function loadRegistry(file: string): Registry { - if (!existsSync(file)) return EMPTY_REGISTRY - let parsed: Record + if (!existsSync(file)) return EMPTY_REGISTRY; + let parsed: Record; try { - parsed = parseToml(readFileSync(file, 'utf8')) as Record + parsed = parseToml(readFileSync(file, "utf8")) as Record; } catch { - return EMPTY_REGISTRY + return EMPTY_REGISTRY; } const templates = (Array.isArray(parsed.template) ? parsed.template : []) - .filter((t): t is Record => typeof t === 'object' && t !== null) - .filter((t) => typeof t.id === 'string' && typeof t.platform === 'string' && typeof t.kind === 'string') + .filter((t): t is Record => typeof t === "object" && t !== null) + .filter((t) => typeof t.id === "string" && typeof t.platform === "string" && typeof t.kind === "string") .map( (t): TemplateEntry => ({ id: t.id as string, platform: t.platform as string, kind: t.kind as string, - size: typeof t.size === 'number' ? t.size : 1, - path: typeof t.path === 'string' ? t.path : '', + size: typeof t.size === "number" ? t.size : 1, + path: typeof t.path === "string" ? t.path : "", packages: strings(t.packages), - status: typeof t.status === 'string' ? t.status : 'experimental', - entitlement: typeof t.entitlement === 'string' ? t.entitlement : undefined, - fill_begin: typeof t.fill_begin === 'string' ? t.fill_begin : undefined, - fill_end: typeof t.fill_end === 'string' ? t.fill_end : undefined, + status: typeof t.status === "string" ? t.status : "experimental", + entitlement: typeof t.entitlement === "string" ? t.entitlement : undefined, + fill_begin: typeof t.fill_begin === "string" ? t.fill_begin : undefined, + fill_end: typeof t.fill_end === "string" ? t.fill_end : undefined, }), - ) - const support = strings((parsed.support as Record | undefined)?.packages) - const uuids: Record = {} - if (typeof parsed.uuids === 'object' && parsed.uuids !== null) + ); + const support = strings((parsed.support as Record | undefined)?.packages); + const uuids: Record = {}; + if (typeof parsed.uuids === "object" && parsed.uuids !== null) for (const [name, uuid] of Object.entries(parsed.uuids as Record)) - if (typeof uuid === 'string') uuids[name] = uuid + if (typeof uuid === "string") uuids[name] = uuid; return { templates, support, uuids, - verifyTolerance: typeof parsed.verify_tolerance === 'number' ? parsed.verify_tolerance : 0.01, - } + verifyTolerance: typeof parsed.verify_tolerance === "number" ? parsed.verify_tolerance : 0.01, + }; } export function loadExemplarsIndex(file: string): ExemplarsIndex { - if (!existsSync(file)) return { exemplars: [] } - let parsed: unknown + if (!existsSync(file)) return { exemplars: [] }; + let parsed: unknown; try { - parsed = JSON.parse(readFileSync(file, 'utf8')) + parsed = JSON.parse(readFileSync(file, "utf8")); } catch { - return { exemplars: [] } + return { exemplars: [] }; } - const raw = (parsed as Record)?.exemplars + const raw = (parsed as Record)?.exemplars; const exemplars = (Array.isArray(raw) ? raw : []) - .filter((e): e is Record => typeof e === 'object' && e !== null) - .filter((e) => typeof e.id === 'string' && typeof e.baseline_hash === 'string') + .filter((e): e is Record => typeof e === "object" && e !== null) + .filter((e) => typeof e.id === "string" && typeof e.baseline_hash === "string") .map( (e): ExemplarEntry => ({ id: e.id as string, - platform: typeof e.platform === 'string' ? e.platform : '', - kind: typeof e.kind === 'string' ? e.kind : '', - size: typeof e.size === 'number' ? e.size : 1, - path: typeof e.path === 'string' ? e.path : '', + platform: typeof e.platform === "string" ? e.platform : "", + kind: typeof e.kind === "string" ? e.kind : "", + size: typeof e.size === "number" ? e.size : 1, + path: typeof e.path === "string" ? e.path : "", packages: strings(e.packages), baseline_hash: e.baseline_hash as string, - notes: typeof e.notes === 'string' ? e.notes : undefined, - fill_begin: typeof e.fill_begin === 'string' ? e.fill_begin : undefined, - fill_end: typeof e.fill_end === 'string' ? e.fill_end : undefined, + notes: typeof e.notes === "string" ? e.notes : undefined, + fill_begin: typeof e.fill_begin === "string" ? e.fill_begin : undefined, + fill_end: typeof e.fill_end === "string" ? e.fill_end : undefined, }), - ) - return { exemplars } + ); + return { exemplars }; } /** Tier resolution (spec C, locked decision 5): exact vetted template match → @@ -143,27 +143,25 @@ export function matchShape( exemplars: ExemplarsIndex, allowlist: string[], ): ShapeMatch { - const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]) - const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p)) - let blockedHigher: ShapeMatch['blockedHigher'] + const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]); + const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p)); + let blockedHigher: ShapeMatch["blockedHigher"]; const templateMatches = registry.templates.filter( - (t) => t.status === 'vetted' && t.platform === shape.platform && t.kind === shape.kind && t.size === shape.size, - ) + (t) => t.status === "vetted" && t.platform === shape.platform && t.kind === shape.kind && t.size === shape.size, + ); for (const template of templateMatches) { - if (packagesOk(template.packages)) return { tier: 'vetted', template } - blockedHigher ??= { tier: 'vetted', requires: template.entitlement ?? 'unknown' } + if (packagesOk(template.packages)) return { tier: "vetted", template }; + blockedHigher ??= { tier: "vetted", requires: template.entitlement ?? "unknown" }; } - const exemplarMatches = exemplars.exemplars.filter( - (e) => e.platform === shape.platform && e.kind === shape.kind, - ) + const exemplarMatches = exemplars.exemplars.filter((e) => e.platform === shape.platform && e.kind === shape.kind); // prefer exact-size, then any - exemplarMatches.sort((a, b) => Number(b.size === shape.size) - Number(a.size === shape.size)) + exemplarMatches.sort((a, b) => Number(b.size === shape.size) - Number(a.size === shape.size)); for (const exemplar of exemplarMatches) { - if (packagesOk(exemplar.packages)) return { tier: 'composed', exemplar, blockedHigher } - blockedHigher ??= { tier: 'composed', requires: 'unknown' } + if (packagesOk(exemplar.packages)) return { tier: "composed", exemplar, blockedHigher }; + blockedHigher ??= { tier: "composed", requires: "unknown" }; } - return { tier: 'free', blockedHigher } + return { tier: "free", blockedHigher }; } diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index e261b4b4..2ef2ddc0 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -1,16 +1,19 @@ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { parse as parseToml } from 'smol-toml' -import { LocalExecutor } from './local_executor.js' -import { ConfigError, type Finished, type SubmitOpts } from './types.js' -import { readAuthoring } from './authoring.js' -import { runGate } from './gate.js' -import { runVerification } from './verify.js' -import { trySubcommand } from './subcommands.js' +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { LocalExecutor } from "./local_executor.js"; +import { ConfigError, type Finished, type SubmitOpts } from "./types.js"; +import { readAuthoring } from "./authoring.js"; +import { runGate } from "./gate.js"; +import { runVerification } from "./verify.js"; +import { trySubcommand } from "./subcommands.js"; function readTomlSafe(fp: string): Record | undefined { - try { return parseToml(readFileSync(fp, 'utf8')) as Record } - catch { return undefined } + try { + return parseToml(readFileSync(fp, "utf8")) as Record; + } catch { + return undefined; + } } const USAGE = `usage: amico-run [--executor local] [--lab ] @@ -18,72 +21,119 @@ const USAGE = `usage: amico-run [--executor local] [--lab ] (spec C: validate + gate before launch) amico-run resolve --platform

--kind --size (tier resolution → JSON) amico-run sandbox --packages A,B,… (generate env/Project.toml) - (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)` + (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)`; export async function main(argv: string[]): Promise { // spec C subcommands — dispatched before the launch flag loop - const sub = trySubcommand(argv) - if (sub !== undefined) return sub + const sub = trySubcommand(argv); + if (sub !== undefined) return sub; - let script: string | undefined - let executor = 'local' - let specPath: string | undefined - const opts: SubmitOpts = { julia: {} } - let projectExplicit = false + let script: string | undefined; + let executor = "local"; + let specPath: string | undefined; + const opts: SubmitOpts = { julia: {} }; + let projectExplicit = false; for (let i = 0; i < argv.length; i++) { - const a = argv[i] + const a = argv[i]; const next = (): string => { - const v = argv[++i] - if (v === undefined) throw new ConfigError(`flag ${a} requires a value`) - return v - } + const v = argv[++i]; + if (v === undefined) throw new ConfigError(`flag ${a} requires a value`); + return v; + }; try { switch (a) { - case '--help': case '-h': console.log(USAGE); return 0 - case '--executor': executor = next(); break - case '--lab': opts.lab = next(); break - case '--runs-root': opts.runsRoot = next(); break - case '--julia': opts.julia!.julia = next(); break - case '--project': opts.julia!.project = next(); projectExplicit = true; break - case '--sysimage': opts.julia!.sysimage = next(); break - case '--spec': specPath = next(); break + case "--help": + case "-h": + console.log(USAGE); + return 0; + case "--executor": + executor = next(); + break; + case "--lab": + opts.lab = next(); + break; + case "--runs-root": + opts.runsRoot = next(); + break; + case "--julia": + opts.julia!.julia = next(); + break; + case "--project": + opts.julia!.project = next(); + projectExplicit = true; + break; + case "--sysimage": + opts.julia!.sysimage = next(); + break; + case "--spec": + specPath = next(); + break; default: - if (a.startsWith('-')) { console.error(`amico-run: unknown flag ${a}\n${USAGE}`); return 64 } - if (script) { console.error(`amico-run: multiple scripts given`); return 64 } - script = a + if (a.startsWith("-")) { + console.error(`amico-run: unknown flag ${a}\n${USAGE}`); + return 64; + } + if (script) { + console.error(`amico-run: multiple scripts given`); + return 64; + } + script = a; } } catch (e) { - if (e instanceof ConfigError) { console.error(`amico-run: ${e.message}`); return 64 } - throw e + if (e instanceof ConfigError) { + console.error(`amico-run: ${e.message}`); + return 64; + } + throw e; } } - if (!script) { console.error(`amico-run: no script given\n${USAGE}`); return 64 } - if (executor !== 'local') { console.error(`amico-run: only --executor local is supported in β`); return 64 } + if (!script) { + console.error(`amico-run: no script given\n${USAGE}`); + return 64; + } + if (executor !== "local") { + console.error(`amico-run: only --executor local is supported in β`); + return 64; + } // ── spec C: the launch gate. Failures leave NO run dir and exit 64. ── if (specPath) { - let specRaw: unknown - try { specRaw = JSON.parse(readFileSync(specPath, 'utf8')) } - catch (e) { console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); return 64 } - let scriptText: string - try { scriptText = readFileSync(script, 'utf8') } - catch (e) { console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); return 64 } - const { config: authoring, warning } = readAuthoring() - if (warning) console.error(`amico-run: ${warning}`) - const gate = runGate(specRaw, scriptText, authoring) - if (!gate.ok) { console.error(`amico-run: gate: ${gate.reason}`); return 64 } + let specRaw: unknown; + try { + specRaw = JSON.parse(readFileSync(specPath, "utf8")); + } catch (e) { + console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); + return 64; + } + let scriptText: string; + try { + scriptText = readFileSync(script, "utf8"); + } catch (e) { + console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); + return 64; + } + const { config: authoring, warning } = readAuthoring(); + if (warning) console.error(`amico-run: ${warning}`); + const gate = runGate(specRaw, scriptText, authoring); + if (!gate.ok) { + console.error(`amico-run: gate: ${gate.reason}`); + return 64; + } // env resolution: spec env.project feeds --project unless the flag was explicit - const env = (specRaw as { env?: { kind?: string; project?: string } }).env - if (env?.project && (env.kind === 'project' || env.kind === 'sandbox')) { + const env = (specRaw as { env?: { kind?: string; project?: string } }).env; + if (env?.project && (env.kind === "project" || env.kind === "sandbox")) { if (projectExplicit && opts.julia!.project !== env.project) - console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`) - else opts.julia!.project = env.project + console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`); + else opts.julia!.project = env.project; } opts.spec = { - canonical: gate.stamp.specCanonical, tier: gate.stamp.tier, hashes: gate.stamp.hashes, - julia_binary: opts.julia!.julia, env_project: opts.julia!.project, - } + canonical: gate.stamp.specCanonical, + tier: gate.stamp.tier, + hashes: gate.stamp.hashes, + julia_binary: opts.julia!.julia, + env_project: opts.julia!.project, + }; } // NOTE: `--sysimage ` is honored (passed through to the Julia process and @@ -93,53 +143,60 @@ export async function main(argv: string[]): Promise { // (CI build on self-hosted runners → R2 → manifest → download), pointed at via // this flag. Until that exists, solves pay the cold start (inspector warms up). - let handle + let handle; try { - handle = await new LocalExecutor().submit(script, opts) + handle = await new LocalExecutor().submit(script, opts); } catch (e) { - if (e instanceof ConfigError) { console.error(`amico-run: ${e.message}`); return 64 } - throw e + if (e instanceof ConfigError) { + console.error(`amico-run: ${e.message}`); + return 64; + } + throw e; } - const onSignal = (): void => { void handle.abort() } - process.on('SIGINT', onSignal) - process.on('SIGTERM', onSignal) + const onSignal = (): void => { + void handle.abort(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); - let fin: Finished | undefined + let fin: Finished | undefined; for await (const ev of handle.events) { - if (ev.kind === 'iter' || ev.kind === 'done') console.log(ev.raw) - else if (ev.kind === 'log') console.log(ev.line) - else fin = { status: ev.status, exitCode: ev.exitCode } + if (ev.kind === "iter" || ev.kind === "done") console.log(ev.raw); + else if (ev.kind === "log") console.log(ev.line); + else fin = { status: ev.status, exitCode: ev.exitCode }; } - const f = fin ?? await handle.finished + const f = fin ?? (await handle.finished); // FINISHED-write failure lane (spec §6 last row): verdict file must exist on disk - if (!existsSync(join(handle.runDir, 'FINISHED'))) { - console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`) - return 64 + if (!existsSync(join(handle.runDir, "FINISHED"))) { + console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`); + return 64; } // spec C: free-tier re-rollout verification runs AFTER FINISHED, BEFORE the // AMICODE_FINISHED line — so consumers see a settled verification state. The // harness (or the fallback) always writes verification.toml; the promote gate // keys off agree==true. - if (opts.spec?.tier === 'free') { - const { config: authoring } = readAuthoring() - await runVerification(handle.runDir, opts.spec, authoring) - const verified = readTomlSafe(join(handle.runDir, 'verification.toml')) - console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`) + if (opts.spec?.tier === "free") { + const { config: authoring } = readAuthoring(); + await runVerification(handle.runDir, opts.spec, authoring); + const verified = readTomlSafe(join(handle.runDir, "verification.toml")); + console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`); } // stdout protocol line — camelCase by design (spec §4) - console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`) - if (f.status === 'aborted') return 130 - if (f.status === 'completed') return 0 - return f.exitCode === 0 ? 1 : f.exitCode + console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`); + if (f.status === "aborted") return 130; + if (f.status === "completed") return 0; + return f.exitCode === 0 ? 1 : f.exitCode; } main(process.argv.slice(2)).then( - c => { process.exitCode = c }, - e => { + (c) => { + process.exitCode = c; + }, + (e) => { // Any unexpected throw is an orchestrator fault, not a solve failure → 64. - console.error(`amico-run: unexpected error: ${e instanceof Error ? e.stack ?? e.message : e}`) - process.exitCode = 64 + console.error(`amico-run: unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : e}`); + process.exitCode = 64; }, -) +); diff --git a/packages/amico-run/src/event_queue.ts b/packages/amico-run/src/event_queue.ts index 361b42f3..61b1bdd1 100644 --- a/packages/amico-run/src/event_queue.ts +++ b/packages/amico-run/src/event_queue.ts @@ -1,26 +1,26 @@ /** Push-based AsyncIterable: producer pushes, single consumer iterates. */ export class EventQueue implements AsyncIterable { - private buf: T[] = [] - private waiters: Array<(r: IteratorResult) => void> = [] - private ended = false + private buf: T[] = []; + private waiters: Array<(r: IteratorResult) => void> = []; + private ended = false; push(v: T): void { - if (this.ended) return // late producers (post-settle) are dropped, never buffered - const w = this.waiters.shift() - if (w) w({ value: v, done: false }) - else this.buf.push(v) + if (this.ended) return; // late producers (post-settle) are dropped, never buffered + const w = this.waiters.shift(); + if (w) w({ value: v, done: false }); + else this.buf.push(v); } close(): void { - this.ended = true - for (const w of this.waiters.splice(0)) w({ value: undefined as never, done: true }) + this.ended = true; + for (const w of this.waiters.splice(0)) w({ value: undefined as never, done: true }); } [Symbol.asyncIterator](): AsyncIterator { return { next: (): Promise> => { - if (this.buf.length > 0) return Promise.resolve({ value: this.buf.shift()!, done: false }) - if (this.ended) return Promise.resolve({ value: undefined as never, done: true }) - return new Promise(res => this.waiters.push(res)) + if (this.buf.length > 0) return Promise.resolve({ value: this.buf.shift()!, done: false }); + if (this.ended) return Promise.resolve({ value: undefined as never, done: true }); + return new Promise((res) => this.waiters.push(res)); }, - } + }; } } diff --git a/packages/amico-run/src/gate.ts b/packages/amico-run/src/gate.ts index 6e7a799d..ad83bfe9 100644 --- a/packages/amico-run/src/gate.ts +++ b/packages/amico-run/src/gate.ts @@ -6,107 +6,105 @@ // env is validated against its OWN Manifest, not the extension-pinned one); // (4) tier-2 masked-baseline check; (5) stamp assembly (canonical spec + // gate-computed spec_hash). Any failure → no Julia process, one clear line. -import { createHash } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { parse as parseToml } from 'smol-toml' -import { validate } from '@amicode/schema' -import type { AuthoringConfig } from './authoring.js' -import { checkImports, scanImports } from './import_scan.js' -import { maskedHash } from './baseline.js' -import { loadExemplarsIndex } from './catalog.js' +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { validate } from "@amicode/schema"; +import type { AuthoringConfig } from "./authoring.js"; +import { checkImports, scanImports } from "./import_scan.js"; +import { maskedHash } from "./baseline.js"; +import { loadExemplarsIndex } from "./catalog.js"; export interface GateStamp { - tier?: string - hashes: Record // spec hashes + gate-computed spec_hash - specCanonical: string // stable-key-order JSON, what gets persisted + tier?: string; + hashes: Record; // spec hashes + gate-computed spec_hash + specCanonical: string; // stable-key-order JSON, what gets persisted } -export type GateResult = - | { ok: true; stamp: GateStamp } - | { ok: false; reason: string; demote_to?: 'free' } +export type GateResult = { ok: true; stamp: GateStamp } | { ok: false; reason: string; demote_to?: "free" }; /** Stable key order at every level so spec_hash is insensitive to author key order. */ function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalize) - if (typeof value === 'object' && value !== null) { - const out: Record = {} + if (Array.isArray(value)) return value.map(canonicalize); + if (typeof value === "object" && value !== null) { + const out: Record = {}; for (const key of Object.keys(value as Record).sort()) - out[key] = canonicalize((value as Record)[key]) - return out + out[key] = canonicalize((value as Record)[key]); + return out; } - return value + return value; } /** Julia Manifest v2 keys deps as [[deps.]] — the parsed `deps` object's * keys ARE the package names. Every Project [deps] name must appear. */ function staleEnvCheck(projectDir: string): string | undefined { - const projectFile = join(projectDir, 'Project.toml') - const manifestFile = join(projectDir, 'Manifest.toml') - if (!existsSync(projectFile)) return `env has no Project.toml at ${projectDir}` + const projectFile = join(projectDir, "Project.toml"); + const manifestFile = join(projectDir, "Manifest.toml"); + if (!existsSync(projectFile)) return `env has no Project.toml at ${projectDir}`; if (!existsSync(manifestFile)) - return `env at ${projectDir} has no Manifest.toml — instantiate it first (JULIA_PKG_USE_CLI_GIT=true julia --project=${projectDir} -e 'using Pkg; Pkg.instantiate()')` + return `env at ${projectDir} has no Manifest.toml — instantiate it first (JULIA_PKG_USE_CLI_GIT=true julia --project=${projectDir} -e 'using Pkg; Pkg.instantiate()')`; try { - const project = parseToml(readFileSync(projectFile, 'utf8')) as Record - const manifest = parseToml(readFileSync(manifestFile, 'utf8')) as Record - const wanted = Object.keys((project.deps as Record) ?? {}) - const present = new Set(Object.keys((manifest.deps as Record) ?? {})) - const missing = wanted.filter((name) => !present.has(name)) + const project = parseToml(readFileSync(projectFile, "utf8")) as Record; + const manifest = parseToml(readFileSync(manifestFile, "utf8")) as Record; + const wanted = Object.keys((project.deps as Record) ?? {}); + const present = new Set(Object.keys((manifest.deps as Record) ?? {})); + const missing = wanted.filter((name) => !present.has(name)); if (missing.length > 0) - return `stale env: ${missing.join(', ')} in Project.toml but not its Manifest — re-instantiate` + return `stale env: ${missing.join(", ")} in Project.toml but not its Manifest — re-instantiate`; } catch (e) { - return `env at ${projectDir} has an unparseable Project/Manifest: ${(e as Error).message}` + return `env at ${projectDir} has an unparseable Project/Manifest: ${(e as Error).message}`; } - return undefined + return undefined; } export function runGate(specRaw: unknown, scriptText: string, authoring: AuthoringConfig): GateResult { // ── step 1: schema ── - const validation = validate(specRaw, 'solvespec') - if (!validation.ok) return { ok: false, reason: `solvespec schema: ${validation.errors[0]}` } - const spec = specRaw as Record - const tier = typeof spec.tier === 'string' ? spec.tier : undefined - const env = (typeof spec.env === 'object' && spec.env !== null ? spec.env : undefined) as + const validation = validate(specRaw, "solvespec"); + if (!validation.ok) return { ok: false, reason: `solvespec schema: ${validation.errors[0]}` }; + const spec = specRaw as Record; + const tier = typeof spec.tier === "string" ? spec.tier : undefined; + const env = (typeof spec.env === "object" && spec.env !== null ? spec.env : undefined) as | { kind?: string; project?: string } - | undefined + | undefined; // ── step 2: import scan ── - const scanned = scanImports(scriptText) - if (!scanned.ok) return { ok: false, reason: scanned.reason } - const checked = checkImports(scanned.roots, authoring) - if (!checked.ok) return { ok: false, reason: checked.reason } + const scanned = scanImports(scriptText); + if (!scanned.ok) return { ok: false, reason: scanned.reason }; + const checked = checkImports(scanned.roots, authoring); + if (!checked.ok) return { ok: false, reason: checked.reason }; // ── step 3: tier/env consistency ── - if (tier === 'free' && env?.kind !== 'sandbox') - return { ok: false, reason: 'free tier requires a sandbox env (env.kind = "sandbox")' } - if ((env?.kind === 'project' || env?.kind === 'sandbox') && env.project) { - const stale = staleEnvCheck(env.project) - if (stale) return { ok: false, reason: stale } + if (tier === "free" && env?.kind !== "sandbox") + return { ok: false, reason: 'free tier requires a sandbox env (env.kind = "sandbox")' }; + if ((env?.kind === "project" || env?.kind === "sandbox") && env.project) { + const stale = staleEnvCheck(env.project); + if (stale) return { ok: false, reason: stale }; } // ── step 4: composed → masked baseline vs the exemplar's build-time hash ── - if (tier === 'composed') { - const exemplarId = (spec.source as Record | undefined)?.exemplar_id - if (typeof exemplarId !== 'string') - return { ok: false, reason: 'tier "composed" requires source.exemplar_id' } - const index = loadExemplarsIndex(authoring.exemplars ?? '') - const entry = index.exemplars.find((e) => e.id === exemplarId) - if (!entry) return { ok: false, reason: `unknown exemplar_id "${exemplarId}" (index: ${authoring.exemplars ?? 'absent'})` } + if (tier === "composed") { + const exemplarId = (spec.source as Record | undefined)?.exemplar_id; + if (typeof exemplarId !== "string") return { ok: false, reason: 'tier "composed" requires source.exemplar_id' }; + const index = loadExemplarsIndex(authoring.exemplars ?? ""); + const entry = index.exemplars.find((e) => e.id === exemplarId); + if (!entry) + return { ok: false, reason: `unknown exemplar_id "${exemplarId}" (index: ${authoring.exemplars ?? "absent"})` }; if (maskedHash(scriptText, entry.fill_begin, entry.fill_end) !== entry.baseline_hash) return { ok: false, reason: `script is no longer the exemplar's physics (edits outside the fill points of "${exemplarId}") — re-assemble as tier "free"`, - demote_to: 'free', - } + demote_to: "free", + }; } // ── step 5: stamp — canonical spec + gate-computed spec_hash ── - const specCanonical = JSON.stringify(canonicalize(spec), null, 2) - const specHash = 'sha256:' + createHash('sha256').update(specCanonical).digest('hex') - const hashes: Record = {} - if (typeof spec.hashes === 'object' && spec.hashes !== null) + const specCanonical = JSON.stringify(canonicalize(spec), null, 2); + const specHash = "sha256:" + createHash("sha256").update(specCanonical).digest("hex"); + const hashes: Record = {}; + if (typeof spec.hashes === "object" && spec.hashes !== null) for (const [key, value] of Object.entries(spec.hashes as Record)) - if (typeof value === 'string') hashes[key] = value - hashes.spec_hash = specHash - return { ok: true, stamp: { tier, hashes, specCanonical } } + if (typeof value === "string") hashes[key] = value; + hashes.spec_hash = specHash; + return { ok: true, stamp: { tier, hashes, specCanonical } }; } diff --git a/packages/amico-run/src/import_scan.ts b/packages/amico-run/src/import_scan.ts index a7cc5a3a..41b7801c 100644 --- a/packages/amico-run/src/import_scan.ts +++ b/packages/amico-run/src/import_scan.ts @@ -7,50 +7,63 @@ // the templates/skeletons all use one statement per line. export const JULIA_STDLIBS = new Set([ - 'LinearAlgebra', 'Random', 'Statistics', 'SparseArrays', 'Printf', 'TOML', 'Dates', - 'Test', 'Pkg', 'Serialization', 'SHA', 'Logging', 'Markdown', 'UUIDs', - 'Distributed', 'InteractiveUtils', 'Base64', 'Unicode', 'REPL', -]) + "LinearAlgebra", + "Random", + "Statistics", + "SparseArrays", + "Printf", + "TOML", + "Dates", + "Test", + "Pkg", + "Serialization", + "SHA", + "Logging", + "Markdown", + "UUIDs", + "Distributed", + "InteractiveUtils", + "Base64", + "Unicode", + "REPL", +]); -export type ScanResult = { ok: true; roots: string[] } | { ok: false; reason: string } -export type CheckResult = { ok: true } | { ok: false; reason: string } +export type ScanResult = { ok: true; roots: string[] } | { ok: false; reason: string }; +export type CheckResult = { ok: true } | { ok: false; reason: string }; -const IMPORT_LINE = /^\s*(using|import)\s+(.+)$/ +const IMPORT_LINE = /^\s*(using|import)\s+(.+)$/; /** Strip a trailing comment (naive: templates never put `#` inside strings on import lines). */ function stripComment(line: string): string { - const hash = line.indexOf('#') - return hash === -1 ? line : line.slice(0, hash) + const hash = line.indexOf("#"); + return hash === -1 ? line : line.slice(0, hash); } export function scanImports(script: string): ScanResult { - const roots: string[] = [] - for (const rawLine of script.split('\n')) { - const line = stripComment(rawLine) - const match = IMPORT_LINE.exec(line) - if (!match) continue - const payload = match[2].trim() - if (payload.endsWith(',')) - return { ok: false, reason: 'multi-line using/import not supported — one statement per line' } - for (const item of payload.split(',')) { - const trimmed = item.trim() - if (!trimmed) continue - const root = trimmed.split(/[.:\s]/, 1)[0] - if (root && !roots.includes(root)) roots.push(root) + const roots: string[] = []; + for (const rawLine of script.split("\n")) { + const line = stripComment(rawLine); + const match = IMPORT_LINE.exec(line); + if (!match) continue; + const payload = match[2].trim(); + if (payload.endsWith(",")) + return { ok: false, reason: "multi-line using/import not supported — one statement per line" }; + for (const item of payload.split(",")) { + const trimmed = item.trim(); + if (!trimmed) continue; + const root = trimmed.split(/[.:\s]/, 1)[0]; + if (root && !roots.includes(root)) roots.push(root); } } - return { ok: true, roots } + return { ok: true, roots }; } -export function checkImports( - roots: string[], - allow: { allowlist: string[]; support_set: string[] }, -): CheckResult { - const permitted = new Set([...allow.allowlist, ...allow.support_set, ...JULIA_STDLIBS]) - const blocked = roots.filter((root) => !permitted.has(root)) - if (blocked.length === 0) return { ok: true } +export function checkImports(roots: string[], allow: { allowlist: string[]; support_set: string[] }): CheckResult { + const permitted = new Set([...allow.allowlist, ...allow.support_set, ...JULIA_STDLIBS]); + const blocked = roots.filter((root) => !permitted.has(root)); + if (blocked.length === 0) return { ok: true }; return { ok: false, - reason: `${blocked.join(', ')}: not in the allowed package set (entitlement allowlist ∪ support set ∪ stdlibs)`, - } + reason: `${blocked.join(", ")}: not in the allowed package set (entitlement allowlist ∪ support set ∪ stdlibs)`, + }; } diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index f8af7d5f..dcd0ab44 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -1,6 +1,7 @@ -export * from './types.js' -export * from './telemetry.js' -export * from './run_dir.js' -export * from './schemas.js' -export * from './event_queue.js' -export * from './local_executor.js' +export * from "./types.js"; +export * from "./telemetry.js"; +export * from "./run_dir.js"; +export * from "./schemas.js"; +export * from "./event_queue.js"; +export * from "./local_executor.js"; +export * from "./scheduler.js"; diff --git a/packages/amico-run/src/local_executor.ts b/packages/amico-run/src/local_executor.ts index da7f84d4..39da67e3 100644 --- a/packages/amico-run/src/local_executor.ts +++ b/packages/amico-run/src/local_executor.ts @@ -1,139 +1,176 @@ -import { spawn } from 'node:child_process' -import { accessSync, constants as fsConstants, createWriteStream, existsSync, mkdirSync } from 'node:fs' -import { constants as osConstants } from 'node:os' -import { delimiter, join, resolve } from 'node:path' -import * as readline from 'node:readline' -import { EventQueue } from './event_queue.js' -import { classifyLine } from './telemetry.js' +import { spawn } from "node:child_process"; +import { accessSync, constants as fsConstants, createWriteStream, existsSync, mkdirSync } from "node:fs"; +import { constants as osConstants } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import * as readline from "node:readline"; +import { EventQueue } from "./event_queue.js"; +import { classifyLine } from "./telemetry.js"; import { - appendIndex, atomicWriteFile, defaultRunsRoot, deriveLabId, generateRunId, - updateLatest, writeFinished, writeManifest, -} from './run_dir.js' + appendIndex, + atomicWriteFile, + defaultRunsRoot, + deriveLabId, + generateRunId, + updateLatest, + writeFinished, + writeManifest, +} from "./run_dir.js"; import { - ConfigError, type Executor, type Finished, type RunEvent, type RunHandle, - type RunStatus, type SubmitOpts, -} from './types.js' + ConfigError, + type Executor, + type Finished, + type RunEvent, + type RunHandle, + type RunStatus, + type SubmitOpts, +} from "./types.js"; -import pkg from '../package.json' with { type: 'json' } -const ORCHESTRATOR_VERSION = pkg.version // single source of truth (esbuild inlines the JSON) +import pkg from "../package.json" with { type: "json" }; +const ORCHESTRATOR_VERSION = pkg.version; // single source of truth (esbuild inlines the JSON) function resolveExecutable(bin: string): void { - const candidates = bin.includes('/') + const candidates = bin.includes("/") ? [resolve(bin)] - : (process.env.PATH ?? '').split(delimiter).filter(Boolean).map(d => join(d, bin)) + : (process.env.PATH ?? "") + .split(delimiter) + .filter(Boolean) + .map((d) => join(d, bin)); for (const c of candidates) { - try { accessSync(c, fsConstants.X_OK); return } catch { /* keep looking */ } + try { + accessSync(c, fsConstants.X_OK); + return; + } catch { + /* keep looking */ + } } - throw new ConfigError(`julia binary not found or not executable: ${bin}`) + throw new ConfigError(`julia binary not found or not executable: ${bin}`); } function signalCode(signal: NodeJS.Signals | null): number { - const n = signal ? (osConstants.signals as Record)[signal] : undefined - return 128 + (n ?? 1) + const n = signal ? (osConstants.signals as Record)[signal] : undefined; + return 128 + (n ?? 1); } export class LocalExecutor implements Executor { async submit(scriptPath: string, opts: SubmitOpts = {}): Promise { // ---- step 1 (spec §5): validate config; failures here create NO run dir ---- - const script = resolve(scriptPath) - if (!existsSync(script)) throw new ConfigError(`script not found: ${script}`) - const juliaBin = opts.julia?.julia ?? 'julia' - resolveExecutable(juliaBin) - const lab = opts.lab ?? 'default' - const labId = deriveLabId(lab) - const runsRoot = opts.runsRoot ?? defaultRunsRoot(labId) - try { mkdirSync(runsRoot, { recursive: true }) } catch (e) { - throw new ConfigError(`runs root not writable: ${runsRoot} (${(e as Error).message})`) + const script = resolve(scriptPath); + if (!existsSync(script)) throw new ConfigError(`script not found: ${script}`); + const juliaBin = opts.julia?.julia ?? "julia"; + resolveExecutable(juliaBin); + const lab = opts.lab ?? "default"; + const labId = deriveLabId(lab); + const runsRoot = opts.runsRoot ?? defaultRunsRoot(labId); + try { + mkdirSync(runsRoot, { recursive: true }); + } catch (e) { + throw new ConfigError(`runs root not writable: ${runsRoot} (${(e as Error).message})`); } // ---- steps 2–5: run dir, manifest FIRST, index, latest ---- - const runId = generateRunId(runsRoot) - const runDir = join(runsRoot, runId) - mkdirSync(runDir) - const createdAt = new Date().toISOString() + const runId = generateRunId(runsRoot); + const runDir = join(runsRoot, runId); + mkdirSync(runDir); + const createdAt = new Date().toISOString(); writeManifest(runDir, { // spec C: --spec launches stamp tier + hashes and bump to v2; bare runs stay v1 - schema_version: opts.spec ? '2' : '1', run_id: runId, script_path: script, - lab, lab_id: labId, created_at: createdAt, + schema_version: opts.spec ? "2" : "1", + run_id: runId, + script_path: script, + lab, + lab_id: labId, + created_at: createdAt, orchestrator_version: ORCHESTRATOR_VERSION, julia: { binary: juliaBin, project: opts.julia?.project, sysimage: opts.julia?.sysimage }, - tier: opts.spec?.tier, hashes: opts.spec?.hashes, - }) - if (opts.spec) atomicWriteFile(runDir, 'solvespec.json', opts.spec.canonical + '\n') - appendIndex(runsRoot, runId, createdAt, script) - updateLatest(runsRoot, runId) + tier: opts.spec?.tier, + hashes: opts.spec?.hashes, + }); + if (opts.spec) atomicWriteFile(runDir, "solvespec.json", opts.spec.canonical + "\n"); + appendIndex(runsRoot, runId, createdAt, script); + updateLatest(runsRoot, runId); // ---- step 6: spawn julia, own process group, cwd = runDir ---- - const args: string[] = [] - if (opts.julia?.project) args.push(`--project=${opts.julia.project}`) - if (opts.julia?.sysimage) args.push(`--sysimage=${opts.julia.sysimage}`) - args.push(script) + const args: string[] = []; + if (opts.julia?.project) args.push(`--project=${opts.julia.project}`); + if (opts.julia?.sysimage) args.push(`--sysimage=${opts.julia.sysimage}`); + args.push(script); - const events = new EventQueue() - const logStream = createWriteStream(join(runDir, 'run.log'), { flags: 'a' }) - let resolveFinished!: (f: Finished) => void - const finished = new Promise(r => { resolveFinished = r }) + const events = new EventQueue(); + const logStream = createWriteStream(join(runDir, "run.log"), { flags: "a" }); + let resolveFinished!: (f: Finished) => void; + const finished = new Promise((r) => { + resolveFinished = r; + }); - let settled = false - let aborting = false + let settled = false; + let aborting = false; const settle = (status: RunStatus, exitCode: number): void => { - if (settled) return - settled = true + if (settled) return; + settled = true; try { - writeFinished(runDir, status, exitCode) // orchestrator verdict, atomic — overwrites - } catch (e) { // any FINISHED a script faked (spec §5 step 8) - process.stderr.write(`amico-run: failed to write FINISHED: ${(e as Error).message}\n`) + writeFinished(runDir, status, exitCode); // orchestrator verdict, atomic — overwrites + } catch (e) { + // any FINISHED a script faked (spec §5 step 8) + process.stderr.write(`amico-run: failed to write FINISHED: ${(e as Error).message}\n`); } - logStream.end() - events.push({ kind: 'finished', status, exitCode }) - events.close() - resolveFinished({ status, exitCode }) - } + logStream.end(); + events.push({ kind: "finished", status, exitCode }); + events.close(); + resolveFinished({ status, exitCode }); + }; // stdbuf (spec §5 "where available") is deliberately omitted in β.1: the β.3 script // convention prints with flush, and the fake-julia fixtures are node (line-flushed). // If live ITER streaming degrades on a real lab machine, β.6's dry-run catches it. const child = spawn(juliaBin, args, { - cwd: runDir, detached: true, stdio: ['ignore', 'pipe', 'pipe'], - }) + cwd: runDir, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); // spawn failure AFTER manifest exists → FINISHED{failed, 127} (spec §6) - child.on('error', () => settle('failed', 127)) + child.on("error", () => settle("failed", 127)); // 'close', NOT 'exit': close waits for stdout/stderr to drain, so every line event // lands before settle() — the events stream must terminate ON the finished event (§3). - child.on('close', (code, signal) => { - const rc = code ?? signalCode(signal) - settle(aborting ? 'aborted' : rc === 0 ? 'completed' : 'failed', rc) - }) + child.on("close", (code, signal) => { + const rc = code ?? signalCode(signal); + settle(aborting ? "aborted" : rc === 0 ? "completed" : "failed", rc); + }); - const onLine = (stream: 'stdout' | 'stderr') => (line: string): void => { - if (settled) return // belt-and-braces; 'close' ordering makes this rare - logStream.write(line + '\n') - events.push(classifyLine(line, stream)) - } - readline.createInterface({ input: child.stdout! }).on('line', onLine('stdout')) - readline.createInterface({ input: child.stderr! }).on('line', onLine('stderr')) + const onLine = + (stream: "stdout" | "stderr") => + (line: string): void => { + if (settled) return; // belt-and-braces; 'close' ordering makes this rare + logStream.write(line + "\n"); + events.push(classifyLine(line, stream)); + }; + readline.createInterface({ input: child.stdout! }).on("line", onLine("stdout")); + readline.createInterface({ input: child.stderr! }).on("line", onLine("stderr")); - const graceMs = opts.graceMs ?? 5000 + const graceMs = opts.graceMs ?? 5000; const abort = async (): Promise => { - if (settled) return - aborting = true + if (settled) return; + aborting = true; const killGroup = (sig: NodeJS.Signals): void => { - try { process.kill(-child.pid!, sig) } catch { /* already gone */ } - } - killGroup('SIGTERM') - const killer = setTimeout(() => killGroup('SIGKILL'), graceMs) - killer.unref() - await finished - clearTimeout(killer) - } + try { + process.kill(-child.pid!, sig); + } catch { + /* already gone */ + } + }; + killGroup("SIGTERM"); + const killer = setTimeout(() => killGroup("SIGKILL"), graceMs); + killer.unref(); + await finished; + clearTimeout(killer); + }; - return { runId, runDir, events, finished, abort } + return { runId, runDir, events, finished, abort }; } } /** Spec §3: interface seam only — implementation is post-β. */ export class RemoteExecutor implements Executor { submit(): Promise { - return Promise.reject(new Error('RemoteExecutor: not implemented in β (D9 plan, Phase 2+)')) + return Promise.reject(new Error("RemoteExecutor: not implemented in β (D9 plan, Phase 2+)")); } } diff --git a/packages/amico-run/src/run_dir.ts b/packages/amico-run/src/run_dir.ts index e799e25b..5c5db6f1 100644 --- a/packages/amico-run/src/run_dir.ts +++ b/packages/amico-run/src/run_dir.ts @@ -1,62 +1,63 @@ -import { existsSync, writeFileSync, renameSync, appendFileSync, symlinkSync, rmSync } from 'node:fs' -import { randomBytes } from 'node:crypto' -import { homedir } from 'node:os' -import { join, dirname, basename, resolve } from 'node:path' -import { ConfigError, type RunStatus } from './types.js' +import { existsSync, writeFileSync, renameSync, appendFileSync, symlinkSync, rmSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { homedir } from "node:os"; +import { join, dirname, basename, resolve } from "node:path"; +import { ConfigError, type RunStatus } from "./types.js"; -const ID_RE = /^[a-z0-9][a-z0-9_-]*$/ +const ID_RE = /^[a-z0-9][a-z0-9_-]*$/; /** Spec §3: id pointers verbatim; path pointers (contain "/" or end ".toml") * derive the id from the parent directory name of the lab.toml. */ export function deriveLabId(lab: string): string { - if (ID_RE.test(lab)) return lab - if (lab.includes('/') || lab.endsWith('.toml')) { - const id = basename(dirname(resolve(lab))) - if (ID_RE.test(id)) return id - throw new ConfigError(`cannot derive lab id from "${lab}": parent dir "${id}" is not a valid id`) + if (ID_RE.test(lab)) return lab; + if (lab.includes("/") || lab.endsWith(".toml")) { + const id = basename(dirname(resolve(lab))); + if (ID_RE.test(id)) return id; + throw new ConfigError(`cannot derive lab id from "${lab}": parent dir "${id}" is not a valid id`); } - throw new ConfigError(`invalid lab pointer "${lab}" (want [a-z0-9][a-z0-9_-]* or a lab.toml path)`) + throw new ConfigError(`invalid lab pointer "${lab}" (want [a-z0-9][a-z0-9_-]* or a lab.toml path)`); } export function defaultRunsRoot(labId: string): string { - return join(homedir(), '.amico', 'runs', labId) + return join(homedir(), ".amico", "runs", labId); } export function generateRunId(runsRoot: string, now = new Date()): string { - const p = (n: number, w = 2) => String(n).padStart(w, '0') - const ts = `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` + - `-${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}Z` + const p = (n: number, w = 2) => String(n).padStart(w, "0"); + const ts = + `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` + + `-${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}Z`; for (;;) { - const id = `r${ts}-${randomBytes(2).toString('hex')}` - if (!existsSync(join(runsRoot, id))) return id + const id = `r${ts}-${randomBytes(2).toString("hex")}`; + if (!existsSync(join(runsRoot, id))) return id; } } /** Write-temp-then-rename in the same dir: a watcher can never observe a partial file. */ export function atomicWriteFile(dir: string, name: string, content: string): void { - const tmp = join(dir, `.${name}.tmp-${process.pid}`) - writeFileSync(tmp, content) - renameSync(tmp, join(dir, name)) + const tmp = join(dir, `.${name}.tmp-${process.pid}`); + writeFileSync(tmp, content); + renameSync(tmp, join(dir, name)); } -const ts = (s: string) => JSON.stringify(s) // JSON escaping is valid TOML basic-string +const ts = (s: string) => JSON.stringify(s); // JSON escaping is valid TOML basic-string export interface Manifest { - schema_version: '1' | '2' - run_id: string - script_path: string - lab: string - lab_id: string - created_at: string - orchestrator_version: string - julia: { binary: string; project?: string; sysimage?: string } + schema_version: "1" | "2"; + run_id: string; + script_path: string; + lab: string; + lab_id: string; + created_at: string; + orchestrator_version: string; + julia: { binary: string; project?: string; sysimage?: string }; // v2 (spec C, --spec launches only) — bare runs stay byte-identical v1 - tier?: string - hashes?: Record + tier?: string; + hashes?: Record; } export function writeManifest(runDir: string, m: Manifest): void { - const hashEntries = Object.entries(m.hashes ?? {}) + const hashEntries = Object.entries(m.hashes ?? {}); const lines = [ `schema_version = ${ts(m.schema_version)}`, ...(m.tier ? [`tier = ${ts(m.tier)}`] : []), @@ -66,35 +67,33 @@ export function writeManifest(runDir: string, m: Manifest): void { `lab_id = ${ts(m.lab_id)}`, `created_at = ${ts(m.created_at)}`, `orchestrator_version = ${ts(m.orchestrator_version)}`, - '', - '[julia]', + "", + "[julia]", `binary = ${ts(m.julia.binary)}`, ...(m.julia.project ? [`project = ${ts(m.julia.project)}`] : []), ...(m.julia.sysimage ? [`sysimage = ${ts(m.julia.sysimage)}`] : []), - ...(hashEntries.length > 0 - ? ['', '[hashes]', ...hashEntries.map(([key, value]) => `${key} = ${ts(value)}`)] - : []), - ] - atomicWriteFile(runDir, 'run.toml', lines.join('\n') + '\n') + ...(hashEntries.length > 0 ? ["", "[hashes]", ...hashEntries.map(([key, value]) => `${key} = ${ts(value)}`)] : []), + ]; + atomicWriteFile(runDir, "run.toml", lines.join("\n") + "\n"); } export function writeFinished(runDir: string, status: RunStatus, exitCode: number): void { - atomicWriteFile(runDir, 'FINISHED', `status = ${ts(status)}\nexit_code = ${exitCode}\n`) + atomicWriteFile(runDir, "FINISHED", `status = ${ts(status)}\nexit_code = ${exitCode}\n`); } export function appendIndex(runsRoot: string, runId: string, createdAt: string, scriptPath: string): void { // The index is a tab-separated, one-line-per-run log; a tab/newline in the // (last-field) script path would corrupt it. Sanitize control chars to a // space — run.toml holds the canonical, TOML-escaped script_path. - const safePath = scriptPath.replace(/[\t\r\n]/g, ' ') - appendFileSync(join(runsRoot, 'index'), `${runId}\t${createdAt}\t${safePath}\n`) + const safePath = scriptPath.replace(/[\t\r\n]/g, " "); + appendFileSync(join(runsRoot, "index"), `${runId}\t${createdAt}\t${safePath}\n`); } export function updateLatest(runsRoot: string, runId: string): void { // Scope the temp name to runId so concurrent same-lab submits don't race on // a shared `.latest.tmp` (one would unlink the other's in-flight temp). - const tmp = join(runsRoot, `.latest.${runId}.tmp`) - rmSync(tmp, { force: true }) - symlinkSync(runId, tmp) - renameSync(tmp, join(runsRoot, 'latest')) + const tmp = join(runsRoot, `.latest.${runId}.tmp`); + rmSync(tmp, { force: true }); + symlinkSync(runId, tmp); + renameSync(tmp, join(runsRoot, "latest")); } diff --git a/packages/amico-run/src/scheduler.ts b/packages/amico-run/src/scheduler.ts new file mode 100644 index 00000000..82d434ff --- /dev/null +++ b/packages/amico-run/src/scheduler.ts @@ -0,0 +1,180 @@ +import { ConfigError, type Executor, type RunHandle, type RunStatus, type SubmitOpts } from "./types.js"; + +// ============================================================================ +// Scheduler (Phase 1.1, #56) — a serial run queue built TO the ratified +// Executor contract (Track C spec, locked 2026-07-02), so the cloud +// RemoteExecutor (Δ8/#32) drops in with zero reshape: +// +// - S12: downstream (RunsManager / Inspector / Catalog) sees ONLY the +// executor's RunHandle — enqueue() resolves to it untouched; nothing here +// branches on executor type. +// - (b) abort() is a REQUEST, not a kill: the queue advances ONLY when a +// run's `finished` resolves (a FINISHED — or executor-inferred terminal — +// landed). Post-abort() the run is still live; the Scheduler never treats +// abort as terminal. +// - (c) per-executor warming budget: the Scheduler owns NO timers. However +// long a run takes to warm/finish (remote cold-start ≫ local seconds) is +// between the executor and its handle; the queue just awaits `finished`. +// - (d) terminal resolution is the executor's job (`finished` never rejects +// per the contract); the Scheduler defensively survives a rogue rejection +// rather than wedging the queue. +// +// Serial by default; `{concurrent: true}` is the NAMED Phase-4 seam (§4.2) — +// rejected loudly today so nothing silently serializes when callers expect a +// parallel lane later. +// ============================================================================ + +/** What to run when this entry reaches the head of the queue. */ +export interface SubmitSpec { + scriptPath: string; + /** Passed to Executor.submit verbatim (lab pointer, runsRoot, julia opts…). */ + opts?: SubmitOpts; +} + +export interface EnqueueOpts { + /** Phase-4 seam (opt-in parallel lane) — NOT implemented; throws ConfigError. */ + concurrent?: boolean; +} + +/** Run lifecycle the RunsManager / StatusBar consume (1.2). `queueId` is the + * Scheduler's own id (assigned at enqueue, before any run exists); `runId` + * appears once the executor has admitted the run. */ +export type SchedulerEvent = + | { kind: "queued"; queueId: string; position: number } + | { kind: "started"; queueId: string; runId: string; runDir: string } + | { kind: "finished"; queueId: string; runId: string; status: RunStatus; exitCode: number } + | { kind: "cancelled"; queueId: string } + | { kind: "error"; queueId: string; message: string }; + +export interface ScheduledRun { + queueId: string; + /** Resolves with the executor's RunHandle when this entry reaches the head + * of the queue and submit() succeeds. Rejects if the entry is cancelled + * before starting, or if submit() throws (e.g. ConfigError). */ + handle: Promise; + /** Dequeue BEFORE start: true iff the entry was still queued (it will never + * run). False in every other case — already started, already cancelled, or + * mid-submit (shifted but `started` not yet emitted; `handle` may still + * REJECT if that submit fails). To stop a live run, `await handle` (in a + * try/catch) and call RunHandle.abort() — a request, per contract (b); + * never via the queue. */ + cancel(): boolean; +} + +interface Entry { + queueId: string; + spec: SubmitSpec; + resolve: (h: RunHandle) => void; + reject: (e: Error) => void; +} + +export class Scheduler { + private readonly queue: Entry[] = []; + private running = false; + private nextId = 1; + private readonly listeners = new Set<(e: SchedulerEvent) => void>(); + + constructor(private readonly executor: Executor) {} + + /** Subscribe to lifecycle events. Returns a dispose function. Multi-consumer + * (RunsManager + StatusBar); a throwing listener is isolated. */ + onEvent(listener: (e: SchedulerEvent) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Queued + running entries — 0 means an enqueue() would start immediately. */ + get depth(): number { + return this.queue.length + (this.running ? 1 : 0); + } + + enqueue(spec: SubmitSpec, opts: EnqueueOpts = {}): ScheduledRun { + if (opts.concurrent) { + throw new ConfigError("Scheduler: the parallel lane (concurrent: true) is deferred to Phase 4 — runs are serial"); + } + const queueId = `q${this.nextId++}`; + let resolve!: (h: RunHandle) => void; + let reject!: (e: Error) => void; + const handle = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // The Scheduler itself observes failures (error event) — callers that only + // consume events must not trip an unhandled-rejection on the same promise. + handle.catch(() => {}); + const entry: Entry = { queueId, spec, resolve, reject }; + this.queue.push(entry); + this.emit({ kind: "queued", queueId, position: this.queue.length - 1 + (this.running ? 1 : 0) }); + void this.pump(); + return { + queueId, + handle, + cancel: (): boolean => { + const i = this.queue.indexOf(entry); + if (i === -1) return false; // already started (or done) — abort via the handle + this.queue.splice(i, 1); + this.emit({ kind: "cancelled", queueId }); + entry.reject(new Error(`Scheduler: ${queueId} cancelled before start`)); + return true; + }, + }; + } + + // -------- internal -------- + + private emit(e: SchedulerEvent): void { + for (const l of this.listeners) { + try { + l(e); + } catch { + /* a bad listener must not wedge the pump */ + } + } + } + + /** The serial pump: one entry at a time; advances ONLY on `finished` + * resolution (contract (b) — never on abort(), which is just a request). */ + private async pump(): Promise { + if (this.running) return; + const entry = this.queue.shift(); + if (!entry) return; + this.running = true; + try { + let handle: RunHandle; + try { + handle = await this.executor.submit(entry.spec.scriptPath, entry.spec.opts); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + this.emit({ kind: "error", queueId: entry.queueId, message: err.message }); + entry.reject(err); + return; // finally advances the queue — a config failure must not wedge it + } + this.emit({ kind: "started", queueId: entry.queueId, runId: handle.runId, runDir: handle.runDir }); + entry.resolve(handle); + try { + const fin = await handle.finished; // contract: never rejects… + this.emit({ + kind: "finished", + queueId: entry.queueId, + runId: handle.runId, + status: fin.status, + exitCode: fin.exitCode, + }); + } catch (e) { + // …but a rogue executor breaking that must not deadlock every queued run. + const msg = e instanceof Error ? e.message : String(e); + this.emit({ kind: "error", queueId: entry.queueId, message: `finished rejected: ${msg}` }); + } + } finally { + this.running = false; + // Microtask deferral, NOT a direct call: a contract-violating executor + // whose submit() throws SYNCHRONOUSLY would otherwise make this finally + // direct recursion — a long backlog of such failures blows the stack and + // strands the rest of the queue. Deferring one microtask keeps the chain + // flat regardless of how the executor misbehaves. + queueMicrotask(() => void this.pump()); + } + } +} diff --git a/packages/amico-run/src/subcommands.ts b/packages/amico-run/src/subcommands.ts index b0cc66a0..c01b1263 100644 --- a/packages/amico-run/src/subcommands.ts +++ b/packages/amico-run/src/subcommands.ts @@ -4,65 +4,77 @@ // the Amicode workflow. Dispatch only fires when argv[0] is the literal // subcommand AND is not an existing file (a bare script named `resolve` keeps // the launch contract). -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { readAuthoring } from './authoring.js' -import { loadExemplarsIndex, loadRegistry, matchShape } from './catalog.js' -import { JULIA_STDLIBS } from './import_scan.js' +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { readAuthoring } from "./authoring.js"; +import { loadExemplarsIndex, loadRegistry, matchShape } from "./catalog.js"; +import { JULIA_STDLIBS } from "./import_scan.js"; /** Tier-3 minimum package set — the free skeleton's `using` block AND the * re-rollout harness both need these in the sandbox env, so `resolve` returns * them for tier free (an empty set would generate an uninstantiable env). */ -const TIER3_MIN_PACKAGES = ['Piccolo', 'CairoMakie', 'JLD2', 'TOML', 'Printf'] +const TIER3_MIN_PACKAGES = ["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"]; function flagValue(argv: string[], name: string): string | undefined { - const i = argv.indexOf(name) - return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; } export function resolveCommand(argv: string[]): number { - const platform = flagValue(argv, '--platform') - const kind = flagValue(argv, '--kind') - const sizeRaw = flagValue(argv, '--size') + const platform = flagValue(argv, "--platform"); + const kind = flagValue(argv, "--kind"); + const sizeRaw = flagValue(argv, "--size"); if (!platform || !kind || !sizeRaw) { - console.error('amico-run resolve: --platform, --kind, --size are all required') - return 64 + console.error("amico-run resolve: --platform, --kind, --size are all required"); + return 64; + } + const size = Number(sizeRaw); + if (!Number.isFinite(size)) { + console.error(`amico-run resolve: --size must be a number (got ${sizeRaw})`); + return 64; } - const size = Number(sizeRaw) - if (!Number.isFinite(size)) { console.error(`amico-run resolve: --size must be a number (got ${sizeRaw})`); return 64 } - const { config } = readAuthoring() - const registry = loadRegistry(config.registry ?? '') - const exemplars = loadExemplarsIndex(config.exemplars ?? '') - const match = matchShape({ platform, kind, size }, registry, exemplars, config.allowlist) + const { config } = readAuthoring(); + const registry = loadRegistry(config.registry ?? ""); + const exemplars = loadExemplarsIndex(config.exemplars ?? ""); + const match = matchShape({ platform, kind, size }, registry, exemplars, config.allowlist); // template/exemplar paths in the catalog are relative to their manifest file; // resolve to absolute so the agent can copy the script directly. - const registryDir = config.registry ? dirname(config.registry) : process.cwd() - const exemplarsDir = config.exemplars ? dirname(config.exemplars) : process.cwd() - const out: Record = { tier: match.tier } + const registryDir = config.registry ? dirname(config.registry) : process.cwd(); + const exemplarsDir = config.exemplars ? dirname(config.exemplars) : process.cwd(); + const out: Record = { tier: match.tier }; if (match.template) { - out.source = { template_id: match.template.id } - out.template_path = resolve(registryDir, match.template.path) - out.packages = match.template.packages + out.source = { template_id: match.template.id }; + out.template_path = resolve(registryDir, match.template.path); + out.packages = match.template.packages; } else if (match.exemplar) { - out.source = { exemplar_id: match.exemplar.id } - out.exemplar_path = resolve(exemplarsDir, match.exemplar.path) - out.packages = match.exemplar.packages + out.source = { exemplar_id: match.exemplar.id }; + out.exemplar_path = resolve(exemplarsDir, match.exemplar.path); + out.packages = match.exemplar.packages; } else { - out.packages = TIER3_MIN_PACKAGES + out.packages = TIER3_MIN_PACKAGES; } - if (match.blockedHigher) out.blocked_higher = match.blockedHigher - console.log(JSON.stringify(out)) - return 0 + if (match.blockedHigher) out.blocked_higher = match.blockedHigher; + console.log(JSON.stringify(out)); + return 0; } export function sandboxCommand(argv: string[]): number { - const target = argv[0] - if (!target || target.startsWith('-')) { console.error('amico-run sandbox: required'); return 64 } - const packagesRaw = flagValue(argv, '--packages') - if (!packagesRaw) { console.error('amico-run sandbox: --packages A,B,… required'); return 64 } - const packages = packagesRaw.split(',').map((p) => p.trim()).filter(Boolean) + const target = argv[0]; + if (!target || target.startsWith("-")) { + console.error("amico-run sandbox: required"); + return 64; + } + const packagesRaw = flagValue(argv, "--packages"); + if (!packagesRaw) { + console.error("amico-run sandbox: --packages A,B,… required"); + return 64; + } + const packages = packagesRaw + .split(",") + .map((p) => p.trim()) + .filter(Boolean); // Julia stdlibs load from @stdlib in LOAD_PATH regardless of a project's // [deps] — they need no uuid and no [deps] entry. Filter them so the sandbox @@ -70,34 +82,34 @@ export function sandboxCommand(argv: string[]): number { // defect #2: TIER3_MIN_PACKAGES ships Printf+TOML, both stdlibs with no // [uuids] entry, which exit-64'd every tier-free launch at env generation). // Non-stdlib packages still require a uuid — the unknown-package guard holds. - const depsNeeded = packages.filter((p) => !JULIA_STDLIBS.has(p)) + const depsNeeded = packages.filter((p) => !JULIA_STDLIBS.has(p)); - const { config } = readAuthoring() - const registry = loadRegistry(config.registry ?? '') - const missing = depsNeeded.filter((p) => !registry.uuids[p]) + const { config } = readAuthoring(); + const registry = loadRegistry(config.registry ?? ""); + const missing = depsNeeded.filter((p) => !registry.uuids[p]); if (missing.length > 0) { - console.error(`amico-run sandbox: no uuid in the registry for: ${missing.join(', ')}`) - return 64 + console.error(`amico-run sandbox: no uuid in the registry for: ${missing.join(", ")}`); + return 64; } const deps = depsNeeded .slice() .sort() .map((p) => `${p} = ${JSON.stringify(registry.uuids[p])}`) - .join('\n') - const envDir = join(target, 'env') - mkdirSync(envDir, { recursive: true }) - writeFileSync(join(envDir, 'Project.toml'), `[deps]\n${deps}\n`) - console.log(`amico-run: wrote ${join(envDir, 'Project.toml')}`) - console.log(`instantiate it (private git deps need CLI git):`) - console.log(` JULIA_PKG_USE_CLI_GIT=true julia --project=${envDir} -e 'using Pkg; Pkg.instantiate()'`) - return 0 + .join("\n"); + const envDir = join(target, "env"); + mkdirSync(envDir, { recursive: true }); + writeFileSync(join(envDir, "Project.toml"), `[deps]\n${deps}\n`); + console.log(`amico-run: wrote ${join(envDir, "Project.toml")}`); + console.log(`instantiate it (private git deps need CLI git):`); + console.log(` JULIA_PKG_USE_CLI_GIT=true julia --project=${envDir} -e 'using Pkg; Pkg.instantiate()'`); + return 0; } /** Dispatch a subcommand if argv[0] names one and is not an existing file. */ export function trySubcommand(argv: string[]): number | undefined { - const head = argv[0] - if (head === 'resolve' && !existsSync(head)) return resolveCommand(argv.slice(1)) - if (head === 'sandbox' && !existsSync(head)) return sandboxCommand(argv.slice(1)) - return undefined + const head = argv[0]; + if (head === "resolve" && !existsSync(head)) return resolveCommand(argv.slice(1)); + if (head === "sandbox" && !existsSync(head)) return sandboxCommand(argv.slice(1)); + return undefined; } diff --git a/packages/amico-run/src/telemetry.ts b/packages/amico-run/src/telemetry.ts index b88159f9..a3f8d72a 100644 --- a/packages/amico-run/src/telemetry.ts +++ b/packages/amico-run/src/telemetry.ts @@ -1,14 +1,14 @@ -import type { RunEvent } from './types.js' +import type { RunEvent } from "./types.js"; -export function classifyLine(line: string, stream: 'stdout' | 'stderr'): RunEvent { - if (stream === 'stdout' && line.startsWith('AMICODE_ITER')) { - const fields: Record = {} - for (const tok of line.slice('AMICODE_ITER'.length).trim().split(/\s+/)) { - const eq = tok.indexOf('=') - if (eq > 0) fields[tok.slice(0, eq)] = tok.slice(eq + 1) +export function classifyLine(line: string, stream: "stdout" | "stderr"): RunEvent { + if (stream === "stdout" && line.startsWith("AMICODE_ITER")) { + const fields: Record = {}; + for (const tok of line.slice("AMICODE_ITER".length).trim().split(/\s+/)) { + const eq = tok.indexOf("="); + if (eq > 0) fields[tok.slice(0, eq)] = tok.slice(eq + 1); } - return { kind: 'iter', raw: line, fields } + return { kind: "iter", raw: line, fields }; } - if (stream === 'stdout' && /^DONE(\s|$)/.test(line)) return { kind: 'done', raw: line } - return { kind: 'log', stream, line } + if (stream === "stdout" && /^DONE(\s|$)/.test(line)) return { kind: "done", raw: line }; + return { kind: "log", stream, line }; } diff --git a/packages/amico-run/src/types.ts b/packages/amico-run/src/types.ts index 67625612..86550fa6 100644 --- a/packages/amico-run/src/types.ts +++ b/packages/amico-run/src/types.ts @@ -1,46 +1,49 @@ -export type RunStatus = 'completed' | 'failed' | 'aborted' +export type RunStatus = "completed" | "failed" | "aborted"; export interface JuliaOpts { - julia?: string // julia binary path; default "julia" from PATH - project?: string // --project= - sysimage?: string // --sysimage= + julia?: string; // julia binary path; default "julia" from PATH + project?: string; // --project= + sysimage?: string; // --sysimage= } export interface SubmitOpts { - lab?: string // lab POINTER (id or lab.toml path), passed through verbatim; default "default" - runsRoot?: string // default: ~/.amico/runs// - julia?: JuliaOpts - graceMs?: number // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI. - spec?: SpecStamp // spec C: gate-passed SolveSpec → solvespec.json persisted + run.toml v2 stamped + lab?: string; // lab POINTER (id or lab.toml path), passed through verbatim; default "default" + runsRoot?: string; // default: ~/.amico/runs// + julia?: JuliaOpts; + graceMs?: number; // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI. + spec?: SpecStamp; // spec C: gate-passed SolveSpec → solvespec.json persisted + run.toml v2 stamped } /** What a gate-passed --spec launch carries into the run dir (spec C). */ export interface SpecStamp { - canonical: string // stable-key-order solvespec.json body - tier?: string - hashes?: Record // incl. gate-computed spec_hash - julia_binary?: string // resolved julia bin — the free-tier verify harness runs under it - env_project?: string // resolved env project — --project for the harness + canonical: string; // stable-key-order solvespec.json body + tier?: string; + hashes?: Record; // incl. gate-computed spec_hash + julia_binary?: string; // resolved julia bin — the free-tier verify harness runs under it + env_project?: string; // resolved env project — --project for the harness } export type RunEvent = - | { kind: 'iter'; raw: string; fields: Record } - | { kind: 'done'; raw: string } - | { kind: 'log'; stream: 'stdout' | 'stderr'; line: string } - | { kind: 'finished'; status: RunStatus; exitCode: number } - -export interface Finished { status: RunStatus; exitCode: number } + | { kind: "iter"; raw: string; fields: Record } + | { kind: "done"; raw: string } + | { kind: "log"; stream: "stdout" | "stderr"; line: string } + | { kind: "finished"; status: RunStatus; exitCode: number }; + +export interface Finished { + status: RunStatus; + exitCode: number; +} export interface RunHandle { - runId: string - runDir: string - events: AsyncIterable // terminates after the 'finished' event - finished: Promise // never rejects - abort(): Promise // idempotent + runId: string; + runDir: string; + events: AsyncIterable; // terminates after the 'finished' event + finished: Promise; // never rejects + abort(): Promise; // idempotent } export interface Executor { - submit(scriptPath: string, opts?: SubmitOpts): Promise + submit(scriptPath: string, opts?: SubmitOpts): Promise; } /** Exit-64-class fault: bad config, nothing solver-related ran. */ diff --git a/packages/amico-run/src/verify.ts b/packages/amico-run/src/verify.ts index eb61fa51..e168bd83 100644 --- a/packages/amico-run/src/verify.ts +++ b/packages/amico-run/src/verify.ts @@ -6,14 +6,14 @@ // writing, we write a fallback verification.toml with agree=false + a reason — // a free run must NEVER end verification-less (absence would read as "pending" // forever and mask a failure, and the auto-promote gate keys off agree==true). -import { spawn } from 'node:child_process' -import { existsSync, renameSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import type { AuthoringConfig } from './authoring.js' -import type { SpecStamp } from './types.js' +import { spawn } from "node:child_process"; +import { existsSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { AuthoringConfig } from "./authoring.js"; +import type { SpecStamp } from "./types.js"; function tomlEscape(s: string): string { - return JSON.stringify(s) + return JSON.stringify(s); } function writeFallback(runDir: string, reason: string, tolerance: number): void { @@ -24,34 +24,35 @@ function writeFallback(runDir: string, reason: string, tolerance: number): void `fidelity_reported = "nan"\n` + `tolerance = ${tolerance}\n` + `integrator = "none"\n` + - `error = ${tomlEscape(reason)}\n` - const tmp = join(runDir, `.verification.toml.tmp-${process.pid}`) - writeFileSync(tmp, body) - renameSync(tmp, join(runDir, 'verification.toml')) + `error = ${tomlEscape(reason)}\n`; + const tmp = join(runDir, `.verification.toml.tmp-${process.pid}`); + writeFileSync(tmp, body); + renameSync(tmp, join(runDir, "verification.toml")); } /** Run the harness; guarantee a verification.toml exists afterward. Never rejects. */ export async function runVerification(runDir: string, spec: SpecStamp, authoring: AuthoringConfig): Promise { - const tolerance = authoring.verify_tolerance - const harness = authoring.verify_harness + const tolerance = authoring.verify_tolerance; + const harness = authoring.verify_harness; if (!harness || !existsSync(harness)) { - writeFallback(runDir, `verification harness not found (${harness ?? 'unset'})`, tolerance) - return + writeFallback(runDir, `verification harness not found (${harness ?? "unset"})`, tolerance); + return; } // The harness interpreter is julia in production; AMICO_VERIFY_RUNNER overrides // it for tests (node fake-harness). The env's project comes from the spec. - const runner = process.env.AMICO_VERIFY_RUNNER ?? spec.julia_binary ?? 'julia' - const args = runner === 'julia' && spec.env_project - ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance)] - : [harness, runDir, String(tolerance)] + const runner = process.env.AMICO_VERIFY_RUNNER ?? spec.julia_binary ?? "julia"; + const args = + runner === "julia" && spec.env_project + ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance)] + : [harness, runDir, String(tolerance)]; const exitCode: number = await new Promise((resolvePromise) => { - const child = spawn(runner, args, { stdio: ['ignore', 'inherit', 'inherit'] }) - child.on('error', () => resolvePromise(127)) - child.on('close', (code) => resolvePromise(code ?? 1)) - }) + const child = spawn(runner, args, { stdio: ["ignore", "inherit", "inherit"] }); + child.on("error", () => resolvePromise(127)); + child.on("close", (code) => resolvePromise(code ?? 1)); + }); - if (!existsSync(join(runDir, 'verification.toml'))) { - writeFallback(runDir, `verification harness exited ${exitCode} without writing verification.toml`, tolerance) + if (!existsSync(join(runDir, "verification.toml"))) { + writeFallback(runDir, `verification harness exited ${exitCode} without writing verification.toml`, tolerance); } } diff --git a/packages/amico-run/test/abort.test.ts b/packages/amico-run/test/abort.test.ts index 8946c0b1..fefcd12b 100644 --- a/packages/amico-run/test/abort.test.ts +++ b/packages/amico-run/test/abort.test.ts @@ -1,44 +1,46 @@ -import { describe, it, expect } from 'vitest' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; -const HANG = `setInterval(() => {}, 1000)` // dies on SIGTERM → 143 +const HANG = `setInterval(() => {}, 1000)`; // dies on SIGTERM → 143 // prints READY only after the SIGTERM handler is installed — the test must not abort // before then, or the signal hits node's default disposition during interpreter boot (→ 143) -const HANG_IGNORE = `process.on('SIGTERM', () => {}); console.log('READY'); setInterval(() => {}, 1000)` +const HANG_IGNORE = `process.on('SIGTERM', () => {}); console.log('READY'); setInterval(() => {}, 1000)`; -describe('abort lane (spec §3/§6)', () => { - it('abort() on a hanging run → FINISHED{aborted, 143} (SIGTERM)', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), julia: { julia: fakeJulia(root, 'j', HANG) }, - }) - await h.abort() - expect(await h.finished).toEqual({ status: 'aborted', exitCode: 143 }) - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'aborted', exit_code: 143 }) - }) +describe("abort lane (spec §3/§6)", () => { + it("abort() on a hanging run → FINISHED{aborted, 143} (SIGTERM)", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", HANG) }, + }); + await h.abort(); + expect(await h.finished).toEqual({ status: "aborted", exitCode: 143 }); + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "aborted", exit_code: 143 }); + }); - it('SIGTERM-ignoring script is SIGKILLed after grace → FINISHED{aborted, 137}', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), - julia: { julia: fakeJulia(root, 'j', HANG_IGNORE) }, - graceMs: 200, // test knob — spec default is 5000 - }) + it("SIGTERM-ignoring script is SIGKILLed after grace → FINISHED{aborted, 137}", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", HANG_IGNORE) }, + graceMs: 200, // test knob — spec default is 5000 + }); for await (const e of h.events) { - if (e.kind === 'log' && e.line === 'READY') void h.abort() // handler installed — now abort + if (e.kind === "log" && e.line === "READY") void h.abort(); // handler installed — now abort } - expect(await h.finished).toEqual({ status: 'aborted', exitCode: 137 }) - }, 15000) + expect(await h.finished).toEqual({ status: "aborted", exitCode: 137 }); + }, 15000); - it('abort() is idempotent and a no-op after completion', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), julia: { julia: fakeJulia(root, 'j', 'process.exit(0)') }, - }) - await h.finished - await expect(h.abort()).resolves.toBeUndefined() - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) -}) + it("abort() is idempotent and a no-op after completion", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", "process.exit(0)") }, + }); + await h.finished; + await expect(h.abort()).resolves.toBeUndefined(); + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); +}); diff --git a/packages/amico-run/test/authoring.test.ts b/packages/amico-run/test/authoring.test.ts index 27903517..9060e22e 100644 --- a/packages/amico-run/test/authoring.test.ts +++ b/packages/amico-run/test/authoring.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, afterEach } from "vitest" -import { mkdtempSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { readAuthoring, DEFAULT_ALLOWLIST, DEFAULT_SUPPORT } from "../src/authoring.js" +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readAuthoring, DEFAULT_ALLOWLIST, DEFAULT_SUPPORT } from "../src/authoring.js"; -let dir: string | undefined +let dir: string | undefined; afterEach(() => { - delete process.env.AMICO_AUTHORING_FILE - if (dir) rmSync(dir, { recursive: true, force: true }) - dir = undefined -}) + delete process.env.AMICO_AUTHORING_FILE; + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); describe("readAuthoring", () => { it("reads the file named by $AMICO_AUTHORING_FILE, fields round-trip", () => { - dir = mkdtempSync(join(tmpdir(), "amico-authoring-")) - const file = join(dir, "authoring.json") + dir = mkdtempSync(join(tmpdir(), "amico-authoring-")); + const file = join(dir, "authoring.json"); writeFileSync( file, JSON.stringify({ @@ -26,36 +26,36 @@ describe("readAuthoring", () => { verify_harness: "/abs/verify_rollout.jl", verify_tolerance: 0.02, }), - ) - process.env.AMICO_AUTHORING_FILE = file - const { config, warning } = readAuthoring() - expect(warning).toBeUndefined() - expect(config.allowlist).toEqual(["Piccolo", "Piccolissimo"]) - expect(config.support_set).toEqual(["JLD2"]) - expect(config.registry).toBe("/abs/registry.toml") - expect(config.exemplars).toBe("/abs/index.json") - expect(config.verify_harness).toBe("/abs/verify_rollout.jl") - expect(config.verify_tolerance).toBe(0.02) - }) + ); + process.env.AMICO_AUTHORING_FILE = file; + const { config, warning } = readAuthoring(); + expect(warning).toBeUndefined(); + expect(config.allowlist).toEqual(["Piccolo", "Piccolissimo"]); + expect(config.support_set).toEqual(["JLD2"]); + expect(config.registry).toBe("/abs/registry.toml"); + expect(config.exemplars).toBe("/abs/index.json"); + expect(config.verify_harness).toBe("/abs/verify_rollout.jl"); + expect(config.verify_tolerance).toBe(0.02); + }); it("missing file → conservative built-in defaults, no warning", () => { - process.env.AMICO_AUTHORING_FILE = "/nonexistent/authoring.json" - const { config, warning } = readAuthoring() - expect(warning).toBeUndefined() - expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST) - expect(config.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]) - expect(config.support_set).toEqual(DEFAULT_SUPPORT) - expect(config.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])) - expect(config.verify_tolerance).toBe(0.001) // spec-20260704-113005 §6 (resolves spec-C open q1) - }) + process.env.AMICO_AUTHORING_FILE = "/nonexistent/authoring.json"; + const { config, warning } = readAuthoring(); + expect(warning).toBeUndefined(); + expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST); + expect(config.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]); + expect(config.support_set).toEqual(DEFAULT_SUPPORT); + expect(config.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])); + expect(config.verify_tolerance).toBe(0.001); // spec-20260704-113005 §6 (resolves spec-C open q1) + }); it("malformed JSON → defaults + a warning naming the file", () => { - dir = mkdtempSync(join(tmpdir(), "amico-authoring-")) - const file = join(dir, "authoring.json") - writeFileSync(file, "{nope") - process.env.AMICO_AUTHORING_FILE = file - const { config, warning } = readAuthoring() - expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST) - expect(warning).toContain("authoring.json") - }) -}) + dir = mkdtempSync(join(tmpdir(), "amico-authoring-")); + const file = join(dir, "authoring.json"); + writeFileSync(file, "{nope"); + process.env.AMICO_AUTHORING_FILE = file; + const { config, warning } = readAuthoring(); + expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST); + expect(warning).toContain("authoring.json"); + }); +}); diff --git a/packages/amico-run/test/baseline.test.ts b/packages/amico-run/test/baseline.test.ts index b49e6f8e..a0b06a87 100644 --- a/packages/amico-run/test/baseline.test.ts +++ b/packages/amico-run/test/baseline.test.ts @@ -1,36 +1,36 @@ -import { describe, it, expect } from "vitest" -import { maskFillPoints, maskedHash } from "../src/baseline.js" +import { describe, it, expect } from "vitest"; +import { maskFillPoints, maskedHash } from "../src/baseline.js"; -const SCRIPT = `using Piccolo\n# ── FILL IN ──────\nT = 10.0\nN = 50\n# ─────────────────\nsolve()\n` +const SCRIPT = `using Piccolo\n# ── FILL IN ──────\nT = 10.0\nN = 50\n# ─────────────────\nsolve()\n`; describe("maskedHash", () => { it("is edit-invariant inside fill points, sensitive outside", () => { - const edited = SCRIPT.replace("T = 10.0", "T = 25.0") - expect(maskedHash(SCRIPT)).toBe(maskedHash(edited)) - const physics = SCRIPT.replace("solve()", "solve!(hacked)") - expect(maskedHash(SCRIPT)).not.toBe(maskedHash(physics)) - }) + const edited = SCRIPT.replace("T = 10.0", "T = 25.0"); + expect(maskedHash(SCRIPT)).toBe(maskedHash(edited)); + const physics = SCRIPT.replace("solve()", "solve!(hacked)"); + expect(maskedHash(SCRIPT)).not.toBe(maskedHash(physics)); + }); it("custom markers override the defaults", () => { - const custom = `a\n# BEGIN-KNOBS\nx = 1\n# END-KNOBS\nb\n` - const edited = custom.replace("x = 1", "x = 999") + const custom = `a\n# BEGIN-KNOBS\nx = 1\n# END-KNOBS\nb\n`; + const edited = custom.replace("x = 1", "x = 999"); expect(maskedHash(custom, "^# BEGIN-KNOBS", "^# END-KNOBS")).toBe( maskedHash(edited, "^# BEGIN-KNOBS", "^# END-KNOBS"), - ) + ); // default markers don't match this file → edits are visible - expect(maskedHash(custom)).not.toBe(maskedHash(edited)) - }) + expect(maskedHash(custom)).not.toBe(maskedHash(edited)); + }); it("an unterminated block masks to EOF", () => { - const open = `head\n# ── FILL IN ──\nx = 1\ny = 2\n` - const edited = open.replace("y = 2", "y = 3") - expect(maskedHash(open)).toBe(maskedHash(edited)) + const open = `head\n# ── FILL IN ──\nx = 1\ny = 2\n`; + const edited = open.replace("y = 2", "y = 3"); + expect(maskedHash(open)).toBe(maskedHash(edited)); // but the head is still sensitive - expect(maskedHash(open)).not.toBe(maskedHash(open.replace("head", "HEAD"))) - }) + expect(maskedHash(open)).not.toBe(maskedHash(open.replace("head", "HEAD"))); + }); it("the masked text keeps the marker lines and replaces interior lines", () => { - const masked = maskFillPoints(SCRIPT) - expect(masked).toContain("# ── FILL IN") - expect(masked).toContain("# ─────") - expect(masked).not.toContain("T = 10.0") - expect(masked).toContain("#MASKED") - }) -}) + const masked = maskFillPoints(SCRIPT); + expect(masked).toContain("# ── FILL IN"); + expect(masked).toContain("# ─────"); + expect(masked).not.toContain("T = 10.0"); + expect(masked).toContain("#MASKED"); + }); +}); diff --git a/packages/amico-run/test/catalog.test.ts b/packages/amico-run/test/catalog.test.ts index 98ab8331..737022af 100644 --- a/packages/amico-run/test/catalog.test.ts +++ b/packages/amico-run/test/catalog.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { loadRegistry, loadExemplarsIndex, matchShape } from "../src/catalog.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadRegistry, loadExemplarsIndex, matchShape } from "../src/catalog.js"; -let dir: string +let dir: string; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "amico-catalog-")) -}) -afterEach(() => rmSync(dir, { recursive: true, force: true })) + dir = mkdtempSync(join(tmpdir(), "amico-catalog-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); const REGISTRY = ` verify_tolerance = 0.01 @@ -47,7 +47,7 @@ packages = ["JLD2", "CairoMakie", "TOML", "Printf"] [uuids] Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -` +`; const INDEX = JSON.stringify({ schema_version: 1, @@ -62,68 +62,85 @@ const INDEX = JSON.stringify({ baseline_hash: "sha256:deadbeef", }, ], -}) +}); function seed() { - writeFileSync(join(dir, "registry.toml"), REGISTRY) - writeFileSync(join(dir, "index.json"), INDEX) + writeFileSync(join(dir, "registry.toml"), REGISTRY); + writeFileSync(join(dir, "index.json"), INDEX); return { registry: loadRegistry(join(dir, "registry.toml")), exemplars: loadExemplarsIndex(join(dir, "index.json")), - } + }; } -const PUBLIC_ALLOW = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"] +const PUBLIC_ALLOW = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]; describe("loaders", () => { it("registry parses templates, support set, uuids, tolerance", () => { - const { registry } = seed() - expect(registry.templates).toHaveLength(3) - expect(registry.support).toEqual(["JLD2", "CairoMakie", "TOML", "Printf"]) - expect(registry.uuids.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad") - expect(registry.verifyTolerance).toBe(0.01) - }) + const { registry } = seed(); + expect(registry.templates).toHaveLength(3); + expect(registry.support).toEqual(["JLD2", "CairoMakie", "TOML", "Printf"]); + expect(registry.uuids.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad"); + expect(registry.verifyTolerance).toBe(0.01); + }); it("missing files → empty catalog, never throws", () => { - expect(loadRegistry(join(dir, "nope.toml")).templates).toEqual([]) - expect(loadExemplarsIndex(join(dir, "nope.json")).exemplars).toEqual([]) - }) -}) + expect(loadRegistry(join(dir, "nope.toml")).templates).toEqual([]); + expect(loadExemplarsIndex(join(dir, "nope.json")).exemplars).toEqual([]); + }); +}); describe("matchShape", () => { it("exact vetted template match → tier 1", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "transmon", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("vetted") - expect(match.template?.id).toBe("transmon-gate-1q") - }) + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "transmon", kind: "gate_synthesis", size: 1 }, + registry, + exemplars, + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("vetted"); + expect(match.template?.id).toBe("transmon-gate-1q"); + }); it("experimental templates are NEVER tier 1 — falls through to the exemplar", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "rydberg", kind: "gate_synthesis", size: 2 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("composed") - expect(match.exemplar?.id).toBe("rydberg-cz") - }) + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "rydberg", kind: "gate_synthesis", size: 2 }, + registry, + exemplars, + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("composed"); + expect(match.exemplar?.id).toBe("rydberg-cz"); + }); it("no template and no exemplar → tier 3 (free)", () => { - const { registry, exemplars } = seed() - expect(matchShape({ platform: "ions", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW).tier).toBe("free") - }) + const { registry, exemplars } = seed(); + expect( + matchShape({ platform: "ions", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW).tier, + ).toBe("free"); + }); it("entitlement-blocked vetted match is excluded AND reported as blocked_higher", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("free") - expect(match.blockedHigher).toEqual({ tier: "vetted", requires: "issimo" }) + const { registry, exemplars } = seed(); + const match = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, PUBLIC_ALLOW); + expect(match.tier).toBe("free"); + expect(match.blockedHigher).toEqual({ tier: "vetted", requires: "issimo" }); // with the issimo packages allowed, the same shape resolves tier 1 - const withIssimo = matchShape( - { platform: "transmon", kind: "state_prep", size: 1 }, + const withIssimo = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, [ + ...PUBLIC_ALLOW, + "Piccolissimo", + "Strettissimo", + "Intonatissimo", + ]); + expect(withIssimo.tier).toBe("vetted"); + expect(withIssimo.template?.id).toBe("issimo-special-1q"); + }); + it("exemplar match on platform+kind tolerates a size mismatch (near match)", () => { + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "rydberg", kind: "gate_synthesis", size: 3 }, registry, exemplars, - [...PUBLIC_ALLOW, "Piccolissimo", "Strettissimo", "Intonatissimo"], - ) - expect(withIssimo.tier).toBe("vetted") - expect(withIssimo.template?.id).toBe("issimo-special-1q") - }) - it("exemplar match on platform+kind tolerates a size mismatch (near match)", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "rydberg", kind: "gate_synthesis", size: 3 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("composed") - }) -}) + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("composed"); + }); +}); diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index bf4368db..b814297f 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -1,150 +1,204 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { execFileSync, execFile } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync, execFile } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; -const BUNDLE = join(__dirname, '..', 'dist', 'amico-run.js') +const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { - execFileSync('node', [join(__dirname, '..', 'esbuild.config.mjs')], { cwd: join(__dirname, '..') }) -}) + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { try { - const stdout = execFileSync('node', [BUNDLE, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }) - return { code: 0, stdout, stderr: '' } + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; } catch (e) { - const err = e as { status?: number; stdout?: string; stderr?: string } - return { code: err.status ?? -1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; } } -describe('amico-run CLI', () => { - it('clean solve: relays iter lines, prints AMICODE_FINISHED, exits 0', () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`) - const script = fakeJulia(root, 's.jl', '') - const r = run([script, '--runs-root', join(root, 'runs'), '--julia', julia]) - expect(r.code).toBe(0) - expect(r.stdout).toContain('AMICODE_ITER iter=1 f=0.5') - expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=.+/) - }) - it('julia rc 7 passes through as exit 7', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--runs-root', join(root, 'runs'), - '--julia', fakeJulia(root, 'j', 'process.exit(7)')]) - expect(r.code).toBe(7) - expect(r.stdout).toContain('status=failed exitCode=7') - }) - it('missing script → 64, stderr one-liner, no run dir', () => { - const root = tmpRoot() - const r = run([join(root, 'nope.jl'), '--runs-root', join(root, 'runs')]) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/script not found/) - }) - it('unknown flag → 64 (never silently swallowed, spec Q68)', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--gates', 'X']) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/unknown flag/) - }) - it('--executor remote → 64 (only local in β)', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--executor', 'remote']) - expect(r.code).toBe(64) - }) - it('--spec: gate failure → 64, one-line stderr reason, NO run dir (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - writeFileSync(join(root, 'bad.json'), JSON.stringify({ nope: true })) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'bad.json'), - '--julia', fakeJulia(root, 'j', '')]) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/solvespec schema/) - expect(existsSync(join(root, 'runs'))).toBe(false) - }) - it('--spec pass: solvespec.json persisted canonical + run.toml v2 stamped (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') +describe("amico-run CLI", () => { + it("clean solve: relays iter lines, prints AMICODE_FINISHED, exits 0", () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`); + const script = fakeJulia(root, "s.jl", ""); + const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("AMICODE_ITER iter=1 f=0.5"); + expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=.+/); + }); + it("julia rc 7 passes through as exit 7", () => { + const root = tmpRoot(); + const r = run([ + fakeJulia(root, "s.jl", ""), + "--runs-root", + join(root, "runs"), + "--julia", + fakeJulia(root, "j", "process.exit(7)"), + ]); + expect(r.code).toBe(7); + expect(r.stdout).toContain("status=failed exitCode=7"); + }); + it("missing script → 64, stderr one-liner, no run dir", () => { + const root = tmpRoot(); + const r = run([join(root, "nope.jl"), "--runs-root", join(root, "runs")]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/script not found/); + }); + it("unknown flag → 64 (never silently swallowed, spec Q68)", () => { + const root = tmpRoot(); + const r = run([fakeJulia(root, "s.jl", ""), "--gates", "X"]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/unknown flag/); + }); + it("--executor remote → 64 (only local in β)", () => { + const root = tmpRoot(); + const r = run([fakeJulia(root, "s.jl", ""), "--executor", "remote"]); + expect(r.code).toBe(64); + }); + it("--spec: gate failure → 64, one-line stderr reason, NO run dir (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + writeFileSync(join(root, "bad.json"), JSON.stringify({ nope: true })); + const r = run([ + script, + "--runs-root", + join(root, "runs"), + "--spec", + join(root, "bad.json"), + "--julia", + fakeJulia(root, "j", ""), + ]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/solvespec schema/); + expect(existsSync(join(root, "runs"))).toBe(false); + }); + it("--spec pass: solvespec.json persisted canonical + run.toml v2 stamped (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); const spec = { - schema_version: '2', script_path: script, lab_id: 'default', - executor: 'local', tier: 'vetted', - hashes: { system_hash: 'sha256:ab' }, - } - writeFileSync(join(root, 'spec.json'), JSON.stringify(spec)) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'spec.json'), - '--julia', fakeJulia(root, 'j', `console.log('DONE f=0.99')`)]) - expect(r.code).toBe(0) - const match = /runDir=(\S+)/.exec(r.stdout) - expect(match).toBeTruthy() - const runDir = match![1] - const persisted = JSON.parse(readFileSync(join(runDir, 'solvespec.json'), 'utf8')) - expect(persisted).toMatchObject({ tier: 'vetted', lab_id: 'default' }) - const manifest = readToml(join(runDir, 'run.toml')) - expect(manifest.schema_version).toBe('2') - expect(manifest.tier).toBe('vetted') - expect((manifest.hashes as Record).system_hash).toBe('sha256:ab') - expect((manifest.hashes as Record).spec_hash).toMatch(/^sha256:/) - }) - it('--spec env.kind=project sets the julia --project arg from env.project (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - const env = join(root, 'env') - mkdirSync(env, { recursive: true }) - writeFileSync(join(env, 'Project.toml'), `[deps]\n`) - writeFileSync(join(env, 'Manifest.toml'), `julia_version = "1.11.0"\n`) + schema_version: "2", + script_path: script, + lab_id: "default", + executor: "local", + tier: "vetted", + hashes: { system_hash: "sha256:ab" }, + }; + writeFileSync(join(root, "spec.json"), JSON.stringify(spec)); + const r = run([ + script, + "--runs-root", + join(root, "runs"), + "--spec", + join(root, "spec.json"), + "--julia", + fakeJulia(root, "j", `console.log('DONE f=0.99')`), + ]); + expect(r.code).toBe(0); + const match = /runDir=(\S+)/.exec(r.stdout); + expect(match).toBeTruthy(); + const runDir = match![1]; + const persisted = JSON.parse(readFileSync(join(runDir, "solvespec.json"), "utf8")); + expect(persisted).toMatchObject({ tier: "vetted", lab_id: "default" }); + const manifest = readToml(join(runDir, "run.toml")); + expect(manifest.schema_version).toBe("2"); + expect(manifest.tier).toBe("vetted"); + expect((manifest.hashes as Record).system_hash).toBe("sha256:ab"); + expect((manifest.hashes as Record).spec_hash).toMatch(/^sha256:/); + }); + it("--spec env.kind=project sets the julia --project arg from env.project (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + const env = join(root, "env"); + mkdirSync(env, { recursive: true }); + writeFileSync(join(env, "Project.toml"), `[deps]\n`); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n`); const spec = { - schema_version: '2', script_path: script, lab_id: 'default', - tier: 'vetted', env: { kind: 'project', project: env }, - } - writeFileSync(join(root, 'spec.json'), JSON.stringify(spec)) - const julia = fakeJulia(root, 'j', `console.log('ARGS ' + process.argv.slice(2).join(' '))`) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'spec.json'), '--julia', julia]) - expect(r.code).toBe(0) - expect(r.stdout).toContain(`--project=${env}`) - }) - it('--spec tier=free: verification runs after FINISHED (AMICODE_VERIFIED + verification.toml); vetted: neither (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - const env = join(root, 'env') - mkdirSync(env, { recursive: true }) - writeFileSync(join(env, 'Project.toml'), `[deps]\n`) - writeFileSync(join(env, 'Manifest.toml'), `julia_version = "1.11.0"\n`) + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "vetted", + env: { kind: "project", project: env }, + }; + writeFileSync(join(root, "spec.json"), JSON.stringify(spec)); + const julia = fakeJulia(root, "j", `console.log('ARGS ' + process.argv.slice(2).join(' '))`); + const r = run([script, "--runs-root", join(root, "runs"), "--spec", join(root, "spec.json"), "--julia", julia]); + expect(r.code).toBe(0); + expect(r.stdout).toContain(`--project=${env}`); + }); + it("--spec tier=free: verification runs after FINISHED (AMICODE_VERIFIED + verification.toml); vetted: neither (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + const env = join(root, "env"); + mkdirSync(env, { recursive: true }); + writeFileSync(join(env, "Project.toml"), `[deps]\n`); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n`); // fake harness (node) that writes agree=true; wired as the julia binary so // runVerification spawns it (AMICO_VERIFY_RUNNER unset → spec.julia_binary) - const harness = fakeJulia(root, 'h.js', - `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[process.argv.length-2],'verification.toml'),'schema_version = "1"\\nagree = true\\n')`) - writeFileSync(join(root, 'authoring.json'), JSON.stringify({ - schema_version: 1, allowlist: ['Piccolo'], support_set: ['JLD2', 'TOML'], - verify_harness: harness, verify_tolerance: 0.01, - })) - const julia = fakeJulia(root, 'j', `console.log('DONE f=0.99')`) - const AUTH = { AMICO_AUTHORING_FILE: join(root, 'authoring.json'), AMICO_VERIFY_RUNNER: harness } + const harness = fakeJulia( + root, + "h.js", + `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[process.argv.length-2],'verification.toml'),'schema_version = "1"\\nagree = true\\n')`, + ); + writeFileSync( + join(root, "authoring.json"), + JSON.stringify({ + schema_version: 1, + allowlist: ["Piccolo"], + support_set: ["JLD2", "TOML"], + verify_harness: harness, + verify_tolerance: 0.01, + }), + ); + const julia = fakeJulia(root, "j", `console.log('DONE f=0.99')`); + const AUTH = { AMICO_AUTHORING_FILE: join(root, "authoring.json"), AMICO_VERIFY_RUNNER: harness }; - const freeSpec = { schema_version: '2', script_path: script, lab_id: 'default', tier: 'free', env: { kind: 'sandbox', project: env } } - writeFileSync(join(root, 'free.json'), JSON.stringify(freeSpec)) - const rFree = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'free.json'), '--julia', julia], AUTH) - expect(rFree.code).toBe(0) - expect(rFree.stdout).toMatch(/AMICODE_VERIFIED agree=true/) - const freeDir = /runDir=(\S+)/.exec(rFree.stdout)![1] - expect(existsSync(join(freeDir, 'verification.toml'))).toBe(true) + const freeSpec = { + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "free", + env: { kind: "sandbox", project: env }, + }; + writeFileSync(join(root, "free.json"), JSON.stringify(freeSpec)); + const rFree = run( + [script, "--runs-root", join(root, "runs"), "--spec", join(root, "free.json"), "--julia", julia], + AUTH, + ); + expect(rFree.code).toBe(0); + expect(rFree.stdout).toMatch(/AMICODE_VERIFIED agree=true/); + const freeDir = /runDir=(\S+)/.exec(rFree.stdout)![1]; + expect(existsSync(join(freeDir, "verification.toml"))).toBe(true); - const vetSpec = { schema_version: '2', script_path: script, lab_id: 'default', tier: 'vetted', env: { kind: 'provisioned' } } - writeFileSync(join(root, 'vet.json'), JSON.stringify(vetSpec)) - const rVet = run([script, '--runs-root', join(root, 'runs2'), '--spec', join(root, 'vet.json'), '--julia', julia], AUTH) - expect(rVet.stdout).not.toMatch(/AMICODE_VERIFIED/) - const vetDir = /runDir=(\S+)/.exec(rVet.stdout)![1] - expect(existsSync(join(vetDir, 'verification.toml'))).toBe(false) - }) - it('SIGTERM to the CLI → abort lane, exit 130', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', `console.log('READY'); setInterval(() => {}, 1000)`) - const script = fakeJulia(root, 's.jl', '') - const code: number = await new Promise(resolveP => { - const child = execFile('node', [BUNDLE, script, '--runs-root', join(root, 'runs'), '--julia', julia]) - child.stdout!.on('data', (d: string) => { if (d.includes('READY')) child.kill('SIGTERM') }) - child.on('exit', c => resolveP(c ?? -1)) - }) - expect(code).toBe(130) - }, 15000) -}) + const vetSpec = { + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "vetted", + env: { kind: "provisioned" }, + }; + writeFileSync(join(root, "vet.json"), JSON.stringify(vetSpec)); + const rVet = run( + [script, "--runs-root", join(root, "runs2"), "--spec", join(root, "vet.json"), "--julia", julia], + AUTH, + ); + expect(rVet.stdout).not.toMatch(/AMICODE_VERIFIED/); + const vetDir = /runDir=(\S+)/.exec(rVet.stdout)![1]; + expect(existsSync(join(vetDir, "verification.toml"))).toBe(false); + }); + it("SIGTERM to the CLI → abort lane, exit 130", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `console.log('READY'); setInterval(() => {}, 1000)`); + const script = fakeJulia(root, "s.jl", ""); + const code: number = await new Promise((resolveP) => { + const child = execFile("node", [BUNDLE, script, "--runs-root", join(root, "runs"), "--julia", julia]); + child.stdout!.on("data", (d: string) => { + if (d.includes("READY")) child.kill("SIGTERM"); + }); + child.on("exit", (c) => resolveP(c ?? -1)); + }); + expect(code).toBe(130); + }, 15000); +}); diff --git a/packages/amico-run/test/failure_lanes.test.ts b/packages/amico-run/test/failure_lanes.test.ts index c6622bd1..b5819530 100644 --- a/packages/amico-run/test/failure_lanes.test.ts +++ b/packages/amico-run/test/failure_lanes.test.ts @@ -1,128 +1,143 @@ -import { describe, it, expect } from 'vitest' -import { chmodSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' +import { describe, it, expect } from "vitest"; +import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; const sub = (root: string, julia: string, script: string) => - new LocalExecutor().submit(script, { runsRoot: join(root, 'runs'), julia: { julia } }) + new LocalExecutor().submit(script, { runsRoot: join(root, "runs"), julia: { julia } }); -describe('§6 failure matrix', () => { - it('nonzero exit → FINISHED{failed, rc}', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'process.exit(3)'), fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 3 }) - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 3 }) - }) +describe("§6 failure matrix", () => { + it("nonzero exit → FINISHED{failed, rc}", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", "process.exit(3)"), fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 3 }); + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "failed", exit_code: 3 }); + }); - it('crash before any output → FINISHED{failed}, manifest still valid', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'throw new Error("boom")'), fakeJulia(root, 's.jl', '')) - const f = await h.finished - expect(f.status).toBe('failed') - expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) - }) + it("crash before any output → FINISHED{failed}, manifest still valid", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", 'throw new Error("boom")'), fakeJulia(root, "s.jl", "")); + const f = await h.finished; + expect(f.status).toBe("failed"); + expect(readToml(join(h.runDir, "run.toml")).run_id).toBe(h.runId); + }); - it('spawn failure after manifest (X_OK dir → spawn error) → FINISHED{failed, 127}', async () => { - const root = tmpRoot() + it("spawn failure after manifest (X_OK dir → spawn error) → FINISHED{failed, 127}", async () => { + const root = tmpRoot(); // a directory passes step-1 X_OK validation, but spawn() itself errors → child.on('error') - const dirAsJulia = join(root, 'julia-dir') - mkdirSync(dirAsJulia, { mode: 0o755 }) - const h = await sub(root, dirAsJulia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 127 }) - expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) // manifest survived - }) + const dirAsJulia = join(root, "julia-dir"); + mkdirSync(dirAsJulia, { mode: 0o755 }); + const h = await sub(root, dirAsJulia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 127 }); + expect(readToml(join(h.runDir, "run.toml")).run_id).toBe(h.runId); // manifest survived + }); - it('shell exec-failure rc passes through verbatim (wrapper execs missing target)', async () => { - const root = tmpRoot() - const wrapper = join(root, 'julia-wrapper') - writeFileSync(wrapper, '#!/usr/bin/env bash\nexec /nonexistent/amico-test-julia "$@"\n') - chmodSync(wrapper, 0o755) - const h = await sub(root, wrapper, fakeJulia(root, 's.jl', '')) - const f = await h.finished - expect(f.status).toBe('failed') - expect([126, 127]).toContain(f.exitCode) // bash version dependent; both are julia-rc passthrough - }) + it("shell exec-failure rc passes through verbatim (wrapper execs missing target)", async () => { + const root = tmpRoot(); + const wrapper = join(root, "julia-wrapper"); + writeFileSync(wrapper, '#!/usr/bin/env bash\nexec /nonexistent/amico-test-julia "$@"\n'); + chmodSync(wrapper, 0o755); + const h = await sub(root, wrapper, fakeJulia(root, "s.jl", "")); + const f = await h.finished; + expect(f.status).toBe("failed"); + expect([126, 127]).toContain(f.exitCode); // bash version dependent; both are julia-rc passthrough + }); - it('crash mid-stream: iter events delivered, then FINISHED{failed}', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + it("crash mid-stream: iter events delivered, then FINISHED{failed}", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` console.log('AMICODE_ITER iter=1 f=0.5') console.log('AMICODE_ITER iter=2 f=0.1') - process.exit(3)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - const evs: string[] = [] - for await (const e of h.events) evs.push(e.kind) - expect(evs.filter(k => k === 'iter')).toHaveLength(2) - expect(evs.at(-1)).toBe('finished') - expect((await h.finished).exitCode).toBe(3) - }) + process.exit(3)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + const evs: string[] = []; + for await (const e of h.events) evs.push(e.kind); + expect(evs.filter((k) => k === "iter")).toHaveLength(2); + expect(evs.at(-1)).toBe("finished"); + expect((await h.finished).exitCode).toBe(3); + }); - it('julia killed by an EXTERNAL signal (not abort) → FINISHED{failed, 128+sig}', async () => { - const root = tmpRoot() + it("julia killed by an EXTERNAL signal (not abort) → FINISHED{failed, 128+sig}", async () => { + const root = tmpRoot(); // self-inflicted SIGTERM stands in for an external kill: abort() was never called, // so status must be failed (143), not aborted - const julia = fakeJulia(root, 'j', `process.kill(process.pid, 'SIGTERM')`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 143 }) - }) + const julia = fakeJulia(root, "j", `process.kill(process.pid, 'SIGTERM')`); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 143 }); + }); - it('manifest is on disk BEFORE julia spawns (script observes it in cwd at startup)', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', - `process.exit(require('node:fs').existsSync('run.toml') ? 0 : 7)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'completed', exitCode: 0 }) - }) + it("manifest is on disk BEFORE julia spawns (script observes it in cwd at startup)", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `process.exit(require('node:fs').existsSync('run.toml') ? 0 : 7)`); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "completed", exitCode: 0 }); + }); - it('garbage / binary stdout never crashes the parser; classified as log', async () => { - const root = tmpRoot() - const h = await sub(root, - fakeJulia(root, 'j', `process.stdout.write(Buffer.from([0xff, 0xfe, 0x0a])); console.log('ok')`), - fakeJulia(root, 's.jl', '')) - expect((await h.finished).status).toBe('completed') - }) + it("garbage / binary stdout never crashes the parser; classified as log", async () => { + const root = tmpRoot(); + const h = await sub( + root, + fakeJulia(root, "j", `process.stdout.write(Buffer.from([0xff, 0xfe, 0x0a])); console.log('ok')`), + fakeJulia(root, "s.jl", ""), + ); + expect((await h.finished).status).toBe("completed"); + }); - it('script that writes nothing at all still yields manifest + FINISHED', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', ''), fakeJulia(root, 's.jl', '')) - await h.finished - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) + it("script that writes nothing at all still yields manifest + FINISHED", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", ""), fakeJulia(root, "s.jl", "")); + await h.finished; + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); it("script's own bogus FINISHED is overwritten by the orchestrator verdict", async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` require('node:fs').writeFileSync('FINISHED', 'status = "completed"\\nexit_code = 0\\n') - process.exit(9)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - await h.finished - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 9 }) - }) + process.exit(9)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + await h.finished; + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "failed", exit_code: 9 }); + }); - it('exactly one finished event; events iterator terminates', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'process.exit(0)'), fakeJulia(root, 's.jl', '')) - let n = 0 - for await (const e of h.events) if (e.kind === 'finished') n++ - expect(n).toBe(1) - }) + it("exactly one finished event; events iterator terminates", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", "process.exit(0)"), fakeJulia(root, "s.jl", "")); + let n = 0; + for await (const e of h.events) if (e.kind === "finished") n++; + expect(n).toBe(1); + }); - it('no partial orchestrator file is ever observable (tight-loop reader, spec §8)', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + it("no partial orchestrator file is ever observable (tight-loop reader, spec §8)", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` let i = 0 const t = setInterval(() => { console.log('AMICODE_ITER iter=' + ++i + ' f=0.1') - if (i >= 20) { clearInterval(t) } }, 10)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - let sawTmp = false - let done = false - void h.finished.then(() => { done = true }) + if (i >= 20) { clearInterval(t) } }, 10)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + let sawTmp = false; + let done = false; + void h.finished.then(() => { + done = true; + }); while (!done) { - if (readdirSync(h.runDir).some(f => f.includes('.tmp-'))) sawTmp = true - await new Promise(r => setTimeout(r, 2)) + if (readdirSync(h.runDir).some((f) => f.includes(".tmp-"))) sawTmp = true; + await new Promise((r) => setTimeout(r, 2)); } - expect(sawTmp).toBe(false) - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) -}) + expect(sawTmp).toBe(false); + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); +}); diff --git a/packages/amico-run/test/gate.test.ts b/packages/amico-run/test/gate.test.ts index 27476e39..f73a3741 100644 --- a/packages/amico-run/test/gate.test.ts +++ b/packages/amico-run/test/gate.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { runGate } from "../src/gate.js" -import { maskedHash } from "../src/baseline.js" -import type { AuthoringConfig } from "../src/authoring.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runGate } from "../src/gate.js"; +import { maskedHash } from "../src/baseline.js"; +import type { AuthoringConfig } from "../src/authoring.js"; -let dir: string +let dir: string; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "amico-gate-")) -}) -afterEach(() => rmSync(dir, { recursive: true, force: true })) + dir = mkdtempSync(join(tmpdir(), "amico-gate-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); -const EXEMPLAR_SCRIPT = `using Piccolo\nusing JLD2, TOML\n# ── FILL IN ──────\nT = 10.0\n# ─────────────────\nsolve()\n` +const EXEMPLAR_SCRIPT = `using Piccolo\nusing JLD2, TOML\n# ── FILL IN ──────\nT = 10.0\n# ─────────────────\nsolve()\n`; function authoring(overrides?: Partial): AuthoringConfig { // exemplars index on disk with the fixture exemplar's build-time baseline - const index = join(dir, "index.json") + const index = join(dir, "index.json"); writeFileSync( index, JSON.stringify({ @@ -33,14 +33,14 @@ function authoring(overrides?: Partial): AuthoringConfig { }, ], }), - ) + ); return { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"], exemplars: index, verify_tolerance: 0.01, ...overrides, - } + }; } function spec(overrides: Record = {}): Record { @@ -52,89 +52,89 @@ function spec(overrides: Record = {}): Record tier: "vetted", env: { kind: "provisioned" }, ...overrides, - } + }; } describe("runGate", () => { it("step 1: schema-invalid spec → one-line schema reason", () => { - const result = runGate({ nope: true }, "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/schema/) - }) + const result = runGate({ nope: true }, "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/schema/); + }); it("step 2: blocked import → reason names the package", () => { - const result = runGate(spec(), "using Piccolo\nusing Zygote\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/Zygote/) - }) + const result = runGate(spec(), "using Piccolo\nusing Zygote\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/Zygote/); + }); it("step 3: free tier requires a sandbox env", () => { - const result = runGate(spec({ tier: "free", env: { kind: "provisioned" } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/free tier requires a sandbox env/) - }) + const result = runGate(spec({ tier: "free", env: { kind: "provisioned" } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/free tier requires a sandbox env/); + }); it("step 3: project env without a Manifest.toml → instantiate message", () => { - const env = join(dir, "env") - mkdirSync(env) - writeFileSync(join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\n`) - const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/instantiate/) - }) + const env = join(dir, "env"); + mkdirSync(env); + writeFileSync(join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\n`); + const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/instantiate/); + }); it("step 3b: stale env — Project dep missing from its OWN Manifest → named + re-instantiate", () => { - const env = join(dir, "env") - mkdirSync(env) + const env = join(dir, "env"); + mkdirSync(env); writeFileSync( join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\nJLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"\n`, - ) - writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n`) - const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/stale env.*JLD2.*re-instantiate/) + ); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n`); + const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/stale env.*JLD2.*re-instantiate/); // consistent pair passes writeFileSync( join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n\n[[deps.JLD2]]\nversion = "0.5.0"\n`, - ) - expect(runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()).ok).toBe(true) - }) + ); + expect(runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()).ok).toBe(true); + }); it("step 3: non-local executor rejected at schema level", () => { - const result = runGate(spec({ executor: "cloud" }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/executor/) - }) + const result = runGate(spec({ executor: "cloud" }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/executor/); + }); it("step 4: composed — inside-fill-point edits pass; outside edits reject with demote_to", () => { - const sandboxSpec = spec({ tier: "composed", source: { exemplar_id: "ex-1" } }) - const filled = EXEMPLAR_SCRIPT.replace("T = 10.0", "T = 25.0") - expect(runGate(sandboxSpec, filled, authoring()).ok).toBe(true) - const hacked = EXEMPLAR_SCRIPT.replace("solve()", "solve!(other_physics)") - const result = runGate(sandboxSpec, hacked, authoring()) - expect(result.ok).toBe(false) + const sandboxSpec = spec({ tier: "composed", source: { exemplar_id: "ex-1" } }); + const filled = EXEMPLAR_SCRIPT.replace("T = 10.0", "T = 25.0"); + expect(runGate(sandboxSpec, filled, authoring()).ok).toBe(true); + const hacked = EXEMPLAR_SCRIPT.replace("solve()", "solve!(other_physics)"); + const result = runGate(sandboxSpec, hacked, authoring()); + expect(result.ok).toBe(false); if (!result.ok) { - expect(result.reason).toMatch(/no longer the exemplar/) - expect(result.demote_to).toBe("free") + expect(result.reason).toMatch(/no longer the exemplar/); + expect(result.demote_to).toBe("free"); } - }) + }); it("step 4: composed without exemplar_id → clear reason", () => { - const result = runGate(spec({ tier: "composed" }), EXEMPLAR_SCRIPT, authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/exemplar_id/) - }) + const result = runGate(spec({ tier: "composed" }), EXEMPLAR_SCRIPT, authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/exemplar_id/); + }); it("step 5: pass returns the stamp; spec_hash is gate-computed and spec-sensitive", () => { - const specA = spec({ hashes: { system_hash: "sha256:ab" } }) - const resultA = runGate(specA, "using Piccolo\n", authoring()) - expect(resultA.ok).toBe(true) + const specA = spec({ hashes: { system_hash: "sha256:ab" } }); + const resultA = runGate(specA, "using Piccolo\n", authoring()); + expect(resultA.ok).toBe(true); if (resultA.ok) { - expect(resultA.stamp.tier).toBe("vetted") - expect(resultA.stamp.hashes.system_hash).toBe("sha256:ab") - expect(resultA.stamp.hashes.spec_hash).toMatch(/^sha256:/) - expect(JSON.parse(resultA.stamp.specCanonical)).toMatchObject({ tier: "vetted" }) - const resultB = runGate(spec({ hashes: { system_hash: "sha256:cd" } }), "using Piccolo\n", authoring()) - if (resultB.ok) expect(resultB.stamp.hashes.spec_hash).not.toBe(resultA.stamp.hashes.spec_hash) + expect(resultA.stamp.tier).toBe("vetted"); + expect(resultA.stamp.hashes.system_hash).toBe("sha256:ab"); + expect(resultA.stamp.hashes.spec_hash).toMatch(/^sha256:/); + expect(JSON.parse(resultA.stamp.specCanonical)).toMatchObject({ tier: "vetted" }); + const resultB = runGate(spec({ hashes: { system_hash: "sha256:cd" } }), "using Piccolo\n", authoring()); + if (resultB.ok) expect(resultB.stamp.hashes.spec_hash).not.toBe(resultA.stamp.hashes.spec_hash); } - }) + }); it("v1 specs (no tier) pass through with import scan only", () => { - const v1 = { schema_version: "1", script_path: "/s.jl", lab_id: "default" } - expect(runGate(v1, "using Piccolo\n", authoring()).ok).toBe(true) - expect(runGate(v1, "using Zygote\n", authoring()).ok).toBe(false) - }) -}) + const v1 = { schema_version: "1", script_path: "/s.jl", lab_id: "default" }; + expect(runGate(v1, "using Piccolo\n", authoring()).ok).toBe(true); + expect(runGate(v1, "using Zygote\n", authoring()).ok).toBe(false); + }); +}); diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index ff491074..26595876 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -1,21 +1,21 @@ -import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; export function tmpRoot(): string { - return mkdtempSync(join(tmpdir(), 'amico-run-test-')) + return mkdtempSync(join(tmpdir(), "amico-run-test-")); } export function readToml(path: string): Record { - return parse(readFileSync(path, 'utf8')) as Record + return parse(readFileSync(path, "utf8")) as Record; } /** Create an executable fake-julia "binary" (node script via shebang). It receives * the julia argv (flags + script path) and ignores it unless the body uses it. */ export function fakeJulia(dir: string, name: string, body: string): string { - const p = join(dir, name) - writeFileSync(p, `#!/usr/bin/env node\n${body}\n`) - chmodSync(p, 0o755) - return p + const p = join(dir, name); + writeFileSync(p, `#!/usr/bin/env node\n${body}\n`); + chmodSync(p, 0o755); + return p; } diff --git a/packages/amico-run/test/import_scan.test.ts b/packages/amico-run/test/import_scan.test.ts index cbd472d3..4bbba736 100644 --- a/packages/amico-run/test/import_scan.test.ts +++ b/packages/amico-run/test/import_scan.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from "vitest" -import { scanImports, checkImports } from "../src/import_scan.js" +import { describe, it, expect } from "vitest"; +import { scanImports, checkImports } from "../src/import_scan.js"; -const ALLOW = { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"] } +const ALLOW = { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"] }; describe("scanImports", () => { it("extracts roots from every using/import form", () => { @@ -9,29 +9,29 @@ describe("scanImports", () => { scanImports( `using Piccolo\nusing JLD2, TOML\nimport LinearAlgebra as LA\nusing Piccolo.NamedTrajectories\nusing CairoMakie: heatmap\n# using Zygote (comment)`, ), - ).toEqual({ ok: true, roots: ["Piccolo", "JLD2", "TOML", "LinearAlgebra", "CairoMakie"] }) - }) + ).toEqual({ ok: true, roots: ["Piccolo", "JLD2", "TOML", "LinearAlgebra", "CairoMakie"] }); + }); it("fails CLOSED on a trailing-comma continuation line (multi-line using)", () => { - const scanned = scanImports(`using Piccolo,\n Zygote\n`) - expect(scanned.ok).toBe(false) - if (!scanned.ok) expect(scanned.reason).toMatch(/one statement per line/) - }) -}) + const scanned = scanImports(`using Piccolo,\n Zygote\n`); + expect(scanned.ok).toBe(false); + if (!scanned.ok) expect(scanned.reason).toMatch(/one statement per line/); + }); +}); describe("checkImports", () => { it("allows allowlist ∪ support ∪ stdlib", () => { - expect(checkImports(["Piccolo", "JLD2", "LinearAlgebra", "Printf"], ALLOW)).toEqual({ ok: true }) - }) + expect(checkImports(["Piccolo", "JLD2", "LinearAlgebra", "Printf"], ALLOW)).toEqual({ ok: true }); + }); it("blocks others with a one-line reason naming every blocked package", () => { - const bad = checkImports(["Piccolo", "Zygote", "Flux"], ALLOW) - expect(bad.ok).toBe(false) + const bad = checkImports(["Piccolo", "Zygote", "Flux"], ALLOW); + expect(bad.ok).toBe(false); if (!bad.ok) { - expect(bad.reason).toMatch(/Zygote/) - expect(bad.reason).toMatch(/Flux/) - expect(bad.reason).toMatch(/not in the allowed package set/) + expect(bad.reason).toMatch(/Zygote/); + expect(bad.reason).toMatch(/Flux/); + expect(bad.reason).toMatch(/not in the allowed package set/); } - }) + }); it("issimo package blocked without entitlement", () => { - expect(checkImports(["Piccolissimo"], ALLOW).ok).toBe(false) - }) -}) + expect(checkImports(["Piccolissimo"], ALLOW).ok).toBe(false); + }); +}); diff --git a/packages/amico-run/test/local_executor.test.ts b/packages/amico-run/test/local_executor.test.ts index 371e9027..393b0cd1 100644 --- a/packages/amico-run/test/local_executor.test.ts +++ b/packages/amico-run/test/local_executor.test.ts @@ -1,75 +1,89 @@ -import { describe, it, expect } from 'vitest' -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' -import { validateManifest, validateFinished } from '../src/schemas.js' -import type { RunEvent } from '../src/types.js' +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; +import { validateManifest, validateFinished } from "../src/schemas.js"; +import type { RunEvent } from "../src/types.js"; const CLEAN = ` console.log('AMICODE_ITER iter=1 f=1.0e-2') console.log('AMICODE_ITER iter=2 f=3.0e-4') console.log('DONE fidelity=0.9999') -` +`; async function collect(events: AsyncIterable): Promise { - const out: RunEvent[] = [] - for await (const e of events) out.push(e) - return out + const out: RunEvent[] = []; + for await (const e of events) out.push(e); + return out; } -describe('LocalExecutor happy path', () => { - it('produces a conforming run dir and ordered event stream', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'julia-clean', CLEAN) - const script = fakeJulia(root, 'solve.jl', '') // content irrelevant; must exist +describe("LocalExecutor happy path", () => { + it("produces a conforming run dir and ordered event stream", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "julia-clean", CLEAN); + const script = fakeJulia(root, "solve.jl", ""); // content irrelevant; must exist const h = await new LocalExecutor().submit(script, { - lab: 'testlab', runsRoot: join(root, 'runs'), julia: { julia }, - }) + lab: "testlab", + runsRoot: join(root, "runs"), + julia: { julia }, + }); // manifest observable before events finish — submit() resolved, so it must exist NOW - const manifest = readToml(join(h.runDir, 'run.toml')) - expect(validateManifest(manifest).ok).toBe(true) - expect(manifest.lab_id).toBe('testlab') + const manifest = readToml(join(h.runDir, "run.toml")); + expect(validateManifest(manifest).ok).toBe(true); + expect(manifest.lab_id).toBe("testlab"); - const evs = await collect(h.events) - expect(evs.filter(e => e.kind === 'iter')).toHaveLength(2) - expect(evs.filter(e => e.kind === 'done')).toHaveLength(1) - const fin = evs.at(-1)! - expect(fin).toEqual({ kind: 'finished', status: 'completed', exitCode: 0 }) - expect(await h.finished).toEqual({ status: 'completed', exitCode: 0 }) + const evs = await collect(h.events); + expect(evs.filter((e) => e.kind === "iter")).toHaveLength(2); + expect(evs.filter((e) => e.kind === "done")).toHaveLength(1); + const fin = evs.at(-1)!; + expect(fin).toEqual({ kind: "finished", status: "completed", exitCode: 0 }); + expect(await h.finished).toEqual({ status: "completed", exitCode: 0 }); - const finished = readToml(join(h.runDir, 'FINISHED')) - expect(validateFinished(finished).ok).toBe(true) - expect(finished.status).toBe('completed') + const finished = readToml(join(h.runDir, "FINISHED")); + expect(validateFinished(finished).ok).toBe(true); + expect(finished.status).toBe("completed"); // run.log mirrors stdout verbatim; index has exactly one line; latest points at the run - expect(readFileSync(join(h.runDir, 'run.log'), 'utf8')).toContain('AMICODE_ITER iter=2') - expect(readFileSync(join(root, 'runs', 'index'), 'utf8').trim().split('\n')).toHaveLength(1) + expect(readFileSync(join(h.runDir, "run.log"), "utf8")).toContain("AMICODE_ITER iter=2"); + expect( + readFileSync(join(root, "runs", "index"), "utf8") + .trim() + .split("\n"), + ).toHaveLength(1); // no temp files left anywhere in the run dir - expect(readdirSync(h.runDir).filter(f => f.includes('.tmp-'))).toHaveLength(0) - }) + expect(readdirSync(h.runDir).filter((f) => f.includes(".tmp-"))).toHaveLength(0); + }); - it('config errors reject BEFORE any run dir exists (exit-64 class)', async () => { - const root = tmpRoot() - await expect(new LocalExecutor().submit(join(root, 'nope.jl'), { runsRoot: join(root, 'runs') })) - .rejects.toThrow(/script not found/) - expect(existsSync(join(root, 'runs'))).toBe(false) - }) + it("config errors reject BEFORE any run dir exists (exit-64 class)", async () => { + const root = tmpRoot(); + await expect(new LocalExecutor().submit(join(root, "nope.jl"), { runsRoot: join(root, "runs") })).rejects.toThrow( + /script not found/, + ); + expect(existsSync(join(root, "runs"))).toBe(false); + }); - it('passes --project/--sysimage through and runs with cwd = runDir', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'julia-echo', - `console.log('ARGS ' + process.argv.slice(2).join(' ')); console.log('CWD ' + process.cwd())`) - const script = fakeJulia(root, 's.jl', '') + it("passes --project/--sysimage through and runs with cwd = runDir", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "julia-echo", + `console.log('ARGS ' + process.argv.slice(2).join(' ')); console.log('CWD ' + process.cwd())`, + ); + const script = fakeJulia(root, "s.jl", ""); const h = await new LocalExecutor().submit(script, { - runsRoot: join(root, 'runs'), julia: { julia, project: '/proj', sysimage: '/img.so' }, - }) - const evs = await collect(h.events) - const argLine = evs.find(e => e.kind === 'log' && e.line.startsWith('ARGS')) as Extract - expect(argLine.line).toContain('--project=/proj') - expect(argLine.line).toContain('--sysimage=/img.so') - const cwdLine = evs.find(e => e.kind === 'log' && e.line.startsWith('CWD')) as Extract - expect(cwdLine.line).toContain(h.runDir) - }) -}) + runsRoot: join(root, "runs"), + julia: { julia, project: "/proj", sysimage: "/img.so" }, + }); + const evs = await collect(h.events); + const argLine = evs.find((e) => e.kind === "log" && e.line.startsWith("ARGS")) as Extract< + RunEvent, + { kind: "log" } + >; + expect(argLine.line).toContain("--project=/proj"); + expect(argLine.line).toContain("--sysimage=/img.so"); + const cwdLine = evs.find((e) => e.kind === "log" && e.line.startsWith("CWD")) as Extract; + expect(cwdLine.line).toContain(h.runDir); + }); +}); diff --git a/packages/amico-run/test/run_dir.test.ts b/packages/amico-run/test/run_dir.test.ts index d05ba01c..b408ba47 100644 --- a/packages/amico-run/test/run_dir.test.ts +++ b/packages/amico-run/test/run_dir.test.ts @@ -1,95 +1,110 @@ -import { describe, it, expect } from 'vitest' -import { existsSync, readFileSync, readlinkSync, mkdirSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, readToml } from './helpers.js' +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync, readlinkSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, readToml } from "./helpers.js"; import { - deriveLabId, generateRunId, atomicWriteFile, - writeManifest, writeFinished, appendIndex, updateLatest, -} from '../src/run_dir.js' -import { ConfigError } from '../src/types.js' -import { validate } from '@amicode/schema' + deriveLabId, + generateRunId, + atomicWriteFile, + writeManifest, + writeFinished, + appendIndex, + updateLatest, +} from "../src/run_dir.js"; +import { ConfigError } from "../src/types.js"; +import { validate } from "@amicode/schema"; -describe('deriveLabId', () => { - it('uses id pointers verbatim', () => expect(deriveLabId('schuster')).toBe('schuster')) - it('derives from parent dir of a lab.toml path', () => - expect(deriveLabId('/labs/schuster/lab.toml')).toBe('schuster')) - it('rejects pointers that fit neither rule', () => - expect(() => deriveLabId('Bad Lab!')).toThrow(ConfigError)) -}) +describe("deriveLabId", () => { + it("uses id pointers verbatim", () => expect(deriveLabId("schuster")).toBe("schuster")); + it("derives from parent dir of a lab.toml path", () => + expect(deriveLabId("/labs/schuster/lab.toml")).toBe("schuster")); + it("rejects pointers that fit neither rule", () => expect(() => deriveLabId("Bad Lab!")).toThrow(ConfigError)); +}); -describe('generateRunId', () => { - it('matches r-<4hex> and avoids collisions', () => { - const root = tmpRoot() - const id = generateRunId(root, new Date('2026-06-10T10:12:45.678Z')) - expect(id).toMatch(/^r20260610-101245Z-[0-9a-f]{4}$/) - mkdirSync(join(root, id)) - const id2 = generateRunId(root, new Date('2026-06-10T10:12:45.678Z')) - expect(id2).not.toBe(id) - }) -}) +describe("generateRunId", () => { + it("matches r-<4hex> and avoids collisions", () => { + const root = tmpRoot(); + const id = generateRunId(root, new Date("2026-06-10T10:12:45.678Z")); + expect(id).toMatch(/^r20260610-101245Z-[0-9a-f]{4}$/); + mkdirSync(join(root, id)); + const id2 = generateRunId(root, new Date("2026-06-10T10:12:45.678Z")); + expect(id2).not.toBe(id); + }); +}); -describe('writers', () => { - it('manifest round-trips through a TOML parser with exact snake_case keys', () => { - const root = tmpRoot() +describe("writers", () => { + it("manifest round-trips through a TOML parser with exact snake_case keys", () => { + const root = tmpRoot(); writeManifest(root, { - schema_version: '1', run_id: 'r1', script_path: '/s.jl', - lab: '/labs/x/lab.toml', lab_id: 'x', - created_at: '2026-06-10T10:12:45Z', orchestrator_version: '0.1.0', - julia: { binary: 'julia', project: '/proj' }, - }) - const m = readToml(join(root, 'run.toml')) - expect(m.schema_version).toBe('1') - expect(m.lab_id).toBe('x') - expect((m.julia as Record).project).toBe('/proj') - expect(m).not.toHaveProperty('sizeClass') // spec §5: intentionally absent - }) + schema_version: "1", + run_id: "r1", + script_path: "/s.jl", + lab: "/labs/x/lab.toml", + lab_id: "x", + created_at: "2026-06-10T10:12:45Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia", project: "/proj" }, + }); + const m = readToml(join(root, "run.toml")); + expect(m.schema_version).toBe("1"); + expect(m.lab_id).toBe("x"); + expect((m.julia as Record).project).toBe("/proj"); + expect(m).not.toHaveProperty("sizeClass"); // spec §5: intentionally absent + }); it('manifest v2: tier + [hashes] emitted only when present; validates as "run" v2 (spec C)', () => { - const root = tmpRoot() + const root = tmpRoot(); const base = { - run_id: 'r1', script_path: '/s.jl', lab: 'default', lab_id: 'default', - created_at: '2026-07-03T00:00:00Z', orchestrator_version: '0.1.0', - julia: { binary: 'julia' }, - } + run_id: "r1", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-07-03T00:00:00Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia" }, + }; // bare (v1) output is byte-stable: no tier/hashes lines at all - writeManifest(root, { schema_version: '1', ...base }) - const v1text = readFileSync(join(root, 'run.toml'), 'utf8') - expect(v1text).not.toContain('tier') - expect(v1text).not.toContain('[hashes]') + writeManifest(root, { schema_version: "1", ...base }); + const v1text = readFileSync(join(root, "run.toml"), "utf8"); + expect(v1text).not.toContain("tier"); + expect(v1text).not.toContain("[hashes]"); // spec-driven (v2) writeManifest(root, { - schema_version: '2', ...base, tier: 'free', - hashes: { system_hash: 'sha256:ab', spec_hash: 'sha256:cd' }, - }) - const m = readToml(join(root, 'run.toml')) - expect(m.schema_version).toBe('2') - expect(m.tier).toBe('free') - expect((m.hashes as Record).spec_hash).toBe('sha256:cd') - expect(validate(m, 'run').errors).toEqual([]) - }) - it('FINISHED carries status + exit_code (snake_case)', () => { - const root = tmpRoot() - writeFinished(root, 'failed', 7) - expect(readToml(join(root, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 7 }) - }) - it('atomicWriteFile leaves no temp file behind', () => { - const root = tmpRoot() - atomicWriteFile(root, 'f.toml', 'a = 1\n') - expect(readFileSync(join(root, 'f.toml'), 'utf8')).toBe('a = 1\n') - expect(existsSync(join(root, `.f.toml.tmp-${process.pid}`))).toBe(false) - }) - it('index appends one tab-separated line per run; latest symlink swings', () => { - const root = tmpRoot() - appendIndex(root, 'r1', 't1', '/a.jl'); appendIndex(root, 'r2', 't2', '/b.jl') - expect(readFileSync(join(root, 'index'), 'utf8')).toBe('r1\tt1\t/a.jl\nr2\tt2\t/b.jl\n') - mkdirSync(join(root, 'r2')) - updateLatest(root, 'r2') - expect(readlinkSync(join(root, 'latest'))).toBe('r2') - }) - it('sanitizes tab/newline in the script path so the TSV index stays one line per run', () => { - const root = tmpRoot() - appendIndex(root, 'r1', 't1', '/weird\tpath\nwith/ctrl.jl') - const lines = readFileSync(join(root, 'index'), 'utf8').trimEnd().split('\n') - expect(lines).toHaveLength(1) // not corrupted into multiple rows - expect(lines[0].split('\t')).toHaveLength(3) // exactly runId/createdAt/path fields - }) -}) + schema_version: "2", + ...base, + tier: "free", + hashes: { system_hash: "sha256:ab", spec_hash: "sha256:cd" }, + }); + const m = readToml(join(root, "run.toml")); + expect(m.schema_version).toBe("2"); + expect(m.tier).toBe("free"); + expect((m.hashes as Record).spec_hash).toBe("sha256:cd"); + expect(validate(m, "run").errors).toEqual([]); + }); + it("FINISHED carries status + exit_code (snake_case)", () => { + const root = tmpRoot(); + writeFinished(root, "failed", 7); + expect(readToml(join(root, "FINISHED"))).toEqual({ status: "failed", exit_code: 7 }); + }); + it("atomicWriteFile leaves no temp file behind", () => { + const root = tmpRoot(); + atomicWriteFile(root, "f.toml", "a = 1\n"); + expect(readFileSync(join(root, "f.toml"), "utf8")).toBe("a = 1\n"); + expect(existsSync(join(root, `.f.toml.tmp-${process.pid}`))).toBe(false); + }); + it("index appends one tab-separated line per run; latest symlink swings", () => { + const root = tmpRoot(); + appendIndex(root, "r1", "t1", "/a.jl"); + appendIndex(root, "r2", "t2", "/b.jl"); + expect(readFileSync(join(root, "index"), "utf8")).toBe("r1\tt1\t/a.jl\nr2\tt2\t/b.jl\n"); + mkdirSync(join(root, "r2")); + updateLatest(root, "r2"); + expect(readlinkSync(join(root, "latest"))).toBe("r2"); + }); + it("sanitizes tab/newline in the script path so the TSV index stays one line per run", () => { + const root = tmpRoot(); + appendIndex(root, "r1", "t1", "/weird\tpath\nwith/ctrl.jl"); + const lines = readFileSync(join(root, "index"), "utf8").trimEnd().split("\n"); + expect(lines).toHaveLength(1); // not corrupted into multiple rows + expect(lines[0].split("\t")).toHaveLength(3); // exactly runId/createdAt/path fields + }); +}); diff --git a/packages/amico-run/test/s31.test.ts b/packages/amico-run/test/s31.test.ts index d0914185..89f6a08b 100644 --- a/packages/amico-run/test/s31.test.ts +++ b/packages/amico-run/test/s31.test.ts @@ -1,23 +1,22 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; // S31 / spec §4: no PHYSICS flag parsing, no MCP, no HTTP in the orchestrator. // (The original /SolveSpec/ ban is lifted by spec C: amico-run is now the // named SolveSpec launch gate — it validates + gates the spec before spawning // Julia. The physics-flag bans below still hold: --spec is a spec-file path, // NOT a physics knob; all physics stays in the script.) -const FORBIDDEN = [/--gate\b/, /--system\b/, /--pulse\b/, - /modelcontextprotocol/i, /node:https?\b/, /\bfetch\s*\(/] +const FORBIDDEN = [/--gate\b/, /--system\b/, /--pulse\b/, /modelcontextprotocol/i, /node:https?\b/, /\bfetch\s*\(/]; -describe('S31 grep rule', () => { - it('src/ contains no forbidden tool-layer patterns', () => { - const srcDir = join(__dirname, '..', 'src') +describe("S31 grep rule", () => { + it("src/ contains no forbidden tool-layer patterns", () => { + const srcDir = join(__dirname, "..", "src"); for (const f of readdirSync(srcDir)) { - const text = readFileSync(join(srcDir, f), 'utf8') + const text = readFileSync(join(srcDir, f), "utf8"); for (const re of FORBIDDEN) { - expect(text, `${f} matches forbidden ${re}`).not.toMatch(re) + expect(text, `${f} matches forbidden ${re}`).not.toMatch(re); } } - }) -}) + }); +}); diff --git a/packages/amico-run/test/scheduler.test.ts b/packages/amico-run/test/scheduler.test.ts new file mode 100644 index 00000000..be538dab --- /dev/null +++ b/packages/amico-run/test/scheduler.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect } from "vitest"; +import { Scheduler, type SchedulerEvent } from "../src/scheduler.js"; +import { + ConfigError, + type Executor, + type Finished, + type RunEvent, + type RunHandle, + type SubmitOpts, +} from "../src/types.js"; +import { EventQueue } from "../src/event_queue.js"; + +// 1.1 Scheduler (#56) — serial queue built TO the ratified Executor contract +// (Track C spec, locked 2026-07-02). The load-bearing behaviors under test: +// - serial: entry N+1 submits only after entry N's `finished` RESOLVES; +// - (b) abort() is a REQUEST, not a kill — post-abort() the run is still +// alive and the queue must NOT advance until `finished` lands; +// - (c) no warming timeout — the Scheduler owns no timers at all; +// - S12 — downstream sees only the executor's RunHandle, passed through. + +/** Controllable fake executor: each submit() returns a handle whose `finished` + * the TEST resolves. Records submit order/args. */ +class FakeExecutor implements Executor { + submits: Array<{ scriptPath: string; opts?: SubmitOpts }> = []; + handles: Array<{ handle: RunHandle; finish: (f: Finished) => void; aborted: boolean[] }> = []; + /** scripts whose submit() should throw ConfigError */ + failFor = new Set(); + + async submit(scriptPath: string, opts?: SubmitOpts): Promise { + this.submits.push({ scriptPath, opts }); + if (this.failFor.has(scriptPath)) throw new ConfigError(`bad config: ${scriptPath}`); + const n = this.submits.length; + let finish!: (f: Finished) => void; + const finished = new Promise((r) => { + finish = r; + }); + const aborted: boolean[] = []; + const handle: RunHandle = { + runId: `run-${n}`, + runDir: `/runs/run-${n}`, + events: new EventQueue(), + finished, + // Contract (b): abort resolves only when finished does (request, not kill). + abort: async () => { + aborted.push(true); + await finished; + }, + }; + this.handles.push({ handle, finish, aborted }); + return handle; + } +} + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +function collect(s: Scheduler): SchedulerEvent[] { + const seen: SchedulerEvent[] = []; + s.onEvent((e) => seen.push(e)); + return seen; +} + +describe("Scheduler — serial queue (#56)", () => { + it("runs entries strictly serially: N+1 submits only after N `finished` resolves", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl"]); // b NOT submitted yet + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl", "b.jl"]); + const [ha, hb] = [await a.handle, await b.handle]; + expect(ha.runId).toBe("run-1"); + expect(hb.runId).toBe("run-2"); + }); + + it("S12: the resolved handle IS the executor RunHandle (identity passthrough)", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const r = s.enqueue({ scriptPath: "a.jl" }); + await tick(); + expect(await r.handle).toBe(ex.handles[0].handle); + }); + + it("passes SubmitOpts through to executor.submit verbatim", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const opts: SubmitOpts = { lab: "lab-7", runsRoot: "/tmp/rr", julia: { project: "/p" } }; + s.enqueue({ scriptPath: "a.jl", opts }); + await tick(); + expect(ex.submits[0].opts).toBe(opts); + }); + + it("contract (b): abort() does NOT advance the queue — only `finished` does", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + s.enqueue({ scriptPath: "b.jl" }); + await tick(); + const ha = await a.handle; + void ha.abort(); // request termination… + await tick(); + await tick(); + expect(ex.submits).toHaveLength(1); // …but the run is still alive: b must NOT start + ex.handles[0].finish({ status: "aborted", exitCode: 143 }); // FINISHED lands + await tick(); + expect(ex.submits).toHaveLength(2); // now b starts + }); + + it("emits the lifecycle: queued → started → finished, with queue position", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "a.jl" }); + s.enqueue({ scriptPath: "b.jl" }); + await tick(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + ex.handles[1].finish({ status: "failed", exitCode: 1 }); + await tick(); + expect(seen).toEqual([ + { kind: "queued", queueId: "q1", position: 0 }, + { kind: "queued", queueId: "q2", position: 1 }, + { kind: "started", queueId: "q1", runId: "run-1", runDir: "/runs/run-1" }, + { kind: "finished", queueId: "q1", runId: "run-1", status: "completed", exitCode: 0 }, + { kind: "started", queueId: "q2", runId: "run-2", runDir: "/runs/run-2" }, + { kind: "finished", queueId: "q2", runId: "run-2", status: "failed", exitCode: 1 }, + ]); + }); + + it("cancel() while queued: never submitted, cancelled event, handle rejects", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + await tick(); + expect(b.cancel()).toBe(true); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl"]); // b never ran + expect(seen.some((e) => e.kind === "cancelled" && e.queueId === "q2")).toBe(true); + await expect(b.handle).rejects.toThrow(/cancel/i); + }); + + it("cancel() after start returns false and the run is untouched (abort via the handle instead)", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + await tick(); + await a.handle; + expect(a.cancel()).toBe(false); + expect(ex.handles[0].aborted).toHaveLength(0); // cancel is NOT an abort + }); + + it("a submit() ConfigError rejects that handle, emits error, and the queue advances", async () => { + const ex = new FakeExecutor(); + ex.failFor.add("bad.jl"); + const s = new Scheduler(ex); + const seen = collect(s); + const bad = s.enqueue({ scriptPath: "bad.jl" }); + const ok = s.enqueue({ scriptPath: "ok.jl" }); + await tick(); + await expect(bad.handle).rejects.toThrow(/bad config/); + expect(seen.some((e) => e.kind === "error" && e.queueId === "q1")).toBe(true); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["bad.jl", "ok.jl"]); // queue not wedged + expect((await ok.handle).runId).toBe("run-2"); // FakeExecutor counts the failed submit too + }); + + it("concurrent: true is a NAMED SEAM — rejected loudly (parallel lane is Phase 4)", () => { + const s = new Scheduler(new FakeExecutor()); + expect(() => s.enqueue({ scriptPath: "a.jl" }, { concurrent: true })).toThrow(ConfigError); + expect(() => s.enqueue({ scriptPath: "a.jl" }, { concurrent: true })).toThrow(/Phase 4/); + }); + + it("multiple listeners both receive events; a disposed listener stops receiving", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a: SchedulerEvent[] = []; + const b: SchedulerEvent[] = []; + const disposeA = s.onEvent((e) => a.push(e)); + s.onEvent((e) => b.push(e)); + s.enqueue({ scriptPath: "x.jl" }); + await tick(); + expect(a.length).toBeGreaterThan(0); + expect(b.length).toBe(a.length); + disposeA(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(b.length).toBeGreaterThan(a.length); // b kept receiving after a disposed + }); + + it("a throwing listener cannot wedge the pump or starve other listeners", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const good: SchedulerEvent[] = []; + s.onEvent(() => { + throw new Error("bad listener"); + }); + s.onEvent((e) => good.push(e)); + s.enqueue({ scriptPath: "x.jl" }); + await tick(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(good.some((e) => e.kind === "finished")).toBe(true); // pump survived + }); + + it("contract (d): a rogue `finished` REJECTION is survived — error event, queue advances", async () => { + // `finished` never rejects per contract; a broken executor must still not + // wedge every queued run behind it. (Pins the defensive branch — a mutation + // deleting it must fail here.) + class RogueExecutor extends FakeExecutor { + async submit(scriptPath: string, opts?: SubmitOpts): Promise { + const h = await super.submit(scriptPath, opts); + if (scriptPath === "rogue.jl") return { ...h, finished: Promise.reject(new Error("boom")) }; + return h; + } + } + const ex = new RogueExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "rogue.jl" }); + const ok = s.enqueue({ scriptPath: "ok.jl" }); + await tick(); + await tick(); + expect( + seen.some((e) => e.kind === "error" && /finished rejected: boom/.test((e as { message: string }).message)), + ).toBe(true); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["rogue.jl", "ok.jl"]); // queue advanced + expect((await ok.handle).runId).toBe("run-2"); + }); + + it("a SYNC-throwing submit (contract-violating executor) cannot blow the stack or strand the queue", async () => { + // The dangerous shape: a big backlog of sync-throwers ACCUMULATES behind one + // pending run, then drains in a single chain when it finishes. With a direct + // finally re-pump that chain is real recursion (RangeError → stranded queue); + // the microtask deferral keeps it flat. (Enqueuing sync-throwers onto an idle + // scheduler never recurses — each enqueue drains its own entry — so the + // backlog-behind-a-pending-run setup is load-bearing for this pin.) + class SyncThrower implements Executor { + good = new FakeExecutor(); + submit(scriptPath: string, opts?: SubmitOpts): Promise { + if (!scriptPath.startsWith("bad-")) return this.good.submit(scriptPath, opts); + throw new ConfigError(`sync boom: ${scriptPath}`); // sync, no Promise + } + } + const ex = new SyncThrower(); + const s = new Scheduler(ex); + s.enqueue({ scriptPath: "first.jl" }); // holds the queue while the backlog builds + await tick(); + const bad = Array.from({ length: 8000 }, (_, i) => s.enqueue({ scriptPath: `bad-${i}.jl` })); + const good = s.enqueue({ scriptPath: "good.jl" }); + ex.good.handles[0].finish({ status: "completed", exitCode: 0 }); // release → drain the 8000 in one go + const h = await good.handle; // resolves only if the whole backlog drained + expect(h.runId).toBe("run-2"); + expect(s.depth).toBe(1); // just the good run, still running + await expect(bad[0].handle).rejects.toThrow(/sync boom/); + await expect(bad[7999].handle).rejects.toThrow(/sync boom/); + }); + + it("an untouched ScheduledRun.handle never surfaces an unhandledRejection (cancel path)", async () => { + // Pins the internal handle.catch(() => {}) suppression explicitly — callers + // that only consume lifecycle events never touch `handle`, and a cancel's + // rejection must not trip the process. + const seen: unknown[] = []; + const trap = (r: unknown): void => { + seen.push(r); + }; + process.on("unhandledRejection", trap); + try { + const s = new Scheduler(new FakeExecutor()); + s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + expect(b.cancel()).toBe(true); // rejects b.handle — nobody is listening + await tick(); + await tick(); + expect(seen).toEqual([]); + } finally { + process.off("unhandledRejection", trap); + } + }); + + it("contract (c): the Scheduler owns no timers (no warming timeout to hard-code)", async () => { + // Structural pin: remote cold-start ≫ local seconds, so ANY scheduler-side + // timeout would violate the per-executor warming budget. Assert the source + // has no timer calls at all (microtasks are fine — they encode no duration). + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const src = readFileSync(fileURLToPath(new URL("../src/scheduler.ts", import.meta.url)), "utf8"); + expect(src).not.toMatch(/setTimeout|setInterval|setImmediate|Date\.now/); + }); +}); diff --git a/packages/amico-run/test/schemas.test.ts b/packages/amico-run/test/schemas.test.ts index 879978af..0e5f3ca4 100644 --- a/packages/amico-run/test/schemas.test.ts +++ b/packages/amico-run/test/schemas.test.ts @@ -1,59 +1,62 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { validateManifest, validateFinished, validateResult } from '../src/schemas.js' +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { validateManifest, validateFinished, validateResult } from "../src/schemas.js"; // These wrappers delegate to the shared @amicode/schema (single source of truth); // this suite is the delegation smoke + the field-precise contract they expose. const goodManifest = { - schema_version: '1', run_id: 'r20260610-101245Z-ab12', script_path: '/s.jl', - lab: 'default', lab_id: 'default', created_at: '2026-06-10T10:12:45Z', - orchestrator_version: '0.1.0', julia: { binary: 'julia' }, -} + schema_version: "1", + run_id: "r20260610-101245Z-ab12", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-06-10T10:12:45Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia" }, +}; -describe('validateManifest', () => { - it('accepts a conforming manifest', () => - expect(validateManifest(goodManifest)).toEqual({ ok: true, errors: [] })) - it('reports each missing/mistyped field by path', () => { - const r = validateManifest({ ...goodManifest, run_id: 42, julia: {} }) - expect(r.ok).toBe(false) - expect(r.errors.join(' ')).toContain('run_id') // wrong-typed top-level field - expect(r.errors.join(' ')).toContain('binary') // /julia missing required "binary" - }) - it('rejects unknown schema_version (v2 is now valid — spec C bump)', () => { - expect(validateManifest({ ...goodManifest, schema_version: '99' }).ok).toBe(false) - expect(validateManifest({ ...goodManifest, schema_version: '2' }).ok).toBe(true) - }) -}) +describe("validateManifest", () => { + it("accepts a conforming manifest", () => expect(validateManifest(goodManifest)).toEqual({ ok: true, errors: [] })); + it("reports each missing/mistyped field by path", () => { + const r = validateManifest({ ...goodManifest, run_id: 42, julia: {} }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toContain("run_id"); // wrong-typed top-level field + expect(r.errors.join(" ")).toContain("binary"); // /julia missing required "binary" + }); + it("rejects unknown schema_version (v2 is now valid — spec C bump)", () => { + expect(validateManifest({ ...goodManifest, schema_version: "99" }).ok).toBe(false); + expect(validateManifest({ ...goodManifest, schema_version: "2" }).ok).toBe(true); + }); +}); -describe('validateFinished', () => { - it('accepts {status, exit_code}', () => - expect(validateFinished({ status: 'aborted', exit_code: 143 }).ok).toBe(true)) - it('rejects bad status and non-integer exit_code', () => { - expect(validateFinished({ status: 'ok', exit_code: 0 }).ok).toBe(false) - expect(validateFinished({ status: 'failed', exit_code: 1.5 }).ok).toBe(false) - }) -}) +describe("validateFinished", () => { + it("accepts {status, exit_code}", () => + expect(validateFinished({ status: "aborted", exit_code: 143 }).ok).toBe(true)); + it("rejects bad status and non-integer exit_code", () => { + expect(validateFinished({ status: "ok", exit_code: 0 }).ok).toBe(false); + expect(validateFinished({ status: "failed", exit_code: 1.5 }).ok).toBe(false); + }); +}); -describe('validateResult (reader-side)', () => { - it('requires schema_version, fidelity number, iterations integer', () => { +describe("validateResult (reader-side)", () => { + it("requires schema_version, fidelity number, iterations integer", () => { // The formalized contract carries schema_version on result.toml (0.1a adds the // emit; the Julia round-trip enforces it). An artifact lacking it is rejected. - expect(validateResult({ schema_version: '1', fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true) - expect(validateResult({ fidelity: 0.999, iterations: 200 }).ok).toBe(false) // no schema_version - expect(validateResult({ schema_version: '1', iterations: 200 }).ok).toBe(false) // no fidelity - }) -}) + expect(validateResult({ schema_version: "1", fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true); + expect(validateResult({ fidelity: 0.999, iterations: 200 }).ok).toBe(false); // no schema_version + expect(validateResult({ schema_version: "1", iterations: 200 }).ok).toBe(false); // no fidelity + }); +}); // Anti-regression (N4): schemas.ts must remain a thin DELEGATION, never re-define // a schema/validator. Guards the "one validator path" invariant (#15 AC7). -describe('schemas.ts is delegation-only (no re-introduced schema)', () => { - const src = readFileSync(join(__dirname, '..', 'src', 'schemas.ts'), 'utf8') - it('imports the shared @amicode/schema', () => - expect(src).toMatch(/from ["']@amicode\/schema["']/)) - it('does not hand-roll validation (no local check helper / additionalProperties / required arrays)', () => { - expect(src).not.toMatch(/additionalProperties/) - expect(src).not.toMatch(/function check\b/) - expect(src).not.toMatch(/errors\.push/) - }) -}) +describe("schemas.ts is delegation-only (no re-introduced schema)", () => { + const src = readFileSync(join(__dirname, "..", "src", "schemas.ts"), "utf8"); + it("imports the shared @amicode/schema", () => expect(src).toMatch(/from ["']@amicode\/schema["']/)); + it("does not hand-roll validation (no local check helper / additionalProperties / required arrays)", () => { + expect(src).not.toMatch(/additionalProperties/); + expect(src).not.toMatch(/function check\b/); + expect(src).not.toMatch(/errors\.push/); + }); +}); diff --git a/packages/amico-run/test/slow/integration.test.ts b/packages/amico-run/test/slow/integration.test.ts index 8cbb5636..87dd4219 100644 --- a/packages/amico-run/test/slow/integration.test.ts +++ b/packages/amico-run/test/slow/integration.test.ts @@ -1,38 +1,39 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { join } from 'node:path' -import { tmpRoot, readToml } from '../helpers.js' -import { validateManifest, validateFinished, validateResult } from '../../src/schemas.js' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpRoot, readToml } from "../helpers.js"; +import { validateManifest, validateFinished, validateResult } from "../../src/schemas.js"; // Slow tier (spec §8): real Piccolo solves through the real CLI. Dev machine only — not CI. // Requires: julia on PATH + a Piccolo project (pass via AMICO_TEST_JULIA_PROJECT to *the test*, // which forwards it as an explicit --project flag — the orchestrator itself stays env-free). -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const BUNDLE = join(__dirname, '..', '..', 'dist', 'amico-run.js') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const BUNDLE = join(__dirname, "..", "..", "dist", "amico-run.js"); function solveAndValidate(script: string): void { - const root = tmpRoot() - const stdout = execFileSync('node', [ - BUNDLE, join(__dirname, script), - '--runs-root', join(root, 'runs'), '--project', PROJECT!, '--lab', 'devlab', - ], { encoding: 'utf8', timeout: 600_000 }) + const root = tmpRoot(); + const stdout = execFileSync( + "node", + [BUNDLE, join(__dirname, script), "--runs-root", join(root, "runs"), "--project", PROJECT!, "--lab", "devlab"], + { encoding: "utf8", timeout: 600_000 }, + ); - expect(stdout).toMatch(/AMICODE_ITER iter=/) - expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=(.+)/) - const runDir = stdout.match(/runDir=(.+)/)![1].trim() - expect(validateManifest(readToml(join(runDir, 'run.toml'))).ok).toBe(true) - expect(validateFinished(readToml(join(runDir, 'FINISHED'))).ok).toBe(true) - const result = readToml(join(runDir, 'result.toml')) - expect(validateResult(result).ok).toBe(true) - expect(result.fidelity as number).toBeGreaterThan(0.99) + expect(stdout).toMatch(/AMICODE_ITER iter=/); + expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=(.+)/); + const runDir = stdout.match(/runDir=(.+)/)![1].trim(); + expect(validateManifest(readToml(join(runDir, "run.toml"))).ok).toBe(true); + expect(validateFinished(readToml(join(runDir, "FINISHED"))).ok).toBe(true); + const result = readToml(join(runDir, "result.toml")); + expect(validateResult(result).ok).toBe(true); + expect(result.fidelity as number).toBeGreaterThan(0.99); } -describe.skipIf(!PROJECT)('slow: real Piccolo solves through amico-run', () => { - it('x-gate solve produces a fully conforming run dir', () => { - solveAndValidate('solve_x_gate.jl') - }, 600_000) +describe.skipIf(!PROJECT)("slow: real Piccolo solves through amico-run", () => { + it("x-gate solve produces a fully conforming run dir", () => { + solveAndValidate("solve_x_gate.jl"); + }, 600_000); - it('h-gate solve produces a fully conforming run dir', () => { - solveAndValidate('solve_h_gate.jl') - }, 600_000) -}) + it("h-gate solve produces a fully conforming run dir", () => { + solveAndValidate("solve_h_gate.jl"); + }, 600_000); +}); diff --git a/packages/amico-run/test/subcommands.test.ts b/packages/amico-run/test/subcommands.test.ts index 9936dc36..6f374fae 100644 --- a/packages/amico-run/test/subcommands.test.ts +++ b/packages/amico-run/test/subcommands.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, beforeAll } from "vitest" -import { execFileSync } from "node:child_process" -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { readToml } from "./helpers.js" +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readToml } from "./helpers.js"; -const BUNDLE = join(__dirname, "..", "dist", "amico-run.js") +const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { - execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }) -}) + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { try { - const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }) - return { code: 0, stdout, stderr: "" } + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; } catch (e) { - const err = e as { status?: number; stdout?: string; stderr?: string } - return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" } + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; } } @@ -35,12 +35,12 @@ packages = ["JLD2", "CairoMakie", "TOML", "Printf"] [uuids] Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -` +`; function authoringDir(): string { - const dir = mkdtempSync(join(tmpdir(), "amico-sub-")) - writeFileSync(join(dir, "registry.toml"), REGISTRY) - writeFileSync(join(dir, "index.json"), JSON.stringify({ schema_version: 1, exemplars: [] })) + const dir = mkdtempSync(join(tmpdir(), "amico-sub-")); + writeFileSync(join(dir, "registry.toml"), REGISTRY); + writeFileSync(join(dir, "index.json"), JSON.stringify({ schema_version: 1, exemplars: [] })); writeFileSync( join(dir, "authoring.json"), JSON.stringify({ @@ -51,79 +51,79 @@ function authoringDir(): string { exemplars: join(dir, "index.json"), verify_tolerance: 0.01, }), - ) - return dir + ); + return dir; } describe("resolve subcommand", () => { it("exact vetted shape → tier vetted with template_path + packages", () => { - const dir = authoringDir() + const dir = authoringDir(); const r = run(["resolve", "--platform", "transmon", "--kind", "gate_synthesis", "--size", "1"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const out = JSON.parse(r.stdout) - expect(out.tier).toBe("vetted") - expect(out.template_path).toMatch(/solve_template\.jl$/) - expect(out.packages).toContain("Piccolo") - rmSync(dir, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.tier).toBe("vetted"); + expect(out.template_path).toMatch(/solve_template\.jl$/); + expect(out.packages).toContain("Piccolo"); + rmSync(dir, { recursive: true, force: true }); + }); it("unknown shape → tier free WITH the skeleton's minimum package set", () => { - const dir = authoringDir() + const dir = authoringDir(); const r = run(["resolve", "--platform", "ions", "--kind", "gate_synthesis", "--size", "1"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - const out = JSON.parse(r.stdout) - expect(out.tier).toBe("free") - expect(out.packages).toEqual(expect.arrayContaining(["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"])) - rmSync(dir, { recursive: true, force: true }) - }) -}) + }); + const out = JSON.parse(r.stdout); + expect(out.tier).toBe("free"); + expect(out.packages).toEqual(expect.arrayContaining(["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"])); + rmSync(dir, { recursive: true, force: true }); + }); +}); describe("sandbox subcommand", () => { it("writes env/Project.toml with [deps] uuids + prints instantiate instructions", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,JLD2"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - expect(existsSync(join(target, "env", "Project.toml"))).toBe(true) - const proj = readToml(join(target, "env", "Project.toml")) - const deps = proj.deps as Record - expect(deps.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad") - expect(deps.JLD2).toBe("033835bb-8acc-5ee8-8aae-3f567f8a3819") - expect(r.stdout).toContain("JULIA_PKG_USE_CLI_GIT=true") - expect(r.stdout).toContain("Pkg.instantiate()") - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(0); + expect(existsSync(join(target, "env", "Project.toml"))).toBe(true); + const proj = readToml(join(target, "env", "Project.toml")); + const deps = proj.deps as Record; + expect(deps.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad"); + expect(deps.JLD2).toBe("033835bb-8acc-5ee8-8aae-3f567f8a3819"); + expect(r.stdout).toContain("JULIA_PKG_USE_CLI_GIT=true"); + expect(r.stdout).toContain("Pkg.instantiate()"); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); it("unknown package (no uuid in registry) → exit 64 naming it", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,Zygote"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/Zygote/) - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/Zygote/); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); it("stdlibs need no [deps] entry — they load from @stdlib (spec-20260704-113005 §3 defect #2)", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); // TOML + Printf are stdlibs with NO uuid in the fixture registry — before the // filter this exit-64'd; now they are dropped from [deps] and the run succeeds. const r = run(["sandbox", target, "--packages", "Piccolo,JLD2,TOML,Printf"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const deps = readToml(join(target, "env", "Project.toml")).deps as Record - expect(Object.keys(deps).sort()).toEqual(["JLD2", "Piccolo"]) // stdlibs filtered out - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) -}) + }); + expect(r.code).toBe(0); + const deps = readToml(join(target, "env", "Project.toml")).deps as Record; + expect(Object.keys(deps).sort()).toEqual(["JLD2", "Piccolo"]); // stdlibs filtered out + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); +}); // Production-path (spec-20260704-113005 §3 defect #2): the EXACT tier-free // resolve output (TIER3_MIN_PACKAGES) must sandbox against the BUNDLED registry, @@ -131,9 +131,9 @@ describe("sandbox subcommand", () => { // (filtered); Piccolo/CairoMakie/JLD2 must all be in the bundled [uuids]. describe("sandbox — bundled-asset production path", () => { function bundledAuthoringDir(): string { - const dir = mkdtempSync(join(tmpdir(), "amico-prod-")) - const registry = join(__dirname, "..", "..", "extension", "templates", "registry.toml") - const exemplars = join(__dirname, "..", "..", "extension", "exemplars", "index.json") + const dir = mkdtempSync(join(tmpdir(), "amico-prod-")); + const registry = join(__dirname, "..", "..", "extension", "templates", "registry.toml"); + const exemplars = join(__dirname, "..", "..", "extension", "exemplars", "index.json"); writeFileSync( join(dir, "authoring.json"), JSON.stringify({ @@ -144,19 +144,19 @@ describe("sandbox — bundled-asset production path", () => { exemplars, verify_tolerance: 0.001, }), - ) - return dir + ); + return dir; } it("TIER3_MIN_PACKAGES sandboxes clean against the bundled registry", () => { - const dir = bundledAuthoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = bundledAuthoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,CairoMakie,JLD2,TOML,Printf"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const deps = readToml(join(target, "env", "Project.toml")).deps as Record - expect(Object.keys(deps).sort()).toEqual(["CairoMakie", "JLD2", "Piccolo"]) - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) -}) + }); + expect(r.code).toBe(0); + const deps = readToml(join(target, "env", "Project.toml")).deps as Record; + expect(Object.keys(deps).sort()).toEqual(["CairoMakie", "JLD2", "Piccolo"]); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); +}); diff --git a/packages/amico-run/test/telemetry.test.ts b/packages/amico-run/test/telemetry.test.ts index 126489ff..2e3cb1e2 100644 --- a/packages/amico-run/test/telemetry.test.ts +++ b/packages/amico-run/test/telemetry.test.ts @@ -1,28 +1,30 @@ -import { describe, it, expect } from 'vitest' -import { classifyLine } from '../src/telemetry.js' +import { describe, it, expect } from "vitest"; +import { classifyLine } from "../src/telemetry.js"; -describe('classifyLine', () => { - it('parses AMICODE_ITER key=value fields', () => { - const ev = classifyLine('AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8', 'stdout') +describe("classifyLine", () => { + it("parses AMICODE_ITER key=value fields", () => { + const ev = classifyLine("AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8", "stdout"); expect(ev).toEqual({ - kind: 'iter', - raw: 'AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8', - fields: { iter: '12', f: '3.4e-5', inf_pr: '1.2e-8' }, - }) - }) - it('classifies DONE as done', () => { - expect(classifyLine('DONE fidelity=0.9999', 'stdout').kind).toBe('done') - }) - it('AMICODE_ITER on stderr is just log (convention is stdout-only)', () => { - expect(classifyLine('AMICODE_ITER iter=1', 'stderr').kind).toBe('log') - }) - it('malformed tokens are skipped, never throw', () => { - const ev = classifyLine('AMICODE_ITER iter=1 ====garbage', 'stdout') - expect(ev.kind).toBe('iter') - }) - it('everything else is log with stream tagged', () => { - expect(classifyLine('Ipopt banner', 'stderr')).toEqual({ - kind: 'log', stream: 'stderr', line: 'Ipopt banner', - }) - }) -}) + kind: "iter", + raw: "AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8", + fields: { iter: "12", f: "3.4e-5", inf_pr: "1.2e-8" }, + }); + }); + it("classifies DONE as done", () => { + expect(classifyLine("DONE fidelity=0.9999", "stdout").kind).toBe("done"); + }); + it("AMICODE_ITER on stderr is just log (convention is stdout-only)", () => { + expect(classifyLine("AMICODE_ITER iter=1", "stderr").kind).toBe("log"); + }); + it("malformed tokens are skipped, never throw", () => { + const ev = classifyLine("AMICODE_ITER iter=1 ====garbage", "stdout"); + expect(ev.kind).toBe("iter"); + }); + it("everything else is log with stream tagged", () => { + expect(classifyLine("Ipopt banner", "stderr")).toEqual({ + kind: "log", + stream: "stderr", + line: "Ipopt banner", + }); + }); +}); diff --git a/packages/amico-run/test/verify.test.ts b/packages/amico-run/test/verify.test.ts index 01a5d70b..2b25c1e0 100644 --- a/packages/amico-run/test/verify.test.ts +++ b/packages/amico-run/test/verify.test.ts @@ -1,27 +1,27 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { runVerification } from "../src/verify.js" -import { readToml } from "./helpers.js" -import type { AuthoringConfig } from "../src/authoring.js" -import type { SpecStamp } from "../src/types.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runVerification } from "../src/verify.js"; +import { readToml } from "./helpers.js"; +import type { AuthoringConfig } from "../src/authoring.js"; +import type { SpecStamp } from "../src/types.js"; -let root: string +let root: string; beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "amico-verify-")) -}) + root = mkdtempSync(join(tmpdir(), "amico-verify-")); +}); afterEach(() => { - delete process.env.AMICO_VERIFY_RUNNER - rmSync(root, { recursive: true, force: true }) -}) + delete process.env.AMICO_VERIFY_RUNNER; + rmSync(root, { recursive: true, force: true }); +}); // A fake harness = a node script that writes verification.toml into argv[1] (the run dir). function fakeHarness(name: string, body: string): string { - const p = join(root, name) - writeFileSync(p, `#!/usr/bin/env node\n${body}\n`) - chmodSync(p, 0o755) - return p + const p = join(root, name); + writeFileSync(p, `#!/usr/bin/env node\n${body}\n`); + chmodSync(p, 0o755); + return p; } function authoring(harness?: string): AuthoringConfig { @@ -30,47 +30,47 @@ function authoring(harness?: string): AuthoringConfig { support_set: [], verify_harness: harness, verify_tolerance: 0.01, - } + }; } -const FREE_SPEC: SpecStamp = { canonical: "{}", tier: "free" } +const FREE_SPEC: SpecStamp = { canonical: "{}", tier: "free" }; describe("runVerification", () => { it("harness writes verification.toml → left intact", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) + const runDir = join(root, "run"); + mkdirSync(runDir); const harness = fakeHarness( "h.js", `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[2],'verification.toml'),'schema_version = "1"\\nagree = true\\nfidelity_rerolled = 0.998\\n')`, - ) - process.env.AMICO_VERIFY_RUNNER = "node" - await runVerification(runDir, FREE_SPEC, authoring(harness)) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(true) - expect(v.fidelity_rerolled).toBe(0.998) - }) + ); + process.env.AMICO_VERIFY_RUNNER = "node"; + await runVerification(runDir, FREE_SPEC, authoring(harness)); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(true); + expect(v.fidelity_rerolled).toBe(0.998); + }); it("missing harness path → fallback verification.toml agree=false + error", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - await runVerification(runDir, FREE_SPEC, authoring(join(root, "nonexistent.jl"))) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(false) - expect(String(v.error)).toMatch(/harness/) - }) + const runDir = join(root, "run"); + mkdirSync(runDir); + await runVerification(runDir, FREE_SPEC, authoring(join(root, "nonexistent.jl"))); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(false); + expect(String(v.error)).toMatch(/harness/); + }); it("harness exits nonzero WITHOUT writing → fallback agree=false + error", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - const harness = fakeHarness("h.js", `process.exit(3)`) - process.env.AMICO_VERIFY_RUNNER = "node" - await runVerification(runDir, FREE_SPEC, authoring(harness)) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(false) - expect(existsSync(join(runDir, "verification.toml"))).toBe(true) - }) + const runDir = join(root, "run"); + mkdirSync(runDir); + const harness = fakeHarness("h.js", `process.exit(3)`); + process.env.AMICO_VERIFY_RUNNER = "node"; + await runVerification(runDir, FREE_SPEC, authoring(harness)); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(false); + expect(existsSync(join(runDir, "verification.toml"))).toBe(true); + }); it("no harness configured at all → fallback agree=false (never verification-less)", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - await runVerification(runDir, FREE_SPEC, authoring(undefined)) - expect(existsSync(join(runDir, "verification.toml"))).toBe(true) - expect(readToml(join(runDir, "verification.toml")).agree).toBe(false) - }) -}) + const runDir = join(root, "run"); + mkdirSync(runDir); + await runVerification(runDir, FREE_SPEC, authoring(undefined)); + expect(existsSync(join(runDir, "verification.toml"))).toBe(true); + expect(readToml(join(runDir, "verification.toml")).agree).toBe(false); + }); +}); diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store new file mode 100644 index 00000000..ea98653f Binary files /dev/null and b/packages/extension/.DS_Store differ diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index c12f94b2..c0b66fad 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -13,7 +13,7 @@ and the Run Inspector renders the live solve. ## Voice -You're a *friend* — "Amico" is Italian for it — who's done pulse design with this +You're a _friend_ — "Amico" is Italian for it — who's done pulse design with this researcher for years. You know the toolchain, the failure modes, the literature. Sound like it — not a generic assistant. @@ -29,8 +29,8 @@ Sound like it — not a generic assistant. - **Honest to a fault.** Charm never covers for a caveat. Say what isn't wired, what's untrusted (a `free`-tier fidelity is untrusted until the re-rollout agrees — and you say so), and what might blow up. -- **Italian, sparing.** A *bravo* on a clean solve, an *andiamo* to kick off, - *piano piano* when it's grinding — seasoning, never costume. One touch, not five. +- **Italian, sparing.** A _bravo_ on a clean solve, an _andiamo_ to kick off, + _piano piano_ when it's grinding — seasoning, never costume. One touch, not five. - **Atomic.** One question per turn, readable in two seconds. ## Workflow (this is the whole job) @@ -69,8 +69,8 @@ workspace owns `solve.jl` — never author in `/tmp`). ``` 5. **Assemble `~/.amico/problems//solvespec.json`**: `{schema_version:"2", script_path:"…/solve.jl", lab_id:"default", - executor:"local", tier:"", env:{kind, project?}, source:, - hashes:{system_hash, formulation_hash}}` — read the hashes from the LAST +executor:"local", tier:"", env:{kind, project?}, source:, +hashes:{system_hash, formulation_hash}}` — read the hashes from the LAST matching events in `~/.amico/problems//events.jsonl` (the `hash` field on the newest `system`/`formulation` events). 6. **Launch through the gate, detached.** Pass `--project` matching the tier's @@ -189,7 +189,7 @@ Stages, in order: Piccolissimo **free-phase CZ path**), honest about depth; 3. no skill matches → **offer free-tier from-scratch authoring anyway** (public packages, **unvetted**, re-rollout-verified). "No template" is never a decline. - Show the model Hamiltonian when you know it. + Show the model Hamiltonian when you know it. - transmon: $\hat H/\hbar = \omega\,\hat a^\dagger\hat a + \tfrac{\delta}{2}\,\hat a^{\dagger 2}\hat a^2 + u_1(t)\,(\hat a + \hat a^\dagger) + i\,u_2(t)\,(\hat a - \hat a^\dagger)$ - Rydberg 3-level ($|0\rangle$ dark, $|1\rangle\!\leftrightarrow\!|r\rangle$ driven, @@ -237,7 +237,7 @@ Stages, in order: **Transmon: single qubit only via the vetted template.** The bundled vetted template builds ONE `TransmonSystem` (scalar `ω`/`δ`) and embeds a single-qubit target: X, Y, Z, H, S, T, √X, and arbitrary single-qubit unitaries. Multi-qubit -*transmon* gates (CNOT, CZ, iSWAP on transmons) have no vetted template or +_transmon_ gates (CNOT, CZ, iSWAP on transmons) have no vetted template or exemplar — but they are **not declined**: they route through the **free-tier** offer (author from scratch, **unvetted**, re-rollout-verified), with that caveat stated up front. (Piccolo's `MultiTransmonSystem` exists; a from-scratch coupled @@ -247,6 +247,7 @@ CZ is the exception:** it resolves to the composed `rydberg-cz` exemplar lists it — honestly caveated (see the PLATFORM stage). **Choose parameters for the regime** (the defaults converge to F > 0.999): + - `levels`: 3 (default) or 4 for more leakage realism. **Avoid 5+** — added levels worsen conditioning and leakage and inflate solve cost, so convergence degrades; if the user insists, warn it may not converge. @@ -279,6 +280,7 @@ script, running with cwd = the run dir, must emit: trajectory from the primal, and prints the lines. **A script that skips these lines gets a dead live plot** — the Inspector sits on "warming up" until completion, then shows a no-pulse-data hint. + - `iter_.png` every few iterations — **archival/publication artifact** (`plot_pulse` is canonical there); the Inspector no longer displays PNGs. See the per-iter plotting idiom below — **`LivePulsePlotCallback`** once the bundled @@ -337,6 +339,7 @@ correct loader in this Piccolo. ## Julia project The Julia project to pass as `--project` is: + **{{JULIA_PROJECT}}**. Always pass it. ## Style diff --git a/packages/extension/CONTRACT.md b/packages/extension/CONTRACT.md index 54147ac4..5ed4f576 100644 --- a/packages/extension/CONTRACT.md +++ b/packages/extension/CONTRACT.md @@ -13,19 +13,19 @@ A run lives at `~/.amico/runs///`, where `runId` is `run.toml` **first** and `FINISHED` **last**; the script (cwd = the run dir) emits the rest. -| Artifact | Writer | Contents | -|---|---|---| -| `run.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). | -| `run.log` | amico-run (stdout tee) | One `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` line per Ipopt iteration (drives the live stats row), plus a final `DONE fidelity=<…>` line and any Julia traceback. | -| `iter_.png` | script | Per-iteration pulse/fidelity plot. `N` is the iteration with **unbounded digits** (`iter_0`, `iter_10`, … `iter_0060`). The inspector globs `iter_*.png`. | -| `result.toml` | script (atomic) | Written `result.toml.tmp` then renamed. At least `fidelity` (float) and `iterations` (int); `wall_seconds` optional. | -| `FINISHED` | amico-run (last, terminal) | `status = "completed" | "failed" | "aborted"` and `exit_code` (int). Its presence is the **only** completion signal — the inspector fires `onFinished` solely on a valid `FINISHED`, so a killed solve shows "running", never a false success. | +| Artifact | Writer | Contents | +| -------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `run.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). | +| `run.log` | amico-run (stdout tee) | One `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` line per Ipopt iteration (drives the live stats row), plus a final `DONE fidelity=<…>` line and any Julia traceback. | +| `iter_.png` | script | Per-iteration pulse/fidelity plot. `N` is the iteration with **unbounded digits** (`iter_0`, `iter_10`, … `iter_0060`). The inspector globs `iter_*.png`. | +| `result.toml` | script (atomic) | Written `result.toml.tmp` then renamed. At least `fidelity` (float) and `iterations` (int); `wall_seconds` optional. | +| `FINISHED` | amico-run (last, terminal) | `status = "completed" | "failed" | "aborted"`and`exit_code`(int). Its presence is the **only** completion signal — the inspector fires`onFinished`solely on a valid`FINISHED`, so a killed solve shows "running", never a false success. | Two convenience files live at the **lab runs root** (`~/.amico/runs//`): -| File | Writer | Contents | -|---|---|---| -| `index` | amico-run (`appendIndex`) | Append-only, tab-separated `\t\t` per run. | +| File | Writer | Contents | +| -------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `index` | amico-run (`appendIndex`) | Append-only, tab-separated `\t\t` per run. | | `latest` | amico-run (`updateLatest`) | Symlink → the most recent ``; written via temp-then-rename so the watcher sees an atomic swing. The inspector follows `latest`. | ## Frozen schemas diff --git a/packages/extension/DEMO_CHECKLIST.md b/packages/extension/DEMO_CHECKLIST.md index 47eff76e..10792754 100644 --- a/packages/extension/DEMO_CHECKLIST.md +++ b/packages/extension/DEMO_CHECKLIST.md @@ -8,7 +8,7 @@ net; rows 4–6 are the live run. - [ ] **Install clean** — followed `RUNBOOK.md` end-to-end on the target machine; total time recorded below (target ≤ 60 min). - [ ] **Healthcheck green** — `node packages/extension/scripts/healthcheck.mjs` exits `0` (julia + pinned Piccolo project · opencode `/event` · `amico-run` · Bedrock creds). -- [ ] **Fallback armed** — Command Palette → **"Amicode: Replay demo run"** stages the bundled solve and the Run Inspector renders it (iter frames + final fidelity + promote prompt), with **no Julia, no opencode, no creds**. Confirm this works *before* relying on the live path. +- [ ] **Fallback armed** — Command Palette → **"Amicode: Replay demo run"** stages the bundled solve and the Run Inspector renders it (iter frames + final fidelity + promote prompt), with **no Julia, no opencode, no creds**. Confirm this works _before_ relying on the live path. ## Live run @@ -25,11 +25,11 @@ net; rows 4–6 are the live run. **Recorded timings (fill in at the dry-run):** -| Step | Time | -|---|---| -| Julia install | | -| `install.sh` (instantiate + precompile + VSIX) | | -| Healthcheck | | -| First live solve (cold) | | -| Replay fallback | instant | -| **Total** | | +| Step | Time | +| ---------------------------------------------- | ------- | +| Julia install | | +| `install.sh` (instantiate + precompile + VSIX) | | +| Healthcheck | | +| First live solve (cold) | | +| Replay fallback | instant | +| **Total** | | diff --git a/packages/extension/DISTILLER.md b/packages/extension/DISTILLER.md index 40139a2c..dd80de46 100644 --- a/packages/extension/DISTILLER.md +++ b/packages/extension/DISTILLER.md @@ -16,6 +16,7 @@ first run `KNOWLEDGE.md` / `problems/` may be empty or absent — that just mean no cards exist yet; create what you need under `amicode/`. Your input is ONE JSON job object (the message you were invoked with): + - `{"kind":"run","run_id":"r...","runs_root":"...","vault":"...","ops":"..."}` - `{"kind":"sweep","session_ids":[...],"vault":"...","ops":"..."}` — distill these sessions - `{"kind":"onboarding","vault":"...","ops":"..."}` — materialize the profile @@ -69,10 +70,12 @@ Your input is ONE JSON job object (the message you were invoked with): `run.toml` may carry `session_id` and `workspace` (newer runs — prefer them). Otherwise recover: + ``` sqlite3 "file:...opencode.db?mode=ro" \ "SELECT DISTINCT session_id FROM part WHERE data LIKE '%%';" ``` + ALL matched sessions are contributing `sessions:`. The **launching** session is the one whose matching part contains the launch command itself (`amico-run`); fallback: the earliest mention by `part.time_created`. The workspace is the @@ -81,6 +84,7 @@ session's `amicode_*` records. A run that joins to nothing still gets a card (note "orphan run — no session recovered"); never drop it silently. Useful transcript queries (ids are 30 chars — never truncate them): + ``` -- substantive check / entity writes and launches for a session: SELECT json_extract(data,'$.tool') FROM part WHERE session_id='' @@ -99,19 +103,19 @@ SELECT json_extract(data,'$.text') FROM part WHERE session_id='' type: amicode-problem slug: x-gate-transmon platform: transmon -problem_kind: gate_synthesis # gate_synthesis | state_prep -target: X # gate name or state name (e.g. cat-state) -status: solved # solved | attempted | failed -best_fidelity: 0.99995 # ONLY from result.toml; omit if none -best_run: r20260703-095831Z-e5b7 # omit if none -pulse_ref: pulses/x-gate-transmon-v1 # null if no successful pulse +problem_kind: gate_synthesis # gate_synthesis | state_prep +target: X # gate name or state name (e.g. cat-state) +status: solved # solved | attempted | failed +best_fidelity: 0.99995 # ONLY from result.toml; omit if none +best_run: r20260703-095831Z-e5b7 # omit if none +pulse_ref: pulses/x-gate-transmon-v1 # null if no successful pulse solve_count: 8 first_seen: 2026-07-03 last_seen: 2026-07-04 sessions: [ses_..., ses_...] -sys_params: # STRUCTURED regime scalars (L1 §2.1.1) — - levels: 3 # the deterministic "high"-confidence gate. - drive_max: 0.2 # Emit the platform's gating scalars: +sys_params: # STRUCTURED regime scalars (L1 §2.1.1) — + levels: 3 # the deterministic "high"-confidence gate. + drive_max: 0.2 # Emit the platform's gating scalars: # transmon: levels (int), drive_max (float) # cavity/bosonic: fock_cutoff (int), chi (float), alpha or fock_index # atoms: levels, rabi_max, delta_max, distance @@ -122,15 +126,19 @@ sys_params: # STRUCTURED regime scalars (L1 §2.1.1) # on ## System + ## Formulation + ## History + - solves , , F ∈ [, ]. ## Lessons + - ``` @@ -175,15 +183,17 @@ New `-v` ONLY when fidelity strictly improves on the card's Entity → card mapping (`/onboarding/events.jsonl`; replay in order, later entries win — update-in-place, never duplicate): + - `profile` entity (`name`,`role`,`org`,`platforms`,`goals`) → `PROFILE.md`: ```markdown # Profile — + - Role: - Org / lab: - Platforms: - Environment: [](environment/.md) — -- Devices: [](devices/.md) # one line per device, if any +- Devices: [](devices/.md) # one line per device, if any - Goals: - Onboarded: (re-run onboarding to update) ``` @@ -240,6 +250,7 @@ params, write a **thin card** (platform + script pointer + "params not extracted") — never skip the demo, never fabricate. Demo card frontmatter (mirror the problem card + these): + ``` type: amicode-demo slug: stanford-bosonics-cat @@ -254,6 +265,7 @@ sys_params: { fock_cutoff: 20, chi: 0.0000328, alpha: 2 } # if readable ``` DEMOS.md line: + ``` - [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity state_prep cat-state, N_fock=20, script scripts/optimize_cat_alpha2.jl ``` @@ -265,4 +277,4 @@ but never merge with the user's own solves (source distinguishes them). ## Finishing a job 1. Write the files. 2. Pathspec-scoped commit (Hard rule 1). 3. Final message: -one line, e.g. `distilled r...-e5b7 → x-gate-transmon (updated, F=0.99995, v1 banked)`. + one line, e.g. `distilled r...-e5b7 → x-gate-transmon (updated, F=0.99995, v1 banked)`. diff --git a/packages/extension/RUNBOOK.md b/packages/extension/RUNBOOK.md index bad50a6f..5b38a67e 100644 --- a/packages/extension/RUNBOOK.md +++ b/packages/extension/RUNBOOK.md @@ -3,16 +3,17 @@ Target: a clean macOS/Linux machine → Amicode demo-ready. Times are estimates; the dominant cost is the first Julia precompile. -| # | Step | ~Time | -|---|------|------| -| 1 | Install Julia: `curl -fsSL https://install.julialang.org \| sh` (then restart your shell) | 5 min | -| 2 | Get the VSIX: in the amicode repo, `pnpm install && pnpm --filter amicode-v2 package` | 5 min | -| 3 | `bash packages/extension/scripts/install.sh` — instantiates the pinned Julia project (precompiles) + installs the VSIX + writes `~/.amico/lab.toml` | 15–25 min | -| 4 | Configure the LLM **through opencode** (amico reads opencode's resolution; it stores no key of its own). Give opencode a provider credential via any path it supports — the simplest is a provider API key in the environment (e.g. `export ANTHROPIC_API_KEY=sk-ant-…`), or `~/.config/opencode` / `opencode auth login`. Then select a matching model in `~/.config/opencode/opencode.jsonc`, e.g. `"model":"anthropic/claude-sonnet-4-6"` (Bedrock: `"model":"amazon-bedrock/us.anthropic.claude-sonnet-4-6"` + `"provider":{"amazon-bedrock":{"region":"us-east-1"}}` and AWS creds in env/`~/.aws`). The healthcheck + chat confirm a provider resolves via opencode's live `/config/providers`. | 5 min | -| 5 | `node packages/extension/scripts/healthcheck.mjs` → expect all ✓, exit 0 | 2 min | -| 6 | Open VS Code → Amicode chat → run a test gate; confirm the Run Inspector renders | 10 min | +| # | Step | ~Time | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| 1 | Install Julia: `curl -fsSL https://install.julialang.org \| sh` (then restart your shell) | 5 min | +| 2 | Get the VSIX: in the amicode repo, `pnpm install && pnpm --filter amicode-v2 package` | 5 min | +| 3 | `bash packages/extension/scripts/install.sh` — instantiates the pinned Julia project (precompiles) + installs the VSIX + writes `~/.amico/lab.toml` | 15–25 min | +| 4 | Configure the LLM **through opencode** (amico reads opencode's resolution; it stores no key of its own). Give opencode a provider credential via any path it supports — the simplest is a provider API key in the environment (e.g. `export ANTHROPIC_API_KEY=sk-ant-…`), or `~/.config/opencode` / `opencode auth login`. Then select a matching model in `~/.config/opencode/opencode.jsonc`, e.g. `"model":"anthropic/claude-sonnet-4-6"` (Bedrock: `"model":"amazon-bedrock/us.anthropic.claude-sonnet-4-6"` + `"provider":{"amazon-bedrock":{"region":"us-east-1"}}` and AWS creds in env/`~/.aws`). The healthcheck + chat confirm a provider resolves via opencode's live `/config/providers`. | 5 min | +| 5 | `node packages/extension/scripts/healthcheck.mjs` → expect all ✓, exit 0 | 2 min | +| 6 | Open VS Code → Amicode chat → run a test gate; confirm the Run Inspector renders | 10 min | ## Troubleshooting (healthcheck failures) + - `✗ julia+project` → re-run `install.sh`; check `julia --version`. - `✗ opencode /event` → `pnpm --filter amicode-v2 fetch:opencode`; re-run. - `✗ amico-run` → `pnpm -r build` (stages `bin/`) or reinstall the VSIX. diff --git a/packages/extension/TESTING.md b/packages/extension/TESTING.md index 5bb9c1b1..abb3d6cc 100644 --- a/packages/extension/TESTING.md +++ b/packages/extension/TESTING.md @@ -42,7 +42,7 @@ restarts reuse it. **Run Inspector** pops with the live pulse, expect **F ≥ 0.999** in ~1–2 min warm. 4. **Fast path** — new session, type "optimize an X gate on my transmon, defaults" — should skip the interview and launch directly. -5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: expect the *honest scope* +5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: expect the _honest scope_ behavior (System recorded, formulation captured for follow-up — no dead reckoning). An **experimental** CZ template exists (`templates/solve_rydberg_cz.jl`, QuEra gate-zone params, public-Piccolo-only) but is NOT yet vetted — its first NLP iteration is diff --git a/packages/extension/demo/run/result.toml b/packages/extension/demo/run/result.toml index d7c71b6c..68a8fe60 100644 --- a/packages/extension/demo/run/result.toml +++ b/packages/extension/demo/run/result.toml @@ -4,6 +4,8 @@ schema_version = "1" wall_seconds = 109.01594400405884 [params] +system = "transmon" +gate = "X" levels = 3 drive_max = 0.2 T = 10.0 diff --git a/packages/extension/demo/run/run.log b/packages/extension/demo/run/run.log index 6606c166..b7c8128c 100644 --- a/packages/extension/demo/run/run.log +++ b/packages/extension/demo/run/run.log @@ -58,7 +58,7 @@ QuantumControlProblem Hint: show_problem(qcp; detail=:full) for pulse plot + sparsity -AMICODE_PULSE_META drives=2 knots=50 labels="a_1","a_2" bounds=-0.2:0.2,-0.2:0.2 +AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2 AMICODE_ITER iter=0 f=7.930693e+01 inf_pr=3.306e+00 inf_du=4.344e-01 AMICODE_PULSE iter=0 dt=0.204082 a=0.00122269,0.079988,0.0285582,0.0540801,0.159005,-0.163859,0.10975,0.0792598,0.225433,-0.0367432,0.16558,0.14032,0.0462271,0.108334,0.179825,-0.0977399,0.165655,0.246999,-0.0193538,-0.211037,-0.174111,-0.125857,-0.221001,0.215075,-0.0648649,-0.127936,0.127302,0.00942714,-0.00511523,0.0107351,0.149222,0.00920709,-0.024628,-0.0666657,-0.0586522,-0.157342,-0.028419,0.0810429,-0.150415,-0.037007,-0.00844921,-0.0615253,0.176932,-0.0139109,0.138256,0.0532826,0.0636576,-0.0740906,-0.00579776,-0.0402367;-0.0235979,-0.00903938,0.0218624,-0.0386896,0.246838,0.241264,0.0996578,0.17691,0.252771,-0.145362,0.0510349,0.0378336,0.0339014,-0.0113967,0.109632,0.0389645,-0.0589498,-0.0433134,-0.170006,-0.0632173,0.116623,-0.0892016,0.11167,0.168127,0.138323,0.0396419,-0.069816,0.0302567,0.0163749,-0.102325,-0.0864511,0.0495011,-0.0660819,0.107856,0.00929036,0.049109,0.045827,0.178588,-0.196605,-0.0222713,0.0668082,-0.074412,-0.0316369,0.0245491,-0.112651,-0.0198992,0.0551696,0.0887624,-0.010815,-0.177116 diff --git a/packages/extension/dev/pulseplot_harness/index.html b/packages/extension/dev/pulseplot_harness/index.html index 5e7190e8..45835f05 100644 --- a/packages/extension/dev/pulseplot_harness/index.html +++ b/packages/extension/dev/pulseplot_harness/index.html @@ -1,40 +1,68 @@ - + - - - pulseplot harness (#66) - - - - - -

- - - - - -
-
- - + body { + --vscode-foreground: #333; + --vscode-editor-background: #fcfcfb; + --vscode-descriptionForeground: #717171; + --vscode-charts-blue: #1a85ff; + --vscode-charts-orange: #a05a00; + --vscode-charts-purple: #8b47b7; + --vscode-charts-green: #2e7d32; + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + font-family: system-ui, sans-serif; + font-size: 13px; + margin: 0; + padding: 16px; + height: 100vh; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 12px; + } + body.dark { + --vscode-foreground: #ccc; + --vscode-editor-background: #1a1a19; + --vscode-descriptionForeground: #9d9d9d; + --vscode-charts-blue: #3794ff; + --vscode-charts-orange: #c17800; + --vscode-charts-purple: #9b6bc4; + --vscode-charts-green: #4d9e51; + } + #controls { + display: flex; + gap: 8px; + align-items: center; + } + #plot-host { + flex: 1; + display: flex; + min-height: 0; + } + #bench-out { + font-family: monospace; + font-size: 12px; + } + + + +
+ + + + + +
+
+ + diff --git a/packages/extension/dev/pulseplot_harness/main.ts b/packages/extension/dev/pulseplot_harness/main.ts index b7174a72..f77d5e97 100644 --- a/packages/extension/dev/pulseplot_harness/main.ts +++ b/packages/extension/dev/pulseplot_harness/main.ts @@ -8,7 +8,15 @@ import { pulseplot } from "../../media/ui/components/pulseplot"; -const HARNESS_META = { drives: 2, knots: 50, labels: ["a_1", "a_2"], bounds: [[-0.2, 0.2], [-0.2, 0.2]] as [number, number][] }; +const HARNESS_META = { + drives: 2, + knots: 50, + labels: ["a_1", "a_2"], + bounds: [ + [-0.2, 0.2], + [-0.2, 0.2], + ] as [number, number][], +}; const root = document.getElementById("plot-host")!; const plot = pulseplot("Harness idle — press play."); @@ -39,16 +47,24 @@ let timer: ReturnType | undefined; let iter = 0; const playBtn = document.getElementById("play")!; playBtn.addEventListener("click", () => { - if (timer) { clearInterval(timer); timer = undefined; playBtn.textContent = "▶ play"; return; } + if (timer) { + clearInterval(timer); + timer = undefined; + playBtn.textContent = "▶ play"; + return; + } plot.meta(HARNESS_META); playBtn.textContent = "⏸ pause"; timer = setInterval(() => { plot.update(syntheticRecord(iter++)); if (iter > 60) iter = 0; - }, 200); // the host's 5 Hz cadence + }, 200); // the host's 5 Hz cadence }); -document.getElementById("clear")!.addEventListener("click", () => { plot.clear(); iter = 0; }); +document.getElementById("clear")!.addEventListener("click", () => { + plot.clear(); + iter = 0; +}); // --- AC8 budget: median + p95 of component update at fixture scale document.getElementById("bench")!.addEventListener("click", () => { @@ -61,7 +77,9 @@ document.getElementById("bench")!.addEventListener("click", () => { times.push(performance.now() - t0); } times.sort((a, b) => a - b); - const med = times[150].toFixed(3), p95 = times[285].toFixed(3), max = times[299].toFixed(3); + const med = times[150].toFixed(3), + p95 = times[285].toFixed(3), + max = times[299].toFixed(3); const verdict = times[285] <= 16 ? "PASS (≤16ms)" : "FAIL (>16ms)"; document.getElementById("bench-out")!.textContent = `update() over 300 frames @ 2×50: median ${med}ms · p95 ${p95}ms · max ${max}ms → ${verdict}`; @@ -69,24 +87,34 @@ document.getElementById("bench")!.addEventListener("click", () => { }); // --- optional recorded replay -fetch("./pulse-events.json").then((r) => (r.ok ? r.json() : undefined)).then((events?: Array>) => { - if (!events) return; - const btn = document.createElement("button"); - btn.textContent = "▶ replay recording"; - btn.addEventListener("click", () => { - if (timer) { clearInterval(timer); timer = undefined; } - let i = 0; - timer = setInterval(() => { - const e = events[i++]; - if (!e) { clearInterval(timer!); timer = undefined; return; } - if (e.type === "pulsemeta") plot.meta(e as never); - else if (e.type === "pulse") plot.update(e as never); - }, 200); +fetch("./pulse-events.json") + .then((r) => (r.ok ? r.json() : undefined)) + .then((events?: Array>) => { + if (!events) return; + const btn = document.createElement("button"); + btn.textContent = "▶ replay recording"; + btn.addEventListener("click", () => { + if (timer) { + clearInterval(timer); + timer = undefined; + } + let i = 0; + timer = setInterval(() => { + const e = events[i++]; + if (!e) { + clearInterval(timer!); + timer = undefined; + return; + } + if (e.type === "pulsemeta") plot.meta(e as never); + else if (e.type === "pulse") plot.update(e as never); + }, 200); + }); + document.getElementById("controls")!.append(btn); }); - document.getElementById("controls")!.append(btn); -}); // --- URL-hash automation for headless-ish eyeballing: #autoplay #bench #dark if (location.hash.includes("dark")) document.body.classList.add("dark"); if (location.hash.includes("autoplay")) (document.getElementById("play") as HTMLButtonElement).click(); -if (location.hash.includes("bench")) setTimeout(() => (document.getElementById("bench") as HTMLButtonElement).click(), 800); +if (location.hash.includes("bench")) + setTimeout(() => (document.getElementById("bench") as HTMLButtonElement).click(), 800); diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 6b04467e..d336deef 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -32,6 +32,18 @@ const targets = [ minify: false, logLevel: "info", }, + // catalog-card dev preview webview bundle (#47 scaffold) + { + entryPoints: ["src/catalog_card_webview.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/catalog_card_webview.js", + sourcemap: true, + minify: false, + logLevel: "info", + }, // bottom-panel Run Inspector webview bundle { entryPoints: ["src/inspector_webview.ts"], diff --git a/packages/extension/julia/README.md b/packages/extension/julia/README.md index 09892424..fd59eaf9 100644 --- a/packages/extension/julia/README.md +++ b/packages/extension/julia/README.md @@ -1,3 +1,5 @@ # Pinned, vetted Julia env (Piccolo 1.19) bundled in the VSIX. + # Provisioned to ~/.amico/julia via `Pkg.instantiate()` (scripts/install.sh). + # Regenerate: re-instantiate ~/.amico/julia, then copy Project.toml + Manifest.toml here. diff --git a/packages/extension/media/brand.css b/packages/extension/media/brand.css index 9f3c55cc..fc55f1a3 100644 --- a/packages/extension/media/brand.css +++ b/packages/extension/media/brand.css @@ -13,8 +13,8 @@ :root { /* color */ - --color-accent: #FFF676; - --color-on-accent: #000000; + --color-accent: #fff676; + --color-on-accent: #000000; --color-ok: var(--vscode-testing-iconPassed); --color-fail: var(--vscode-errorForeground); --color-run: var(--vscode-progressBar-background); diff --git a/packages/extension/media/layout.css b/packages/extension/media/layout.css index 12aeb420..3b7fcb86 100644 --- a/packages/extension/media/layout.css +++ b/packages/extension/media/layout.css @@ -1,16 +1,51 @@ /* layout.css — formal layout selectors. Composition only; values from brand.css. */ -* { box-sizing: border-box; } -.stack { display: flex; flex-direction: column; gap: var(--space-md); } -.row { display: flex; align-items: center; gap: var(--space-md); } -.wrap { flex-wrap: wrap; } -.grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 112px), 1fr)); gap: var(--space-sm); } +* { + box-sizing: border-box; +} +.stack { + display: flex; + flex-direction: column; + gap: var(--space-md); +} +.row { + display: flex; + align-items: center; + gap: var(--space-md); +} +.wrap { + flex-wrap: wrap; +} +.grid-fit { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 112px), 1fr)); + gap: var(--space-sm); +} /* metric-row: content-width tiles, left-aligned, wrapping — NOT stretched to fill (that made a scalar like the iteration count occupy a huge tile). */ -.metric-row { display: flex; align-items: stretch; gap: var(--space-sm); flex-wrap: wrap; } -.grow { flex: 1; } -.push-end { margin-left: auto; } -.scroll-y { overflow-y: auto; } -.gap-xs { gap: var(--space-xs); } -.gap-sm { gap: var(--space-sm); } -.gap-lg { gap: var(--space-lg); } -.pad-lg { padding: var(--space-lg); } +.metric-row { + display: flex; + align-items: stretch; + gap: var(--space-sm); + flex-wrap: wrap; +} +.grow { + flex: 1; +} +.push-end { + margin-left: auto; +} +.scroll-y { + overflow-y: auto; +} +.gap-xs { + gap: var(--space-xs); +} +.gap-sm { + gap: var(--space-sm); +} +.gap-lg { + gap: var(--space-lg); +} +.pad-lg { + padding: var(--space-lg); +} diff --git a/packages/extension/media/ui/atoms/button.ts b/packages/extension/media/ui/atoms/button.ts index e8886dca..1febcf5f 100644 --- a/packages/extension/media/ui/atoms/button.ts +++ b/packages/extension/media/ui/atoms/button.ts @@ -2,7 +2,9 @@ import { defineStyle } from "../style"; -defineStyle("button", ` +defineStyle( + "button", + ` .btn { font-family: var(--text-font); font-size: var(--text-small); color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); background: var(--vscode-button-secondaryBackground, transparent); @@ -11,7 +13,8 @@ defineStyle("button", ` cursor: pointer; display: inline-flex; align-items: center; gap: var(--space-xs); } .btn:hover:not(:disabled) { border-color: var(--color-accent); } .btn:disabled { opacity: 0.4; cursor: default; } -`); +`, +); export interface ButtonAtom { el: HTMLButtonElement; @@ -23,6 +26,13 @@ export function button(label: string, onClick: () => void): ButtonAtom { el.className = "btn"; el.type = "button"; el.textContent = label; - el.addEventListener("click", () => { if (!el.disabled) onClick(); }); - return { el, enable: (on: boolean) => { el.disabled = !on; } }; + el.addEventListener("click", () => { + if (!el.disabled) onClick(); + }); + return { + el, + enable: (on: boolean) => { + el.disabled = !on; + }, + }; } diff --git a/packages/extension/media/ui/atoms/icon.ts b/packages/extension/media/ui/atoms/icon.ts index 2ebacf9d..8d1fa808 100644 --- a/packages/extension/media/ui/atoms/icon.ts +++ b/packages/extension/media/ui/atoms/icon.ts @@ -1,17 +1,36 @@ -// Icon atoms. The mark is the <0||0> brand ket — intentionally not native. +// Icon atoms. The mark is the Harmoniqs H-robot SILHOUETTE — same glyph + +// animation language as the fork's AmicoSpinner (spinner.tsx): the screen slit +// is knocked out via fill-rule evenodd, NO face pixels (illegible at small +// sizes — the full-face Mark is for large canvases only), currentColor so it +// rides the theme, gentle pulse-opacity, static under prefers-reduced-motion. import { defineStyle } from "../style"; -defineStyle("mark", ` - .mark { font-family: var(--text-mono); - letter-spacing: 1px; font-weight: 700; - border: var(--border-width) solid var(--border-color); - border-radius: var(--border-radius); padding: 1px 7px; } -`); +defineStyle( + "mark", + ` + .mark { display: inline-flex; align-items: center; } + .mark svg { width: 20px; height: 17.5px; display: block; color: var(--vscode-foreground); } + @media (prefers-reduced-motion: no-preference) { + .mark svg { animation: mark-pulse 1.2s ease-in-out infinite both; } + } + @keyframes mark-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } +`, +); + +const SVG_NS = "http://www.w3.org/2000/svg"; export function mark(): HTMLSpanElement { const el = document.createElement("span"); el.className = "mark"; - el.textContent = "<0||0>"; + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 64 56"); + svg.setAttribute("fill", "currentColor"); + svg.setAttribute("aria-label", "Amicode"); + const body = document.createElementNS(SVG_NS, "path"); + body.setAttribute("fill-rule", "evenodd"); + body.setAttribute("d", "M2 2h16v14h28V2h16v52H46V40H18v14H2Z M9 19h46v18H9Z"); + svg.append(body); + el.append(svg); return el; } diff --git a/packages/extension/media/ui/atoms/pill.ts b/packages/extension/media/ui/atoms/pill.ts index d7b3ab26..ff5cc9a9 100644 --- a/packages/extension/media/ui/atoms/pill.ts +++ b/packages/extension/media/ui/atoms/pill.ts @@ -1,8 +1,13 @@ // Pill atom — a status indicator. State is a class applied here, in TS. +// Variations: the default carries a status dot (live run states — the dot +// pulses while running); `dot: false` yields a plain badge (labels like +// "recommended" that describe a THING, not a process). import { defineStyle } from "../style"; -defineStyle("pill", ` +defineStyle( + "pill", + ` .pill { font-size: var(--text-small); font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; padding: var(--space-xs) var(--space-md); border-radius: var(--border-radius-round); @@ -10,25 +15,33 @@ defineStyle("pill", ` display: inline-flex; align-items: center; gap: var(--space-sm); } .pill::before { content: ""; width: var(--square-dot); height: var(--square-dot); border-radius: 50%; background: currentColor; } + .pill.no-dot::before { content: none; } .pill.idle { color: var(--color-dim); } .pill.running { color: var(--color-run); } .pill.running::before { animation: pill-pulse 1.1s ease-in-out infinite; } .pill.done { color: var(--color-ok); } .pill.failed { color: var(--color-fail); } @keyframes pill-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } } -`); +`, +); export type PillState = "idle" | "running" | "done" | "failed"; +export interface PillOptions { + /** Status dot before the label (default true). Badges pass false. */ + dot?: boolean; +} + export interface PillAtom { el: HTMLSpanElement; set(state: PillState, label: string): void; } -export function pill(state: PillState = "idle", label = state): PillAtom { +export function pill(state: PillState = "idle", label: string = state, opts: PillOptions = {}): PillAtom { const el = document.createElement("span"); + const variant = opts.dot === false ? " no-dot" : ""; const set = (s: PillState, l: string) => { - el.className = "pill " + s; + el.className = "pill " + s + variant; el.textContent = l; }; set(state, label); diff --git a/packages/extension/media/ui/atoms/text.ts b/packages/extension/media/ui/atoms/text.ts index f1bef606..721fca78 100644 --- a/packages/extension/media/ui/atoms/text.ts +++ b/packages/extension/media/ui/atoms/text.ts @@ -2,13 +2,16 @@ import { defineStyle } from "../style"; -defineStyle("text", ` +defineStyle( + "text", + ` .mono { font-family: var(--text-mono); } .dim { color: var(--color-dim); } .small { font-size: var(--text-small); } .label-k { font-size: var(--text-label); text-transform: uppercase; letter-spacing: 0.6px; font-weight: 600; color: var(--color-dim); } -`); +`, +); export interface TextAtom { el: HTMLSpanElement; @@ -19,5 +22,10 @@ export function text(className = "", initial = ""): TextAtom { const el = document.createElement("span"); if (className) el.className = className; el.textContent = initial; - return { el, set(t) { el.textContent = t; } }; + return { + el, + set(t) { + el.textContent = t; + }, + }; } diff --git a/packages/extension/media/ui/brand_accent.ts b/packages/extension/media/ui/brand_accent.ts new file mode 100644 index 00000000..3d998a99 --- /dev/null +++ b/packages/extension/media/ui/brand_accent.ts @@ -0,0 +1,149 @@ +// Brand accent solver — the Harmoniqs yellow, theme-calculated. +// +// #FFF676 is the canonical brand accent (brand.css). At ~96% lightness it +// sings on dark themes and vanishes on light ones, so each webview computes +// the DEPLOYED accent from the active theme at boot: hold the brand's OKLCH +// hue + chroma, and if contrast against the theme's editor background already +// meets target, ship the brand hex EXACTLY (dark themes — decision: brand- +// exact wherever physics allows); otherwise walk lightness down to the +// closest-to-brand value that passes (light themes get a deeper gold). +// --color-on-accent is picked black/white by contrast on the computed fill — +// yellow itself is never text (fills + borders only). +// +// Pure math up top (unit-tested in node); applyBrandAccent() is the DOM +// applier — sets --color-accent/--color-on-accent at :root and recomputes on +// theme switches (VS Code mutates body attributes when the theme changes). + +const BRAND_HEX = "#FFF676"; +const CONTRAST_TARGET = 3.0; // WCAG non-text UI component minimum + +type RGB = [number, number, number]; // 0..1 + +export function parseColor(s: string): RGB | undefined { + const t = s.trim(); + const hex = t.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1]; + if (hex) { + const h = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex; + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as RGB; + } + const rgb = t.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); + if (rgb) return [+rgb[1] / 255, +rgb[2] / 255, +rgb[3] / 255] as RGB; + return undefined; +} + +const toHex = (rgb: RGB): string => + "#" + + rgb + .map((c) => + Math.round(Math.min(1, Math.max(0, c)) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("") + .toUpperCase(); + +// -- OKLCH (Björn Ottosson's OKLab) ----------------------------------------- + +const lin = (c: number): number => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); +const gam = (c: number): number => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055); + +export function srgbToOklch([r, g, b]: RGB): { L: number; C: number; h: number } { + const [lr, lg, lb] = [lin(r), lin(g), lin(b)]; + const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); + const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); + const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + return { L, C: Math.hypot(a, bb), h: (Math.atan2(bb, a) * 180) / Math.PI }; +} + +export function oklchToSrgb({ L, C, h }: { L: number; C: number; h: number }): RGB { + const a = C * Math.cos((h * Math.PI) / 180); + const b = C * Math.sin((h * Math.PI) / 180); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + gam(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + gam(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + gam(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ] as RGB; +} + +/** In-gamut conversion: reduce chroma until every channel lands in sRGB. */ +function oklchToSrgbClamped(c: { L: number; C: number; h: number }): RGB { + let C = c.C; + for (let i = 0; i < 20; i++) { + const rgb = oklchToSrgb({ ...c, C }); + if (rgb.every((v) => v >= -0.001 && v <= 1.001)) return rgb; + C *= 0.85; + } + return oklchToSrgb({ ...c, C: 0 }); +} + +// -- WCAG contrast ----------------------------------------------------------- + +export function relativeLuminance([r, g, b]: RGB): number { + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +export function contrast(a: RGB, b: RGB): number { + const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +// -- The solve --------------------------------------------------------------- + +export interface BrandAccent { + /** Lines: borders, focus rings, ☑ marks — solved to ≥3:1 vs the theme bg. */ + accent: string; + /** Fills: button backgrounds — stays the brand lemon on EVERY theme (black + * text on #FFF676 is ~19:1); on light themes the component's boundary + * comes from a border in `accent`, never from darkening the fill (a + * 3:1-darkened gold passes WCAG math but reads muddy under text). */ + accentFill: string; + /** Text on accentFill, contrast-picked. */ + onAccent: string; + /** True when the LINE accent shipped as the unmodified brand hex (dark themes). */ + brandExact: boolean; +} + +export function solveBrandAccent(background: string): BrandAccent { + const bg = parseColor(background) ?? parseColor("#1e1e1e")!; + const brand = parseColor(BRAND_HEX)!; + const onAccent = contrast([0, 0, 0], brand) >= contrast([1, 1, 1], brand) ? "#000000" : "#FFFFFF"; + + if (contrast(brand, bg) >= CONTRAST_TARGET) { + return { accent: BRAND_HEX, accentFill: BRAND_HEX, onAccent, brandExact: true }; + } + // Light theme: hold brand hue+chroma, binary-search the HIGHEST lightness + // that still meets target — the closest-to-brand gold that survives. This + // is the LINE color only; the fill stays brand. + const { C, h, L: brandL } = srgbToOklch(brand); + let lo = 0.15, + hi = brandL; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (contrast(oklchToSrgbClamped({ L: mid, C, h }), bg) >= CONTRAST_TARGET) lo = mid; + else hi = mid; + } + const rgb = oklchToSrgbClamped({ L: lo, C, h }); + return { accent: toHex(rgb), accentFill: BRAND_HEX, onAccent, brandExact: false }; +} + +// -- DOM applier ------------------------------------------------------------- + +/** Compute the accent from the live theme and pin it at :root; re-solve when + * VS Code swaps themes (body attributes mutate). Call once per webview boot. */ +export function applyBrandAccent(): void { + const apply = (): void => { + const bg = getComputedStyle(document.body).getPropertyValue("--vscode-editor-background"); + const { accent, accentFill, onAccent } = solveBrandAccent(bg); + document.documentElement.style.setProperty("--color-accent", accent); + document.documentElement.style.setProperty("--color-accent-fill", accentFill); + document.documentElement.style.setProperty("--color-on-accent", onAccent); + }; + apply(); + new MutationObserver(apply).observe(document.body, { attributes: true }); +} diff --git a/packages/extension/media/ui/components/catalogcard.ts b/packages/extension/media/ui/components/catalogcard.ts new file mode 100644 index 00000000..e6d15be8 --- /dev/null +++ b/packages/extension/media/ui/components/catalogcard.ts @@ -0,0 +1,287 @@ +// Catalog entry card (#47, UX2) — the atom of the catalog. Krishna's p5 +// sketch, top to bottom: chip header → pulse plot (with a quiet plot-attached +// action row — the resume hand-off, unlabeled by design) → metadata / +// pulse-data / high-level-metrics panels → model catalog (sibling chips). +// +// Data contract: `entry` is schema-true (catalog-entry.schema.json fields +// verbatim); `entry.proposed` is the clearly-separated extension block whose +// fields render with the "proposed" treatment (they are the feedback artifact +// for the Krishna field-selection questions — Jack's lane to resolve); +// `pulse` is the optional hydrated block sharing pulseplot's meta/values +// shapes (models a CatalogStore hydrating pulse_path on open). + +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; +import { metric } from "./metric"; +import { chip, type ChipFields } from "./chip"; +import { pulseplot, type PulsePlotMeta, type PulsePlotRecord } from "./pulseplot"; + +defineStyle( + "catalogcard", + ` + .catalogcard { display: flex; flex-direction: column; gap: var(--space-md); + padding: var(--space-lg); min-width: 0; + background: var(--bg-box); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); } + .catalogcard .cc-head { display: flex; align-items: center; gap: var(--space-md); flex-wrap: wrap; } + .catalogcard .cc-plot { min-height: 200px; display: flex; } + .catalogcard .cc-panels { display: grid; gap: var(--space-sm); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } + .catalogcard .cc-panel { border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--space-sm) var(--space-md); + display: flex; flex-direction: column; gap: var(--space-xs); } + .catalogcard .cc-kv { display: flex; justify-content: space-between; gap: var(--space-md); + font-size: var(--text-small); min-width: 0; } + .catalogcard .cc-kv .v { font-family: var(--text-mono); overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + .catalogcard .proposed { border-bottom: 1px dashed var(--color-dim); opacity: 0.75; } + .catalogcard .metric.proposed { border-style: dashed; border-bottom: var(--border-width) dashed var(--border-color-hero); opacity: 0.8; } + .catalogcard .cc-metrics { display: flex; flex-wrap: wrap; gap: var(--space-sm); } + .catalogcard .cc-metrics .metric { flex: 1 1 120px; min-width: 0; } + .catalogcard .cc-siblings { display: flex; gap: var(--space-sm); overflow-x: auto; + padding-bottom: var(--space-xs); } + .catalogcard .cc-actions { display: flex; gap: var(--space-sm); margin-left: auto; } + .catalogcard .cc-actions button { font-family: var(--text-font); font-size: var(--text-small); + padding: var(--space-xs) var(--space-md); cursor: pointer; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 2px; + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + background: var(--vscode-button-secondaryBackground, var(--bg-box)); } + .catalogcard .cc-actions button:hover { background: var(--vscode-button-secondaryHoverBackground, var(--bg-box)); } +`, +); + +/** Schema-true catalog-entry fields (catalog-entry.schema.json v1). */ +export interface CatalogEntry { + schema_version: string; + run_id: string; + lab_id: string; + fidelity: number; + pulse_path: string; + gate?: string; + created_at?: string; + params?: Record; + /** NOT in the schema — proposed extensions, rendered visibly marked. */ + proposed?: { + tags?: string[]; + index?: number; + iterations?: number; + wall_seconds?: number; + /** User-assigned system name — human identity over machine family. */ + system_name?: string; + }; +} + +export interface CardPulse { + meta: PulsePlotMeta; + record: PulsePlotRecord; +} + +export interface CatalogCard { + el: HTMLDivElement; +} + +/** The pulse actions — the save → tune → warm-start → promote ladder. + * Tune: refine THIS pulse (resume the chat interview with this run as + * context). Warm-Start: seed a NEW solve from this pulse's trajectory. + * Promote: send the pulse toward hardware (Intonato path; stub until then). + * Vocabulary note for the field-selection round: "promote" also means + * promote-to-catalog elsewhere (the prompt that opens this card). */ +export const PULSE_ACTIONS: ReadonlyArray<{ id: string; label: string }> = [ + { id: "tune", label: "Tune" }, + { id: "warmstart", label: "Warm-Start" }, + { id: "promote", label: "Promote" }, +]; + +export interface CatalogCardOpts { + pulse?: CardPulse; + siblings?: ChipFields[]; + /** Wired by the host; the row renders only when provided. */ + onAction?: (id: string) => void; +} + +export function catalogcard(entry: CatalogEntry, opts: CatalogCardOpts = {}): CatalogCard { + const el = document.createElement("div"); + el.className = "catalogcard"; + + // 1 — chip header + const head = document.createElement("div"); + head.className = "cc-head"; + // User-named system wins the identity slot (proposed-marked via `tag` + // styling in the chip); the derived family is the fallback. + const named = entry.proposed?.system_name; + head.append( + chip({ gate: entry.gate, system: named ?? systemDescriptor(entry.params), ...entry.proposed }).el, + text("mono small dim", entry.run_id).el, + ); + // Pulse actions live in the header, right-justified — with the identity, + // acting on the pulse it names. VS Code button styling, uniform secondary. + if (opts.onAction) { + const actions = document.createElement("div"); + actions.className = "cc-actions"; + for (const a of PULSE_ACTIONS) { + const b = document.createElement("button"); + b.textContent = a.label; + b.addEventListener("click", () => opts.onAction!(a.id)); + actions.append(b); + } + head.append(actions); + } + el.append(head); + + // 2 — pulse plot (hydrated; degrades to pulseplot's empty state) + const plotHost = document.createElement("div"); + plotHost.className = "cc-plot"; + const plot = pulseplot("Pulse not hydrated — entry carries pulse_path only."); + if (opts.pulse) { + plot.meta(opts.pulse.meta); + plot.update(opts.pulse.record); + } + plotHost.append(plot.el); + el.append(plotHost); + + // 3 — panels: metadata · pulse data · high-level metrics + const panels = document.createElement("div"); + panels.className = "cc-panels"; + panels.append( + panel("metadata", [ + kv("run", entry.run_id), + kv("system", systemDescriptor(entry.params) ?? "—"), + kv("gate", entry.gate ?? "—"), + // the user-assigned name (chip identity) vs the derived family above + ...(entry.proposed?.system_name ? [kv("name", entry.proposed.system_name, true)] : []), + ...(entry.proposed?.tags?.length ? [kv("tags", entry.proposed.tags.join(", "), true)] : []), + kv("created", entry.created_at ?? "—"), + // solver telemetry — provenance, not pulse quality (proposed fields) + ...(entry.proposed?.iterations !== undefined ? [kv("iterations", String(entry.proposed.iterations), true)] : []), + ...(entry.proposed?.wall_seconds !== undefined + ? [kv("wall", `${entry.proposed.wall_seconds.toFixed(0)}s`, true)] + : []), + ]), + panel("pulse data", [ + kv("pulse", entry.pulse_path.split("/").slice(-2).join("/")), + ...Object.entries(entry.params ?? {}).map(([k, v]) => kv(k, String(v))), + ]), + metricsPanel(entry, opts.pulse), + ); + el.append(panels); + + // 4 — model catalog: sibling chips + if (opts.siblings?.length) { + el.append(text("label-k", "model catalog").el); + const sibs = document.createElement("div"); + sibs.className = "cc-siblings"; + for (const s of opts.siblings) sibs.append(chip(s).el); + el.append(sibs); + } + + return { el }; +} + +/** Hamiltonian-based identity for the chip: the system FAMILY only + * (params.system, e.g. "transmon"). Level counts are modeling resolution, + * not identity — the same device simulated at 3 vs 4 levels is one system — + * so they stay in the pulse-data panel's params rows, not the chip. The + * real structured Hamiltonian-identity key (family + subsystem topology + + * parameters) is a Phase-3 CatalogStore schema decision. */ +function systemDescriptor(params?: Record): string | undefined { + const sys = params?.system; + return typeof sys === "string" ? sys : undefined; +} + +function panel(title: string, rows: HTMLElement[]): HTMLDivElement { + const p = document.createElement("div"); + p.className = "cc-panel"; + p.append(text("label-k", title).el, ...rows); + return p; +} + +function kv(k: string, v: string, proposed = false): HTMLDivElement { + const row = document.createElement("div"); + row.className = "cc-kv"; + row.append(text("dim", k).el, text(proposed ? "v proposed" : "v", v).el); + return row; +} + +function metricsPanel(entry: CatalogEntry, pulse?: CardPulse): HTMLDivElement { + const p = document.createElement("div"); + p.className = "cc-panel"; + p.append(text("label-k", "high-level metrics").el); + + // All four wear the same hero metric styling; provisional ones (definition + // or data source still a domain-owner decision) get a dashed border. + const heroCard = (label: string, value: string, proposed = false): HTMLDivElement => { + const m = metric(label, { variant: "hero" }); + m.value(value); + if (proposed) m.el.classList.add("proposed"); + return m.el; + }; + + const row = document.createElement("div"); + row.className = "cc-metrics"; + p.append(row); + + row.append(heroCard("fidelity", entry.fidelity.toFixed(5))); + + // Gate time — real: params.T (template units are ns). + const T = entry.params?.T; + if (typeof T === "number") row.append(heroCard("gate time", `${T} ns`)); + + // Spectral bandwidth — computed from the hydrated knots: the frequency + // containing 95% of the pulse's AC power (DC/mean removed; ZOH samples at + // 1/dt). Definitional choices (threshold, DC handling, per-drive max) are + // domain-owner calls → proposed-marked, definition in the label. Units GHz + // for the template's ns time base. + const bw = spectralBandwidth(pulse); + if (bw !== undefined) row.append(heroCard("bandwidth (95% pwr)", `${bw.toPrecision(3)} GHz`, true)); + + // Robustness — fidelity sensitivity to parameter error. Needs perturbed + // rollouts recorded at solve time; NOT derivable from saved artifacts, so + // it renders as an empty proposed card (intent, not an invented number). + row.append(heroCard("robustness", "—", true)); + + return p; +} + +/** 95%-power occupied bandwidth, max across drives: smallest f such that the + * cumulative one-sided power spectrum (DC removed) reaches 95% of total. + * Plain O(N²) DFT — knots are ≤ a few hundred points. Undefined without + * pulse data, a usable dt, or any AC power. */ +function spectralBandwidth(pulse?: CardPulse): number | undefined { + if (!pulse || !(pulse.record.dt > 0)) return undefined; + const dt = pulse.record.dt; + let worst: number | undefined; + for (const drive of pulse.record.values) { + const n = drive.length; + if (n < 2 || drive.some((v) => !Number.isFinite(v))) continue; + const mean = drive.reduce((a, b) => a + b, 0) / n; + const x = drive.map((v) => v - mean); + const half = Math.floor(n / 2); + const power: number[] = []; + for (let k = 1; k <= half; k++) { + // one-sided, DC excluded + let re = 0, + im = 0; + for (let t = 0; t < n; t++) { + const ph = (-2 * Math.PI * k * t) / n; + re += x[t] * Math.cos(ph); + im += x[t] * Math.sin(ph); + } + power.push(re * re + im * im); + } + const total = power.reduce((a, b) => a + b, 0); + if (total <= 0) continue; + let cum = 0; + for (let k = 0; k < power.length; k++) { + cum += power[k]; + if (cum >= 0.95 * total) { + const f = (k + 1) / (n * dt); // bin k+1 → frequency + if (worst === undefined || f > worst) worst = f; + break; + } + } + } + return worst; +} diff --git a/packages/extension/media/ui/components/chip.ts b/packages/extension/media/ui/components/chip.ts new file mode 100644 index 00000000..7ecd0b7e --- /dev/null +++ b/packages/extension/media/ui/components/chip.ts @@ -0,0 +1,52 @@ +// Chip component (#47) — the catalog entry's compact handle, per the p5 +// sketch: gate · system · tag · #index. Identity is HAMILTONIAN-based (a +// pulse's validity is a property of the system it was optimized against); +// the lab is provenance and lives in the card's metadata panel, not here. +// Fields the catalog-entry schema doesn't carry yet (tag, index) render with +// the "proposed" treatment — visually present, visibly not-yet-real (the +// card is the feedback artifact for exactly that field selection). + +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; + +defineStyle( + "chip", + ` + .chip { display: inline-flex; align-items: center; gap: var(--space-sm); + padding: var(--space-xs) var(--space-md); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius-round); + background: var(--bg-box); font-size: var(--text-small); + white-space: nowrap; } + .chip .chip-gate { font-weight: 600; } + .chip .chip-sep { color: var(--color-dim); opacity: 0.6; } + .chip .proposed { border-bottom: 1px dashed var(--color-dim); opacity: 0.75; } +`, +); + +export interface ChipFields { + gate?: string; + /** System descriptor (e.g. "transmon·3lvl") — Hamiltonian-based identity. */ + system?: string; + /** User-added tags — not in the catalog-entry schema, proposed (marked). + * The quick-digest handles for hyperparameter sweeps ("high-R", "fast"). */ + tags?: string[]; + /** Not in the catalog-entry schema — proposed (marked). */ + index?: number; +} + +export interface Chip { + el: HTMLSpanElement; +} + +export function chip(f: ChipFields): Chip { + const el = document.createElement("span"); + el.className = "chip"; + const sep = (): HTMLSpanElement => text("chip-sep", "·").el; + + el.append(text("chip-gate mono", f.gate ?? "?").el); + if (f.system) el.append(sep(), text("dim", f.system).el); + for (const t of f.tags ?? []) el.append(sep(), text("proposed", t).el); + if (f.index !== undefined) el.append(sep(), text("proposed mono", `#${String(f.index).padStart(4, "0")}`).el); + return { el }; +} diff --git a/packages/extension/media/ui/components/metric.ts b/packages/extension/media/ui/components/metric.ts index 0def7e42..d4866221 100644 --- a/packages/extension/media/ui/components/metric.ts +++ b/packages/extension/media/ui/components/metric.ts @@ -3,7 +3,9 @@ import { defineStyle } from "../style"; import { text } from "../atoms/text"; -defineStyle("metric", ` +defineStyle( + "metric", + ` .metric { background: var(--bg-box); border: var(--border-width) solid var(--border-color); border-radius: var(--border-radius); padding: var(--space-sm) var(--space-md); @@ -14,7 +16,8 @@ defineStyle("metric", ` /* hero = the number that matters: accent border, larger value. */ .metric-hero { border-color: var(--border-color-hero); } .metric-hero .v { font-size: var(--text-hero); font-weight: 600; } -`); +`, +); export type MetricVariant = "counter" | "small" | "hero"; diff --git a/packages/extension/media/ui/components/pulseplot.ts b/packages/extension/media/ui/components/pulseplot.ts index 678e5420..dcb09514 100644 --- a/packages/extension/media/ui/components/pulseplot.ts +++ b/packages/extension/media/ui/components/pulseplot.ts @@ -11,7 +11,9 @@ import { defineStyle } from "../style"; import { text } from "../atoms/text"; -defineStyle("pulseplot", ` +defineStyle( + "pulseplot", + ` .pulseplot { display: flex; flex-direction: column; gap: var(--space-xs); flex: 1 1 240px; min-width: 0; min-height: 240px; background: var(--bg-plot); @@ -30,15 +32,25 @@ defineStyle("pulseplot", ` .pulseplot.pp-empty .pp-panel, .pulseplot.pp-empty .pp-axis { display: none; } .pulseplot .pp-hint { place-self: center; margin: auto; opacity: 0.55; font-style: italic; } .pulseplot:not(.pp-empty) .pp-hint { display: none; } -`); +`, +); -const MAX_KNOTS = 512; // above this, stride-decimate before rendering -const W = 1000; // viewBox coordinate space (preserveAspectRatio=none) +const MAX_KNOTS = 512; // above this, stride-decimate before rendering +const W = 1000; // viewBox coordinate space (preserveAspectRatio=none) const H = 100; -const PAD = 0.08; // y-domain padding around the bounds band +const PAD = 0.08; // y-domain padding around the bounds band -export interface PulsePlotMeta { drives: number; knots: number; labels: string[]; bounds: [number, number][] } -export interface PulsePlotRecord { iter: number; dt: number; values: number[][] } +export interface PulsePlotMeta { + drives: number; + knots: number; + labels: string[]; + bounds: [number, number][]; +} +export interface PulsePlotRecord { + iter: number; + dt: number; + values: number[][]; +} interface Panel { el: HTMLDivElement; @@ -101,15 +113,18 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { const y = yScale(lo, hi); const band = svgEl("rect"); band.setAttribute("class", "pp-band"); - band.setAttribute("x", "0"); band.setAttribute("width", String(W)); + band.setAttribute("x", "0"); + band.setAttribute("width", String(W)); band.setAttribute("y", String(y(hi))); band.setAttribute("height", String(y(lo) - y(hi))); const mkLine = (cls: string, v: number): SVGLineElement => { const line = svgEl("line"); line.setAttribute("class", cls); - line.setAttribute("x1", "0"); line.setAttribute("x2", String(W)); - line.setAttribute("y1", String(y(v))); line.setAttribute("y2", String(y(v))); + line.setAttribute("x1", "0"); + line.setAttribute("x2", String(W)); + line.setAttribute("y1", String(y(v))); + line.setAttribute("y2", String(y(v))); return line; }; const limits: [SVGLineElement, SVGLineElement] = [mkLine("pp-limit", hi), mkLine("pp-limit", lo)]; @@ -124,7 +139,7 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { el.append(panel); return { el: panel, svg, step, band, limits, zero, bounds: m.bounds[i] }; }); - el.append(axis); // shared time axis, bottom panel only + el.append(axis); // shared time axis, bottom panel only } function update(r: PulsePlotRecord): void { @@ -156,7 +171,8 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { /** y-scale: bounds band → viewBox with PAD headroom; non-finite clamps to edge. */ function yScale(lo: number, hi: number): (v: number) => number { const pad = PAD * (hi - lo || 1); - const min = lo - pad, max = hi + pad; + const min = lo - pad, + max = hi + pad; return (v) => { const t = Number.isFinite(v) ? (v - min) / (max - min) : v > 0 ? 1 : 0; return H - Math.min(1, Math.max(0, t)) * H; diff --git a/packages/extension/media/ui/components/sparkline.ts b/packages/extension/media/ui/components/sparkline.ts index f64ed5a0..110aab90 100644 --- a/packages/extension/media/ui/components/sparkline.ts +++ b/packages/extension/media/ui/components/sparkline.ts @@ -3,9 +3,12 @@ import { defineStyle } from "../style"; -defineStyle("sparkline", ` +defineStyle( + "sparkline", + ` .sparkline { display: block; margin-top: var(--space-xs); } -`); +`, +); const SVGNS = "http://www.w3.org/2000/svg"; @@ -13,9 +16,16 @@ const SVGNS = "http://www.w3.org/2000/svg"; export function makeSparkBuffer(capacity: number) { const buf: number[] = []; return { - push(v: number) { buf.push(v); if (buf.length > capacity) buf.shift(); }, - values(): number[] { return buf.slice(); }, - reset() { buf.length = 0; }, + push(v: number) { + buf.push(v); + if (buf.length > capacity) buf.shift(); + }, + values(): number[] { + return buf.slice(); + }, + reset() { + buf.length = 0; + }, }; } @@ -27,7 +37,9 @@ export interface Sparkline { export function sparkline(capacity = 60): Sparkline { const buf = makeSparkBuffer(capacity); - const W = 120, H = 26, PAD = 2; + const W = 120, + H = 26, + PAD = 2; const svg = document.createElementNS(SVGNS, "svg") as SVGSVGElement; svg.setAttribute("viewBox", `0 0 ${W} ${H}`); svg.setAttribute("width", String(W)); @@ -43,9 +55,14 @@ export function sparkline(capacity = 60): Sparkline { // Only positive, finite objectives on a log axis; largest at top so a // converging (descending) objective reads as a descending line. const vs = buf.values().filter((v) => v > 0 && Number.isFinite(v)); - if (vs.length < 2) { poly.setAttribute("points", ""); return; } + if (vs.length < 2) { + poly.setAttribute("points", ""); + return; + } const logs = vs.map((v) => Math.log10(v)); - const lo = Math.min(...logs), hi = Math.max(...logs), span = hi - lo || 1; + const lo = Math.min(...logs), + hi = Math.max(...logs), + span = hi - lo || 1; const pts = logs.map((l, i) => { const x = PAD + (i / (logs.length - 1)) * (W - 2 * PAD); const y = PAD + (1 - (l - lo) / span) * (H - 2 * PAD); @@ -56,7 +73,13 @@ export function sparkline(capacity = 60): Sparkline { return { el: svg, - update(v: number) { buf.push(v); render(); }, - reset() { buf.reset(); render(); }, + update(v: number) { + buf.push(v); + render(); + }, + reset() { + buf.reset(); + render(); + }, }; } diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index b6e66912..52cf193c 100644 --- a/packages/extension/media/ui/views/inspector.ts +++ b/packages/extension/media/ui/views/inspector.ts @@ -1,10 +1,14 @@ -// Inspector view — pure composition of atoms/components + layout selectors. -// Owns the message protocol (runlabel / iteration / warming / completed / -// pulsemeta / pulse / ping) shared with run_inspector.ts. +// Inspector view — per-run panes (1.3) over the post-#67 native pulse protocol. +// Owns the runId-keyed message protocol shared with run_inspector.ts: +// runlabel · iteration · warming · completed · pulsemeta · pulse (+ activate/ping) +// every message carries `runId`; the view keeps ONE `panel` per runId and shows +// the ACTIVE one (host sends `activate`). A late/throttled message for a +// background run updates ITS pane only — never the visible pane's badge/plot +// (no cross-talk; #67's plot-only-pulse property preserved per pane). // -// The live pulse renders NATIVELY from pulse data (#66) — the per-iter PNGs -// remain run-dir/archival artifacts but are no longer displayed. A run whose -// log carries no pulse lines shows a hint instead of a plot. +// The live pulse renders NATIVELY from pulse data (#66); per-iter PNGs remain +// archival. Pane MARKUP is the design lane (UX4 #49) — this is the plumbing +// reshape (freeze 2: the runId-keyed protocol, not the DOM). import { defineStyle } from "../style"; import { mark } from "../atoms/icon"; @@ -17,11 +21,18 @@ import { sparkline } from "../components/sparkline"; import { controlEnablement, type ControlStatus } from "../control-state"; import { formatElapsed, computeEta, ratePerSec } from "../../../src/run_timing"; -defineStyle("inspector-view", ` +defineStyle( + "inspector-view", + ` body { margin: 0; height: 100vh; font-family: var(--text-font); font-size: var(--text-body); color: var(--vscode-foreground); } .brand { font-weight: 600; } -`); + /* Panes carry .stack (display:flex from layout.css). Use two-class selectors so + these win over .stack on specificity — not on stylesheet order. */ + .pane:not(.active) { display: none; } + .pane.active { display: flex; } +`, +); const IDLE_HINT = "No solve in progress — fire one from the Amicode chat, or run “Replay demo run”."; const WARMING_HINT = "Julia warming up — compiling the solver (~1–2 min). The pulse will stream here."; @@ -32,7 +43,18 @@ export interface InspectorView { onMessage(msg: unknown): void; } -export function createInspectorView(post: (msg: unknown) => void): InspectorView { +/** One run's pane — the β single-run view, now instanced per runId. No + * single-run globals: everything (status, plot, metrics, gotPulse) is closed + * over here, so N panes never share state. */ +interface Panel { + el: HTMLElement; + apply(msg: Record): void; + /** Hidden panes pause their 1 Hz timing ticker (review/audit #8) — the strip + * re-renders and resumes on activation. */ + setActive(active: boolean): void; +} + +function createPanel(post: (msg: unknown) => void, runId?: string): Panel { const status = pill("idle"); const runLabel = text("mono small dim"); const pulse = pulseplot(IDLE_HINT); @@ -47,9 +69,7 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView const spark = sparkline(); hero.el.append(spark.el); - /** Whether the current run has delivered pulse data — decides the - * completed-without-data hint. Reset on warming (a NEW run started). */ - let gotPulse = false; + let gotPulse = false; // per-pane: decides the completed-without-data hint const brand = document.createElement("div"); brand.className = "row gap-sm brand"; @@ -66,9 +86,9 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView // Control row — Stop / Save pulse / Open run dir. Each posts to the extension // (run_inspector.ts routes {type:"control", action} to the matching command). - const stopBtn = button("■ Stop", () => post({ type: "control", action: "stop" })); - const saveBtn = button("↓ Save pulse", () => post({ type: "control", action: "save" })); - const openBtn = button("↗ Open run dir", () => post({ type: "control", action: "open" })); + const stopBtn = button("■ Stop", () => post({ type: "control", action: "stop", runId })); + const saveBtn = button("↓ Save pulse", () => post({ type: "control", action: "save", runId })); + const openBtn = button("↗ Open run dir", () => post({ type: "control", action: "open", runId })); const controls = document.createElement("div"); controls.className = "row gap-sm wrap push-end"; controls.append(stopBtn.el, saveBtn.el, openBtn.el); @@ -77,7 +97,9 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView let hasData = false; const applyControls = () => { const e = controlEnablement(controlStatus, hasData); - stopBtn.enable(e.stop); saveBtn.enable(e.save); openBtn.enable(e.open); + stopBtn.enable(e.stop); + saveBtn.enable(e.save); + openBtn.enable(e.open); }; applyControls(); @@ -87,11 +109,19 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView let createdAtMs: number | undefined; let maxIter: number | undefined; let latestIter = 0; - const iterStamps: number[] = []; // arrival times → rate + const iterStamps: number[] = []; // arrival times → rate let tick: ReturnType | undefined; - const clearTick = () => { if (tick) { clearInterval(tick); tick = undefined; } }; + const clearTick = () => { + if (tick) { + clearInterval(tick); + tick = undefined; + } + }; const renderTiming = () => { - if (createdAtMs === undefined) { timing.set(""); return; } + if (createdAtMs === undefined) { + timing.set(""); + return; + } const parts = [`elapsed ${formatElapsed((Date.now() - createdAtMs) / 1000)}`]; const r = ratePerSec(iterStamps); if (r !== undefined) { @@ -107,23 +137,27 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView footer.append(timing.el, controls); const el = document.createElement("div"); - el.className = "stack pad-lg scroll-y"; + el.className = "pane stack pad-lg scroll-y"; el.style.height = "100vh"; el.append(topbar, pulse.el, grid, footer); return { el, - onMessage(msg: any): void { - if (!msg || typeof msg !== "object") return; + setActive(active: boolean): void { + if (!active) { + clearTick(); + return; + } + if (createdAtMs !== undefined) { + renderTiming(); + if (!tick) tick = setInterval(renderTiming, 1000); + } + }, + apply(msg: Record): void { switch (msg.type) { - case "ping": { - post({ type: "pong", seq: msg.seq, t0: msg.t0 }); - break; - } - case "runlabel": { + case "runlabel": runLabel.set(String(msg.text ?? "")); break; - } case "timing": { if (msg.terminal) { clearTick(); @@ -144,7 +178,9 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView feasibility.value((msg.eq_viol as number).toExponential(2)); optimality.value((msg.kkt_error as number).toExponential(2)); status.set("running", "running"); - controlStatus = "running"; hasData = true; applyControls(); + controlStatus = "running"; + hasData = true; + applyControls(); latestIter = msg.iter as number; iterStamps.push(Date.now()); if (iterStamps.length > 12) iterStamps.shift(); @@ -152,22 +188,20 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView spark.update(msg.f_val as number); break; } - case "warming": { - // A NEW run started but has no data yet — clear the previous run's - // plot + stats so the old pulse doesn't linger while the new solve - // compiles/warms up. + case "warming": gotPulse = false; pulse.waiting(WARMING_HINT); for (const m of metrics) m.clear(); hero.label("objective"); status.set("running", "warming up"); - controlStatus = "warming"; hasData = false; applyControls(); - iterStamps.length = 0; latestIter = 0; // new run — reset rate history + controlStatus = "warming"; + hasData = false; + applyControls(); + iterStamps.length = 0; + latestIter = 0; // new run — reset rate history spark.reset(); break; - } case "completed": { - // Authoritative terminal state from the watcher (FINISHED on disk). const ok = msg.status === "completed"; const stopped = msg.status === "stopped"; // stopped = graceful user stop (neutral, dim); completed = success; @@ -178,23 +212,81 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView hero.label("fidelity"); hero.value((msg.fidelity as number).toFixed(5)); } - if (!gotPulse) pulse.waiting(NO_DATA_HINT); // old runs / non-emitting scripts - controlStatus = (ok ? "completed" : stopped ? "stopped" : "failed"); applyControls(); + if (!gotPulse) pulse.waiting(NO_DATA_HINT); // old runs / non-emitting scripts + controlStatus = ok ? "completed" : stopped ? "stopped" : "failed"; + applyControls(); break; } - case "pulsemeta": { - pulse.meta({ drives: msg.drives, knots: msg.knots, labels: msg.labels, bounds: msg.bounds }); + case "pulsemeta": + pulse.meta({ + drives: msg.drives as number, + knots: msg.knots as number, + labels: msg.labels as string[], + bounds: msg.bounds as [number, number][], + }); break; - } - case "pulse": { - // Plot-only: never touches the status pill (a throttled record can - // legally land after "completed"; the badge must not regress). + case "pulse": + // Plot-only (never the badge): a throttled record can land after + // "completed", and for a background run must not touch the visible pane. gotPulse = true; - hasData = true; applyControls(); - pulse.update({ iter: msg.iter, dt: msg.dt, values: msg.values }); + hasData = true; + applyControls(); + pulse.update({ iter: msg.iter as number, dt: msg.dt as number, values: msg.values as number[][] }); break; - } } }, }; } + +export function createInspectorView(post: (msg: unknown) => void): InspectorView { + const panels = new Map(); + let active: string | undefined; + + // Shell holds the panes; an empty-state hint shows until the first run. + const empty = text("dim", IDLE_HINT); + empty.el.className = "pad-lg dim"; + + const el = document.createElement("div"); + el.style.height = "100vh"; + el.append(empty.el); + + const panelFor = (runId: string): Panel => { + let p = panels.get(runId); + if (!p) { + p = createPanel(post, runId); + panels.set(runId, p); + el.append(p.el); + } + return p; + }; + + const activate = (runId: string): void => { + active = runId; + empty.el.style.display = "none"; + for (const [id, p] of panels) { + p.el.classList.toggle("active", id === runId); + p.setActive(id === runId); + } + if (!panels.has(runId)) panelFor(runId).el.classList.add("active"); // pane may arrive before data + }; + + return { + el, + onMessage(msg: any): void { + if (!msg || typeof msg !== "object") return; + if (msg.type === "ping") { + post({ type: "pong", seq: msg.seq, t0: msg.t0 }); + return; + } + if (msg.type === "activate") { + if (typeof msg.runId === "string") activate(msg.runId); + return; + } + // Every other message is runId-keyed → route to that run's pane. A message + // with no runId (legacy/none) falls back to the active pane. + const runId = typeof msg.runId === "string" ? msg.runId : active; + if (!runId) return; + panelFor(runId).apply(msg as Record); + }, + }; +} diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.ttf new file mode 100644 index 00000000..c6f9a5e7 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff new file mode 100644 index 00000000..b804d7b3 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 new file mode 100644 index 00000000..0acaaff0 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf new file mode 100644 index 00000000..9ff4a5e0 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff new file mode 100644 index 00000000..9759710d Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 new file mode 100644 index 00000000..f390922e Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf new file mode 100644 index 00000000..f522294f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff new file mode 100644 index 00000000..9bdd534f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 new file mode 100644 index 00000000..75344a1f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf new file mode 100644 index 00000000..4e98259c Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff new file mode 100644 index 00000000..e7730f66 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 new file mode 100644 index 00000000..395f28be Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf new file mode 100644 index 00000000..b8461b27 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff new file mode 100644 index 00000000..acab069f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 new file mode 100644 index 00000000..735f6948 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.ttf new file mode 100644 index 00000000..4060e627 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff new file mode 100644 index 00000000..f38136ac Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff2 new file mode 100644 index 00000000..ab2ad21d Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf new file mode 100644 index 00000000..dc007977 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff new file mode 100644 index 00000000..67807b0b Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 new file mode 100644 index 00000000..5931794d Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.ttf new file mode 100644 index 00000000..0e9b0f35 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff new file mode 100644 index 00000000..6f43b594 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff2 new file mode 100644 index 00000000..b50920e1 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.ttf new file mode 100644 index 00000000..dd45e1ed Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff new file mode 100644 index 00000000..21f58129 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff2 new file mode 100644 index 00000000..eb24a7ba Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf new file mode 100644 index 00000000..728ce7a1 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff new file mode 100644 index 00000000..0ae390d7 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 new file mode 100644 index 00000000..29657023 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.ttf new file mode 100644 index 00000000..70d559b4 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff new file mode 100644 index 00000000..eb5159d4 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff2 new file mode 100644 index 00000000..215c143f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf new file mode 100644 index 00000000..2f65a8a3 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff new file mode 100644 index 00000000..8d47c02d Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 new file mode 100644 index 00000000..cfaa3bda Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf new file mode 100644 index 00000000..d5850df9 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff new file mode 100644 index 00000000..7e02df96 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 new file mode 100644 index 00000000..349c06dc Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf new file mode 100644 index 00000000..537279f6 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff new file mode 100644 index 00000000..31b84829 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 new file mode 100644 index 00000000..a90eea85 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.ttf new file mode 100644 index 00000000..fd679bf3 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff new file mode 100644 index 00000000..0e7da821 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff2 new file mode 100644 index 00000000..b3048fc1 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.ttf new file mode 100644 index 00000000..871fd7d1 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff new file mode 100644 index 00000000..7f292d91 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 new file mode 100644 index 00000000..c5a8462f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.ttf new file mode 100644 index 00000000..7a212caf Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff new file mode 100644 index 00000000..d241d9be Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 new file mode 100644 index 00000000..e1bccfe2 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.ttf new file mode 100644 index 00000000..00bff349 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff new file mode 100644 index 00000000..e6e9b658 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 new file mode 100644 index 00000000..249a2866 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.ttf new file mode 100644 index 00000000..74f08921 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff new file mode 100644 index 00000000..e1ec5457 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 new file mode 100644 index 00000000..680c1308 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf new file mode 100644 index 00000000..c83252c5 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff new file mode 100644 index 00000000..2432419f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 new file mode 100644 index 00000000..771f1af7 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/katex.min.css b/packages/extension/media/vendor/katex/katex.min.css new file mode 100644 index 00000000..317de128 --- /dev/null +++ b/packages/extension/media/vendor/katex/katex.min.css @@ -0,0 +1,1162 @@ +@font-face { + font-display: block; + font-family: KaTeX_AMS; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"), + url(fonts/KaTeX_AMS-Regular.woff) format("woff"), + url(fonts/KaTeX_AMS-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Caligraphic; + font-style: normal; + font-weight: 700; + src: + url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"), + url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"), + url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Caligraphic; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"), + url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Fraktur; + font-style: normal; + font-weight: 700; + src: + url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"), + url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"), + url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Fraktur; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"), + url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Main; + font-style: normal; + font-weight: 700; + src: + url(fonts/KaTeX_Main-Bold.woff2) format("woff2"), + url(fonts/KaTeX_Main-Bold.woff) format("woff"), + url(fonts/KaTeX_Main-Bold.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Main; + font-style: italic; + font-weight: 700; + src: + url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"), + url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"), + url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Main; + font-style: italic; + font-weight: 400; + src: + url(fonts/KaTeX_Main-Italic.woff2) format("woff2"), + url(fonts/KaTeX_Main-Italic.woff) format("woff"), + url(fonts/KaTeX_Main-Italic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Main; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Main-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Main-Regular.woff) format("woff"), + url(fonts/KaTeX_Main-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Math; + font-style: italic; + font-weight: 700; + src: + url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"), + url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"), + url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Math; + font-style: italic; + font-weight: 400; + src: + url(fonts/KaTeX_Math-Italic.woff2) format("woff2"), + url(fonts/KaTeX_Math-Italic.woff) format("woff"), + url(fonts/KaTeX_Math-Italic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: "KaTeX_SansSerif"; + font-style: normal; + font-weight: 700; + src: + url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"), + url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"), + url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: "KaTeX_SansSerif"; + font-style: italic; + font-weight: 400; + src: + url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"), + url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"), + url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: "KaTeX_SansSerif"; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"), + url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"), + url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Script; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Script-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Script-Regular.woff) format("woff"), + url(fonts/KaTeX_Script-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Size1; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Size1-Regular.woff) format("woff"), + url(fonts/KaTeX_Size1-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Size2; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Size2-Regular.woff) format("woff"), + url(fonts/KaTeX_Size2-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Size3; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Size3-Regular.woff) format("woff"), + url(fonts/KaTeX_Size3-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Size4; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Size4-Regular.woff) format("woff"), + url(fonts/KaTeX_Size4-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Typewriter; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"), + url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype"); +} +.katex { + font: + normal 1.21em KaTeX_Main, + Times New Roman, + serif; + line-height: 1.2; + position: relative; + text-indent: 0; + text-rendering: auto; +} +.katex * { + -ms-high-contrast-adjust: none !important; + border-color: currentColor; +} +.katex .katex-version:after { + content: "0.17.0"; +} +.katex .katex-mathml { + border: 0; + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} +.katex .katex-html > .newline { + display: block; +} +.katex .base { + position: relative; + white-space: nowrap; + width: -webkit-min-content; + width: -moz-min-content; + width: min-content; +} +.katex .base, +.katex .strut { + display: inline-block; +} +.katex .textbf { + font-weight: 700; +} +.katex .textit { + font-style: italic; +} +.katex .textrm { + font-family: KaTeX_Main; +} +.katex .textsf { + font-family: KaTeX_SansSerif; +} +.katex .texttt { + font-family: KaTeX_Typewriter; +} +.katex .mathnormal { + font-family: KaTeX_Math; + font-style: italic; +} +.katex .mathit { + font-family: KaTeX_Main; + font-style: italic; +} +.katex .mathrm { + font-style: normal; +} +.katex .mathbf { + font-family: KaTeX_Main; + font-weight: 700; +} +.katex .boldsymbol { + font-family: KaTeX_Math; + font-style: italic; + font-weight: 700; +} +.katex .amsrm, +.katex .mathbb, +.katex .textbb { + font-family: KaTeX_AMS; +} +.katex .mathcal { + font-family: KaTeX_Caligraphic; +} +.katex .mathfrak, +.katex .textfrak { + font-family: KaTeX_Fraktur; +} +.katex .mathboldfrak, +.katex .textboldfrak { + font-family: KaTeX_Fraktur; + font-weight: 700; +} +.katex .mathtt { + font-family: KaTeX_Typewriter; +} +.katex .mathscr, +.katex .textscr { + font-family: KaTeX_Script; +} +.katex .mathsf, +.katex .textsf { + font-family: KaTeX_SansSerif; +} +.katex .mathboldsf, +.katex .textboldsf { + font-family: KaTeX_SansSerif; + font-weight: 700; +} +.katex .mathitsf, +.katex .mathsfit, +.katex .textitsf { + font-family: KaTeX_SansSerif; + font-style: italic; +} +.katex .mainrm { + font-family: KaTeX_Main; + font-style: normal; +} +.katex .vlist-t { + border-collapse: collapse; + display: inline-table; + table-layout: fixed; +} +.katex .vlist-r { + display: table-row; +} +.katex .vlist { + display: table-cell; + position: relative; + vertical-align: bottom; +} +.katex .vlist > span { + display: block; + height: 0; + position: relative; +} +.katex .vlist > span > span { + display: inline-block; +} +.katex .vlist > span > .pstrut { + overflow: hidden; + width: 0; +} +.katex .vlist-t2 { + margin-right: -2px; +} +.katex .vlist-s { + display: table-cell; + font-size: 1px; + min-width: 2px; + vertical-align: bottom; + width: 2px; +} +.katex .vbox { + align-items: baseline; + display: inline-flex; + flex-direction: column; +} +.katex .hbox { + width: 100%; +} +.katex .hbox, +.katex .thinbox { + display: inline-flex; + flex-direction: row; +} +.katex .thinbox { + max-width: 0; + width: 0; +} +.katex .msupsub { + text-align: left; +} +.katex .mfrac > span > span { + text-align: center; +} +.katex .mfrac .frac-line { + border-bottom-style: solid; + display: inline-block; + width: 100%; +} +.katex .hdashline, +.katex .hline, +.katex .mfrac .frac-line, +.katex .overline .overline-line, +.katex .rule, +.katex .underline .underline-line { + min-height: 1px; +} +.katex .mspace { + display: inline-block; +} +.katex .smash { + display: inline; + line-height: 0; +} +.katex .clap, +.katex .llap, +.katex .rlap { + position: relative; + width: 0; +} +.katex .clap > .inner, +.katex .llap > .inner, +.katex .rlap > .inner { + position: absolute; +} +.katex .clap > .fix, +.katex .llap > .fix, +.katex .rlap > .fix { + display: inline-block; +} +.katex .llap > .inner { + right: 0; +} +.katex .clap > .inner, +.katex .rlap > .inner { + left: 0; +} +.katex .clap > .inner > span { + margin-left: -50%; + margin-right: 50%; +} +.katex .rule { + border: 0 solid; + display: inline-block; + position: relative; +} +.katex .hline, +.katex .overline .overline-line, +.katex .underline .underline-line { + border-bottom-style: solid; + display: inline-block; + width: 100%; +} +.katex .hdashline { + border-bottom-style: dashed; + display: inline-block; + width: 100%; +} +.katex .sqrt > .root { + margin-left: 0.2777777778em; + margin-right: -0.5555555556em; +} +.katex .fontsize-ensurer.reset-size1.size1, +.katex .sizing.reset-size1.size1 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size1.size2, +.katex .sizing.reset-size1.size2 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size1.size3, +.katex .sizing.reset-size1.size3 { + font-size: 1.4em; +} +.katex .fontsize-ensurer.reset-size1.size4, +.katex .sizing.reset-size1.size4 { + font-size: 1.6em; +} +.katex .fontsize-ensurer.reset-size1.size5, +.katex .sizing.reset-size1.size5 { + font-size: 1.8em; +} +.katex .fontsize-ensurer.reset-size1.size6, +.katex .sizing.reset-size1.size6 { + font-size: 2em; +} +.katex .fontsize-ensurer.reset-size1.size7, +.katex .sizing.reset-size1.size7 { + font-size: 2.4em; +} +.katex .fontsize-ensurer.reset-size1.size8, +.katex .sizing.reset-size1.size8 { + font-size: 2.88em; +} +.katex .fontsize-ensurer.reset-size1.size9, +.katex .sizing.reset-size1.size9 { + font-size: 3.456em; +} +.katex .fontsize-ensurer.reset-size1.size10, +.katex .sizing.reset-size1.size10 { + font-size: 4.148em; +} +.katex .fontsize-ensurer.reset-size1.size11, +.katex .sizing.reset-size1.size11 { + font-size: 4.976em; +} +.katex .fontsize-ensurer.reset-size2.size1, +.katex .sizing.reset-size2.size1 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size2.size2, +.katex .sizing.reset-size2.size2 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size2.size3, +.katex .sizing.reset-size2.size3 { + font-size: 1.1666666667em; +} +.katex .fontsize-ensurer.reset-size2.size4, +.katex .sizing.reset-size2.size4 { + font-size: 1.3333333333em; +} +.katex .fontsize-ensurer.reset-size2.size5, +.katex .sizing.reset-size2.size5 { + font-size: 1.5em; +} +.katex .fontsize-ensurer.reset-size2.size6, +.katex .sizing.reset-size2.size6 { + font-size: 1.6666666667em; +} +.katex .fontsize-ensurer.reset-size2.size7, +.katex .sizing.reset-size2.size7 { + font-size: 2em; +} +.katex .fontsize-ensurer.reset-size2.size8, +.katex .sizing.reset-size2.size8 { + font-size: 2.4em; +} +.katex .fontsize-ensurer.reset-size2.size9, +.katex .sizing.reset-size2.size9 { + font-size: 2.88em; +} +.katex .fontsize-ensurer.reset-size2.size10, +.katex .sizing.reset-size2.size10 { + font-size: 3.4566666667em; +} +.katex .fontsize-ensurer.reset-size2.size11, +.katex .sizing.reset-size2.size11 { + font-size: 4.1466666667em; +} +.katex .fontsize-ensurer.reset-size3.size1, +.katex .sizing.reset-size3.size1 { + font-size: 0.7142857143em; +} +.katex .fontsize-ensurer.reset-size3.size2, +.katex .sizing.reset-size3.size2 { + font-size: 0.8571428571em; +} +.katex .fontsize-ensurer.reset-size3.size3, +.katex .sizing.reset-size3.size3 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size3.size4, +.katex .sizing.reset-size3.size4 { + font-size: 1.1428571429em; +} +.katex .fontsize-ensurer.reset-size3.size5, +.katex .sizing.reset-size3.size5 { + font-size: 1.2857142857em; +} +.katex .fontsize-ensurer.reset-size3.size6, +.katex .sizing.reset-size3.size6 { + font-size: 1.4285714286em; +} +.katex .fontsize-ensurer.reset-size3.size7, +.katex .sizing.reset-size3.size7 { + font-size: 1.7142857143em; +} +.katex .fontsize-ensurer.reset-size3.size8, +.katex .sizing.reset-size3.size8 { + font-size: 2.0571428571em; +} +.katex .fontsize-ensurer.reset-size3.size9, +.katex .sizing.reset-size3.size9 { + font-size: 2.4685714286em; +} +.katex .fontsize-ensurer.reset-size3.size10, +.katex .sizing.reset-size3.size10 { + font-size: 2.9628571429em; +} +.katex .fontsize-ensurer.reset-size3.size11, +.katex .sizing.reset-size3.size11 { + font-size: 3.5542857143em; +} +.katex .fontsize-ensurer.reset-size4.size1, +.katex .sizing.reset-size4.size1 { + font-size: 0.625em; +} +.katex .fontsize-ensurer.reset-size4.size2, +.katex .sizing.reset-size4.size2 { + font-size: 0.75em; +} +.katex .fontsize-ensurer.reset-size4.size3, +.katex .sizing.reset-size4.size3 { + font-size: 0.875em; +} +.katex .fontsize-ensurer.reset-size4.size4, +.katex .sizing.reset-size4.size4 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size4.size5, +.katex .sizing.reset-size4.size5 { + font-size: 1.125em; +} +.katex .fontsize-ensurer.reset-size4.size6, +.katex .sizing.reset-size4.size6 { + font-size: 1.25em; +} +.katex .fontsize-ensurer.reset-size4.size7, +.katex .sizing.reset-size4.size7 { + font-size: 1.5em; +} +.katex .fontsize-ensurer.reset-size4.size8, +.katex .sizing.reset-size4.size8 { + font-size: 1.8em; +} +.katex .fontsize-ensurer.reset-size4.size9, +.katex .sizing.reset-size4.size9 { + font-size: 2.16em; +} +.katex .fontsize-ensurer.reset-size4.size10, +.katex .sizing.reset-size4.size10 { + font-size: 2.5925em; +} +.katex .fontsize-ensurer.reset-size4.size11, +.katex .sizing.reset-size4.size11 { + font-size: 3.11em; +} +.katex .fontsize-ensurer.reset-size5.size1, +.katex .sizing.reset-size5.size1 { + font-size: 0.5555555556em; +} +.katex .fontsize-ensurer.reset-size5.size2, +.katex .sizing.reset-size5.size2 { + font-size: 0.6666666667em; +} +.katex .fontsize-ensurer.reset-size5.size3, +.katex .sizing.reset-size5.size3 { + font-size: 0.7777777778em; +} +.katex .fontsize-ensurer.reset-size5.size4, +.katex .sizing.reset-size5.size4 { + font-size: 0.8888888889em; +} +.katex .fontsize-ensurer.reset-size5.size5, +.katex .sizing.reset-size5.size5 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size5.size6, +.katex .sizing.reset-size5.size6 { + font-size: 1.1111111111em; +} +.katex .fontsize-ensurer.reset-size5.size7, +.katex .sizing.reset-size5.size7 { + font-size: 1.3333333333em; +} +.katex .fontsize-ensurer.reset-size5.size8, +.katex .sizing.reset-size5.size8 { + font-size: 1.6em; +} +.katex .fontsize-ensurer.reset-size5.size9, +.katex .sizing.reset-size5.size9 { + font-size: 1.92em; +} +.katex .fontsize-ensurer.reset-size5.size10, +.katex .sizing.reset-size5.size10 { + font-size: 2.3044444444em; +} +.katex .fontsize-ensurer.reset-size5.size11, +.katex .sizing.reset-size5.size11 { + font-size: 2.7644444444em; +} +.katex .fontsize-ensurer.reset-size6.size1, +.katex .sizing.reset-size6.size1 { + font-size: 0.5em; +} +.katex .fontsize-ensurer.reset-size6.size2, +.katex .sizing.reset-size6.size2 { + font-size: 0.6em; +} +.katex .fontsize-ensurer.reset-size6.size3, +.katex .sizing.reset-size6.size3 { + font-size: 0.7em; +} +.katex .fontsize-ensurer.reset-size6.size4, +.katex .sizing.reset-size6.size4 { + font-size: 0.8em; +} +.katex .fontsize-ensurer.reset-size6.size5, +.katex .sizing.reset-size6.size5 { + font-size: 0.9em; +} +.katex .fontsize-ensurer.reset-size6.size6, +.katex .sizing.reset-size6.size6 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size6.size7, +.katex .sizing.reset-size6.size7 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size6.size8, +.katex .sizing.reset-size6.size8 { + font-size: 1.44em; +} +.katex .fontsize-ensurer.reset-size6.size9, +.katex .sizing.reset-size6.size9 { + font-size: 1.728em; +} +.katex .fontsize-ensurer.reset-size6.size10, +.katex .sizing.reset-size6.size10 { + font-size: 2.074em; +} +.katex .fontsize-ensurer.reset-size6.size11, +.katex .sizing.reset-size6.size11 { + font-size: 2.488em; +} +.katex .fontsize-ensurer.reset-size7.size1, +.katex .sizing.reset-size7.size1 { + font-size: 0.4166666667em; +} +.katex .fontsize-ensurer.reset-size7.size2, +.katex .sizing.reset-size7.size2 { + font-size: 0.5em; +} +.katex .fontsize-ensurer.reset-size7.size3, +.katex .sizing.reset-size7.size3 { + font-size: 0.5833333333em; +} +.katex .fontsize-ensurer.reset-size7.size4, +.katex .sizing.reset-size7.size4 { + font-size: 0.6666666667em; +} +.katex .fontsize-ensurer.reset-size7.size5, +.katex .sizing.reset-size7.size5 { + font-size: 0.75em; +} +.katex .fontsize-ensurer.reset-size7.size6, +.katex .sizing.reset-size7.size6 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size7.size7, +.katex .sizing.reset-size7.size7 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size7.size8, +.katex .sizing.reset-size7.size8 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size7.size9, +.katex .sizing.reset-size7.size9 { + font-size: 1.44em; +} +.katex .fontsize-ensurer.reset-size7.size10, +.katex .sizing.reset-size7.size10 { + font-size: 1.7283333333em; +} +.katex .fontsize-ensurer.reset-size7.size11, +.katex .sizing.reset-size7.size11 { + font-size: 2.0733333333em; +} +.katex .fontsize-ensurer.reset-size8.size1, +.katex .sizing.reset-size8.size1 { + font-size: 0.3472222222em; +} +.katex .fontsize-ensurer.reset-size8.size2, +.katex .sizing.reset-size8.size2 { + font-size: 0.4166666667em; +} +.katex .fontsize-ensurer.reset-size8.size3, +.katex .sizing.reset-size8.size3 { + font-size: 0.4861111111em; +} +.katex .fontsize-ensurer.reset-size8.size4, +.katex .sizing.reset-size8.size4 { + font-size: 0.5555555556em; +} +.katex .fontsize-ensurer.reset-size8.size5, +.katex .sizing.reset-size8.size5 { + font-size: 0.625em; +} +.katex .fontsize-ensurer.reset-size8.size6, +.katex .sizing.reset-size8.size6 { + font-size: 0.6944444444em; +} +.katex .fontsize-ensurer.reset-size8.size7, +.katex .sizing.reset-size8.size7 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size8.size8, +.katex .sizing.reset-size8.size8 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size8.size9, +.katex .sizing.reset-size8.size9 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size8.size10, +.katex .sizing.reset-size8.size10 { + font-size: 1.4402777778em; +} +.katex .fontsize-ensurer.reset-size8.size11, +.katex .sizing.reset-size8.size11 { + font-size: 1.7277777778em; +} +.katex .fontsize-ensurer.reset-size9.size1, +.katex .sizing.reset-size9.size1 { + font-size: 0.2893518519em; +} +.katex .fontsize-ensurer.reset-size9.size2, +.katex .sizing.reset-size9.size2 { + font-size: 0.3472222222em; +} +.katex .fontsize-ensurer.reset-size9.size3, +.katex .sizing.reset-size9.size3 { + font-size: 0.4050925926em; +} +.katex .fontsize-ensurer.reset-size9.size4, +.katex .sizing.reset-size9.size4 { + font-size: 0.462962963em; +} +.katex .fontsize-ensurer.reset-size9.size5, +.katex .sizing.reset-size9.size5 { + font-size: 0.5208333333em; +} +.katex .fontsize-ensurer.reset-size9.size6, +.katex .sizing.reset-size9.size6 { + font-size: 0.5787037037em; +} +.katex .fontsize-ensurer.reset-size9.size7, +.katex .sizing.reset-size9.size7 { + font-size: 0.6944444444em; +} +.katex .fontsize-ensurer.reset-size9.size8, +.katex .sizing.reset-size9.size8 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size9.size9, +.katex .sizing.reset-size9.size9 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size9.size10, +.katex .sizing.reset-size9.size10 { + font-size: 1.2002314815em; +} +.katex .fontsize-ensurer.reset-size9.size11, +.katex .sizing.reset-size9.size11 { + font-size: 1.4398148148em; +} +.katex .fontsize-ensurer.reset-size10.size1, +.katex .sizing.reset-size10.size1 { + font-size: 0.2410800386em; +} +.katex .fontsize-ensurer.reset-size10.size2, +.katex .sizing.reset-size10.size2 { + font-size: 0.2892960463em; +} +.katex .fontsize-ensurer.reset-size10.size3, +.katex .sizing.reset-size10.size3 { + font-size: 0.337512054em; +} +.katex .fontsize-ensurer.reset-size10.size4, +.katex .sizing.reset-size10.size4 { + font-size: 0.3857280617em; +} +.katex .fontsize-ensurer.reset-size10.size5, +.katex .sizing.reset-size10.size5 { + font-size: 0.4339440694em; +} +.katex .fontsize-ensurer.reset-size10.size6, +.katex .sizing.reset-size10.size6 { + font-size: 0.4821600771em; +} +.katex .fontsize-ensurer.reset-size10.size7, +.katex .sizing.reset-size10.size7 { + font-size: 0.5785920926em; +} +.katex .fontsize-ensurer.reset-size10.size8, +.katex .sizing.reset-size10.size8 { + font-size: 0.6943105111em; +} +.katex .fontsize-ensurer.reset-size10.size9, +.katex .sizing.reset-size10.size9 { + font-size: 0.8331726133em; +} +.katex .fontsize-ensurer.reset-size10.size10, +.katex .sizing.reset-size10.size10 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size10.size11, +.katex .sizing.reset-size10.size11 { + font-size: 1.1996142719em; +} +.katex .fontsize-ensurer.reset-size11.size1, +.katex .sizing.reset-size11.size1 { + font-size: 0.2009646302em; +} +.katex .fontsize-ensurer.reset-size11.size2, +.katex .sizing.reset-size11.size2 { + font-size: 0.2411575563em; +} +.katex .fontsize-ensurer.reset-size11.size3, +.katex .sizing.reset-size11.size3 { + font-size: 0.2813504823em; +} +.katex .fontsize-ensurer.reset-size11.size4, +.katex .sizing.reset-size11.size4 { + font-size: 0.3215434084em; +} +.katex .fontsize-ensurer.reset-size11.size5, +.katex .sizing.reset-size11.size5 { + font-size: 0.3617363344em; +} +.katex .fontsize-ensurer.reset-size11.size6, +.katex .sizing.reset-size11.size6 { + font-size: 0.4019292605em; +} +.katex .fontsize-ensurer.reset-size11.size7, +.katex .sizing.reset-size11.size7 { + font-size: 0.4823151125em; +} +.katex .fontsize-ensurer.reset-size11.size8, +.katex .sizing.reset-size11.size8 { + font-size: 0.578778135em; +} +.katex .fontsize-ensurer.reset-size11.size9, +.katex .sizing.reset-size11.size9 { + font-size: 0.6945337621em; +} +.katex .fontsize-ensurer.reset-size11.size10, +.katex .sizing.reset-size11.size10 { + font-size: 0.8336012862em; +} +.katex .fontsize-ensurer.reset-size11.size11, +.katex .sizing.reset-size11.size11 { + font-size: 1em; +} +.katex .delimsizing.size1 { + font-family: KaTeX_Size1; +} +.katex .delimsizing.size2 { + font-family: KaTeX_Size2; +} +.katex .delimsizing.size3 { + font-family: KaTeX_Size3; +} +.katex .delimsizing.size4 { + font-family: KaTeX_Size4; +} +.katex .delimsizing.mult .delim-size1 > span { + font-family: KaTeX_Size1; +} +.katex .delimsizing.mult .delim-size4 > span { + font-family: KaTeX_Size4; +} +.katex .nulldelimiter { + display: inline-block; + width: 0.12em; +} +.katex .delimcenter, +.katex .op-symbol { + position: relative; +} +.katex .op-symbol.small-op { + font-family: KaTeX_Size1; +} +.katex .op-symbol.large-op { + font-family: KaTeX_Size2; +} +.katex .accent > .vlist-t, +.katex .op-limits > .vlist-t { + text-align: center; +} +.katex .accent .accent-body { + position: relative; +} +.katex .accent .accent-body:not(.accent-full) { + width: 0; +} +.katex .overlay { + display: block; +} +.katex .mtable .vertical-separator { + display: inline-block; + min-width: 1px; +} +.katex .mtable .arraycolsep { + display: inline-block; +} +.katex .mtable .col-align-c > .vlist-t { + text-align: center; +} +.katex .mtable .col-align-l > .vlist-t { + text-align: left; +} +.katex .mtable .col-align-r > .vlist-t { + text-align: right; +} +.katex .svg-align { + text-align: left; +} +.katex svg { + fill: currentColor; + stroke: currentColor; + display: block; + height: inherit; + position: absolute; + width: 100%; +} +.katex svg path { + stroke: none; +} +.katex svg { + fill-rule: nonzero; + fill-opacity: 1; + stroke-width: 1; + stroke-linecap: butt; + stroke-linejoin: miter; + stroke-miterlimit: 4; + stroke-dasharray: none; + stroke-dashoffset: 0; + stroke-opacity: 1; +} +.katex img { + border-style: none; + max-height: none; + max-width: none; + min-height: 0; + min-width: 0; +} +.katex .stretchy { + display: block; + overflow: hidden; + position: relative; + width: 100%; +} +.katex .stretchy:after, +.katex .stretchy:before { + content: ""; +} +.katex .hide-tail { + overflow: hidden; + position: relative; + width: 100%; +} +.katex .halfarrow-left { + left: 0; + overflow: hidden; + position: absolute; + width: 50.2%; +} +.katex .halfarrow-right { + overflow: hidden; + position: absolute; + right: 0; + width: 50.2%; +} +.katex .brace-left { + left: 0; + overflow: hidden; + position: absolute; + width: 25.1%; +} +.katex .brace-center { + left: 25%; + overflow: hidden; + position: absolute; + width: 50%; +} +.katex .brace-right { + overflow: hidden; + position: absolute; + right: 0; + width: 25.1%; +} +.katex .x-arrow-pad { + padding: 0 0.5em; +} +.katex .cd-arrow-pad { + padding: 0 0.55556em 0 0.27778em; +} +.katex .mover, +.katex .munder, +.katex .x-arrow { + text-align: center; +} +.katex .boxpad { + padding: 0 0.3em; +} +.katex .fbox, +.katex .fcolorbox { + border: 0.04em solid; + box-sizing: border-box; +} +.katex .cancel-pad { + padding: 0 0.2em; +} +.katex .cancel-lap { + margin-left: -0.2em; + margin-right: -0.2em; +} +.katex .sout { + border-bottom-style: solid; + border-bottom-width: 0.08em; +} +.katex .angl { + border-right: 0.049em solid; + border-top: 0.049em solid; + box-sizing: border-box; + margin-right: 0.03889em; +} +.katex .anglpad { + padding: 0 0.03889em; +} +.katex .eqn-num:before { + content: "(" counter(katexEqnNo) ")"; + counter-increment: katexEqnNo; +} +.katex .mml-eqn-num:before { + content: "(" counter(mmlEqnNo) ")"; + counter-increment: mmlEqnNo; +} +.katex .mtr-glue { + width: 50%; +} +.katex .cd-vert-arrow { + display: inline-block; + position: relative; +} +.katex .cd-label-left { + display: inline-block; + position: absolute; + right: calc(50% + 0.3em); + text-align: left; +} +.katex .cd-label-right { + display: inline-block; + left: calc(50% + 0.3em); + position: absolute; + text-align: right; +} +.katex-display { + display: block; + margin: 1em 0; + text-align: center; +} +.katex-display > .katex { + display: block; + text-align: center; + white-space: nowrap; +} +.katex-display > .katex > .katex-html { + display: block; + position: relative; +} +.katex-display > .katex > .katex-html > .tag { + position: absolute; + right: 0; +} +.katex-display.leqno > .katex > .katex-html > .tag { + left: 0; + right: auto; +} +.katex-display.fleqn > .katex { + padding-left: 2em; + text-align: left; +} +body { + counter-reset: katexEqnNo mmlEqnNo; +} diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 406b25fb..dc97916c 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -186,13 +186,11 @@ export const AmicodeTools = async (_input: unknown) => ({ items: { type: "string" }, description: "Optional one-per-option short qualifier rendered dimly under each button " + - "(e.g. \"fully supported end-to-end\"). Same length as options; omit for none.", + '(e.g. "fully supported end-to-end"). Same length as options; omit for none.', }, }, async execute(a: { question: string; options: string[]; details?: string[] | null }) { - const opts = Array.isArray(a.options) - ? a.options.filter((o) => typeof o === "string" && o.trim() !== "") - : []; + const opts = Array.isArray(a.options) ? a.options.filter((o) => typeof o === "string" && o.trim() !== "") : []; if (!a.question || a.question.trim() === "") return "Cannot ask: empty question."; if (opts.length < 2 || opts.length > 6) return "Cannot ask: need 2-6 non-empty options."; if (Array.isArray(a.details) && a.details.length > 0 && a.details.length !== opts.length) @@ -221,7 +219,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, name: { type: "string", - description: "For create/open: the problem name (or slug) to create/find. For rename/archive: the target slug.", + description: + "For create/open: the problem name (or slug) to create/find. For rename/archive: the target slug.", }, new_name: { type: ["string", "null"], @@ -381,7 +380,7 @@ export const AmicodeTools = async (_input: unknown) => ({ params: { type: ["object", "null"], additionalProperties: { type: "number" }, - description: "Extra named numeric model parameters to merge (e.g. {\"T1\": 80}); null for none.", + description: 'Extra named numeric model parameters to merge (e.g. {"T1": 80}); null for none.', }, }, async execute(a: { levels?: number | null; drive_max?: number | null; params?: Record | null }) { @@ -421,22 +420,22 @@ export const AmicodeTools = async (_input: unknown) => ({ args: { problem: { type: "string", - description: "Problem kind, e.g. \"gate_synthesis\", \"state_prep\", \"min_time\".", + description: 'Problem kind, e.g. "gate_synthesis", "state_prep", "min_time".', }, target: { type: "string", - description: "The target, e.g. \"X\", \"H\", \"sqrt(X)\", or a description of the unitary/state.", + description: 'The target, e.g. "X", "H", "sqrt(X)", or a description of the unitary/state.', }, objective: { type: ["string", "null"], - description: "Objective; null for the default \"unitary infidelity\".", + description: 'Objective; null for the default "unitary infidelity".', }, constraints: { // Optional nullable array — see the details field above. legacyJsonSchema // strips "null" → optional singular-typed array (provider-agnostic). type: ["array", "null"], items: { type: "string" }, - description: "Constraint list; omit for the default [\"amplitude bound (drive_max)\"].", + description: 'Constraint list; omit for the default ["amplitude bound (drive_max)"].', }, }, async execute(a: { problem: string; target: string; objective?: string | null; constraints?: string[] | null }) { @@ -452,9 +451,7 @@ export const AmicodeTools = async (_input: unknown) => ({ target: a.target, objective: given(a.objective) ? a.objective : "unitary infidelity", constraints: - Array.isArray(a.constraints) && a.constraints.length > 0 - ? a.constraints - : ["amplitude bound (drive_max)"], + Array.isArray(a.constraints) && a.constraints.length > 0 ? a.constraints : ["amplitude bound (drive_max)"], }; if (existing?.solve) entity.solve = existing.solve; const problems = validateFormulation(entity); @@ -485,14 +482,17 @@ export const AmicodeTools = async (_input: unknown) => ({ T: { type: ["number", "null"], description: "Gate time T in ns; null if not applicable." }, N: { type: ["integer", "null"], description: "Number of timesteps N; null if not applicable." }, max_iter: { type: ["integer", "null"], description: "Solver max iterations; null for the default." }, - integrator: { type: ["string", "null"], description: "Integrator name (e.g. \"MagnusGL4\"); null for the default." }, + integrator: { + type: ["string", "null"], + description: 'Integrator name (e.g. "MagnusGL4"); null for the default.', + }, tier: { type: ["string", "null"], - description: "Authoring tier: \"vetted\" | \"composed\" | \"free\" (spec C); null if unknown.", + description: 'Authoring tier: "vetted" | "composed" | "free" (spec C); null if unknown.', }, note: { type: ["string", "null"], - description: "Short free-text note, e.g. \"X gate, T=10ns, N=50, defaults\"; null for none.", + description: 'Short free-text note, e.g. "X gate, T=10ns, N=50, defaults"; null for none.', }, }, async execute(a: { @@ -569,7 +569,7 @@ export const AmicodeTools = async (_input: unknown) => ({ amicode_verify: { description: "Record the free-tier re-rollout VERIFICATION outcome on the Run entity (spec C). " + - "Call this AFTER a `tier=\"free\"` solve finishes: amico-run runs the fixed re-rollout " + + 'Call this AFTER a `tier="free"` solve finishes: amico-run runs the fixed re-rollout ' + "harness and writes verification.toml; read it and pass agree + the two fidelities here. " + "Bookkeeping AFTER the fact — no stage gate (a verification record must never be lost). " + "Promotion of a free run is blocked until agree = true.", @@ -651,9 +651,10 @@ export const AmicodeTools = async (_input: unknown) => ({ } catch (err) { return `Cannot record device session: ${err instanceof Error ? err.message : String(err)}`; } - const warn = stub.pulse_ref || stub.run_dir - ? "" - : " Note: no pulse/run referenced yet — re-record after the solve finishes."; + const warn = + stub.pulse_ref || stub.run_dir + ? "" + : " Note: no pulse/run referenced yet — re-record after the solve finishes."; return ( `Hardware intent noted for "${meta.slug}" — pending your sign-off.${warn}\n\n` + `The send-to-device gate, when wired: (1) automated checks — fidelity ≥ threshold, ` + @@ -672,8 +673,7 @@ export const AmicodeTools = async (_input: unknown) => ({ args: { device_session_ref: { type: ["string", "null"], - description: - "Path to the recorded device_session.toml; null to auto-reference the recorded one if present.", + description: "Path to the recorded device_session.toml; null to auto-reference the recorded one if present.", }, note: { type: ["string", "null"], @@ -782,11 +782,20 @@ export const AmicodeTools = async (_input: unknown) => ({ param: { type: "string", description: "parameter name, e.g. N | T | levels | drive_max | warm_start" }, value: { type: ["string", "number", "boolean", "null"], description: "recommended value (propose)" }, confidence: { type: ["string", "null"], description: "high | medium | low (propose)" }, - provenance: { type: ["array", "null"], description: "[{source, ref, note}] (propose) — cite where it came from" }, + provenance: { + type: ["array", "null"], + description: "[{source, ref, note}] (propose) — cite where it came from", + }, alternatives: { type: ["array", "null"], description: "optional [{value, note}] considered (propose)" }, outcome: { type: ["string", "null"], description: "accepted | overridden (outcome)" }, - applied_value: { type: ["string", "number", "boolean", "null"], description: "the value actually applied (outcome)" }, - auto_accepted: { type: ["boolean", "null"], description: "true when Veloce (L2) auto-accepted this without asking (propose)" }, + applied_value: { + type: ["string", "number", "boolean", "null"], + description: "the value actually applied (outcome)", + }, + auto_accepted: { + type: ["boolean", "null"], + description: "true when Veloce (L2) auto-accepted this without asking (propose)", + }, }, async execute(a: { action: string; @@ -802,7 +811,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }) { try { const slug = readActiveSlug(); - if (!slug) return "No active problem yet — recommendation not recorded (recommendations begin at the problem stage)."; + if (!slug) + return "No active problem yet — recommendation not recorded (recommendations begin at the problem stage)."; const key = `${a.stage ?? "?"}/${a.param ?? "?"}`; if (a.action === "outcome") { const seq = appendEvent(slug, { @@ -831,9 +841,10 @@ export const AmicodeTools = async (_input: unknown) => ({ }, source: { tool: "amicode_recommend", stage: a.stage }, }); - const prov = Array.isArray(a.provenance) && a.provenance.length - ? (a.provenance[0] as { source?: string }).source ?? "?" - : "none"; + const prov = + Array.isArray(a.provenance) && a.provenance.length + ? ((a.provenance[0] as { source?: string }).source ?? "?") + : "none"; const auto = a.auto_accepted ? " ⚡auto" : ""; return `Recommended ${a.param}=${JSON.stringify(a.value)} (${a.confidence ?? "?"}, via ${prov})${auto} [event ${seq}].`; } catch (err) { @@ -870,7 +881,9 @@ export const AmicodeTools = async (_input: unknown) => ({ const e = JSON.parse(line); if (e.entity === "veloce" && e.diff?.mode) mode = e.diff.mode; } - } catch { /* no events yet */ } + } catch { + /* no events yet */ + } return `Veloce is ${mode}.`; } const mode = a.action === "on" ? "on" : "off"; diff --git a/packages/extension/opencode-plugin/distill_queue.ts b/packages/extension/opencode-plugin/distill_queue.ts index d35831e7..d5d32f31 100644 --- a/packages/extension/opencode-plugin/distill_queue.ts +++ b/packages/extension/opencode-plugin/distill_queue.ts @@ -88,7 +88,11 @@ export function releaseLock(opsDir: string): void { /** Reclaim a stale lock (older than 15 min, dead pid) by renaming it aside — * only one reclaimer's rename succeeds — then claiming fresh. Returns true if * THIS caller now holds the lock. */ -export function reclaimIfStale(opsDir: string, pid: number, clock: { now: number; isPidAlive: (pid: number) => boolean }): boolean { +export function reclaimIfStale( + opsDir: string, + pid: number, + clock: { now: number; isPidAlive: (pid: number) => boolean }, +): boolean { let owner: { pid: number; ts: number }; try { owner = JSON.parse(fs.readFileSync(path.join(lockDir(opsDir), "owner"), "utf8")); @@ -227,7 +231,10 @@ export async function runDrainLoop( handler: (job: DistillJob) => Promise, clock: DrainClock, ): Promise { - if (!claimLock(opsDir, clock.pid) && !reclaimIfStale(opsDir, clock.pid, { now: clock.now(), isPidAlive: clock.isPidAlive })) { + if ( + !claimLock(opsDir, clock.pid) && + !reclaimIfStale(opsDir, clock.pid, { now: clock.now(), isPidAlive: clock.isPidAlive }) + ) { return false; } // We hold the lock. diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index ba581ff5..29afb950 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -377,7 +377,10 @@ export function canonicalJson(value: unknown): string { /** Kebab-case slug from a problem name; empty result → "untitled". */ export function deriveSlug(name: string): string { - const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); return slug || "untitled"; } @@ -422,8 +425,7 @@ export function truncateDiffForSentinel( diff: Record, maxBytes = 1024, ): Record { - const trunc = (v: unknown): unknown => - typeof v === "string" && v.length > 120 ? v.slice(0, 120) + "…" : v; + const trunc = (v: unknown): unknown => (typeof v === "string" && v.length > 120 ? v.slice(0, 120) + "…" : v); const out: Record = {}; for (const [k, { from, to }] of Object.entries(diff)) out[k] = { from: trunc(from), to: trunc(to) }; const keys = Object.keys(out); diff --git a/packages/extension/opencode-plugin/onboarding.ts b/packages/extension/opencode-plugin/onboarding.ts index 11c0ac47..41f75e78 100644 --- a/packages/extension/opencode-plugin/onboarding.ts +++ b/packages/extension/opencode-plugin/onboarding.ts @@ -49,7 +49,10 @@ export function sanitizePayload(entity: OnboardingEntity, payload: Record l.trim()).length; + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim()).length; } catch { return 0; } diff --git a/packages/extension/opencode-plugin/problems.ts b/packages/extension/opencode-plugin/problems.ts index d3026648..cc0c087a 100644 --- a/packages/extension/opencode-plugin/problems.ts +++ b/packages/extension/opencode-plugin/problems.ts @@ -126,9 +126,7 @@ export function openProblem(query: string): ProblemMeta | undefined { return exact; } const q = query.toLowerCase().trim(); - const matches = listProblems().filter( - (m) => m.status !== "archived" && m.name.toLowerCase().includes(q), - ); + const matches = listProblems().filter((m) => m.status !== "archived" && m.name.toLowerCase().includes(q)); if (matches.length === 0) return undefined; matches.sort((a, b) => (b.recorded ?? "").localeCompare(a.recorded ?? "")); setActiveSlug(matches[0].slug); @@ -197,7 +195,10 @@ export interface EventInput { export function lastEventSeq(slug: string): number { const file = path.join(problemDir(slug), "events.jsonl"); if (!fs.existsSync(file)) return 0; - return fs.readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").length; + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim() !== "").length; } /** Append one event to the problem's events.jsonl; returns its monotonic seq @@ -206,7 +207,11 @@ export function appendEvent(slug: string, input: EventInput): number { const file = path.join(problemDir(slug), "events.jsonl"); let seq = 1; if (fs.existsSync(file)) { - seq = fs.readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").length + 1; + seq = + fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim() !== "").length + 1; } const record = { seq, diff --git a/packages/extension/opencode-plugin/score_guard.ts b/packages/extension/opencode-plugin/score_guard.ts index 396f248b..db4d5f10 100644 --- a/packages/extension/opencode-plugin/score_guard.ts +++ b/packages/extension/opencode-plugin/score_guard.ts @@ -152,7 +152,12 @@ export function guardAndRecordStage(manifestDir: string, stateDir: string, stage } if (!state) { state = freshScoreState(manifest.id, manifest.version); - appendUsage(stateDir, { kind: "session_started", ts: new Date().toISOString(), score_id: manifest.id, score_version: manifest.version }); + appendUsage(stateDir, { + kind: "session_started", + ts: new Date().toISOString(), + score_id: manifest.id, + score_version: manifest.version, + }); } const verdict = checkStagePrereqs(manifest.stages, state, stageId); if (!verdict.ok) { diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index f6685932..9d7e0994 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,9 +1,15 @@ { "version": "1.17.3", "repo": "harmoniqs/opencode", - "tag": "v1.17.3-amicode.1", + "tag": "v1.17.3-amicode.2", "platforms": { - "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455" }, - "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10" } + "darwin-arm64": { + "asset": "opencode-darwin-arm64.zip", + "sha256": "3b41ea7344718e2c985b38b8e166603dd6b06e5472af8ac95431c897a557df33" + }, + "linux-x64": { + "asset": "opencode-linux-x64.tar.gz", + "sha256": "2aff796ab4685ecf9e506255a1a45dd86a0096d76f2a1b8473f4367b204d1ae8" + } } } diff --git a/packages/extension/package.json b/packages/extension/package.json index 3d74850a..723b8678 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -76,6 +76,10 @@ "command": "amicode.openInspector", "title": "Amicode: Open Run Inspector" }, + { + "command": "amicode.selectRun", + "title": "Amicode: Select run to inspect…" + }, { "command": "amicode.restartServer", "title": "Amicode: Restart opencode server" @@ -99,6 +103,10 @@ { "command": "amicode.distillNow", "title": "Amicode: Distill now (update my memory)" + }, + { + "command": "amicode.catalog.remove", + "title": "Remove from Catalog" } ], "configuration": { @@ -106,8 +114,8 @@ "properties": { "amicode.inspector.autoOpen": { "type": "boolean", - "default": false, - "description": "Automatically reveal the Run Inspector panel when a solve starts. When off (default), use the status-bar item or the 'Amicode: Open Run Inspector' command." + "default": true, + "description": "Automatically reveal the Run Inspector panel when a solve starts (default on). Turn off if a starting solve stealing focus bothers you." }, "amicode.opencodeBinary": { "type": "string", @@ -136,19 +144,25 @@ }, "amicode.skillRoots": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], "description": "Roots to search for co-located package skills (.jl/skills//SKILL.md). Empty = ~/harmoniqs/packages. First root containing a package's skills wins." }, "amicode.platformSkills": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], "description": "Platform-skill names indexed from the central library (public). Empty = atoms, transmon, fluxonium, ions, bosonic. Only listed names are indexed — the library also holds process skills that must not leak." }, "amicode.skillLibraryRoots": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], "description": "Roots for the central platform-skill library. Empty = ~/harmoniqs/amico-plugin/skills." }, @@ -166,8 +180,28 @@ "type": "boolean", "default": false, "description": "Amico Veloce: start sessions with autonomy on — auto-accept high-confidence downstream recommendations without asking. Resource gates (solve launch, hardware) always confirm; any interruption drops veloce. Off by default." + }, + "amicode.chat.autoOpen": { + "type": "boolean", + "default": true, + "description": "Open the Amicode chat automatically once the opencode server is ready." } } + }, + "menus": { + "view/item/context": [ + { + "command": "amicode.catalog.remove", + "when": "view == amicode.catalog && viewItem == amicodeCatalogEntry", + "group": "7_modification" + } + ], + "commandPalette": [ + { + "command": "amicode.catalog.remove", + "when": "false" + } + ] } }, "scripts": { @@ -190,6 +224,7 @@ "@types/vscode": "^1.95.0", "@vscode/vsce": "^3.2.0", "esbuild": "^0.24.0", + "happy-dom": "^20.10.6", "smol-toml": "^1.3.0", "typescript": "^5.6.0", "vitest": "^2.1.0" @@ -197,4 +232,4 @@ "dependencies": { "yaml": "^2.9.0" } -} \ No newline at end of file +} diff --git a/packages/extension/scores/README.md b/packages/extension/scores/README.md index f64041ab..d0573b51 100644 --- a/packages/extension/scores/README.md +++ b/packages/extension/scores/README.md @@ -22,34 +22,35 @@ Body = prose (per-stage narration, physics, defaults rationale, off-path guidanc ```yaml --- type: score -schema_version: 1 # supported: 1; unknown FIELDS are ignored (additive policy) -id: my-score # directory name must match -version: 1 # bump on revision; in-flight sessions stay pinned to theirs -derived_from: null # or a sibling score id — lineage for forks +schema_version: 1 # supported: 1; unknown FIELDS are ignored (additive policy) +id: my-score # directory name must match +version: 1 # bump on revision; in-flight sessions stay pinned to theirs +derived_from: null # or a sibling score id — lineage for forks name: "Shown on the entry card" outcome: "What the user will HAVE at the end" audience: [algorithms, no-physics-assumed] duration_estimate: "60–90 min" -device: {backend: pasqal, qpu_runnable: true, emulators: [emu-mps]} # optional -entitlements: [] # empty/absent = public; ids must be in entitlements.toml +device: { backend: pasqal, qpu_runnable: true, emulators: [emu-mps] } # optional +entitlements: [] # empty/absent = public; ids must be in entitlements.toml stages: - - id: application # ordered list; loopbacks OK, no DAGs (v1) - emits: [circuit] # ONLY workflow-frames entities: circuit, system, - # formulation, pulse, run, device_session, knowledge + - id: application # ordered list; loopbacks OK, no DAGs (v1) + emits: + [circuit] # ONLY workflow-frames entities: circuit, system, + # formulation, pulse, run, device_session, knowledge questions: - id: graph prompt: "Which graph?" - choices: [sample, upload] # choices → rendered as amicode_ask buttons - default: sample # must be one of choices; marked "(recommended)" + choices: [sample, upload] # choices → rendered as amicode_ask buttons + default: sample # must be one of choices; marked "(recommended)" skip_if: "mode == simulate" # optional - memory_hooks: [some-slug] # optional; must resolve to memory/.md + memory_hooks: [some-slug] # optional; must resolve to memory/.md - id: solve emits: [run, pulse] - executor: cloud-altissimo # or local - template: templates/solve.jl # resolved relative to the score dir; must exist + executor: cloud-altissimo # or local + template: templates/solve.jl # resolved relative to the score dir; must exist - id: device-qpu emits: [device_session] - gate: heavy # light|heavy — checks must pass BEFORE entering + gate: heavy # light|heavy — checks must pass BEFORE entering optional: true --- [Amico's voice for this score — markdown + LaTeX, carried verbatim into the prompt] diff --git a/packages/extension/scores/memory/confidence-rubric.md b/packages/extension/scores/memory/confidence-rubric.md index 9a8c6ef7..f96d57da 100644 --- a/packages/extension/scores/memory/confidence-rubric.md +++ b/packages/extension/scores/memory/confidence-rubric.md @@ -11,7 +11,7 @@ never to model judgment. ## Resolution order (pick the highest available, then score it) 1. **own-precedent** — a `## Your recent problems` card matching the full 3-tuple - `(platform, problem_kind, target)`. A match is a *candidate*; score by §high. + `(platform, problem_kind, target)`. A match is a _candidate_; score by §high. 2. **demo** — a `## Reference demos` card matching the full 3-tuple → **medium**. 3. **physics** — the platform skill's canonical value (speed limit, cutoff sizing) → **medium**. @@ -20,6 +20,7 @@ never to model judgment. ## The `high` predicate (own-precedent only, mechanical) A candidate own-precedent card scores **high** iff: + - `platform`, `problem_kind`, `target` all equal, AND - **every gating scalar for the platform** matches within tolerance (below), AND - for a **warm-start** rec additionally: the card's `pulse_ref` resolves to a @@ -30,11 +31,11 @@ pulse). A bare 3-tuple match is NEVER high on its own. ### Gating scalars + tolerances (per platform) -| Platform | Gating scalars (tolerance) | -|---|---| -| transmon | `levels` (exact), `drive_max` (±10%) | -| cavity / bosonic | `fock_cutoff` (exact), `chi` (±10%), target `alpha` or Fock index (exact) | -| atoms (Rydberg) | `levels` (exact), `rabi_max` (±10%), `delta_max` (±10%), distance/blockade (±10%) | +| Platform | Gating scalars (tolerance) | +| ---------------- | --------------------------------------------------------------------------------- | +| transmon | `levels` (exact), `drive_max` (±10%) | +| cavity / bosonic | `fock_cutoff` (exact), `chi` (±10%), target `alpha` or Fock index (exact) | +| atoms (Rydberg) | `levels` (exact), `rabi_max` (±10%), `delta_max` (±10%), distance/blockade (±10%) | **Fail-safe:** a platform NOT listed here, or a card missing any required `sys_params` field, scores **medium, never high**. Unknown regime → fail safe. diff --git a/packages/extension/scores/overture/SCORE.md b/packages/extension/scores/overture/SCORE.md index 726745e4..4e010ad6 100644 --- a/packages/extension/scores/overture/SCORE.md +++ b/packages/extension/scores/overture/SCORE.md @@ -24,7 +24,13 @@ stages: questions: - id: environment prompt: "How will pulses eventually reach hardware — what are we patching into?" - choices: ["extant QICK control code (on-prem, à la Stanford/UChicago)", "a cloud system with an emulator (à la Pasqal)", "simulation only for now", "something else"] + choices: + [ + "extant QICK control code (on-prem, à la Stanford/UChicago)", + "a cloud system with an emulator (à la Pasqal)", + "simulation only for now", + "something else", + ] default: "simulation only for now" rationale_ref: "#environments" - id: devices @@ -90,13 +96,13 @@ Per-stage guidance and the `amicode_profile` mapping: is available in-flow. - **`local-sim`** — simulation only for now (nothing to patch into yet). - **`other`** — record exactly what they say. - Record: `amicode_profile {entity:"environment", payload:{slug, archetype, - control_stack, integration, emulator, endpoints}}` — where `slug` is a short - kebab name (e.g. `stanford-qick-lab`) and **`endpoints` holds pointers only, - NEVER tokens, keys, or passwords** (Amico refuses to store secrets). -4. **devices** *(optional)* — if they name a device, record + Record: `amicode_profile {entity:"environment", payload:{slug, archetype, +control_stack, integration, emulator, endpoints}}` — where `slug` is a short + kebab name (e.g. `stanford-qick-lab`) and **`endpoints` holds pointers only, + NEVER tokens, keys, or passwords** (Amico refuses to store secrets). +4. **devices** _(optional)_ — if they name a device, record `amicode_profile {entity:"device", payload:{name, platform, environment:, - qubits, params}}`. If they skip, move on — devices can be added any time. +qubits, params}}`. If they skip, move on — devices can be added any time. 5. **goals** — record `amicode_profile {entity:"profile", payload:{goals:"..."}}` in their own words. 6. **handoff** — this is the pivot. FIRST record the completion marker: diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index 038a63f2..b3c67291 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -159,12 +159,12 @@ Per-stage notes: (e.g. Piccolissimo **free-phase CZ path**); (3) no skill → **offer free-tier from-scratch authoring anyway** (public packages, **unvetted**, re-rollout- verified). When this FIRST entity records, mention once: "I'll track our progress - in the strip up top — click any part of it to inspect." Never repeat it. Then route, in order: (1) matching **platform skill** in the - `## Skill index` → skill-guided; (2) `issimo` + package skill → the private path - (e.g. Piccolissimo **free-phase CZ path**); (3) no skill → **offer free-tier - from-scratch authoring anyway** (public packages, **unvetted**, re-rollout- - verified). When this FIRST entity records, mention once: "I'll track our progress in the strip up top — click any part of it to inspect." Never repeat it. + + **Naming (user-facing):** `issimo` is an internal entitlement code — NEVER + write it in chat. When describing capabilities or paths to the user, name the + actual package (**Piccolissimo**, **Strettissimo**, **Intonatissimo**) or say + "private-package access"; bare `issimo` reads as a truncated "Piccolissimo". - transmon: $\hat H/\hbar = \omega\,\hat a^\dagger\hat a + \tfrac{\delta}{2}\,\hat a^{\dagger 2}\hat a^2 + u_1(t)\,(\hat a + \hat a^\dagger) + i\,u_2(t)\,(\hat a - \hat a^\dagger)$ - Rydberg 3-level ($|0\rangle$ dark, $|1\rangle\!\leftrightarrow\!|r\rangle$ driven, @@ -185,6 +185,7 @@ Per-stage notes: lists a matching skill (`atoms`, `transmon`, `fluxonium`, `ions`, `bosonic`), **invoke it by name** for the physics before authoring — do not hand-roll the Hamiltonian from memory when a skill carries it. + 2. **model** — convention: **`T` = scalar gate time (ns), `N` = number of timesteps** — never conflate them. Record via `amicode_set_model`. Levels are **platform-dependent** — do not default @@ -206,7 +207,7 @@ Per-stage notes: 4. **problem** — Two problem TYPES, **both first-class** — never force one into the other: - **Gate synthesis** (target = a unitary). Transmon single-qubit gates (X, Y, Z, - H, S, T, √X, arbitrary unitary) use the vetted template. Multi-qubit *transmon* + H, S, T, √X, arbitrary unitary) use the vetted template. Multi-qubit _transmon_ gates (CNOT, CZ, iSWAP) have no vetted template — **not declined**: free-tier offer (author from scratch, **unvetted**, re-rollout-verified), caveat up front. **Rydberg CZ is the exception** — the composed `rydberg-cz` exemplar (2-qubit, diff --git a/packages/extension/scripts/build_exemplars.mjs b/packages/extension/scripts/build_exemplars.mjs index b4b54c8f..270903d6 100644 --- a/packages/extension/scripts/build_exemplars.mjs +++ b/packages/extension/scripts/build_exemplars.mjs @@ -8,69 +8,88 @@ // baseline_hash — the SAME mask+sha the amico-run gate recomputes at launch // (deliberately reimplemented here to keep the build dep-free of amico-run; // test/exemplars_build.test.ts cross-checks the two via a shared fixture). -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { parse as parseToml } from 'smol-toml' +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseToml } from "smol-toml"; -const here = dirname(fileURLToPath(import.meta.url)) -const exemplarsDir = join(here, '..', 'exemplars') +const here = dirname(fileURLToPath(import.meta.url)); +const exemplarsDir = join(here, "..", "exemplars"); // Masked baseline: interior lines between the fill markers → "#MASKED"; marker // lines kept; unterminated block masks to EOF. MUST match amico-run/src/baseline.ts. -const DEFAULT_BEGIN = /^# ── FILL IN/ -const DEFAULT_END = /^# ─────/ +const DEFAULT_BEGIN = /^# ── FILL IN/; +const DEFAULT_END = /^# ─────/; function maskFillPoints(text, beginSrc, endSrc) { - const begin = beginSrc ? new RegExp(beginSrc) : DEFAULT_BEGIN - const end = endSrc ? new RegExp(endSrc) : DEFAULT_END - const out = [] - let inside = false - for (const line of text.split('\n')) { - if (!inside && begin.test(line)) { inside = true; out.push(line); continue } - if (inside && end.test(line)) { inside = false; out.push(line); continue } - out.push(inside ? '#MASKED' : line) + const begin = beginSrc ? new RegExp(beginSrc) : DEFAULT_BEGIN; + const end = endSrc ? new RegExp(endSrc) : DEFAULT_END; + const out = []; + let inside = false; + for (const line of text.split("\n")) { + if (!inside && begin.test(line)) { + inside = true; + out.push(line); + continue; + } + if (inside && end.test(line)) { + inside = false; + out.push(line); + continue; + } + out.push(inside ? "#MASKED" : line); } - return out.join('\n') + return out.join("\n"); } function maskedHash(text, beginSrc, endSrc) { - return 'sha256:' + createHash('sha256').update(maskFillPoints(text, beginSrc, endSrc)).digest('hex') + return ( + "sha256:" + + createHash("sha256") + .update(maskFillPoints(text, beginSrc, endSrc)) + .digest("hex") + ); } function readEntries(tomlFile) { - if (!existsSync(tomlFile)) return [] - const parsed = parseToml(readFileSync(tomlFile, 'utf8')) - return Array.isArray(parsed.exemplar) ? parsed.exemplar : [] + if (!existsSync(tomlFile)) return []; + const parsed = parseToml(readFileSync(tomlFile, "utf8")); + return Array.isArray(parsed.exemplar) ? parsed.exemplar : []; } -const exemplars = [] +const exemplars = []; // 1. in-repo entries — scripts already live under exemplars/, paths are as-authored -for (const entry of readEntries(join(exemplarsDir, 'EXEMPLARS.toml'))) { - const scriptPath = join(exemplarsDir, entry.path) - if (!existsSync(scriptPath)) { console.error(`build_exemplars: missing in-repo script ${entry.path}`); process.exit(1) } - const text = readFileSync(scriptPath, 'utf8') - exemplars.push({ ...entry, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }) +for (const entry of readEntries(join(exemplarsDir, "EXEMPLARS.toml"))) { + const scriptPath = join(exemplarsDir, entry.path); + if (!existsSync(scriptPath)) { + console.error(`build_exemplars: missing in-repo script ${entry.path}`); + process.exit(1); + } + const text = readFileSync(scriptPath, "utf8"); + exemplars.push({ ...entry, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }); } // 2. external demo-repo entries — copy the script in-tree, rewrite path -const demosRoot = process.env.AMICODE_DEMOS_ROOT +const demosRoot = process.env.AMICODE_DEMOS_ROOT; if (demosRoot && existsSync(demosRoot)) { for (const demo of readdirSync(demosRoot, { withFileTypes: true })) { - if (!demo.isDirectory()) continue - const tomlFile = join(demosRoot, demo.name, 'EXEMPLARS.toml') + if (!demo.isDirectory()) continue; + const tomlFile = join(demosRoot, demo.name, "EXEMPLARS.toml"); for (const entry of readEntries(tomlFile)) { - const srcScript = join(demosRoot, demo.name, entry.path) - if (!existsSync(srcScript)) { console.error(`build_exemplars: missing demo script ${demo.name}/${entry.path}`); continue } - const destRel = join(entry.id, 'script.jl') - const destAbs = join(exemplarsDir, destRel) - mkdirSync(dirname(destAbs), { recursive: true }) - copyFileSync(srcScript, destAbs) - const text = readFileSync(destAbs, 'utf8') - exemplars.push({ ...entry, path: destRel, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }) + const srcScript = join(demosRoot, demo.name, entry.path); + if (!existsSync(srcScript)) { + console.error(`build_exemplars: missing demo script ${demo.name}/${entry.path}`); + continue; + } + const destRel = join(entry.id, "script.jl"); + const destAbs = join(exemplarsDir, destRel); + mkdirSync(dirname(destAbs), { recursive: true }); + copyFileSync(srcScript, destAbs); + const text = readFileSync(destAbs, "utf8"); + exemplars.push({ ...entry, path: destRel, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }); } } } -writeFileSync(join(exemplarsDir, 'index.json'), JSON.stringify({ schema_version: 1, exemplars }, null, 2) + '\n') -console.log(`build_exemplars: wrote index.json (${exemplars.length} exemplar${exemplars.length === 1 ? '' : 's'})`) +writeFileSync(join(exemplarsDir, "index.json"), JSON.stringify({ schema_version: 1, exemplars }, null, 2) + "\n"); +console.log(`build_exemplars: wrote index.json (${exemplars.length} exemplar${exemplars.length === 1 ? "" : "s"})`); diff --git a/packages/extension/scripts/distill_batch.mjs b/packages/extension/scripts/distill_batch.mjs index 303ddf1d..4c871660 100644 --- a/packages/extension/scripts/distill_batch.mjs +++ b/packages/extension/scripts/distill_batch.mjs @@ -66,7 +66,8 @@ function distillerConfig() { agent: { distiller: { description: "Amico's background memory distiller (headless; no subagents)", - prompt: "You are Amico's distiller. Follow the distiller instructions exactly. Your input is one JSON job object. Work silently; never spawn subagents; finish with a one-line summary.", + prompt: + "You are Amico's distiller. Follow the distiller instructions exactly. Your input is one JSON job object. Work silently; never spawn subagents; finish with a one-line summary.", model: MODEL, }, }, @@ -115,7 +116,14 @@ function workspaceHygiene() { if (e.entity === "formulation" && e.diff?.target?.to) target = e.diff.target.to; } catch {} } - if (target && !ws.toLowerCase().includes(String(target).toLowerCase().replace(/[^a-z0-9]/g, ""))) + if ( + target && + !ws.toLowerCase().includes( + String(target) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""), + ) + ) flags.push(`${ws} → recorded target "${target}"`); } return flags; @@ -157,7 +165,9 @@ console.log(`model: ${MODEL}`); console.log(`runs w/ result.toml: ${runs.length} substantive sessions: ${sessions.length}`); console.log(`workspace hygiene flags (${hygiene.length}):`); for (const f of hygiene) console.log(` ⚠ ${f}`); -console.log(`opencode server alive: ${serverAlive} → DB archive step ${serverAlive ? "SKIPPED (deferred to a no-server window)" : "eligible"}`); +console.log( + `opencode server alive: ${serverAlive} → DB archive step ${serverAlive ? "SKIPPED (deferred to a no-server window)" : "eligible"}`, +); if (has("--dry-run")) { console.log("\n[dry-run] no distills spawned."); @@ -169,7 +179,8 @@ let ok = 0, if (has("--runs-only") || has("--all")) { const sel = runs.slice(0, limit === Infinity ? runs.length : limit); console.log(`\n[runs] distilling ${sel.length} run(s):`); - for (const r of sel) (distill({ kind: "run", run_id: r, vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, r) ? ok++ : fail++); + for (const r of sel) + distill({ kind: "run", run_id: r, vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, r) ? ok++ : fail++; } if (has("--demos-ingest")) { const DEMOS = path.join(HOME, "harmoniqs", "demos"); @@ -179,12 +190,17 @@ if (has("--demos-ingest")) { const sel = dirs.slice(0, limit === Infinity ? dirs.length : limit); console.log(`\n[demos] ingesting ${sel.length} demo(s) from ${DEMOS}:`); for (const d of sel) - (distill({ kind: "demo", demo_dir: path.join(DEMOS, d), vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, d) ? ok++ : fail++); + distill({ kind: "demo", demo_dir: path.join(DEMOS, d), vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, d) + ? ok++ + : fail++; } if (has("--sweeps") || has("--all")) { const sel = sessions.slice(0, limit === Infinity ? sessions.length : limit); console.log(`\n[sweeps] distilling ${sel.length} session(s):`); - for (const s of sel) (distill({ kind: "sweep", session_ids: [s], vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, s.slice(0, 20)) ? ok++ : fail++); + for (const s of sel) + distill({ kind: "sweep", session_ids: [s], vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, s.slice(0, 20)) + ? ok++ + : fail++; } // Summary report (spec §5 step 5) — stdout + a vault notes/ file. @@ -193,7 +209,8 @@ const report = [ `# Batch retro-ingest report — ${stamp}`, ``, `- runs distilled ok: ${ok}, failed: ${fail}`, - `- substantive sessions seen: ${sessions.length}` + (has("--sweeps") || has("--all") ? "" : " (sweeps NOT run this pass)"), + `- substantive sessions seen: ${sessions.length}` + + (has("--sweeps") || has("--all") ? "" : " (sweeps NOT run this pass)"), `- workspace hygiene flags: ${hygiene.length}`, ...hygiene.map((f) => ` - ⚠ ${f}`), `- DB archive of empty sessions: ${serverAlive ? "DEFERRED (server alive) — run with server stopped to sweep empties + agent='distiller' rows" : "eligible"}`, diff --git a/packages/extension/scripts/fetch_opencode.mjs b/packages/extension/scripts/fetch_opencode.mjs index 811ab9d9..c8ac46c1 100644 --- a/packages/extension/scripts/fetch_opencode.mjs +++ b/packages/extension/scripts/fetch_opencode.mjs @@ -1,35 +1,42 @@ #!/usr/bin/env node // Download-at-build vendoring of the opencode chat-server binary, pinned by // opencode.lock.json (spec §2/§3). Importable module + CLI in one file. -import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { - chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, writeFileSync, -} from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; -const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); export function loadManifest(root = PKG_ROOT) { - const m = JSON.parse(readFileSync(join(root, 'opencode.lock.json'), 'utf8')) - if (typeof m.version !== 'string' || m.version === '') throw new Error('manifest: version must be a non-empty string') - const platforms = m.platforms ?? {} - if (Object.keys(platforms).length === 0) throw new Error('manifest: platforms missing') + const m = JSON.parse(readFileSync(join(root, "opencode.lock.json"), "utf8")); + if (typeof m.version !== "string" || m.version === "") + throw new Error("manifest: version must be a non-empty string"); + const platforms = m.platforms ?? {}; + if (Object.keys(platforms).length === 0) throw new Error("manifest: platforms missing"); for (const [key, p] of Object.entries(platforms)) { - if (typeof p.asset !== 'string' || p.asset === '') throw new Error(`manifest: ${key}.asset missing`) - if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? '')) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`) + if (typeof p.asset !== "string" || p.asset === "") throw new Error(`manifest: ${key}.asset missing`); + if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? "")) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`); } - return m + return m; } export function resolvePlatform(manifest, flag) { - const key = flag ?? `${process.platform}-${process.arch}` + const key = flag ?? `${process.platform}-${process.arch}`; if (!(key in manifest.platforms)) { - throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(', ')})`) + throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(", ")})`); } - return key + return key; } /** Release coordinates: default = upstream sst/opencode at v; a manifest @@ -37,98 +44,114 @@ export function resolvePlatform(manifest, flag) { * private — downloads go through the authenticated `gh` path in that case). */ export function releaseCoords(manifest) { return { - repo: manifest.repo ?? 'sst/opencode', + repo: manifest.repo ?? "sst/opencode", tag: manifest.tag ?? `v${manifest.version}`, - private: manifest.repo != null, // our mirror is private; upstream is not - } + private: manifest.repo != null, // our mirror is private; upstream is not + }; } export function assetUrl(manifest, platform) { - const { repo, tag } = releaseCoords(manifest) - return `https://github.com/${repo}/releases/download/${tag}/${manifest.platforms[platform].asset}` + const { repo, tag } = releaseCoords(manifest); + return `https://github.com/${repo}/releases/download/${tag}/${manifest.platforms[platform].asset}`; } -export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex') +export const sha256 = (buf) => createHash("sha256").update(buf).digest("hex"); async function defaultDownload(url) { - let r - try { r = await fetch(url) } catch (e) { - throw new Error(`download failed: ${e.message} for ${url}`) // spec §6: URL on connection failures too + let r; + try { + r = await fetch(url); + } catch (e) { + throw new Error(`download failed: ${e.message} for ${url}`); // spec §6: URL on connection failures too } - if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`) - return Buffer.from(await r.arrayBuffer()) + if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`); + return Buffer.from(await r.arrayBuffer()); } /** Private-release download via the gh CLI (the team's auth path for our * private repos). Plain fetch 404s on private assets — gh handles the token. */ function ghDownload(repo, tag, asset) { - const work = mkdtempSync(join(PKG_ROOT, '.ghdl-')) + const work = mkdtempSync(join(PKG_ROOT, ".ghdl-")); try { - execFileSync('gh', ['release', 'download', tag, '--repo', repo, '--pattern', asset, '--dir', work], - { stdio: ['ignore', 'ignore', 'inherit'] }) - return readFileSync(join(work, asset)) + execFileSync("gh", ["release", "download", tag, "--repo", repo, "--pattern", asset, "--dir", work], { + stdio: ["ignore", "ignore", "inherit"], + }); + return readFileSync(join(work, asset)); } catch (e) { - throw new Error(`gh release download failed for ${repo}@${tag} ${asset}: ${e.message} — is \`gh\` installed and authed for ${repo}?`) + throw new Error( + `gh release download failed for ${repo}@${tag} ${asset}: ${e.message} — is \`gh\` installed and authed for ${repo}?`, + ); } finally { - rmSync(work, { recursive: true, force: true }) + rmSync(work, { recursive: true, force: true }); } } export async function fetchOpencode({ root = PKG_ROOT, platform, download = defaultDownload } = {}) { - const manifest = loadManifest(root) - const key = resolvePlatform(manifest, platform) - const { asset, sha256: want } = manifest.platforms[key] - const destDir = join(root, 'vendor', 'opencode', key) - const bin = join(destDir, 'opencode') - const stamp = join(destDir, '.sha256') + const manifest = loadManifest(root); + const key = resolvePlatform(manifest, platform); + const { asset, sha256: want } = manifest.platforms[key]; + const destDir = join(root, "vendor", "opencode", key); + const bin = join(destDir, "opencode"); + const stamp = join(destDir, ".sha256"); - if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, 'utf8').trim() === want) { - return { skipped: true, path: bin } // offline repeat builds + if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, "utf8").trim() === want) { + return { skipped: true, path: bin }; // offline repeat builds } - const coords = releaseCoords(manifest) - const bytes = coords.private && download === defaultDownload - ? ghDownload(coords.repo, coords.tag, asset) - : await download(assetUrl(manifest, key)) - const got = sha256(bytes) + const coords = releaseCoords(manifest); + const bytes = + coords.private && download === defaultDownload + ? ghDownload(coords.repo, coords.tag, asset) + : await download(assetUrl(manifest, key)); + const got = sha256(bytes); if (got !== want) { // Possible supply-chain signal: no retry, no override (spec §3 step 4). - throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`) + throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`); } - mkdirSync(destDir, { recursive: true }) - const work = mkdtempSync(join(destDir, '.unpack-')) // same fs → rename is atomic + mkdirSync(destDir, { recursive: true }); + const work = mkdtempSync(join(destDir, ".unpack-")); // same fs → rename is atomic try { - const archive = join(work, asset) - writeFileSync(archive, bytes) - if (asset.endsWith('.zip')) execFileSync('unzip', ['-oq', archive, '-d', work]) - else execFileSync('tar', ['-xzf', archive, '-C', work]) - if (!existsSync(join(work, 'opencode'))) throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`) - renameSync(join(work, 'opencode'), bin) - chmodSync(bin, 0o755) - writeFileSync(stamp, got + '\n') // stamp last (spec §3 step 5) + const archive = join(work, asset); + writeFileSync(archive, bytes); + if (asset.endsWith(".zip")) execFileSync("unzip", ["-oq", archive, "-d", work]); + else execFileSync("tar", ["-xzf", archive, "-C", work]); + if (!existsSync(join(work, "opencode"))) + throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`); + renameSync(join(work, "opencode"), bin); + chmodSync(bin, 0o755); + writeFileSync(stamp, got + "\n"); // stamp last (spec §3 step 5) } finally { - rmSync(work, { recursive: true, force: true }) + rmSync(work, { recursive: true, force: true }); } - return { skipped: false, path: bin } + return { skipped: false, path: bin }; } async function main(argv) { - const flagIdx = argv.indexOf('--platform') - const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined - if (argv.includes('--record')) { // pin-time only (spec §3 step 6) - const manifest = loadManifest() + const flagIdx = argv.indexOf("--platform"); + const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined; + if (argv.includes("--record")) { + // pin-time only (spec §3 step 6) + const manifest = loadManifest(); for (const key of Object.keys(manifest.platforms)) { - const bytes = await defaultDownload(assetUrl(manifest, key)) - console.log(`${key} ${sha256(bytes)}`) + const bytes = await defaultDownload(assetUrl(manifest, key)); + console.log(`${key} ${sha256(bytes)}`); } - return 0 + return 0; } - const r = await fetchOpencode({ platform }) - console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`) - return 0 + const r = await fetchOpencode({ platform }); + console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`); + return 0; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main(process.argv.slice(2)).then(c => { process.exitCode = c }, e => { console.error(`[fetch-opencode] ${e.message}`); process.exitCode = 1 }) + main(process.argv.slice(2)).then( + (c) => { + process.exitCode = c; + }, + (e) => { + console.error(`[fetch-opencode] ${e.message}`); + process.exitCode = 1; + }, + ); } diff --git a/packages/extension/scripts/healthcheck.mjs b/packages/extension/scripts/healthcheck.mjs index 40973e60..dc889395 100644 --- a/packages/extension/scripts/healthcheck.mjs +++ b/packages/extension/scripts/healthcheck.mjs @@ -1,46 +1,66 @@ #!/usr/bin/env node // Amicode healthcheck — exit 0 iff julia+project, opencode /event, amico-run, // and LLM creds all resolve; else non-zero with a precise ✗ line per failure. -import { execFileSync } from 'node:child_process' -import { existsSync, realpathSync } from 'node:fs' -import { homedir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { bootOpencodeAndProbe } from './opencode_probe.mjs' +import { execFileSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { bootOpencodeAndProbe } from "./opencode_probe.mjs"; -const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const JULIA_PROJECT = join(homedir(), '.amico', 'julia') // absolute — '~' is NOT expanded in flags -const CHECK_ORDER = ['julia', 'opencode', 'amicorun', 'creds'] -const LABEL = { julia: 'julia+project', opencode: 'opencode /event', amicorun: 'amico-run', creds: 'LLM creds' } +const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const JULIA_PROJECT = join(homedir(), ".amico", "julia"); // absolute — '~' is NOT expanded in flags +const CHECK_ORDER = ["julia", "opencode", "amicorun", "creds"]; +const LABEL = { julia: "julia+project", opencode: "opencode /event", amicorun: "amico-run", creds: "LLM creds" }; /** PURE: results = { [name]: {ok} | {ok:false,reason,fix} } → { exitCode, lines }. Unit-tested. */ export function resolveChecks(results) { - const lines = [] - let failed = 0 + const lines = []; + let failed = 0; for (const name of CHECK_ORDER) { - const r = results[name] ?? { ok: false, reason: 'not run', fix: 'internal' } - if (r.ok) lines.push(`✓ ${LABEL[name]}`) - else { failed++; lines.push(`✗ ${LABEL[name]}: ${r.reason} → ${r.fix}`) } + const r = results[name] ?? { ok: false, reason: "not run", fix: "internal" }; + if (r.ok) lines.push(`✓ ${LABEL[name]}`); + else { + failed++; + lines.push(`✗ ${LABEL[name]}: ${r.reason} → ${r.fix}`); + } } - lines.push(failed === 0 ? `\nAll ${CHECK_ORDER.length} checks passed.` : `\n${failed} check(s) failed.`) - return { exitCode: failed === 0 ? 0 : 1, lines } + lines.push(failed === 0 ? `\nAll ${CHECK_ORDER.length} checks passed.` : `\n${failed} check(s) failed.`); + return { exitCode: failed === 0 ? 0 : 1, lines }; } // ---- probe implementations (impure; run only when executed as CLI) ---- function probeJulia() { - if (!existsSync(JULIA_PROJECT)) return { ok: false, reason: `no julia project at ${JULIA_PROJECT}`, fix: 'run scripts/install.sh' } - try { execFileSync('julia', [`--project=${JULIA_PROJECT}`, '-e', 'using Piccolo'], { stdio: 'ignore', timeout: 300_000 }); return { ok: true } } - catch (e) { return { ok: false, reason: `julia/Piccolo load failed (${(e.message || '').slice(0, 80)})`, fix: 'run scripts/install.sh to instantiate' } } + if (!existsSync(JULIA_PROJECT)) + return { ok: false, reason: `no julia project at ${JULIA_PROJECT}`, fix: "run scripts/install.sh" }; + try { + execFileSync("julia", [`--project=${JULIA_PROJECT}`, "-e", "using Piccolo"], { stdio: "ignore", timeout: 300_000 }); + return { ok: true }; + } catch (e) { + return { + ok: false, + reason: `julia/Piccolo load failed (${(e.message || "").slice(0, 80)})`, + fix: "run scripts/install.sh to instantiate", + }; + } } function probeAmicorun() { - for (const dir of [join(EXT_ROOT, 'bin', 'launcher'), join(EXT_ROOT, '..', 'amico-run', 'launcher')]) { - const p = join(dir, 'amico-run') + for (const dir of [join(EXT_ROOT, "bin", "launcher"), join(EXT_ROOT, "..", "amico-run", "launcher")]) { + const p = join(dir, "amico-run"); if (existsSync(p)) { - try { execFileSync(p, ['--help'], { stdio: 'ignore', timeout: 15_000 }); return { ok: true } } - catch (e) { return { ok: false, reason: `amico-run --help failed (${(e.message || '').slice(0, 60)})`, fix: 'rebuild amico-run / check node on PATH' } } + try { + execFileSync(p, ["--help"], { stdio: "ignore", timeout: 15_000 }); + return { ok: true }; + } catch (e) { + return { + ok: false, + reason: `amico-run --help failed (${(e.message || "").slice(0, 60)})`, + fix: "rebuild amico-run / check node on PATH", + }; + } } } - return { ok: false, reason: 'amico-run launcher not found', fix: 'pnpm -r build (stages bin/) or check the VSIX' } + return { ok: false, reason: "amico-run launcher not found", fix: "pnpm -r build (stages bin/) or check the VSIX" }; } // opencode + LLM creds (0.3): ONE boot of the vendored opencode answers both — @@ -50,30 +70,45 @@ function probeAmicorun() { // LLM call. boot.signal is key-free (stripped at the probe boundary). function opencodeChecks(boot) { if (boot.binMissing) { - const miss = { ok: false, reason: 'vendored opencode binary missing', fix: 'pnpm --filter amicode-v2 fetch:opencode' } - return { opencode: miss, creds: { ok: false, reason: 'opencode unavailable (binary missing)', fix: miss.fix } } + const miss = { + ok: false, + reason: "vendored opencode binary missing", + fix: "pnpm --filter amicode-v2 fetch:opencode", + }; + return { opencode: miss, creds: { ok: false, reason: "opencode unavailable (binary missing)", fix: miss.fix } }; } const opencode = boot.eventOk ? { ok: true } - : { ok: false, reason: `vendored opencode did not serve /event 200 (${boot.up ? `status ${boot.eventStatus}` : 'server not up'})`, fix: 'pnpm --filter amicode-v2 fetch:opencode' } - const creds = boot.signal ?? { ok: false, reason: 'opencode did not boot — creds unverifiable', fix: 'fix opencode boot first' } - return { opencode, creds } + : { + ok: false, + reason: `vendored opencode did not serve /event 200 (${boot.up ? `status ${boot.eventStatus}` : "server not up"})`, + fix: "pnpm --filter amicode-v2 fetch:opencode", + }; + const creds = boot.signal ?? { + ok: false, + reason: "opencode did not boot — creds unverifiable", + fix: "fix opencode boot first", + }; + return { opencode, creds }; } async function main() { - const boot = await bootOpencodeAndProbe({ timeoutMs: 90_000 }) - const { opencode, creds } = opencodeChecks(boot) - const results = { julia: probeJulia(), opencode, amicorun: probeAmicorun(), creds } - const { exitCode, lines } = resolveChecks(results) - console.log(lines.join('\n')) - process.exitCode = exitCode + const boot = await bootOpencodeAndProbe({ timeoutMs: 90_000 }); + const { opencode, creds } = opencodeChecks(boot); + const results = { julia: probeJulia(), opencode, amicorun: probeAmicorun(), creds }; + const { exitCode, lines } = resolveChecks(results); + console.log(lines.join("\n")); + process.exitCode = exitCode; } // realpath-compare so a symlinked invocation path (e.g. macOS /tmp→/private/tmp) // can't make this silently no-op and exit 0 — a false "healthcheck passed". function isMain() { - if (!process.argv[1]) return false - try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } - catch { return false } + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } } -if (isMain()) await main() +if (isMain()) await main(); diff --git a/packages/extension/scripts/opencode_probe.mjs b/packages/extension/scripts/opencode_probe.mjs index a232b4b8..459379c4 100644 --- a/packages/extension/scripts/opencode_probe.mjs +++ b/packages/extension/scripts/opencode_probe.mjs @@ -40,7 +40,8 @@ function freePort() { * remaining fields explain why. */ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeoutMs = 30000 } = {}) { - if (!existsSync(bin)) return { binMissing: true, up: false, eventOk: false, log: `vendored binary missing at ${bin}` }; + if (!existsSync(bin)) + return { binMissing: true, up: false, eventOk: false, log: `vendored binary missing at ${bin}` }; const proj = mkdtempSync(join(tmpdir(), "amicode-probe-")); mkdirSync(join(proj, ".opencode"), { recursive: true }); @@ -53,15 +54,23 @@ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeou const port = await freePort(); let log = ""; const child = spawn(bin, ["serve", "--port", String(port)], { cwd: proj, stdio: ["ignore", "pipe", "pipe"] }); - const onData = (d) => { log += d; }; + const onData = (d) => { + log += d; + }; child.stdout.on("data", onData); child.stderr.on("data", onData); const cleanup = () => { - try { child.kill("SIGTERM"); } catch {} + try { + child.kill("SIGTERM"); + } catch {} // .unref() the SIGKILL fallback so it can't hold `node healthcheck.mjs` open // for 3s after it's otherwise done (the child usually exits on SIGTERM). - setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 3000).unref(); + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, 3000).unref(); }; try { @@ -71,13 +80,17 @@ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeou try { const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }); if (r.status < 500) up = true; - } catch { /* not up yet */ } + } catch { + /* not up yet */ + } if (!up) await new Promise((r) => setTimeout(r, 200)); } if (!up) return { up: false, eventOk: false, log }; // /event gate (headers only; SSE body streaming doesn't block us). - let eventStatus, eventCtype, eventOk = false; + let eventStatus, + eventCtype, + eventOk = false; try { const ev = await fetch(`http://127.0.0.1:${port}/event`, { signal: AbortSignal.timeout(10000) }); eventStatus = ev.status; diff --git a/packages/extension/scripts/plugin_exercise.ts b/packages/extension/scripts/plugin_exercise.ts index 6a3393d1..6b396529 100644 --- a/packages/extension/scripts/plugin_exercise.ts +++ b/packages/extension/scripts/plugin_exercise.ts @@ -34,17 +34,26 @@ const pack: any = await AmicodeTools({}); const tools = pack.tool; // create → pick_system → set_model → formulate → solve -const s0 = lastSentinel(await tools.amicode_problem.execute({ action: "create", name: "X gate on Q1", new_name: null })); +const s0 = lastSentinel( + await tools.amicode_problem.execute({ action: "create", name: "X gate on Q1", new_name: null }), +); assert(s0.entity === "problem" && s0.action === "created", "problem/created sentinel"); const slug: string = s0.problem; lastSentinel(await tools.amicode_pick_system.execute({ platform: "transmon", omega: 4.8, delta: -0.2, notes: null })); lastSentinel(await tools.amicode_set_model.execute({ levels: 4, drive_max: 0.2, params: null })); -lastSentinel(await tools.amicode_formulate.execute({ problem: "gate_synthesis", target: "X", objective: null, constraints: null })); +lastSentinel( + await tools.amicode_formulate.execute({ problem: "gate_synthesis", target: "X", objective: null, constraints: null }), +); const s4 = lastSentinel( await tools.amicode_solve.execute({ run_dir: "/home/u/.amico/runs/default/20260703-190412-abcd", - T: 10, N: 50, max_iter: 60, integrator: "MagnusGL4", tier: "vetted", note: "X gate", + T: 10, + N: 50, + max_iter: 60, + integrator: "MagnusGL4", + tier: "vetted", + note: "X gate", }), ); assert(s4.entity === "run", "solve emits a run sentinel"); @@ -57,22 +66,38 @@ assert(s5.entity === "run" && s5.action === "updated", "verify updates the run e // Workspace layout const ws = path.join(tmp, slug); -for (const f of ["entities/system.toml", "entities/system.json", "entities/formulation.toml", "entities/run.toml", "problem.json"]) { +for (const f of [ + "entities/system.toml", + "entities/system.json", + "entities/formulation.toml", + "entities/run.toml", + "problem.json", +]) { assert(fs.existsSync(path.join(ws, f)), `workspace file ${f}`); } // Event log: >=5 events, monotonic seq, incl. the solve-params Formulation merge -const events = fs.readFileSync(path.join(ws, "events.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); +const events = fs + .readFileSync(path.join(ws, "events.jsonl"), "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); assert(events.length >= 5, `>=5 events (got ${events.length})`); events.forEach((e: any, i: number) => assert(e.seq === i + 1, `monotonic seq at index ${i} (got ${e.seq})`)); const formEvents = events.filter((e: any) => e.entity === "formulation"); assert(formEvents.length >= 2, `formulation created + solve-merge update (got ${formEvents.length})`); const sysEvents = events.filter((e: any) => e.entity === "system"); -assert(sysEvents.some((e: any) => e.hash?.startsWith("sha256:")), "system events carry a content hash"); +assert( + sysEvents.some((e: any) => e.hash?.startsWith("sha256:")), + "system events carry a content hash", +); // Run ref parsed from run_dir's last two segments const runs = JSON.parse(fs.readFileSync(path.join(ws, "runs.json"), "utf8")); -assert(runs.runs.length === 1 && runs.runs[0].run_id === "20260703-190412-abcd" && runs.runs[0].lab === "default", "runs.json ref"); +assert( + runs.runs.length === 1 && runs.runs[0].run_id === "20260703-190412-abcd" && runs.runs[0].lab === "default", + "runs.json ref", +); assert(runs.runs[0].tier === "vetted", "run ref carries tier"); console.error(`OK — ${events.length} events, ${formEvents.length} formulation events, workspace "${slug}"`); diff --git a/packages/extension/src/catalog_card_shell.ts b/packages/extension/src/catalog_card_shell.ts new file mode 100644 index 00000000..aa6f36e7 --- /dev/null +++ b/packages/extension/src/catalog_card_shell.ts @@ -0,0 +1,145 @@ +// Catalog-card shell (#47, v1) — hosts the catalogcard component in a webview. +// +// The card appears only through the save-to-catalog flow: a converged run's +// promote prompt (live solves via the watcher, demo replays via the replay +// command) → "Save to catalog" → `amicode.catalogCard.open` with the run dir. +// The entry is hydrated from the REAL run artifacts: run.toml (identity), +// result.toml (fidelity, params — params.gate/params.system lifted to the +// entry's top level; iterations/wall → the proposed block), and the run.log +// pulse lines (meta + newest record → the card's plot). Not palette- +// contributed — there is no card without a run to save. +// +// Persistence note: opening a card stores nothing durable; the session +// catalog (trees.ts) records POINTERS only. Where promoted artifacts persist +// is the open Phase-3 CatalogStore design (Q91/Q92). + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { PulseStream, readTomlSafe, type PulseEvent } from "./run_dir_reader"; + +export function registerCatalogCard(ctx: vscode.ExtensionContext): void { + const open = new Map(); // run_id → live panel + ctx.subscriptions.push( + vscode.commands.registerCommand( + "amicode.catalogCard.open", + (runDir: string, systemName?: string, tags?: string[]) => { + const data = hydrateFromRunDir(runDir, systemName, tags); + if (!data) { + void vscode.window.showErrorMessage( + "Amicode: cannot build a catalog entry — run dir is missing run.toml/result.toml.", + ); + return; + } + const key = String(data.entry.run_id); + const existing = open.get(key); + if (existing) { + existing.reveal(vscode.ViewColumn.One); + return; + } // re-focus, don't re-create + const panel = vscode.window.createWebviewPanel( + "amicode.catalogCard", + `Catalog: ${data.entry.run_id}`, + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(ctx.extensionUri, "dist"), + vscode.Uri.joinPath(ctx.extensionUri, "media"), + ], + }, + ); + open.set(key, panel); + panel.onDidDispose(() => open.delete(key), null, ctx.subscriptions); + panel.webview.onDidReceiveMessage((m) => { + if (m?.type !== "whatnext") return; + // Wire the save → tune → warm-start ladder to the CHAT (the agent owns the + // solve workflow): stage a concrete prompt on the clipboard and open the + // chat. Promote (team catalog) stays honestly unwired until Phase 3. + const e = data.entry; + const ident = `${e.gate ?? "gate"} on ${e.system ?? String(e.lab_id)} (run ${e.run_id}, F=${Number(e.fidelity).toFixed(5)})`; + if (m.id === "warmstart" || m.id === "tune") { + const prompt = + m.id === "warmstart" + ? `Warm-start a new solve from the banked pulse of ${ident}: load ${runDir}/pulse.jld2 as the initial trajectory (load_traj), keep the same formulation, and run it.` + : `Tune the solve for ${ident}: start from ${runDir}/pulse.jld2, keep the formulation but ask me which weights/params (Q, R, T, N, max_iter) to adjust before launching.`; + void vscode.env.clipboard.writeText(prompt).then(async () => { + await vscode.commands.executeCommand("amicode.openChat"); + void vscode.window.showInformationMessage( + `Amicode: ${m.id} prompt copied — paste into the chat to launch.`, + ); + }); + } else if (m.id === "promote") { + void vscode.window.showInformationMessage( + "Amicode: team-catalog promotion isn't wired yet (Phase 3) — the pulse stays in your local bank.", + ); + } + }); + const uri = (...p: string[]) => panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); + const nonce = Math.random().toString(36).slice(2); + panel.webview.html = ` + + + + + + + +`; + }, + ), + ); +} + +/** Build the card's data from real run artifacts. Returns undefined when the + * dir lacks the promote-shaped basics. Shape mirrors the webview's CARD_DATA. + * Exported for tests. */ +export function hydrateFromRunDir( + runDir: string, + systemName?: string, + tags?: string[], +): { entry: Record; pulse?: { meta: unknown; record: unknown } } | undefined { + const manifest = readTomlSafe(path.join(runDir, "run.toml")); + const result = readTomlSafe(path.join(runDir, "result.toml")); + if (!manifest || !result) return undefined; + + const params = (result.params ?? {}) as Record; + const entry: Record = { + schema_version: "1", + run_id: String(manifest.run_id ?? path.basename(runDir)), + lab_id: String(manifest.lab_id ?? "default"), + gate: typeof params.gate === "string" ? params.gate : undefined, + fidelity: Number(result.fidelity ?? 0), + pulse_path: path.join(runDir, "pulse.jld2"), + created_at: manifest.created_at, + params, + // Not in catalog-entry.schema.json — rendered visibly marked. The + // user-assigned system name is the sharpest schema question here: human + // identity ("Emerald-Q3") vs machine params (family/levels/δ). + proposed: { + system_name: systemName, + tags, + iterations: result.iterations, + wall_seconds: result.wall_seconds, + }, + }; + + // Pulse plot from the run's own AMICODE_PULSE lines: meta + newest record. + let pulse: { meta: unknown; record: unknown } | undefined; + try { + const stream = new PulseStream(); + let meta: PulseEvent | undefined, newest: PulseEvent | undefined; + for (const line of fs.readFileSync(path.join(runDir, "run.log"), "utf8").split("\n")) { + const e = stream.onLine(line); + if (e?.type === "meta") { + meta = e; + newest = undefined; + } else if (e?.type === "record") newest = e; + } + if (meta?.type === "meta" && newest?.type === "record") pulse = { meta: meta.meta, record: newest.record }; + } catch { + /* no run.log → card renders the not-hydrated state */ + } + + return { entry, pulse }; +} diff --git a/packages/extension/src/catalog_card_webview.ts b/packages/extension/src/catalog_card_webview.ts new file mode 100644 index 00000000..ab937b8b --- /dev/null +++ b/packages/extension/src/catalog_card_webview.ts @@ -0,0 +1,72 @@ +// Catalog-card webview entry (#47) — mounts the card from host-injected data +// (window.__CARD_DATA__, hydrated from the real run dir by the save-to-catalog +// flow); the baked fixture below is the fallback for hostless debugging. + +import { applyBrandAccent } from "../media/ui/brand_accent"; +import { catalogcard, type CatalogEntry, type CardPulse } from "../media/ui/components/catalogcard"; + +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + +declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; +declare global { + interface Window { + __CARD_DATA__?: { entry: CatalogEntry; pulse?: CardPulse }; + } +} + +// Grounded in packages/schema/test/fixtures/valid/catalog-entry.toml; the +// `proposed` block is NOT schema — it renders visibly marked (field-selection +// feedback artifact for Krishna/Andrew). +const ENTRY: CatalogEntry = { + schema_version: "1", + run_id: "r20260615-000000Z-ab12", + lab_id: "default", + gate: "X", + fidelity: 0.99995, + pulse_path: "/Users/researcher/.amico/runs/default/r20260615-000000Z-ab12/pulse.jld2", + created_at: "2026-06-15T00:00:00Z", + params: { system: "transmon", levels: 3, T: 10.0, N: 50, drive_max: 0.2 }, + proposed: { tags: ["smooth"], index: 1, iterations: 60, wall_seconds: 41 }, +}; + +const PULSE: CardPulse = { + meta: { + drives: 2, + knots: 25, + labels: ["u_1", "u_2"], + bounds: [ + [-0.2, 0.2], + [-0.2, 0.2], + ], + }, + record: { + iter: 60, + dt: 0.4, + values: [ + [ + 0.012, 0.048, 0.096, 0.141, 0.172, 0.184, 0.176, 0.149, 0.108, 0.058, 0.006, -0.043, -0.084, -0.113, -0.128, + -0.127, -0.111, -0.083, -0.047, -0.008, 0.028, 0.055, 0.068, 0.062, 0.033, + ], + [ + -0.021, -0.052, -0.079, -0.096, -0.1, -0.089, -0.065, -0.031, 0.009, 0.049, 0.084, 0.109, 0.121, 0.118, 0.1, + 0.07, 0.032, -0.009, -0.048, -0.079, -0.098, -0.102, -0.089, -0.061, -0.024, + ], + ], + }, +}; + +const SIBLINGS = [ + { gate: "X", system: "transmon", tags: ["fast"], index: 2 }, + { gate: "X", system: "transmon", tags: ["robust"], index: 3 }, + { gate: "H", system: "transmon", tags: ["smooth"], index: 7 }, +]; + +const vscodeApi = acquireVsCodeApi(); +const injected = window.__CARD_DATA__; +const card = catalogcard(injected?.entry ?? ENTRY, { + pulse: injected ? injected.pulse : PULSE, + siblings: injected ? [] : SIBLINGS, // sibling entries need a store — none yet on the real path + onAction: (id) => vscodeApi.postMessage({ type: "whatnext", id }), +}); +document.body.style.padding = "16px"; +document.body.append(card.el); diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index f82c40a5..7f043f07 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; import { randomBytes } from "node:crypto"; +import * as path from "node:path"; +import * as os from "node:os"; // ============================================================================ // ChatPanel — a single WebviewPanel that iframes opencode's SolidJS chat at @@ -18,15 +20,39 @@ const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ "amicode.savePulse", "amicode.openRunDir", "amicode.openInspector", + // ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's — + // the fork forwards it here so the editor's Command Palette (where every + // Amicode: command lives) opens as users expect. + "workbench.action.showCommands", ]); +/** VS Code theme kind → the fork app's ColorScheme. */ +function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" { + return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? "light" : "dark"; +} + export class ChatPanel { private static current?: ChatPanel; private readonly disposables: vscode.Disposable[] = []; - private constructor(private readonly panel: vscode.WebviewPanel, opencodeUrl: URL) { + private constructor( + private readonly panel: vscode.WebviewPanel, + opencodeUrl: URL, + ) { this.panel.webview.html = this.renderHtml(opencodeUrl); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + // Live theme bridge: editor theme changes flow extension → outer relay → + // iframe → the app's setColorScheme (boot theme rides ?colorScheme=). + vscode.window.onDidChangeActiveColorTheme( + (t) => + void this.panel.webview.postMessage({ + source: "amicode", + kind: "theme", + colorScheme: themeKindToScheme(t.kind), + }), + null, + this.disposables, + ); this.panel.webview.onDidReceiveMessage( (msg) => { // iframe → extension command bridge: the opencode "Amico" palette group @@ -34,6 +60,70 @@ export class ChatPanel { // the outer webview relay (renderHtml) forwards it here. We honor ONLY // allowlisted amicode.* commands so the framed app can't run arbitrary // vscode commands. + if ( + msg && + typeof msg === "object" && + (msg as { source?: unknown }).source === "amicode" && + (msg as { kind?: unknown }).kind === "open-external" && + typeof (msg as { url?: unknown }).url === "string" && + /^https:\/\//i.test((msg as { url: string }).url) // scheme is case-insensitive (RFC 3986) + ) { + // target=_blank/window.open are dead inside the framed app — open + // https links via the editor (system browser). https-only. + void vscode.env.openExternal(vscode.Uri.parse((msg as { url: string }).url)); + return; + } + if ( + msg && + typeof msg === "object" && + (msg as { source?: unknown }).source === "amicode" && + (msg as { kind?: unknown }).kind === "clipboard-request" + ) { + // Paste bridge: navigator.clipboard is unavailable to the framed app + // (the webview parent has no clipboard-read to delegate), so the app + // asks US — the extension host reads the OS clipboard and replies. + // Visibility gate: the app renders LLM-driven content, so a hidden + // panel must not be able to sample the clipboard in the background — + // reads only answer while the user can see the chat. + if (!this.panel.visible) return; + void vscode.env.clipboard.readText().then((text) => + this.panel.webview.postMessage({ + source: "amicode", + kind: "clipboard", + nonce: (msg as { nonce?: string }).nonce, + text, + }), + ); + return; + } + if ( + msg && + typeof msg === "object" && + (msg as { source?: unknown }).source === "amicode" && + (msg as { kind?: unknown }).kind === "save-file" && + typeof (msg as { filename?: unknown }).filename === "string" && + typeof (msg as { dataUrl?: unknown }).dataUrl === "string" + ) { + // Save bridge (run-card PNG export): downloads are dead inside the + // framed app — the extension shows a save dialog and writes the file. + // PNG-only, basename-only, bounded size: the payload is untrusted. + const raw = msg as { filename: string; dataUrl: string }; + const prefix = "data:image/png;base64,"; + const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined; + const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-"); + if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return; + void (async () => { + const target = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)), + filters: { Images: ["png"] }, + }); + if (!target) return; + await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64")); + const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal"); + if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target); + })(); + return; + } if ( msg && typeof msg === "object" && @@ -57,19 +147,14 @@ export class ChatPanel { ChatPanel.current.panel.reveal(vscode.ViewColumn.One); return ChatPanel.current; } - const panel = vscode.window.createWebviewPanel( - "amicode.chat", - "Amicode Chat", - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - // The chat lives at localhost; we let the webview reach out via http://127.0.0.1 - // through normal browser networking. No localResourceRoots needed for the iframe - // itself — we only host one extension-local asset (the loading splash). - localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], - }, - ); + const panel = vscode.window.createWebviewPanel("amicode.chat", "Amicode Chat", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + // The chat lives at localhost; we let the webview reach out via http://127.0.0.1 + // through normal browser networking. No localResourceRoots needed for the iframe + // itself — we only host one extension-local asset (the loading splash). + localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], + }); panel.iconPath = vscode.Uri.joinPath(ctx.extensionUri, "media", "amico.svg"); ChatPanel.current = new ChatPanel(panel, opencodeUrl); return ChatPanel.current; @@ -88,6 +173,11 @@ export class ChatPanel { "connect-src 'self'", ].join("; "); const origin = JSON.stringify(opencodeUrl.origin); + // Boot theme: the app's preload reads ?colorScheme= and seeds its scheme + // storage, so the chat opens in the EDITOR's theme (prefers-color-scheme + // inside the webview iframe reports the OS, not VS Code). + const framed = new URL(opencodeUrl.href); + framed.searchParams.set("colorScheme", themeKindToScheme(vscode.window.activeColorTheme.kind)); return /* html */ ` @@ -100,7 +190,7 @@ export class ChatPanel { - +