From 17bf0f4a29d3ab9f3adf39ad9f4848c8cbe87456 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Thu, 2 Jul 2026 20:59:00 -0400 Subject: [PATCH 01/50] =?UTF-8?q?feat(1.1):=20Scheduler=20=E2=80=94=20seri?= =?UTF-8?q?al=20run=20queue=20built=20to=20the=20ratified=20Executor=20con?= =?UTF-8?q?tract=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enqueue(spec, {concurrent?}) → ScheduledRun{queueId, handle: Promise, cancel}` plus a multi-consumer lifecycle stream (queued/started/finished/cancelled/error) for RunsManager/StatusBar (1.2). Lives in @amicode/amico-run (node-only, no vscode) beside the LocalExecutor it drives. Built TO the Track C contract (ratified 2026-07-02) so Δ8's RemoteExecutor drops in with zero reshape: - S12: enqueue resolves to the executor's RunHandle UNTOUCHED (identity passthrough, pinned by test) — downstream never sees an executor type. - (b) abort() is a request, not a kill: the pump advances ONLY when `finished` resolves; a post-abort() run still holds the queue (pinned by test). - (c) per-executor warming budget: the Scheduler owns NO timers — structurally pinned (test greps the source for setTimeout/setInterval). - (d) `finished` never rejects per contract; a rogue rejection is survived (error event) rather than wedging every queued run. Semantics: strictly serial; cancel() dequeues only pre-start (a live run is stopped via RunHandle.abort(), never the queue); a submit() ConfigError rejects that entry's handle, emits `error`, and the queue advances; `concurrent: true` is the NAMED Phase-4 seam — rejected loudly (ConfigError) instead of silently serializing. Listener errors are isolated from the pump. TDD: 12 tests (RED first) — serial ordering, S12 identity, opts passthrough, abort≠terminated, lifecycle sequence with positions, cancel pre/post start, ConfigError advance, the concurrent seam, multi-listener + dispose, throwing listener, and the no-timers structural pin. Repo suite green (schema 34, amico-run 59, extension 80); build + typecheck clean. Closes #56. Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/index.ts | 1 + packages/amico-run/src/scheduler.ts | 156 +++++++++++++++++ packages/amico-run/test/scheduler.test.ts | 204 ++++++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 packages/amico-run/src/scheduler.ts create mode 100644 packages/amico-run/test/scheduler.test.ts diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index f8af7d5f..7fd1e8e4 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -4,3 +4,4 @@ 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/scheduler.ts b/packages/amico-run/src/scheduler.ts new file mode 100644 index 00000000..b892b1b4 --- /dev/null +++ b/packages/amico-run/src/scheduler.ts @@ -0,0 +1,156 @@ +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 if the entry was still queued (it will never + * run), false once started — a live run is stopped via 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. + this.emit({ kind: 'error', queueId: entry.queueId, message: `finished rejected: ${(e as Error).message}` }) + } + } finally { + this.running = false + void this.pump() // next entry, if any + } + } +} diff --git a/packages/amico-run/test/scheduler.test.ts b/packages/amico-run/test/scheduler.test.ts new file mode 100644 index 00000000..e00e5187 --- /dev/null +++ b/packages/amico-run/test/scheduler.test.ts @@ -0,0 +1,204 @@ +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 (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. + 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/) + }) +}) From b9ed9feeb0f5ec7b0838f0120e7efbb17e79414d Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Thu, 2 Jul 2026 21:12:13 -0400 Subject: [PATCH 02/50] =?UTF-8?q?fix(1.1):=20scheduler=20review=20?= =?UTF-8?q?=E2=80=94=20microtask-deferred=20re-pump=20+=20enforce=20the=20?= =?UTF-8?q?defensive=20claims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review (mutation-tested; 0 must-fix, 2 should-fix) — all folded in: - finally's re-pump is now queueMicrotask-deferred: a contract-violating executor whose submit() throws SYNCHRONOUSLY previously made the finally a direct recursion — a backlog of such failures accumulated behind a pending run blew the stack on drain (RangeError) and STRANDED the rest of the queue. Mutation-verified: reverting to the direct call fails the new test (RangeError + timeout); the deferral drains 8000 sync-throwers flat. - The rogue-`finished`-rejection branch is now enforced, not just advertised: new test pins error-event + queue-advance (mutation-verified: deleting the branch fails it). Rejection reason normalized (instanceof Error) to match the submit path. - Explicit unhandledRejection pin for the internal handle.catch suppression (an untouched ScheduledRun.handle never trips the process on cancel). - cancel() docstring now names all three false cases (started / already cancelled / mid-submit, where handle may still reject); no-timers grep widened to setImmediate|Date.now. 15 tests green; repo suite green; typecheck clean. Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/scheduler.ts | 19 ++++-- packages/amico-run/test/scheduler.test.ts | 73 ++++++++++++++++++++++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/packages/amico-run/src/scheduler.ts b/packages/amico-run/src/scheduler.ts index b892b1b4..dd1153bf 100644 --- a/packages/amico-run/src/scheduler.ts +++ b/packages/amico-run/src/scheduler.ts @@ -52,9 +52,12 @@ export interface ScheduledRun { * 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 if the entry was still queued (it will never - * run), false once started — a live run is stopped via RunHandle.abort() - * (a request, per contract (b)), never via the queue. */ + /** 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 } @@ -146,11 +149,17 @@ export class Scheduler { 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. - this.emit({ kind: 'error', queueId: entry.queueId, message: `finished rejected: ${(e as Error).message}` }) + const msg = e instanceof Error ? e.message : String(e) + this.emit({ kind: 'error', queueId: entry.queueId, message: `finished rejected: ${msg}` }) } } finally { this.running = false - void this.pump() // next entry, if any + // 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/test/scheduler.test.ts b/packages/amico-run/test/scheduler.test.ts index e00e5187..4bf78981 100644 --- a/packages/amico-run/test/scheduler.test.ts +++ b/packages/amico-run/test/scheduler.test.ts @@ -192,13 +192,82 @@ describe('Scheduler — serial queue (#56)', () => { 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. + // 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/) + expect(src).not.toMatch(/setTimeout|setInterval|setImmediate|Date\.now/) }) }) From 6c0c6fc036900400ff78346d878bff07ab221289 Mon Sep 17 00:00:00 2001 From: Kate Date: Fri, 3 Jul 2026 19:19:50 -0400 Subject: [PATCH 03/50] =?UTF-8?q?feat:=20catalog=20entry=20card=20?= =?UTF-8?q?=E2=80=94=20components,=20save-to-catalog=20flow,=20session=20c?= =?UTF-8?q?atalog=20(UX2)=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(47): catalog entry card — components, save-to-catalog flow, session catalog (UX2) The catalog card (Krishna p5, UX2) shipped end to end as a seam prototype. Identity is Hamiltonian-anchored and user-named; the card is the feedback artifact for the open field-selection questions. Components (media/ui): - chip: the entry's handle — gate · system · tags · #index. The system slot carries the USER-ASSIGNED name (researchers think in named devices, "Emerald-Q3"); derived family is the fallback. Tags render as dashed "proposed" segments — none of tags/index/name are in catalog-entry.schema.json, and the marking is deliberate. - catalogcard: header (chip + run-id + Tune/Warm-Start/Promote actions, VS Code secondary-button styling, right-justified) → pulseplot (reused, hydrated) → metadata / pulse-data / high-level-metrics panels → sibling-chips row. Metrics are uniform hero cards in a wrapping flex row: fidelity · gate time (params.T) · spectral bandwidth (95%-power, computed client-side from the knots, DC removed; definitional choices marked proposed) · robustness (empty proposed slot — needs perturbed rollouts recorded at solve time). Solver telemetry (iterations/wall) lives in metadata: provenance, not pulse quality. Drive labels are u_i, matching the trajectory component and Piccolo's plot defaults. Flow + shell (src): - Save to catalog: a converged run's promote prompt (live) or the demo replay prompt → optional system-name and tags input → a pointer entry in the session catalog (workspaceState — NOT the Phase-3 CatalogStore; Q91/Q92 open) → the card opens, hydrated from the real run artifacts (run.toml identity; result.toml fidelity/params with gate/system lifted; run.log pulse lines → the plot). - Catalog tree view: chip-shaped rows (gate · system · fidelity, tags in description/tooltip), newest-first, deduped by run_id; click reopens the card. amicode.catalog.refresh actually registered. - The solve-template change that records params.gate/params.system on NEW runs ships separately; the bundled demo fixture already carries them (schema-validated). Tests: pulse-line grammar fixtures, hydration (field mapping, gate lift, newest-record pulse, degradation), session tree (dedup, order, open-card command, empty state). Closes #47's Kate-lane scope as amended (actions row instead of the "what do next" section; resume hand-off design returns to UX1 — see the deferred-items comment on #46). Co-Authored-By: Claude Fable 5 * feat(47): remove-from-catalog — non-destructive unsave via the tree context menu Right-click a Catalog row → "Remove from Catalog": deletes the POINTER record only (workspaceState) — the run dir and pulse.jld2 stay on disk, per the raw-data-trust principle. Archive/supersede lifecycle belongs to the Phase-3 CatalogStore (Q94/Q95), deliberately not faked here. Kept off the command palette (when: false) — the action only means something on a row. Tests: removal preserves remaining order, is idempotent, and rows carry the context value gating the menu. Part of #47. Co-Authored-By: Claude Fable 5 * fix(47): reveal-or-create dedupe for catalog-card panels (review) Clicking the same catalog row repeatedly spawned duplicate panels. Per Jack's review: run_id → live-panel map, second open re-focuses via reveal, disposal cleans the map so a closed tab re-creates fresh. Shell test covers all three; vscode stub grows createWebviewPanel + registerCommand to support it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- packages/extension/demo/run/result.toml | 2 + packages/extension/demo/run/run.log | 2 +- packages/extension/esbuild.config.mjs | 12 + .../media/ui/components/catalogcard.ts | 270 ++++++++++++++++++ .../extension/media/ui/components/chip.ts | 49 ++++ packages/extension/package.json | 19 ++ packages/extension/src/catalog_card_shell.ts | 104 +++++++ .../extension/src/catalog_card_webview.ts | 55 ++++ packages/extension/src/extension.ts | 58 +++- packages/extension/src/file_watcher.ts | 5 +- packages/extension/src/trees.ts | 80 +++++- packages/extension/test/__mocks__/vscode.ts | 33 ++- packages/extension/test/catalog_shell.test.ts | 133 +++++++++ .../extension/test/watcher_contract.test.ts | 14 +- .../test/watcher_statemachine.test.ts | 2 +- 15 files changed, 819 insertions(+), 19 deletions(-) create mode 100644 packages/extension/media/ui/components/catalogcard.ts create mode 100644 packages/extension/media/ui/components/chip.ts create mode 100644 packages/extension/src/catalog_card_shell.ts create mode 100644 packages/extension/src/catalog_card_webview.ts create mode 100644 packages/extension/test/catalog_shell.test.ts 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/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/media/ui/components/catalogcard.ts b/packages/extension/media/ui/components/catalogcard.ts new file mode 100644 index 00000000..6df3b640 --- /dev/null +++ b/packages/extension/media/ui/components/catalogcard.ts @@ -0,0 +1,270 @@ +// 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, { hero: true }); + 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..24336e7b --- /dev/null +++ b/packages/extension/media/ui/components/chip.ts @@ -0,0 +1,49 @@ +// 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/package.json b/packages/extension/package.json index d1c304ae..70e3c4ff 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -83,6 +83,10 @@ { "command": "amicode.replayDemo", "title": "Amicode: Replay demo run" + }, + { + "command": "amicode.catalog.remove", + "title": "Remove from Catalog" } ], "configuration": { @@ -109,6 +113,21 @@ "description": "Path to the lab.toml hardware profile, validated on load. Empty = ~/.amico/lab.toml (where install.sh writes the starter)." } } + }, + "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": { diff --git a/packages/extension/src/catalog_card_shell.ts b/packages/extension/src/catalog_card_shell.ts new file mode 100644 index 00000000..20c1f394 --- /dev/null +++ b/packages/extension/src/catalog_card_shell.ts @@ -0,0 +1,104 @@ +// 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") vscode.window.showInformationMessage(`what-next → ${m.id} (stub)`); + }); + 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..fa4f26ed --- /dev/null +++ b/packages/extension/src/catalog_card_webview.ts @@ -0,0 +1,55 @@ +// 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 { catalogcard, type CatalogEntry, type CardPulse } from "../media/ui/components/catalogcard"; + +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.100, -0.089, -0.065, -0.031, 0.009, 0.049, + 0.084, 0.109, 0.121, 0.118, 0.100, 0.070, 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/extension.ts b/packages/extension/src/extension.ts index 2dc3428c..b3c3bac5 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -6,6 +6,7 @@ import { fetchProviderSignal } from "./llm_creds.mjs"; import { resolveOpencodeBinary, OpencodeMissingError } from "./opencode_binary"; import { ChatPanel } from "./chat_panel"; import { registerRunInspector } from "./run_inspector"; +import { registerCatalogCard } from "./catalog_card_shell"; import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "./opencode_config"; @@ -14,6 +15,7 @@ import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; import { RunsRootWatcher } from "./file_watcher"; import { stageDemoRun } from "./demo_replay"; +import { readTomlSafe } from "./run_dir_reader"; // ============================================================================ // Extension entry point. Boot order on activate: @@ -40,8 +42,51 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const runsRoot = resolveRunsRoot(vscode.workspace.getConfiguration("amicode").get("runsRoot", "")); // 1. UI surfaces - registerTrees(ctx); + const trees = registerTrees(ctx); registerRunInspector(ctx); + registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow + ctx.subscriptions.push( + // #47 session catalog: record the save (workspaceState + tree), then open + // the card. Both prompts (demo replay, live promote) route through here. + vscode.commands.registerCommand("amicode.catalog.save", async (runDir: string) => { + const manifest = readTomlSafe(path.join(runDir, "run.toml")) ?? {}; + const result = readTomlSafe(path.join(runDir, "result.toml")) ?? {}; + const params = (result.params ?? {}) as Record; + const family = typeof params.system === "string" ? params.system : undefined; + // System identity is USER-NAMED (researchers think in named devices — + // "Emerald-Q3" — not families); the family prefills as the default and + // level counts stay in the card's params rows. Esc keeps the family. + const name = await vscode.window.showInputBox({ + prompt: "Name this system (shown on the catalog entry)", + value: family ?? "", + placeHolder: "e.g. Emerald-Q3", + }); + const system = name?.trim() ? name.trim() : family; + // Tags: the quick-digest handles for hyperparameter sweeps ("high-R", + // "T=8", "fast-ansatz") — optional, comma-separated. + const tagsRaw = await vscode.window.showInputBox({ + prompt: "Tags (comma-separated, optional)", + placeHolder: "e.g. high-R, T=8, fast", + }); + const tags = tagsRaw?.split(",").map((t) => t.trim()).filter(Boolean) ?? []; + await trees.catalog.save({ + run_id: String(manifest.run_id ?? path.basename(runDir)), + runDir, + lab_id: String(manifest.lab_id ?? "default"), + gate: typeof params.gate === "string" ? params.gate : undefined, + system, + tags, + fidelity: Number(result.fidelity ?? 0), + saved_at: new Date().toISOString(), + }); + await vscode.commands.executeCommand("amicode.catalogCard.open", runDir, system, tags); + }), + vscode.commands.registerCommand("amicode.catalog.refresh", () => trees.catalog.refresh()), + // Context-menu removal: unsave the pointer; run artifacts stay on disk. + vscode.commands.registerCommand("amicode.catalog.remove", async (entry?: { run_id?: string }) => { + if (entry?.run_id) await trees.catalog.remove(entry.run_id); + }), + ); statusBar = new StatusBarManager(); ctx.subscriptions.push({ dispose: () => statusBar?.dispose() }); @@ -208,6 +253,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const runDir = stageDemoRun(demoDir, runsRoot); runsChannel.appendLine(`[demo] replayed → ${runDir}`); await vscode.commands.executeCommand("amicode.runInspector.focus"); + // Save-to-catalog prompt (#47): the watcher suppresses the promote + // prompt for runs already finished at switch (anti-re-pop), so the + // explicit replay owns its own prompt → the catalog card. + const fid = Number((readTomlSafe(path.join(runDir, "result.toml")) ?? {}).fidelity ?? NaN); + if (fid >= 0.99) { + const choice = await vscode.window.showInformationMessage( + `Amicode: demo solve converged (F=${fid.toFixed(4)}). Save to catalog?`, + "Save to catalog", "Not now", + ); + if (choice === "Save to catalog") await vscode.commands.executeCommand("amicode.catalog.save", runDir); + } } catch (e) { void vscode.window.showErrorMessage(`Amicode: replay failed — ${(e as Error).message}`); } diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index 5a960491..bf25b419 100644 --- a/packages/extension/src/file_watcher.ts +++ b/packages/extension/src/file_watcher.ts @@ -94,8 +94,9 @@ class LiveRunSink implements RunSink { "Yes — promote", "No — keep local only", ); if (choice === "Yes — promote") { - vscode.window.showInformationMessage(`Amicode: promotion stub — would catalog ${info.runId}.`); - await vscode.commands.executeCommand("amicode.catalog.refresh").then(undefined, () => undefined); + // #47: record in the session catalog + open the card (store + // persistence is still Phase 3 — the session catalog is workspaceState). + await vscode.commands.executeCommand("amicode.catalog.save", info.runDir).then(undefined, () => undefined); } })(); } diff --git a/packages/extension/src/trees.ts b/packages/extension/src/trees.ts index 4e93dfbe..56b385d2 100644 --- a/packages/extension/src/trees.ts +++ b/packages/extension/src/trees.ts @@ -1,10 +1,12 @@ import * as vscode from "vscode"; // ============================================================================ -// Placeholder TreeViews for vault / catalog / armonia. -// v0 ships an empty-state message; real implementations connect to -// ArmoniaService when that lands. Registering them now reserves the -// activitybar real estate. +// TreeViews for vault / catalog / armonia. +// vault + armonia ship an empty-state message; real implementations connect +// to ArmoniaService when that lands. The catalog tree is the #47 SESSION +// catalog: entries collected by the save-to-catalog flow, persisted in +// workspaceState — explicitly NOT the Phase-3 CatalogStore (vault-backed, +// git-lfs pulse artifacts); it seeds the UX3 browser. // ============================================================================ class PlaceholderTree implements vscode.TreeDataProvider { @@ -22,13 +24,79 @@ class PlaceholderTree implements vscode.TreeDataProvider { refresh(): void { this._onDidChange.fire(); } } +/** A saved session-catalog entry (#47). Promote-shaped, mirrors the card's + * hydration source: enough to label the row and reopen the card. */ +export interface SessionCatalogEntry { + run_id: string; + runDir: string; + lab_id: string; + fidelity: number; + gate?: string; + /** User-named system (falls back to the derived family). */ + system?: string; + /** User-added tags — quick-digest handles for hyperparameter sweeps. */ + tags?: string[]; + saved_at: string; +} + +const CATALOG_KEY = "amicode.sessionCatalog"; + +export class SessionCatalogTree implements vscode.TreeDataProvider { + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChange.event; + + constructor(private readonly ctx: vscode.ExtensionContext) {} + + private entries(): SessionCatalogEntry[] { + return this.ctx.workspaceState.get(CATALOG_KEY, []); + } + + /** Record a save (newest first, deduped by run_id) and refresh the view. */ + async save(entry: SessionCatalogEntry): Promise { + const rest = this.entries().filter((e) => e.run_id !== entry.run_id); + await this.ctx.workspaceState.update(CATALOG_KEY, [entry, ...rest]); + this._onDidChange.fire(); + } + + /** Remove the POINTER record (unsave). Non-destructive by design: the run + * dir and pulse.jld2 stay on disk — deleting artifacts (and archive/ + * supersede semantics) belongs to the Phase-3 CatalogStore (Q94/Q95). */ + async remove(run_id: string): Promise { + await this.ctx.workspaceState.update(CATALOG_KEY, this.entries().filter((e) => e.run_id !== run_id)); + this._onDidChange.fire(); + } + + getTreeItem(el: SessionCatalogEntry | string): vscode.TreeItem { + if (typeof el === "string") return new vscode.TreeItem(el, vscode.TreeItemCollapsibleState.None); + // Chip-shaped label: gate · system · fidelity (Hamiltonian-based identity; + // the lab is provenance — it lives in the tooltip + the card's metadata). + const item = new vscode.TreeItem( + `${el.gate ?? "?"} · ${el.system ?? "?"} · F=${el.fidelity.toFixed(5)}`, + vscode.TreeItemCollapsibleState.None, + ); + item.description = el.run_id; + item.tooltip = `lab: ${el.lab_id}${el.tags?.length ? `\ntags: ${el.tags.join(", ")}` : ""}\nsaved ${el.saved_at}\n${el.runDir}`; + if (el.tags?.length) item.description = `${el.run_id} · ${el.tags.join(" · ")}`; + item.command = { command: "amicode.catalogCard.open", title: "Open catalog card", arguments: [el.runDir, el.system, el.tags] }; + item.contextValue = "amicodeCatalogEntry"; // enables the row's context menu (remove) + return item; + } + + getChildren(): (SessionCatalogEntry | string)[] { + const e = this.entries(); + return e.length ? e : ["(empty — save a converged run to the catalog)"]; + } + + refresh(): void { this._onDidChange.fire(); } +} + export function registerTrees(ctx: vscode.ExtensionContext): { vault: PlaceholderTree; - catalog: PlaceholderTree; + catalog: SessionCatalogTree; armonia: PlaceholderTree; } { const vault = new PlaceholderTree("(vault tree will appear when Armonia mounts are configured)"); - const catalog = new PlaceholderTree("(catalog tree will appear when a public vault is mounted)"); + const catalog = new SessionCatalogTree(ctx); const armonia = new PlaceholderTree("(no mounts yet — run Amicode: Open Chat to start)"); ctx.subscriptions.push( diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index f8817cc3..2500724f 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -5,10 +5,34 @@ export const window = { showInformationMessage: () => Promise.resolve(undefined), showErrorMessage: () => Promise.resolve(undefined), showWarningMessage: () => Promise.resolve(undefined), + showInputBox: () => Promise.resolve(undefined), createOutputChannel: () => ({ appendLine() {}, append() {}, dispose() {} }), registerWebviewViewProvider: () => ({ dispose() {} }), + createWebviewPanel: (_viewType: string, _title: string, _column?: unknown, _opts?: unknown) => { + const disposeCbs: Array<() => void> = []; + return { + webview: { + html: "", + cspSource: "test:", + asWebviewUri: (u: unknown) => u, + onDidReceiveMessage: () => ({ dispose() {} }), + }, + revealCount: 0, + reveal() { this.revealCount += 1; }, + onDidDispose(cb: () => void, _thisArg?: unknown, _subs?: unknown) { disposeCbs.push(cb); return { dispose() {} }; }, + dispose() { for (const cb of disposeCbs) cb(); }, + }; + }, +}; +const registeredCommands = new Map unknown>(); +export const commands = { + registerCommand: (id: string, fn: (...a: unknown[]) => unknown) => { + registeredCommands.set(id, fn); + return { dispose() { registeredCommands.delete(id); } }; + }, + executeCommand: (id: string, ...a: unknown[]) => Promise.resolve(registeredCommands.get(id)?.(...a)), }; -export const commands = { executeCommand: () => Promise.resolve(undefined) }; +export const ViewColumn = { One: 1, Two: 2 }; export const workspace = { workspaceFolders: [] as unknown[], getConfiguration: () => ({ get: (_k: string, d?: unknown) => d ?? "" }), @@ -29,3 +53,10 @@ export class EventEmitter { export class Disposable { dispose() {} } +export class TreeItem { + description?: string; + tooltip?: string; + command?: unknown; + constructor(public label: string, public collapsibleState?: number) {} +} +export const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; diff --git a/packages/extension/test/catalog_shell.test.ts b/packages/extension/test/catalog_shell.test.ts new file mode 100644 index 00000000..1e0155b6 --- /dev/null +++ b/packages/extension/test/catalog_shell.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as vscode from "vscode"; +import { hydrateFromRunDir, registerCatalogCard } from "../src/catalog_card_shell"; +import { SessionCatalogTree, type SessionCatalogEntry } from "../src/trees"; + +// The save-to-catalog flow (#47): entry hydration from real run artifacts, +// and the session catalog (pointer records in workspaceState — NOT the +// Phase-3 CatalogStore; Q91/Q92 open). + +function stageRun(opts: { pulseLines?: string; gate?: string; system?: string }): string { + const dir = mkdtempSync(join(tmpdir(), "card-run-")); + writeFileSync(join(dir, "run.toml"), + 'schema_version = "1"\nrun_id = "r20260703-000000Z-cafe"\nlab_id = "default"\nscript_path = "/s.jl"\n' + + 'lab = "default"\ncreated_at = "2026-07-03T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n'); + const params = [ + opts.system ? `system = "${opts.system}"` : "", + opts.gate ? `gate = "${opts.gate}"` : "", + "levels = 3", + ].filter(Boolean).join("\n"); + writeFileSync(join(dir, "result.toml"), + `schema_version = "1"\nfidelity = 0.9998\niterations = 60\nwall_seconds = 41.5\n[params]\n${params}\n`); + if (opts.pulseLines !== undefined) writeFileSync(join(dir, "run.log"), opts.pulseLines); + return dir; +} + +describe("hydrateFromRunDir — entry from real run artifacts", () => { + it("maps identity, fidelity, params (gate lifted to top level), proposed block, and the newest pulse", () => { + const dir = stageRun({ + gate: "X", system: "transmon", + pulseLines: + 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n' + + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n" + + "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n", + }); + const data = hydrateFromRunDir(dir)!; + expect(data.entry).toMatchObject({ + run_id: "r20260703-000000Z-cafe", + lab_id: "default", + gate: "X", + fidelity: 0.9998, + proposed: { iterations: 60, wall_seconds: 41.5 }, + }); + expect((data.entry.params as Record).system).toBe("transmon"); + expect(data.pulse).toMatchObject({ record: { iter: 2 } }); // newest record, not the first + }); + + it("degrades: no run.log → no pulse; missing result.toml → undefined", () => { + const dir = stageRun({ gate: "X" }); + expect(hydrateFromRunDir(dir)!.pulse).toBeUndefined(); + const empty = mkdtempSync(join(tmpdir(), "card-empty-")); + expect(hydrateFromRunDir(empty)).toBeUndefined(); + }); +}); + +describe("registerCatalogCard — reveal-or-create panel dedupe", () => { + it("re-opening the same entry reveals the live panel; dispose allows re-create", async () => { + const dir = stageRun({ gate: "X", system: "transmon" }); + const ctx = { subscriptions: [], extensionUri: vscode.Uri.file("/ext") } as never; + registerCatalogCard(ctx); + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + + await vscode.commands.executeCommand("amicode.catalogCard.open", dir); + await vscode.commands.executeCommand("amicode.catalogCard.open", dir); + expect(spy).toHaveBeenCalledTimes(1); // one panel per run_id + const panel = spy.mock.results[0].value as { revealCount: number; dispose: () => void }; + expect(panel.revealCount).toBe(1); // second click re-focuses + + panel.dispose(); // user closes the tab + await vscode.commands.executeCommand("amicode.catalogCard.open", dir); + expect(spy).toHaveBeenCalledTimes(2); // closed → fresh panel + spy.mockRestore(); + }); +}); + +describe("SessionCatalogTree — pointer records, newest first", () => { + function makeCtx() { + const store = new Map(); + return { + workspaceState: { + get: (k: string, d: unknown) => (store.has(k) ? store.get(k) : d), + update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); }, + }, + } as never; + } + const entry = (run_id: string, over: Partial = {}): SessionCatalogEntry => ({ + run_id, runDir: `/runs/${run_id}`, lab_id: "default", fidelity: 0.999, + gate: "X", system: "transmon", saved_at: "2026-07-03T00:00:00Z", ...over, + }); + + it("saves newest-first, dedupes by run_id, and rows open the card for the run dir", async () => { + const tree = new SessionCatalogTree(makeCtx()); + await tree.save(entry("r1")); + await tree.save(entry("r2")); + await tree.save(entry("r1", { fidelity: 0.5 })); // re-save moves to front, replaces + const rows = tree.getChildren() as SessionCatalogEntry[]; + expect(rows.map((r) => r.run_id)).toEqual(["r1", "r2"]); + expect(rows[0].fidelity).toBe(0.5); + + const item = tree.getTreeItem(rows[1]) as { label: string; command?: { command: string; arguments: unknown[] } }; + expect(item.label).toContain("transmon"); + expect(item.command?.command).toBe("amicode.catalogCard.open"); + expect(item.command?.arguments).toEqual(["/runs/r2", "transmon", undefined]); // runDir + name + tags → card + }); + + it("remove() unsaves the pointer only — remaining entries and order survive", async () => { + const tree = new SessionCatalogTree(makeCtx()); + await tree.save(entry("r1")); + await tree.save(entry("r2")); + await tree.save(entry("r3")); + await tree.remove("r2"); + const rows = tree.getChildren() as SessionCatalogEntry[]; + expect(rows.map((r) => r.run_id)).toEqual(["r3", "r1"]); + await tree.remove("r2"); // idempotent — removing a gone entry is a no-op + expect((tree.getChildren() as SessionCatalogEntry[]).length).toBe(2); + }); + + it("rows carry the context value that enables the remove menu", async () => { + const tree = new SessionCatalogTree(makeCtx()); + await tree.save(entry("r1")); + const item = tree.getTreeItem((tree.getChildren() as SessionCatalogEntry[])[0]) as { contextValue?: string }; + expect(item.contextValue).toBe("amicodeCatalogEntry"); + }); + + it("empty state renders the hint row", () => { + const tree = new SessionCatalogTree(makeCtx()); + const rows = tree.getChildren(); + expect(rows).toHaveLength(1); + expect(String(rows[0])).toContain("empty"); + }); +}); diff --git a/packages/extension/test/watcher_contract.test.ts b/packages/extension/test/watcher_contract.test.ts index ff86d049..612b0b6f 100644 --- a/packages/extension/test/watcher_contract.test.ts +++ b/packages/extension/test/watcher_contract.test.ts @@ -53,7 +53,7 @@ describe('ingestRunDir — β.1 contract reading (replay)', () => { const sink = fakeSink() const dir = stageRun({ status: 'completed', exit: 0, iters: [1, 2, 3], fidelity: 0.999 }) writeFileSync(join(dir, 'run.log'), - 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n' + + 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n' + 'AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n' + 'AMICODE_ITER iter=1 f=0.1 inf_pr=1e-8 inf_du=1e-6\n' + 'AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n' + @@ -90,11 +90,11 @@ describe('AMICODE_ITER parsing — Inf/NaN are kept, not dropped', () => { // is untouched. describe('AMICODE_PULSE_META parsing (#66 pinned grammar)', () => { it('parses drives/knots/labels/bounds from a well-formed meta line', () => { - const m = parsePulseMetaLine('AMICODE_PULSE_META drives=2 knots=50 labels="a_1","a_2" bounds=-0.2:0.2,-0.2:0.2') + const m = parsePulseMetaLine('AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2') expect(m).toEqual({ drives: 2, knots: 50, - labels: ['a_1', 'a_2'], + labels: ['u_1', 'u_2'], bounds: [[-0.2, 0.2], [-0.2, 0.2]], }) }) @@ -122,7 +122,7 @@ describe('AMICODE_PULSE record parsing (#66 pinned grammar)', () => { // dropped; the last meta wins and resets state; count-mismatched records and // internally-inconsistent metas are ignored. describe('PulseStream — cross-line policy (#66)', () => { - const META = 'AMICODE_PULSE_META drives=2 knots=3 labels="a_1","a_2" bounds=-0.2:0.2,-0.2:0.2' + const META = 'AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2' const REC = 'AMICODE_PULSE iter=6 dt=0.2 a=0.1,0.2,0.3;0.4,0.5,0.6' it('drops records that arrive before any meta', () => { @@ -142,8 +142,8 @@ describe('PulseStream — cross-line policy (#66)', () => { it('treats a meta whose label or bounds count disagrees with drives= as malformed (no state change)', () => { const ps = new PulseStream() - expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="a_1" bounds=-0.2:0.2,-0.2:0.2')).toBeUndefined() // 1 label ≠ 2 drives - expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="a_1","a_2" bounds=-0.2:0.2')).toBeUndefined() // 1 bound ≠ 2 drives + expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1" bounds=-0.2:0.2,-0.2:0.2')).toBeUndefined() // 1 label ≠ 2 drives + expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2')).toBeUndefined() // 1 bound ≠ 2 drives expect(ps.onLine(REC)).toBeUndefined() // bad metas did NOT arm the stream }) @@ -155,7 +155,7 @@ describe('PulseStream — cross-line policy (#66)', () => { expect(ps.onLine(META)).toMatchObject({ type: 'meta' }) expect(ps.onLine(REC)).toMatchObject({ type: 'record' }) // a NEW meta with a different shape governs subsequent records - expect(ps.onLine('AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.1:0.1')).toMatchObject({ type: 'meta' }) + expect(ps.onLine('AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.1:0.1')).toMatchObject({ type: 'meta' }) expect(ps.onLine(REC)).toBeUndefined() // old-shape record now ignored expect(ps.onLine('AMICODE_PULSE iter=9 dt=0.2 a=0.1,0.2')).toMatchObject({ type: 'record', record: { iter: 9 } }) }) diff --git a/packages/extension/test/watcher_statemachine.test.ts b/packages/extension/test/watcher_statemachine.test.ts index 75dd23bb..8826f7c2 100644 --- a/packages/extension/test/watcher_statemachine.test.ts +++ b/packages/extension/test/watcher_statemachine.test.ts @@ -38,7 +38,7 @@ function setLatest(root: string, target: string): void { symlinkSync(target, link); } const tick = (w: RunsRootWatcher): void => (w as unknown as { tick(): void }).tick(); -const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n'; +const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n'; describe("RunsRootWatcher state machine", () => { beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); From 84cbc737d7d864403b47559d70cf345632364e14 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Sat, 4 Jul 2026 00:53:09 -0400 Subject: [PATCH 04/50] =?UTF-8?q?1.2=20=E2=80=94=20RunsManager:=20multi-ru?= =?UTF-8?q?n=20engine=20keyed=20on=20the=20append-only=20runs/index=20(#70?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(1.2): RunsManager — multi-run engine keyed on the append-only runs/index (#57) Replaces β's single-run RunsRootWatcher. Discovery now tails `runs/index` (amico-run's appendIndex TSV) instead of following the `latest` symlink — `latest` keeps being written (frozen contract) but a second concurrent solve no longer yanks tracking off the first mid-flight: every run WITHOUT a FINISHED gets its own pipeline (replay → run-dir watch → run.log tail) and is tracked to completion. - run_registry.ts (pure, vscode-free): parseIndexLine (tolerant of torn/blank lines — the tail heals) + RunRegistry (idempotent by runId; iter high-water; FINISHED-keyed terminal state). - log_tailer.ts: LogTailer extracted verbatim from file_watcher.ts — reused for every run.log AND the index (both append-only). - runs_manager.ts: per-run pipelines with per-run PulseStream/SinkDedup; runId-gated routing — the SELECTED run drives the single-run Inspector + StatusBar (selection auto-follows the newest started run, β latest-follow parity; `selectRun` is 1.3's seam), while completions + the promote-once prompt fire for EVERY run, selected or not. Completion keys on FINISHED (never result.toml presence). Poll backstop + idempotent consumers as before. attachScheduler consumes the #56 lifecycle (structural SchedulerLike so this is independent of #68's merge): `started` registers + selects immediately. - extension.ts: RunsManager replaces the watcher; replayDemo now renders via EXPLICIT selection (pokeDiscovery + selectRun) since a finished-at-discovery run registers quietly (idle-at-launch parity) — promote stays suppressed. - file_watcher.ts deleted (superseded); its statemachine tests ported to runs_manager.test.ts (idle-on-finished, warming→FINISHED-keyed completion, #66 pulse tail routing, replay-seeded meta) + new multi-run coverage: concurrent runs both tracked with background completion/promote, re-select replay without promote re-pop, missing-dir index lines tolerated, the Scheduler seam, and the demo-replay explicit-selection path. 15 new tests; repo green (extension 103, amico-run 47, schema 34); typecheck + build clean. Closes #57. Co-Authored-By: Claude Fable 5 * fix(1.2): runs-manager review — disk-checked warming, scheduler metadata backfill, watch guards Adversarial review (0 must-fix, 2 should-fix) — applied: - selectRun re-checks DISK for FINISHED before posting warming (β parity): registry phase can be ≤700ms stale, and warming-after-completion inverted the terminal badge — real once 1.3's user-driven selectRun lands. - RunRegistry.backfill: a scheduler-registered run (runId+runDir only) gains createdAt/scriptPath when its index line lands — the 1.3 trees would otherwise see undefined metadata on every scheduler-launched run. - fs.watch 'error' listeners on all three watcher sites (root, per-run dir, tailer) — an unhandled FSWatcher error is an uncaught host exception; the poll backstop keeps things live. - RunRegistry.all() returns copies (1.3 callers can't mutate registry state); honest header comment on the transient replay/tail re-delivery window. - test/log_tailer.test.ts (review gap — the tailer is now load-bearing for discovery): torn-line carry-over, truncation re-read, startOffset contract, poke self-attach. 19 tests green, typecheck clean. Still owed next session (review nits, test-only): cross-run PULSE routing gate test, backfill unit test, stale-warming pin test; inspector pendingPulse reset on selection switch is deferred to 1.3. Co-Authored-By: Claude Fable 5 * test(1.2): close the owed #70 review-nit gaps (all mutation-verified) The three test gaps the RunsManager review flagged: - RunRegistry.backfill: fills ONLY missing metadata (first-registration wins), no-throw on unknown runId — mutation-verified (dropping the missing-only guard fails it). - RunRegistry.all(): returns copies — a mutated snapshot can't corrupt registry state. - cross-run PULSE routing: a background run's pulse RECORD is gated on selection (not just iter) — never reaches the inspector while another run is selected. - stale-warming: selecting a run whose FINISHED landed inside the ≤700ms poll window shows completion, NOT warming (no terminal-badge inversion) — mutation-verified (reverting the disk re-check fails it). extension 111 tests (+8), repo green (amico-run 47, schema 34); typecheck clean. * fix(1.2): review #70 — pinned selection, torn-FINISHED retry, markFinished guard, single-pass discovery, one terminal orchestration Addresses jack-champagne's static/design pass, one commit per nothing — all five findings land together because #1/#4 reshape the same registerRun path: #1 (design, the #72 seam): explicit selectRun PINS the selection; auto-follow (newest registered live run, β latest-follow parity) only applies while nothing is pinned. A background solve starting can no longer yank the view off a run the user deliberately opened. Mutation-verified. #2: a FINISHED that is present but torn/invalid at discovery no longer finalizes as status:undefined-forever — the run falls through to the live path, whose checkFinished re-reads next tick (the retry the live lane already had). Promote stays suppressed (launch replay). No warming either (disk-checked: FINISHED exists). #3: RunRegistry.markFinished guards phase itself (first terminal wins) — a stray re-mark can't leave status:"failed" beside a stale fidelity. The guard now lives on the public surface, not only in completeRun. #4: discovery ingests the run dir ONCE. Auto-follow assigns selection BEFORE the registration replay, so the single pipelineSink pass both seeds state and feeds the display through routeIter/routePulse's selection gate — displaySink now backs only explicit selection replays. #5: the FINISHED→status→result.toml→fidelity orchestration lives ONCE, in run_dir_reader.readTerminalState (say-why callback preserved via the manager's channel); ingestRunDir and RunsManager.readTerminal both delegate, so a contract change (e.g. #64's formulation.toml) is edited in one place. Also rebased onto main (#68 Scheduler, #77 vsix-gate, #79 permission grant). 118 extension tests pass; typecheck + build clean. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- packages/extension/src/demo_replay.ts | 2 +- packages/extension/src/extension.ts | 26 +- packages/extension/src/file_watcher.ts | 339 -------------- packages/extension/src/log_tailer.ts | 91 ++++ packages/extension/src/run_dir_reader.ts | 57 ++- packages/extension/src/run_inspector.ts | 2 +- packages/extension/src/run_registry.ts | 99 +++++ packages/extension/src/runs_manager.ts | 414 ++++++++++++++++++ packages/extension/test/log_tailer.test.ts | 61 +++ packages/extension/test/run_registry.test.ts | 76 ++++ packages/extension/test/runs_manager.test.ts | 355 +++++++++++++++ .../test/watcher_statemachine.test.ts | 128 ------ 12 files changed, 1150 insertions(+), 500 deletions(-) delete mode 100644 packages/extension/src/file_watcher.ts create mode 100644 packages/extension/src/log_tailer.ts create mode 100644 packages/extension/src/run_registry.ts create mode 100644 packages/extension/src/runs_manager.ts create mode 100644 packages/extension/test/log_tailer.test.ts create mode 100644 packages/extension/test/run_registry.test.ts create mode 100644 packages/extension/test/runs_manager.test.ts delete mode 100644 packages/extension/test/watcher_statemachine.test.ts diff --git a/packages/extension/src/demo_replay.ts b/packages/extension/src/demo_replay.ts index 8451f04b..c5005fc6 100644 --- a/packages/extension/src/demo_replay.ts +++ b/packages/extension/src/demo_replay.ts @@ -7,7 +7,7 @@ import { generateRunId, appendIndex, updateLatest } from "@amicode/amico-run"; * rewrite run.toml's `run_id` to match the new directory, append the * index, and swing `latest` to it. Reuses the β.1 run-dir primitives so the * staged run is byte-for-byte contract-identical and the existing - * RunsRootWatcher renders it exactly like a live solve. + * RunsManager discovers it off the index and renders it like a live solve. * * Filesystem side effects only (pure w.r.t. its inputs). Returns the staged * run directory. diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index b3c3bac5..ab4cc0e7 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -13,7 +13,7 @@ import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; -import { RunsRootWatcher } from "./file_watcher"; +import { RunsManager } from "./runs_manager"; import { stageDemoRun } from "./demo_replay"; import { readTomlSafe } from "./run_dir_reader"; @@ -29,7 +29,7 @@ import { readTomlSafe } from "./run_dir_reader"; let serverManager: ServerManager | undefined; let statusBar: StatusBarManager | undefined; let sseClient: OpencodeEventClient | undefined; -let watcher: RunsRootWatcher | undefined; +let runsManager: RunsManager | undefined; let opencodeReadyUrl: URL | undefined; @@ -90,12 +90,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { statusBar = new StatusBarManager(); ctx.subscriptions.push({ dispose: () => statusBar?.dispose() }); - // 2. Start watching the runs root immediately — solves may already exist - // from prior dev-host sessions, and watchers are cheap. + // 2. Start the multi-run RunsManager immediately — it tails the append-only + // runs/index (1.2, #57), so solves from prior dev-host sessions register and + // a still-live run resumes; every concurrent run is tracked to completion. fs.mkdirSync(runsRoot, { recursive: true }); - watcher = new RunsRootWatcher({ runsRoot, channel: runsChannel, statusBar }); - watcher.start(); - ctx.subscriptions.push(watcher); + runsManager = new RunsManager({ runsRoot, channel: runsChannel, statusBar }); + runsManager.start(); + ctx.subscriptions.push(runsManager); // Validate lab.toml on load (0.1b / S17). A malformed hardware profile would // otherwise silently solve against the wrong hardware or fail opaquely mid-solve. @@ -241,8 +242,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } }), // On-site fallback (β.6): stage the bundled pre-baked solve into the runs - // root. The watcher already running on runsRoot follows `latest` → - // ingestRunDir replays the converged solve — no Julia, no opencode, no creds. + // root. Under the multi-run RunsManager (#57) a run that is FINISHED at + // discovery registers quietly (no auto-display), so the demo is shown by + // EXPLICIT selection: poke the index tail (same-tick registration), then + // selectRun → the display replay renders the converged solve — no Julia, + // no opencode, no creds. Promote stays suppressed (finished at discovery). vscode.commands.registerCommand("amicode.replayDemo", async () => { const demoDir = path.join(ctx.extensionPath, "demo", "run"); if (!fs.existsSync(path.join(demoDir, "FINISHED"))) { @@ -251,6 +255,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } try { const runDir = stageDemoRun(demoDir, runsRoot); + runsManager?.pokeDiscovery(); + runsManager?.selectRun(path.basename(runDir)); runsChannel.appendLine(`[demo] replayed → ${runDir}`); await vscode.commands.executeCommand("amicode.runInspector.focus"); // Save-to-catalog prompt (#47): the watcher suppresses the promote @@ -276,7 +282,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { export function deactivate(): void { sseClient?.dispose(); serverManager?.stop(); - watcher?.dispose(); + runsManager?.dispose(); statusBar?.dispose(); } diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts deleted file mode 100644 index bf25b419..00000000 --- a/packages/extension/src/file_watcher.ts +++ /dev/null @@ -1,339 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as vscode from "vscode"; -import { validateFinished, validateResult } from "@amicode/amico-run"; -import { getInspector } from "./run_inspector"; -import type { StatusBarManager } from "./status_bar"; -import type { RunStatus } from "./types"; -import { - AMICODE_ITER_RE, ingestRunDir, readTomlSafe, parseAmicoNum, PulseStream, SinkDedup, - type IterRecord, type PulseEvent, type RunCompletion, type PromoteInfo, type RunSink, -} from "./run_dir_reader"; - -// ============================================================================ -// RunsRootWatcher — watches the β.1 run-dir contract and drives the Inspector -// + status bar. Follows the `latest` symlink; for the active run it reads: -// run.toml → run identity (run_id, lab_id), written FIRST -// run.log → AMICODE_ITER lines → live stats row · AMICODE_PULSE -// lines → native live pulse plot (#66; iter_.png stays a -// run-dir/archival artifact but is no longer displayed) -// result.toml → fidelity (display + promote gate), atomic -// FINISHED → authoritative terminal signal {status, exit_code} -// -// Completion keys on FINISHED (not result.toml). The contract-reading logic is -// the pure `ingestRunDir` in run_dir_reader.ts (replay/late-join, unit-tested); -// the live path here adds incremental fs.watch + run.log tailing. -// ============================================================================ - -export interface RunsRootWatcherOptions { - runsRoot: string; - channel: vscode.OutputChannel; - statusBar?: StatusBarManager; - promoteThreshold?: number; -} - -/** Live sink: routes to the Inspector + status bar, carrying newest-wins and - * promote-once guards so replay-then-incremental never double-fires. */ -class LiveRunSink implements RunSink { - /** Newest-wins guard: frame display vs log-line iters tracked separately so the - * log high-water mark can't suppress lagging frames (see SinkDedup). */ - private readonly dedup = new SinkDedup(); - /** Live pulse-line gate (#66). Re-armed by replayed meta events so tailed - * records after a mid-flight switch keep their governing shape. */ - private readonly pulses = new PulseStream(); - constructor( - private readonly opts: RunsRootWatcherOptions, - private readonly runId: string, - private readonly runDir: string, - /** Shared across run-switches so a run promotes at most once (no re-pop). */ - private readonly promotedRuns: Set, - ) {} - - iter(rec: IterRecord): void { - this.dedup.noteIter(rec.iter); - getInspector()?.postIterationRecord(rec); - // Live status-bar update — show "running · iter N" as it solves, not only at - // completion (#5 AC3). - this.opts.statusBar?.setRun({ - runId: this.runId, outputDir: this.runDir, startedAt: 0, - status: "running", latestIter: rec.iter, - }); - } - run(c: RunCompletion): void { - // Tell the inspector the run is terminal so the badge leaves "running". - // Fires on live finish (onFinished) AND replay of an already-finished run - // (ingestRunDir) — both route through this sink. - getInspector()?.postCompletion(c.status, c.fidelity); - this.opts.statusBar?.setRun({ - runId: c.runId, outputDir: c.runDir, startedAt: 0, - status: c.status, latestIter: this.dedup.high >= 0 ? this.dedup.high : undefined, - fidelity: c.fidelity, - }); - this.opts.channel.appendLine(`[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`); - if (c.status !== "completed") { - this.opts.channel.appendLine(`[runs] see ${path.join(c.runDir, "run.log")}`); - } - } - pulse(e: PulseEvent): void { - if (e.type === "meta") this.pulses.arm(e.meta); - getInspector()?.postPulse(e); - } - /** Tail path (#66 AC6): feed a raw run.log line through the pulse gate and - * forward any accepted event. On a live run this is the ONLY meta carrier — - * ingest ran against an empty log. */ - pulseLine(line: string): void { - const e = this.pulses.onLine(line); - if (e) this.pulse(e); - } - promote(info: PromoteInfo): void { - if (this.promotedRuns.has(info.runId)) return; - this.promotedRuns.add(info.runId); - void (async () => { - const choice = await vscode.window.showInformationMessage( - `Amicode: solve converged (F=${info.fidelity.toFixed(4)}). Promote pulse to catalog?`, - "Yes — promote", "No — keep local only", - ); - if (choice === "Yes — promote") { - // #47: record in the session catalog + open the card (store - // persistence is still Phase 3 — the session catalog is workspaceState). - await vscode.commands.executeCommand("amicode.catalog.save", info.runDir).then(undefined, () => undefined); - } - })(); - } -} - -export class RunsRootWatcher implements vscode.Disposable { - private rootWatcher?: fs.FSWatcher; - private activeRunDir?: string; - private activeRunWatcher?: fs.FSWatcher; - private logTailer?: LogTailer; - private sink?: LiveRunSink; - private finishedSeen = false; - /** Runs already promoted (or already-finished when first switched to) — so the - * promote prompt fires at most once per run, never re-popping on re-switch / - * launch-follows-latest. */ - private readonly promotedRuns = new Set(); - /** Polling backstop. macOS fs.watch (FSEvents) coalesces and silently drops - * events — especially under load — so the symlink-follow + run-dir watches - * can miss `latest` swings and FINISHED, and the log tailer's change events - * can go quiet. The periodic tick re-resolves `latest`, re-checks FINISHED, - * and pokes the tailer (which self-attaches if run.log appeared unseen); the - * fs.watch paths stay for low latency. All sinks are idempotent - * (finishedSeen, log byte-offset), so double-delivery is harmless. */ - private poll?: NodeJS.Timeout; - private static readonly POLL_MS = 700; - - constructor(private readonly opts: RunsRootWatcherOptions) {} - - start(): void { - fs.mkdirSync(this.opts.runsRoot, { recursive: true }); - const latest = path.join(this.opts.runsRoot, "latest"); - if (fs.existsSync(latest)) { - // On launch, stay IDLE for a previous, already-finished run — don't re-display - // its last plot. Only resume a still-running run. A run that starts AFTER - // launch is picked up normally (idle → warming → frames). To baseline a - // finished run we set activeRunDir WITHOUT a sink, so the poll won't render it. - try { - const target = fs.realpathSync(latest); - if (fs.existsSync(path.join(target, "FINISHED"))) { this.activeRunDir = target; this.finishedSeen = true; } - else this.followLatest(); - } catch { /* noop */ } - } - this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => { - if (filename === "latest") this.followLatest(); - }); - this.poll = setInterval(() => this.tick(), RunsRootWatcher.POLL_MS); - this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot} (fs.watch + ${RunsRootWatcher.POLL_MS}ms poll)`); - } - - /** fs.watch backstop: re-resolve `latest`, then rescan the active run for new - * frames / FINISHED and drain the log — catching anything FSEvents dropped. */ - private tick(): void { - try { - if (fs.existsSync(path.join(this.opts.runsRoot, "latest"))) this.followLatest(); - const runDir = this.activeRunDir; - if (!runDir || !this.sink) return; - if (!this.finishedSeen && fs.existsSync(path.join(runDir, "FINISHED"))) { - this.finishedSeen = true; this.onFinished(runDir); - } - this.logTailer?.poke(); // drain appended AMICODE_ITER lines - } catch { /* transient fs race — next tick retries */ } - } - - dispose(): void { - if (this.poll) clearInterval(this.poll); - this.poll = undefined; - try { this.rootWatcher?.close(); } catch { /* noop */ } - try { this.activeRunWatcher?.close(); } catch { /* noop */ } - this.logTailer?.dispose(); - this.rootWatcher = undefined; - this.activeRunWatcher = undefined; - this.logTailer = undefined; - } - - private followLatest(): void { - let target: string | undefined; - try { target = fs.realpathSync(path.join(this.opts.runsRoot, "latest")); } - catch (err) { this.opts.channel.appendLine(`[runs] latest unresolved: ${(err as Error).message}`); return; } - if (target === this.activeRunDir) return; - this.opts.channel.appendLine(`[runs] active run -> ${target}`); - this.switchToRun(target); - } - - private switchToRun(runDir: string): void { - try { this.activeRunWatcher?.close(); } catch { /* noop */ } - this.logTailer?.dispose(); - this.activeRunDir = runDir; - - const runId = String(readTomlSafe(path.join(runDir, "run.toml"))?.run_id ?? path.basename(runDir)); - // If the run was ALREADY finished when we switched to it (e.g. launch follows - // `latest` to a prior completed run, or the user switches back), don't pop the - // promote prompt — only a FRESH live completion promotes. Pre-marking the run - // suppresses the replay-driven promote below. - const finishedAtSwitch = fs.existsSync(path.join(runDir, "FINISHED")); - if (finishedAtSwitch) this.promotedRuns.add(runId); - - this.sink = new LiveRunSink(this.opts, runId, runDir, this.promotedRuns); - getInspector()?.reveal(); - getInspector()?.setRunLabel(runId); - - // Replay everything already on disk (late-join safe). Returns the run.log - // bytes consumed so the tailer attaches exactly there (no skipped iters). - let logBytes = 0; - try { logBytes = ingestRunDir(runDir, this.sink, this.opts.promoteThreshold ?? 0.99); } - catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } - this.finishedSeen = finishedAtSwitch; - - // Fresh unfinished run → Julia warming up; show that instead of an idle - // panel so the ~minute cold start isn't read as frozen. The view swaps the - // hint for the plot when the first pulse record arrives. - if (!finishedAtSwitch) getInspector()?.setWarmingUp(); - - // Incremental: FINISHED (pulse/iter lines arrive via the log tailer). - this.activeRunWatcher = fs.watch(runDir, { persistent: false }, (_e, filename) => { - if (!filename) return; - if (!fs.existsSync(path.join(runDir, filename))) return; - if (filename === "FINISHED" && !this.finishedSeen) { this.finishedSeen = true; this.onFinished(runDir); } - }); - - // Incremental: appended AMICODE_ITER lines — start at the ingest offset so a - // line written between the replay read and this attach isn't skipped. - this.logTailer = new LogTailer({ - path: path.join(runDir, "run.log"), - startOffset: logBytes, - channel: this.opts.channel, - onLine: (line) => { - const m = AMICODE_ITER_RE.exec(line); - if (m) { this.sink?.iter({ iter: +m[1], f_val: parseAmicoNum(m[2]), inf_pr: parseAmicoNum(m[3]), inf_du: parseAmicoNum(m[4]) }); return; } - this.sink?.pulseLine(line); - }, - }); - this.logTailer.start(); - } - - private onFinished(runDir: string): void { - const finished = readTomlSafe(path.join(runDir, "FINISHED")); - if (!finished || !validateFinished(finished).ok) return; - const status = finished.status as RunStatus; - const runId = String(readTomlSafe(path.join(runDir, "run.toml"))?.run_id ?? path.basename(runDir)); - let fidelity: number | undefined; - if (status === "completed") { - const result = readTomlSafe(path.join(runDir, "result.toml")); - if (result) { - const v = validateResult(result); - if (v.ok) fidelity = result.fidelity as number; - // Don't silently drop fidelity + skip promote on a present-but-invalid - // result.toml — say why (S4). e.g. a pre-0.1a result.toml with no - // schema_version, or a fidelity gross-out-of-range. - else this.opts.channel.appendLine(`[runs] result.toml present but invalid: ${v.errors.join("; ")}`); - } - } - this.sink?.run({ runId, runDir, status, fidelity }); - if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) { - this.sink?.promote({ runId, runDir, fidelity }); - } - } -} - -// =========================================================================== -// LogTailer — follows run.log as julia appends, emitting each new line. -// =========================================================================== - -interface LogTailerOptions { path: string; channel: vscode.OutputChannel; onLine: (line: string) => void; startOffset?: number } - -class LogTailer implements vscode.Disposable { - private watcher?: fs.FSWatcher; - private offset = 0; - private buf = ""; - private pollTimer?: NodeJS.Timeout; - private disposed = false; - private attached = false; - - constructor(private readonly opts: LogTailerOptions) {} - - /** Backstop drain (called by the watcher's poll). Attaches first if run.log - * has appeared since start() (the 250ms retry timer may not have fired yet — - * same coalesced-FSEvents rationale as the poll itself). attach() sets the - * start offset, so it never re-reads lines ingestRunDir already replayed. */ - poke(): void { - if (this.disposed) return; - if (!this.attached && fs.existsSync(this.opts.path)) this.attach(); - if (this.attached) this.drain(); - } - - start(): void { - const tryAttach = () => { - if (this.disposed) return; - if (fs.existsSync(this.opts.path)) this.attach(); - else this.pollTimer = setTimeout(tryAttach, 250); - }; - tryAttach(); - } - - dispose(): void { - this.disposed = true; - if (this.pollTimer) clearTimeout(this.pollTimer); - try { this.watcher?.close(); } catch { /* noop */ } - this.watcher = undefined; - } - - private attach(): void { - if (this.disposed || this.attached) return; - // Start where ingestRunDir stopped reading (startOffset), not at current EOF — - // otherwise lines appended between the replay read and this attach are lost. - this.offset = this.opts.startOffset ?? 0; - this.attached = true; - try { - this.watcher = fs.watch(this.opts.path, { persistent: false }, (event) => { - if (event === "change") this.drain(); - }); - } catch (err) { - this.opts.channel.appendLine(`[runs] log tail attach failed: ${(err as Error).message}`); - } - // Drain immediately to catch lines already written past startOffset. - this.drain(); - } - - private drain(): void { - if (this.disposed) return; - let fd: number; - try { fd = fs.openSync(this.opts.path, "r"); } catch { return; } - try { - const size = fs.fstatSync(fd).size; - if (size < this.offset) { this.offset = 0; this.buf = ""; } - if (size === this.offset) return; - const chunk = Buffer.allocUnsafe(size - this.offset); - const read = fs.readSync(fd, chunk, 0, chunk.length, this.offset); - this.offset += read; - this.buf += chunk.subarray(0, read).toString("utf8"); - let nl: number; - while ((nl = this.buf.indexOf("\n")) >= 0) { - const line = this.buf.slice(0, nl); - this.buf = this.buf.slice(nl + 1); - try { this.opts.onLine(line); } catch (e) { this.opts.channel.appendLine(`[runs] onLine threw: ${String(e)}`); } - } - } finally { - try { fs.closeSync(fd); } catch { /* noop */ } - } - } -} diff --git a/packages/extension/src/log_tailer.ts b/packages/extension/src/log_tailer.ts new file mode 100644 index 00000000..319f81c4 --- /dev/null +++ b/packages/extension/src/log_tailer.ts @@ -0,0 +1,91 @@ +import * as fs from "node:fs"; +import * as vscode from "vscode"; + +// =========================================================================== +// LogTailer — follows an append-only text file (run.log, runs/index) as lines +// are appended, emitting each new line exactly once. Extracted verbatim from +// file_watcher.ts for 1.2 (#57): the multi-run RunsManager runs one tailer per +// live run's run.log PLUS one on the append-only runs/index (discovery). +// =========================================================================== + +export interface LogTailerOptions { path: string; channel: vscode.OutputChannel; onLine: (line: string) => void; startOffset?: number } + +export class LogTailer implements vscode.Disposable { + private watcher?: fs.FSWatcher; + private offset = 0; + private buf = ""; + private pollTimer?: NodeJS.Timeout; + private disposed = false; + private attached = false; + + constructor(private readonly opts: LogTailerOptions) {} + + /** Backstop drain (called by the owner's poll). Attaches first if the file + * has appeared since start() (the 250ms retry timer may not have fired yet — + * same coalesced-FSEvents rationale as the poll itself). attach() sets the + * start offset, so it never re-reads lines a replay already consumed. */ + poke(): void { + if (this.disposed) return; + if (!this.attached && fs.existsSync(this.opts.path)) this.attach(); + if (this.attached) this.drain(); + } + + start(): void { + const tryAttach = () => { + if (this.disposed) return; + if (fs.existsSync(this.opts.path)) this.attach(); + else this.pollTimer = setTimeout(tryAttach, 250); + }; + tryAttach(); + } + + dispose(): void { + this.disposed = true; + if (this.pollTimer) clearTimeout(this.pollTimer); + try { this.watcher?.close(); } catch { /* noop */ } + this.watcher = undefined; + } + + private attach(): void { + if (this.disposed || this.attached) return; + // Start where the replay stopped reading (startOffset), not at current EOF — + // otherwise lines appended between the replay read and this attach are lost. + this.offset = this.opts.startOffset ?? 0; + this.attached = true; + try { + this.watcher = fs.watch(this.opts.path, { persistent: false }, (event) => { + if (event === "change") this.drain(); + }); + // Unhandled FSWatcher 'error' would crash the host; the owner's poll + // (poke) keeps draining even if this watcher dies. + this.watcher.on("error", (e) => this.opts.channel.appendLine(`[runs] log tail watch error: ${String(e)}`)); + } catch (err) { + this.opts.channel.appendLine(`[runs] log tail attach failed: ${(err as Error).message}`); + } + // Drain immediately to catch lines already written past startOffset. + this.drain(); + } + + private drain(): void { + if (this.disposed) return; + let fd: number; + try { fd = fs.openSync(this.opts.path, "r"); } catch { return; } + try { + const size = fs.fstatSync(fd).size; + if (size < this.offset) { this.offset = 0; this.buf = ""; } + if (size === this.offset) return; + const chunk = Buffer.allocUnsafe(size - this.offset); + const read = fs.readSync(fd, chunk, 0, chunk.length, this.offset); + this.offset += read; + this.buf += chunk.subarray(0, read).toString("utf8"); + let nl: number; + while ((nl = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, nl); + this.buf = this.buf.slice(nl + 1); + try { this.opts.onLine(line); } catch (e) { this.opts.channel.appendLine(`[runs] onLine threw: ${String(e)}`); } + } + } finally { + try { fs.closeSync(fd); } catch { /* noop */ } + } + } +} diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index c07f5acc..bfd81ae7 100644 --- a/packages/extension/src/run_dir_reader.ts +++ b/packages/extension/src/run_dir_reader.ts @@ -6,7 +6,7 @@ import type { RunStatus } from "./types"; // ============================================================================ // Pure (vscode-free) reader for the β.1 run-dir contract. Unit-testable in -// isolation; the vscode-coupled RunsRootWatcher (file_watcher.ts) consumes it. +// isolation; the vscode-coupled RunsManager (runs_manager.ts) consumes it. // ============================================================================ // Float group accepts Julia's @printf %e output incl. Inf/-Inf/NaN, so stagnation @@ -136,6 +136,34 @@ export function readTomlSafe(fp: string): Record | undefined { catch { return undefined; } } +/** Terminal state of a run dir, read + validated in ONE place (review #70: the + * FINISHED→status→result.toml→fidelity orchestration used to live both here + * and in RunsManager.readTerminal — a contract change had to be edited in two + * places or finished-at-discovery diverged from live-completed). + * + * Returns undefined while FINISHED is absent OR present-but-torn/invalid + * (mid-write) — callers retry on their next pass. A present-but-invalid + * result.toml is NAMED via `onInvalidResult` (S4: say why, never silently + * drop fidelity); the default keeps this reader vscode-free via console.warn. */ +export function readTerminalState( + runDir: string, + onInvalidResult: (why: string) => void = (why) => console.warn(`[amico] ${why}`), +): { status: RunStatus; fidelity?: number } | undefined { + const finished = readTomlSafe(path.join(runDir, "FINISHED")); + if (!finished || !validateFinished(finished).ok) return undefined; + const status = finished.status as RunStatus; + let fidelity: number | undefined; + if (status === "completed") { + const result = readTomlSafe(path.join(runDir, "result.toml")); + if (result) { + const v = validateResult(result); + if (v.ok) fidelity = result.fidelity as number; + else onInvalidResult(`result.toml present but invalid (${runDir}): ${v.errors.join("; ")}`); + } + } + return { status, fidelity }; +} + /** Pure, stateless replay of a run dir against the β.1 contract. Calls each * sink method at most once per relevant artifact. Safe to re-invoke (the live * sink's guards make it idempotent). Returns the number of run.log bytes @@ -169,26 +197,13 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0 if (newestPulse) sink.pulse(newestPulse); } - // FINISHED is the authoritative terminal signal - const finished = readTomlSafe(path.join(runDir, "FINISHED")); - if (!finished || !validateFinished(finished).ok) return logBytes; - const status = finished.status as RunStatus; - - let fidelity: number | undefined; - if (status === "completed") { - const result = readTomlSafe(path.join(runDir, "result.toml")); - if (result) { - const v = validateResult(result); - if (v.ok) fidelity = result.fidelity as number; - // Present-but-nonconforming result.toml: surface WHY rather than silently - // dropping fidelity + skipping promote (S4). console.warn keeps this reader - // vscode-free; the live watcher logs to its channel too. - else console.warn(`[amico] result.toml present but invalid (${runDir}): ${v.errors.join("; ")}`); - } - } - sink.run({ runId, runDir, status, fidelity }); - if (status === "completed" && fidelity !== undefined && fidelity >= promoteThreshold) { - sink.promote({ runId, runDir, fidelity }); + // FINISHED is the authoritative terminal signal — single orchestration point + // (readTerminalState) shared with the manager's finished-at-discovery path. + const t = readTerminalState(runDir); + if (!t) return logBytes; + sink.run({ runId, runDir, status: t.status, fidelity: t.fidelity }); + if (t.status === "completed" && t.fidelity !== undefined && t.fidelity >= promoteThreshold) { + sink.promote({ runId, runDir, fidelity: t.fidelity }); } return logBytes; } diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 60009024..846f0527 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -83,7 +83,7 @@ class InspectorView implements vscode.WebviewViewProvider { } } - // -------- public surface used by RunsRootWatcher -------- + // -------- public surface used by RunsManager -------- postIterationRecord(rec: { iter: number; f_val: number; inf_pr: number; inf_du: number }): void { // Ok to drop iter records pre-materialization: the stats row refreshes on diff --git a/packages/extension/src/run_registry.ts b/packages/extension/src/run_registry.ts new file mode 100644 index 00000000..95ec482d --- /dev/null +++ b/packages/extension/src/run_registry.ts @@ -0,0 +1,99 @@ +import type { RunStatus } from "./types"; + +// ============================================================================ +// Pure multi-run registry (1.2, #57) — vscode-free so the state machine is +// unit-testable. The append-only `runs/index` (written by amico-run's +// appendIndex: `runId\tcreatedAt\tscriptPath\n`) is the multi-run source of +// truth; RunsManager tails it and registers every run here. The `latest` +// symlink keeps being WRITTEN by amico-run (frozen contract) but is no longer +// followed for discovery. +// ============================================================================ + +export interface IndexEntry { + runId: string; + createdAt: string; + scriptPath: string; +} + +/** Parse one `runs/index` line (TSV: runId, createdAt, scriptPath). The writer + * sanitizes tabs/newlines out of the path, but tolerate extra tabs anyway by + * re-joining the tail. Malformed/blank lines → undefined (never throw — the + * index is append-only and a torn final line heals on the next tail drain). */ +export function parseIndexLine(line: string): IndexEntry | undefined { + if (!line || !line.trim()) return undefined; + const parts = line.split("\t"); + if (parts.length < 3) return undefined; + const [runId, createdAt, ...rest] = parts; + if (!runId || !createdAt) return undefined; + return { runId, createdAt, scriptPath: rest.join("\t") }; +} + +/** Where a run is in its lifecycle. `live` = discovered without FINISHED (a + * pipeline is tailing it); `finished` = FINISHED observed (authoritative, + * keyed on the FINISHED file — never on result.toml presence). */ +export type RunPhase = "live" | "finished"; + +export interface RunRecord { + runId: string; + runDir: string; + createdAt?: string; + scriptPath?: string; + phase: RunPhase; + /** Terminal status once phase === "finished". */ + status?: RunStatus; + fidelity?: number; + /** High-water AMICODE_ITER seen (drives the status bar). */ + latestIter?: number; +} + +/** Multi-run record store. Registration is idempotent by runId (the index + * replays from offset 0 on every launch; re-registration is a no-op). */ +export class RunRegistry { + private readonly map = new Map(); + + /** True if newly registered; false if the runId was already known. */ + register(rec: RunRecord): boolean { + if (this.map.has(rec.runId)) return false; + this.map.set(rec.runId, { ...rec }); + return true; + } + + /** Fill ONLY missing metadata on an existing record — a scheduler-registered + * run (runId+runDir only) gains createdAt/scriptPath when its index line + * lands later. Never overwrites present values (first registration wins for + * everything stateful). */ + backfill(runId: string, meta: { createdAt?: string; scriptPath?: string }): void { + const r = this.map.get(runId); + if (!r) return; + if (r.createdAt === undefined && meta.createdAt !== undefined) r.createdAt = meta.createdAt; + if (r.scriptPath === undefined && meta.scriptPath !== undefined) r.scriptPath = meta.scriptPath; + } + + get(runId: string): RunRecord | undefined { + return this.map.get(runId); + } + + /** Snapshot COPIES — callers (1.3 trees) can't mutate registry state. */ + all(): RunRecord[] { + return [...this.map.values()].map((r) => ({ ...r })); + } + + noteIter(runId: string, iter: number): void { + const r = this.map.get(runId); + if (!r) return; + if (r.latestIter === undefined || iter > r.latestIter) r.latestIter = iter; + } + + /** First terminal wins: re-marking an already-finished run is a no-op, so a + * stray second call can't leave e.g. status:"failed" beside a stale + * fidelity from an earlier "completed" (review #70 — the guard lives HERE, + * not only in the manager's completeRun, because this is public surface the + * 1.3 consumers touch). */ + markFinished(runId: string, status: RunStatus, fidelity?: number): void { + const r = this.map.get(runId); + if (!r || r.phase === "finished") return; + r.phase = "finished"; + r.status = status; + if (fidelity !== undefined) r.fidelity = fidelity; + } +} diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts new file mode 100644 index 00000000..75eb6df6 --- /dev/null +++ b/packages/extension/src/runs_manager.ts @@ -0,0 +1,414 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { getInspector } from "./run_inspector"; +import { LogTailer } from "./log_tailer"; +import { parseIndexLine, RunRegistry, type RunRecord } from "./run_registry"; +import type { StatusBarManager } from "./status_bar"; +import type { RunStatus } from "./types"; +import { + AMICODE_ITER_RE, ingestRunDir, readTerminalState, parseAmicoNum, PulseStream, SinkDedup, + type IterRecord, type PulseEvent, type RunCompletion, type PromoteInfo, type RunSink, +} from "./run_dir_reader"; + +// ============================================================================ +// RunsManager (1.2, #57) — the multi-run evolution of β's RunsRootWatcher. +// +// Discovery: tails the APPEND-ONLY `runs/index` (amico-run appends one TSV line +// per run) instead of following the `latest` symlink — `latest` keeps being +// written (frozen contract) but is display-era plumbing; the index is the +// multi-run source of truth. Every line registers a run; every run WITHOUT a +// FINISHED gets its own live pipeline (replay → run-dir watch → run.log tail), +// so N concurrent solves are ALL tracked to completion — a second solve no +// longer yanks tracking off the first mid-flight. +// +// Fan-out: per-run events land in the registry (state) and are ROUTED to the +// single-run Inspector/StatusBar only for the SELECTED run (1.3 fans the +// inspector itself into per-run views; `selectRun` is its seam). Completions +// and the promote prompt fire for EVERY run, selected or not. Selection +// auto-follows the newest started run (parity with β's latest-follow UX) — +// UNLESS a run was selected explicitly (selectRun pins; auto-follow defers), +// so a background solve can't yank the view off a deliberately-opened run. +// +// Completion keys on FINISHED (never result.toml presence); the contract +// reading is the pure `ingestRunDir`. Double-delivery between a selection +// replay and a live tail is tolerated by design — terminal state and the +// registry are idempotent, and the pulse/stats surfaces converge: the tailer +// may transiently re-deliver records OLDER than a replayed newest (plot/stats +// briefly regress) but every drain reads to EOF, so the last delivery is +// always the true newest (same rationale as the poll backstop). +// +// Scheduler (1.1, #56/#68): `attachScheduler` consumes the lifecycle stream — +// a `started` event registers the run immediately (faster than the index +// tail; also the only path for runs under a non-default runsRoot), with the +// same pin-aware auto-follow as index discovery. +// Structural type so this compiles independently of the Scheduler landing. +// ============================================================================ + +export interface RunsManagerOptions { + runsRoot: string; + channel: vscode.OutputChannel; + statusBar?: StatusBarManager; + promoteThreshold?: number; +} + +/** The #56 Scheduler's lifecycle surface (structural — see amico-run scheduler.ts). */ +export interface SchedulerLifecycleEvent { + kind: "queued" | "started" | "finished" | "cancelled" | "error"; + queueId: string; + runId?: string; + runDir?: string; + position?: number; + status?: string; + exitCode?: number; + message?: string; +} +export interface SchedulerLike { + onEvent(listener: (e: SchedulerLifecycleEvent) => void): () => void; +} + +/** One live run's incremental machinery. State-only: routing decisions live in + * the manager (selection may change while this pipeline runs). */ +class RunPipeline implements vscode.Disposable { + readonly pulses = new PulseStream(); + readonly dedup = new SinkDedup(); + finishedSeen = false; + dirWatcher?: fs.FSWatcher; + tailer?: LogTailer; + + constructor(readonly runId: string, readonly runDir: string) {} + + dispose(): void { + try { this.dirWatcher?.close(); } catch { /* noop */ } + this.tailer?.dispose(); + this.dirWatcher = undefined; + this.tailer = undefined; + } +} + +export class RunsManager implements vscode.Disposable { + private readonly registry = new RunRegistry(); + private readonly pipelines = new Map(); + private indexTailer?: LogTailer; + private rootWatcher?: fs.FSWatcher; + private poll?: NodeJS.Timeout; + private selected?: string; + /** True once a run was selected EXPLICITLY (selectRun — demo command, 1.3 + * user clicks). Auto-follow (a newly-registered live run taking the view, + * β latest-follow parity) only applies while NOT pinned — a background + * solve starting must never yank the view off a run the user deliberately + * opened (review #70; the seam 1.3's selection UI builds on). */ + private pinned = false; + private schedulerDispose?: () => void; + /** Promote-once + never-on-replay: runs finished at DISCOVERY are pre-marked + * so only a fresh live completion prompts (ports β's finishedAtSwitch). */ + private readonly promotedRuns = new Set(); + private static readonly POLL_MS = 700; + + constructor(private readonly opts: RunsManagerOptions) {} + + start(): void { + fs.mkdirSync(this.opts.runsRoot, { recursive: true }); + // Discovery = tail the append-only index from offset 0. Launch replays the + // whole history: finished runs register terminal (idle — nothing rendered); + // a run still live across a window reload gets a pipeline and, being the + // newest live line, wins auto-selection (resume, β parity). + this.indexTailer = new LogTailer({ + path: path.join(this.opts.runsRoot, "index"), + startOffset: 0, + channel: this.opts.channel, + onLine: (line) => { + const e = parseIndexLine(line); + if (e) this.registerRun(e.runId, path.join(this.opts.runsRoot, e.runId), e.createdAt, e.scriptPath); + }, + }); + this.indexTailer.start(); + this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => { + if (filename === "index") this.indexTailer?.poke(); + }); + // An unhandled FSWatcher 'error' is an uncaught exception in the extension + // host (e.g. the watched dir deleted). The poll backstop keeps us live. + this.rootWatcher.on("error", (e) => this.opts.channel.appendLine(`[runs] root watch error: ${String(e)}`)); + this.poll = setInterval(() => this.tick(), RunsManager.POLL_MS); + this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot}/index (fs.watch + ${RunsManager.POLL_MS}ms poll)`); + } + + /** Poll backstop — macOS FSEvents coalesces/drops events, so re-poke the + * index tail and every live pipeline (FINISHED re-check + log drain). All + * consumers are idempotent, so double-delivery is harmless. */ + private tick(): void { + try { + this.indexTailer?.poke(); + for (const p of this.pipelines.values()) { + this.checkFinished(p); + p.tailer?.poke(); + } + } catch { /* transient fs race — next tick retries */ } + } + + dispose(): void { + if (this.poll) clearInterval(this.poll); + this.poll = undefined; + try { this.rootWatcher?.close(); } catch { /* noop */ } + this.rootWatcher = undefined; + this.indexTailer?.dispose(); + this.indexTailer = undefined; + this.schedulerDispose?.(); + this.schedulerDispose = undefined; + for (const p of this.pipelines.values()) p.dispose(); + this.pipelines.clear(); + } + + /** Consume the #56 Scheduler lifecycle: `started` registers the run + * immediately (its runDir is authoritative — may live outside runsRoot); + * auto-follow applies unless an explicit selection is pinned. */ + attachScheduler(scheduler: SchedulerLike): void { + this.schedulerDispose?.(); + this.schedulerDispose = scheduler.onEvent((e) => { + if (e.kind === "started" && e.runId && e.runDir) { + this.registerRun(e.runId, e.runDir); + return; + } + this.opts.channel.appendLine(`[runs] scheduler ${e.kind} ${e.runId ?? e.queueId}${e.message ? `: ${e.message}` : ""}`); + }); + } + + /** EXPLICIT selection (demo replay command; 1.3's user clicks): routes the + * single-run Inspector/StatusBar at a run AND PINS the selection — after + * this, auto-follow never steals the view (see `pinned`). Replays the run + * dir for display, then live events flow. */ + selectRun(runId: string): void { + const rec = this.registry.get(runId); + if (!rec) return; + this.pinned = true; + if (this.selected === runId) return; + this.selected = runId; + getInspector()?.reveal(); + getInspector()?.setRunLabel(runId); + // Display replay (late-join safe): full history from disk → inspector. + // Promote inside the replay stays guarded by promotedRuns, so re-selecting + // a finished run never re-pops the prompt. + try { ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); } + catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + // Fresh/live run → Julia warming up (the view swaps the hint when the first + // pulse record arrives). Same post-replay order as β's switchToRun — and, + // like β, re-check DISK (not the registry phase): FINISHED may have landed + // inside the ≤700ms poll window, and warming-after-completion would invert + // the terminal badge until the next tick. + if (rec.phase !== "finished" && !fs.existsSync(path.join(rec.runDir, "FINISHED"))) { + getInspector()?.setWarmingUp(); + } + } + + /** Force immediate index-tail drain — for flows that just appended an index + * line (demo replay) and want same-tick registration instead of waiting on + * fs.watch/poll. */ + pokeDiscovery(): void { + this.indexTailer?.poke(); + } + + /** Registry snapshot (1.3 trees / tests). */ + runs(): RunRecord[] { + return this.registry.all(); + } + + get selectedRun(): string | undefined { + return this.selected; + } + + // -------- internal -------- + + private registerRun(runId: string, runDir: string, createdAt?: string, scriptPath?: string): void { + if (this.registry.get(runId)) { + // Idempotent — the index replays from 0 every launch. But a run first + // registered off the Scheduler's `started` event (runId+runDir only) + // gains its createdAt/scriptPath when the index line lands here. + this.registry.backfill(runId, { createdAt, scriptPath }); + return; + } + if (!fs.existsSync(runDir)) { + this.opts.channel.appendLine(`[runs] index names ${runId} but ${runDir} is missing — skipped`); + return; + } + const finishedAtDiscovery = fs.existsSync(path.join(runDir, "FINISHED")); + if (finishedAtDiscovery) { + const t = this.readTerminal(runDir); + if (t) { + // Terminal at discovery: record it (status/fidelity for the registry) but + // render nothing and never re-pop the promote prompt (β launch parity). + this.registry.register({ runId, runDir, createdAt, scriptPath, phase: "finished", status: t.status, fidelity: t.fidelity }); + this.promotedRuns.add(runId); + return; + } + // FINISHED present but torn/invalid (caught mid-write) — do NOT finalize + // with an undefined status that nothing revisits (review #70): fall + // through to the live path, whose checkFinished re-reads next tick (the + // same retry the live lane already has). Promote stays suppressed: + // terminal-at-discovery is a launch replay regardless of the torn write. + this.promotedRuns.add(runId); + } + this.registry.register({ runId, runDir, createdAt, scriptPath, phase: "live" }); + const p = new RunPipeline(runId, runDir); + this.pipelines.set(runId, p); + + // Auto-follow BEFORE the replay (β latest-follow parity: a newly REGISTERED + // live run is by definition the newest start) — unless an explicit selection + // is pinned. Deciding first lets the ONE ingest below both seed pipeline + // state and feed the display through routeIter/routePulse's selection gate + // (review #70: the old shape parsed the whole run.log twice per discovery — + // a state pass, then selectRun's display pass). + const follow = !this.pinned; + if (follow && this.selected !== runId) { + this.selected = runId; + getInspector()?.reveal(); + getInspector()?.setRunLabel(runId); + } + + // Single replay: arms the pipeline's pulse stream (meta), seeds iter + // high-water, routes to the inspector iff selected above, and yields the + // byte offset the live tail starts from. + let logBytes = 0; + try { logBytes = ingestRunDir(runDir, this.pipelineSink(p), this.opts.promoteThreshold ?? 0.99); } + catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + + // FINISHED landed between the existsSync check and the replay (rare race): + // completeRun already tore the pipeline down (and — selection was assigned + // above — showed the completion); don't attach watch/tail to a disposed + // pipeline. + if (this.registry.get(runId)?.phase === "finished") return; + + // Incremental: FINISHED (authoritative terminal), then appended log lines. + p.dirWatcher = fs.watch(runDir, { persistent: false }, (_e, filename) => { + if (filename === "FINISHED") this.checkFinished(p); + }); + p.dirWatcher.on("error", (e) => this.opts.channel.appendLine(`[runs] ${runId} dir watch error: ${String(e)}`)); + p.tailer = new LogTailer({ + path: path.join(runDir, "run.log"), + startOffset: logBytes, + channel: this.opts.channel, + onLine: (line) => { + const m = AMICODE_ITER_RE.exec(line); + if (m) { this.routeIter(p, { iter: +m[1], f_val: parseAmicoNum(m[2]), inf_pr: parseAmicoNum(m[3]), inf_du: parseAmicoNum(m[4]) }); return; } + const e = p.pulses.onLine(line); + if (e) this.routePulse(p.runId, e); + }, + }); + p.tailer.start(); + + // Fresh/live run with no data yet → Julia warming up (post-replay, β order). + // Disk-checked: a torn FINISHED (fall-through above) must not read "warming". + if (follow && !fs.existsSync(path.join(runDir, "FINISHED"))) { + getInspector()?.setWarmingUp(); + } + } + + /** Sink for a pipeline's SINGLE registration replay: seeds registry/pulse + * state and — because auto-follow assigns selection BEFORE the replay — + * feeds the display through routeIter/routePulse's selection gate in the + * same pass (review #70: no second display ingest). */ + private pipelineSink(p: RunPipeline): RunSink { + return { + iter: (rec: IterRecord) => this.routeIter(p, rec), + // A FINISHED that landed between the existsSync check and this replay — + // rare race; treat exactly like a live completion. + run: (c: RunCompletion) => this.completeRun(p.runId, c.status, c.fidelity), + pulse: (e: PulseEvent) => { + if (e.type === "meta") p.pulses.arm(e.meta); + this.routePulse(p.runId, e); + }, + promote: (info: PromoteInfo) => this.promptPromote(info), + }; + } + + /** Display sink for selection replays: inspector + status bar; promote stays + * guarded. For a still-live run, meta also re-arms the pipeline stream. */ + private displaySink(rec: RunRecord): RunSink { + const p = this.pipelines.get(rec.runId); + return { + iter: (r: IterRecord) => { + this.registry.noteIter(rec.runId, r.iter); + getInspector()?.postIterationRecord(r); + this.opts.statusBar?.setRun({ runId: rec.runId, outputDir: rec.runDir, startedAt: 0, status: "running", latestIter: r.iter }); + }, + run: (c: RunCompletion) => { + getInspector()?.postCompletion(c.status, c.fidelity); + this.opts.statusBar?.setRun({ runId: c.runId, outputDir: c.runDir, startedAt: 0, status: c.status, latestIter: this.registry.get(rec.runId)?.latestIter, fidelity: c.fidelity }); + }, + pulse: (e: PulseEvent) => { + if (e.type === "meta") p?.pulses.arm(e.meta); + getInspector()?.postPulse(e); + }, + promote: (info: PromoteInfo) => this.promptPromote(info), + }; + } + + private routeIter(p: RunPipeline, rec: IterRecord): void { + p.dedup.noteIter(rec.iter); + this.registry.noteIter(p.runId, rec.iter); + if (this.selected !== p.runId) return; + getInspector()?.postIterationRecord(rec); + // Live status-bar update — "running · iter N" as it solves (#5 AC3). + this.opts.statusBar?.setRun({ runId: p.runId, outputDir: p.runDir, startedAt: 0, status: "running", latestIter: rec.iter }); + } + + private routePulse(runId: string, e: PulseEvent): void { + if (this.selected !== runId) return; + getInspector()?.postPulse(e); + } + + private checkFinished(p: RunPipeline): void { + if (p.finishedSeen) return; + if (!fs.existsSync(path.join(p.runDir, "FINISHED"))) return; + const t = this.readTerminal(p.runDir); + if (!t) return; // torn/invalid FINISHED — next tick retries + p.finishedSeen = true; + this.completeRun(p.runId, t.status, t.fidelity); + } + + /** Terminal handling for ANY run, selected or not: registry, teardown, + * channel, inspector/status-bar (selected only), promote (any run, once). */ + private completeRun(runId: string, status: RunStatus, fidelity?: number): void { + const rec = this.registry.get(runId); + if (!rec || rec.phase === "finished") return; // idempotent (watch + poll can both fire) + this.registry.markFinished(runId, status, fidelity); + const p = this.pipelines.get(runId); + p?.dispose(); + this.pipelines.delete(runId); + this.opts.channel.appendLine(`[runs] ${runId} ${status}${fidelity !== undefined ? ` F=${fidelity.toFixed(6)}` : ""}`); + if (status !== "completed") this.opts.channel.appendLine(`[runs] see ${path.join(rec.runDir, "run.log")}`); + if (this.selected === runId) { + getInspector()?.postCompletion(status, fidelity); + this.opts.statusBar?.setRun({ runId, outputDir: rec.runDir, startedAt: 0, status, latestIter: rec.latestIter, fidelity }); + } + if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) { + this.promptPromote({ runId, runDir: rec.runDir, fidelity }); + } + } + + /** FINISHED (+ result.toml fidelity) with the same validation + say-why + * logging as β (S4: a present-but-invalid result.toml is named, not + * silently dropped). */ + private readTerminal(runDir: string): { status: RunStatus; fidelity?: number } | undefined { + // Delegates to the reader's single orchestration point (review #70 — the + // FINISHED→result.toml sequence must not be maintained twice); only the + // say-why channel is manager-specific. + return readTerminalState(runDir, (why) => this.opts.channel.appendLine(`[runs] ${why}`)); + } + + private promptPromote(info: PromoteInfo): void { + if (this.promotedRuns.has(info.runId)) return; + this.promotedRuns.add(info.runId); + void (async () => { + const choice = await vscode.window.showInformationMessage( + `Amicode: solve converged (F=${info.fidelity.toFixed(4)}). Promote pulse to catalog?`, + "Yes — promote", "No — keep local only", + ); + if (choice === "Yes — promote") { + // #47: record in the session catalog + open the card (store persistence + // is still Phase 3 — the session catalog is workspaceState). Ported from + // file_watcher.ts (Kate's #73), which this manager supersedes. + await vscode.commands.executeCommand("amicode.catalog.save", info.runDir).then(undefined, () => undefined); + } + })(); + } +} diff --git a/packages/extension/test/log_tailer.test.ts b/packages/extension/test/log_tailer.test.ts new file mode 100644 index 00000000..098185e7 --- /dev/null +++ b/packages/extension/test/log_tailer.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LogTailer } from "../src/log_tailer"; + +// LogTailer is now load-bearing for multi-run DISCOVERY (it tails runs/index), +// not just run.log display — pin the buffering semantics the RunsManager +// depends on: newline-delimited emission, torn-line carry-over, truncation +// reset, and the startOffset contract. + +const channel = { appendLine() {}, append() {} } as never; + +function harness(content?: string, startOffset = 0) { + const dir = mkdtempSync(join(tmpdir(), "tail-")); + const p = join(dir, "index"); + if (content !== undefined) writeFileSync(p, content); + const lines: string[] = []; + const t = new LogTailer({ path: p, startOffset, channel, onLine: (l) => lines.push(l) }); + return { p, t, lines }; +} + +describe("LogTailer", () => { + it("emits complete lines once; a torn final line (no newline yet) waits and heals", () => { + const { p, t, lines } = harness("a\t1\t/s.jl\nb\t2\t/s"); // second line torn mid-write + t.poke(); + expect(lines).toEqual(["a\t1\t/s.jl"]); // torn tail NOT emitted + appendFileSync(p, ".jl\nc\t3\t/t.jl\n"); // writer finishes + appends + t.poke(); + expect(lines).toEqual(["a\t1\t/s.jl", "b\t2\t/s.jl", "c\t3\t/t.jl"]); // healed, no split + t.dispose(); + }); + + it("truncation resets to offset 0 and re-reads (consumers must be idempotent)", () => { + const { p, t, lines } = harness("one\ntwo\n"); + t.poke(); + expect(lines).toEqual(["one", "two"]); + writeFileSync(p, "one\n"); // file shrank (rewrite) + t.poke(); + expect(lines).toEqual(["one", "two", "one"]); // full re-read from 0 + t.dispose(); + }); + + it("startOffset skips exactly the replayed bytes (no double-emit, no skipped line)", () => { + const body = "replayed\n"; + const { p, t, lines } = harness(body + "fresh\n", Buffer.byteLength(body, "utf8")); + t.poke(); + expect(lines).toEqual(["fresh"]); + t.dispose(); + }); + + it("poke() self-attaches when the file appears after start()", () => { + const { p, t, lines } = harness(undefined); // file doesn't exist yet + t.poke(); + expect(lines).toEqual([]); + writeFileSync(p, "late\n"); + t.poke(); // attaches + drains + expect(lines).toEqual(["late"]); + t.dispose(); + }); +}); diff --git a/packages/extension/test/run_registry.test.ts b/packages/extension/test/run_registry.test.ts new file mode 100644 index 00000000..d501c91c --- /dev/null +++ b/packages/extension/test/run_registry.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { parseIndexLine, RunRegistry } from "../src/run_registry"; + +// Pure multi-run registry (1.2, #57) — the vscode-free state the RunsManager +// keys on. The index grammar matches amico-run's appendIndex writer +// (`runId\tcreatedAt\tscriptPath\n`, path sanitized of tabs/newlines). + +describe("parseIndexLine — runs/index grammar", () => { + it("parses the writer's TSV line", () => { + expect(parseIndexLine("r20260703-010203Z-ab12\t2026-07-03T01:02:03Z\t/tmp/solve.jl")).toEqual({ + runId: "r20260703-010203Z-ab12", createdAt: "2026-07-03T01:02:03Z", scriptPath: "/tmp/solve.jl", + }); + }); + it("rejects blank and malformed lines (torn final line heals on next drain)", () => { + expect(parseIndexLine("")).toBeUndefined(); + expect(parseIndexLine(" ")).toBeUndefined(); + expect(parseIndexLine("r1\tonly-two-fields")).toBeUndefined(); + expect(parseIndexLine("\t\t/s.jl")).toBeUndefined(); // empty runId + }); + it("re-joins extra tabs into the path (defensive — the writer sanitizes)", () => { + expect(parseIndexLine("r1\t2026-01-01T00:00:00Z\t/a\tb.jl")?.scriptPath).toBe("/a\tb.jl"); + }); +}); + +describe("RunRegistry", () => { + it("registration is idempotent by runId (index replays from 0 every launch)", () => { + const reg = new RunRegistry(); + expect(reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" })).toBe(true); + expect(reg.register({ runId: "r1", runDir: "/elsewhere", phase: "finished" })).toBe(false); + expect(reg.get("r1")?.runDir).toBe("/runs/r1"); // first registration wins + expect(reg.get("r1")?.phase).toBe("live"); + }); + it("noteIter is a monotonic high-water mark", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); + reg.noteIter("r1", 5); + reg.noteIter("r1", 3); // out-of-order (poll double-delivery) + expect(reg.get("r1")?.latestIter).toBe(5); + reg.noteIter("nope", 9); // unknown run — no throw + }); + it("markFinished sets phase/status/fidelity and keeps latestIter", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); + reg.noteIter("r1", 42); + reg.markFinished("r1", "completed", 0.9991); + expect(reg.get("r1")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9991, latestIter: 42 }); + }); + it("markFinished: first terminal wins — re-marking can't leave a contradictory status/fidelity pair (review #70 #3)", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); + reg.markFinished("r1", "completed", 0.999); + reg.markFinished("r1", "failed"); // stray second call (public surface, 1.3 consumers) + expect(reg.get("r1")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.999 }); + }); + it("backfill fills ONLY missing metadata (scheduler-registered run gains createdAt/scriptPath from a later index line)", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); // scheduler path: no metadata + expect(reg.get("r1")?.createdAt).toBeUndefined(); + expect(reg.get("r1")?.scriptPath).toBeUndefined(); + reg.backfill("r1", { createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); + expect(reg.get("r1")).toMatchObject({ createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); + // never overwrites a present value (first registration wins for everything) + reg.backfill("r1", { createdAt: "2099-01-01T00:00:00Z", scriptPath: "/other.jl" }); + expect(reg.get("r1")).toMatchObject({ createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); + reg.backfill("nope", { createdAt: "x" }); // unknown run — no throw + }); + it("all() returns COPIES — callers can't mutate registry state", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); + const snap = reg.all(); + snap[0].phase = "finished"; + snap[0].latestIter = 999; + expect(reg.get("r1")?.phase).toBe("live"); + expect(reg.get("r1")?.latestIter).toBeUndefined(); + }); +}); diff --git a/packages/extension/test/runs_manager.test.ts b/packages/extension/test/runs_manager.test.ts new file mode 100644 index 00000000..3e1fa499 --- /dev/null +++ b/packages/extension/test/runs_manager.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { appendFileSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as vscodeMock from "vscode"; + +// Drive the live RunsManager (1.2, #57) over a temp runs root and assert the +// inspector calls. Ports the RunsRootWatcher state-machine coverage (idle-on- +// finished baseline, warming→completion, #66 pulse routing) onto index-driven +// discovery, and adds the multi-run behaviors: concurrent runs all tracked, +// selection routing, background completion/promote, the Scheduler seam, and +// the explicit-selection demo-replay path. +// +// The inspector is mocked (getInspector() returns spies); `vscode` is the +// aliased stub. tick() is called directly so the poll path is deterministic. + +const { inspector } = vi.hoisted(() => ({ + inspector: { + setWarmingUp: vi.fn(), + postCompletion: vi.fn(), + postIterationRecord: vi.fn(), + postPulse: vi.fn(), + setRunLabel: vi.fn(), + reveal: vi.fn(), + }, +})); +vi.mock("../src/run_inspector", () => ({ getInspector: () => inspector })); + +import { RunsManager, type SchedulerLifecycleEvent, type SchedulerLike } from "../src/runs_manager"; + +const channel = { appendLine() {}, append() {} } as never; +const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n'; + +function writeManifest(dir: string, runId: string): void { + writeFileSync(join(dir, "run.toml"), + `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\n` + + `lab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`); +} +/** Stage a run dir + its index line (the amico-run writer's TSV format). */ +function stageRun(root: string, runId: string, opts: { finished?: string; fidelity?: number; log?: string } = {}): string { + const dir = join(root, runId); + mkdirSync(dir, { recursive: true }); + writeManifest(dir, runId); + if (opts.log !== undefined) writeFileSync(join(dir, "run.log"), opts.log); + if (opts.fidelity !== undefined) writeFileSync(join(dir, "result.toml"), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = 9\n`); + if (opts.finished) writeFileSync(join(dir, "FINISHED"), `status = "${opts.finished}"\nexit_code = 0\n`); + appendFileSync(join(root, "index"), `${runId}\t2026-07-03T00:00:00Z\t/s.jl\n`); + return dir; +} +const tick = (m: RunsManager): void => (m as unknown as { tick(): void }).tick(); + +describe("RunsManager state machine (ported from RunsRootWatcher)", () => { + beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + + it("a run already FINISHED at launch stays idle — nothing re-rendered", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + stageRun(root, "r1", { finished: "completed", fidelity: 0.9999 }); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + tick(m); + expect(inspector.postPulse).not.toHaveBeenCalled(); + expect(inspector.postCompletion).not.toHaveBeenCalled(); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); + expect(m.runs()).toHaveLength(1); // …but it IS registered + expect(m.runs()[0]).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9999 }); + m.dispose(); + }); + + it("fresh run → warming-up → completion (FINISHED-keyed, not result.toml presence)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const run = stageRun(root, "r2"); // manifest only, no data yet + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(inspector.setWarmingUp).toHaveBeenCalledTimes(1); + expect(inspector.setRunLabel).toHaveBeenCalledWith("r2"); + expect(m.selectedRun).toBe("r2"); + + // result.toml alone must NOT complete the run (FINISHED is authoritative). + writeFileSync(join(run, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 18\n'); + tick(m); + expect(inspector.postCompletion).not.toHaveBeenCalled(); + + writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + tick(m); + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + m.dispose(); + }); + + it("live tail forwards meta and each record in order as they land (#66)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const run = stageRun(root, "p1"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(inspector.postPulse).not.toHaveBeenCalled(); + + writeFileSync(join(run, "run.log"), META_LINE + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n"); + tick(m); + expect(inspector.postPulse).toHaveBeenCalledTimes(2); + expect(inspector.postPulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "meta" })); + expect(inspector.postPulse).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); + + appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n"); + tick(m); + expect(inspector.postPulse).toHaveBeenCalledTimes(3); + expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); + m.dispose(); + }); + + it("replay-seeded meta arms the live stream: tailed records flow without a re-sent meta", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + // Mid-flight discovery: meta + one record ALREADY on disk, run not finished. + const run = stageRun(root, "p2", { log: META_LINE + "AMICODE_PULSE iter=3 dt=0.2 a=0.1,0.2\n" }); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); // display replay → meta + newest record + expect(inspector.postPulse).toHaveBeenCalledTimes(2); + + appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=4 dt=0.2 a=0.3,0.4\n"); + tick(m); // record parses against the armed meta + expect(inspector.postPulse).toHaveBeenCalledTimes(3); + expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); + m.dispose(); + }); +}); + +describe("RunsManager multi-run (#57)", () => { + beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + + it("two concurrent live runs: newest auto-selected, BOTH tracked to completion", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const a = stageRun(root, "rA"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(m.selectedRun).toBe("rA"); + + const b = stageRun(root, "rB"); // second solve starts + tick(m); // index tail discovers it + expect(m.selectedRun).toBe("rB"); // auto-follow the newest start + inspector.postIterationRecord.mockClear(); + + // Background run A keeps streaming — tracked (registry) but NOT displayed. + appendFileSync(join(a, "run.log"), "AMICODE_ITER iter=7 f=0.1 inf_pr=1e-8 inf_du=1e-6\n"); + tick(m); + expect(inspector.postIterationRecord).not.toHaveBeenCalled(); + expect(m.runs().find(r => r.runId === "rA")?.latestIter).toBe(7); + + // A finishes in the background: registry terminal, inspector untouched… + writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9995\niterations = 7\n'); + writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + tick(m); + expect(inspector.postCompletion).not.toHaveBeenCalled(); // rB is selected + expect(m.runs().find(r => r.runId === "rA")).toMatchObject({ phase: "finished", fidelity: 0.9995 }); + // …but the promote prompt STILL fires (fan-out is per-run, not per-selection). + expect(promote).toHaveBeenCalledTimes(1); + + // B completes while selected → completion reaches the inspector. + writeFileSync(join(b, "FINISHED"), 'status = "failed"\nexit_code = 3\n'); + tick(m); + expect(inspector.postCompletion).toHaveBeenCalledWith("failed", undefined); + promote.mockRestore(); + m.dispose(); + }); + + it("selectRun back to a finished run replays its terminal state — promote never re-pops", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const a = stageRun(root, "rA"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 3\n'); + writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + tick(m); // live completion (promotes once) + stageRun(root, "rB"); + tick(m); // selection moves to rB + expect(m.selectedRun).toBe("rB"); + + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + inspector.postCompletion.mockClear(); + m.selectRun("rA"); // user switches back (1.3 seam) + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(promote).not.toHaveBeenCalled(); // promote-once held + promote.mockRestore(); + m.dispose(); + }); + + it("PULSE events are gated on selection too (not just iter) — background run's plot never reaches the inspector", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const a = stageRun(root, "rA", { log: META_LINE }); // rA armed with meta + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + stageRun(root, "rB"); + tick(m); + expect(m.selectedRun).toBe("rB"); // rA now background + inspector.postPulse.mockClear(); + + // A background pulse RECORD on rA must not reach the inspector (rB selected). + appendFileSync(join(a, "run.log"), "AMICODE_PULSE iter=5 dt=0.2 a=0.1,0.2\n"); + tick(m); + expect(inspector.postPulse).not.toHaveBeenCalled(); + m.dispose(); + }); + + it("selecting a run whose FINISHED landed inside the poll window shows completion, never warming", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const a = stageRun(root, "rA"); // live at discovery → pipeline + selected + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + stageRun(root, "rB"); + tick(m); // selection moves to rB (rA still "live" in registry) + inspector.setWarmingUp.mockClear(); + inspector.postCompletion.mockClear(); + + // rA finishes on disk but the poll hasn't ticked (registry still says live). + writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 3\n'); + writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + m.selectRun("rA"); // user switches back BEFORE the tick + // selectRun re-checks disk → completion, NOT warming (no terminal-badge inversion). + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); + m.dispose(); + }); + + it("an index line naming a missing run dir is tolerated (skipped, no throw)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + writeFileSync(join(root, "index"), "rGone\t2026-07-03T00:00:00Z\t/s.jl\n"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + tick(m); + expect(m.runs()).toHaveLength(0); + m.dispose(); + }); + + it("scheduler `started` registers + selects the run immediately (the #56 seam)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + + let emit!: (e: SchedulerLifecycleEvent) => void; + const scheduler: SchedulerLike = { onEvent: (l) => { emit = l; return () => { /* dispose */ }; } }; + m.attachScheduler(scheduler); + + // A scheduler-launched run — no index line yet (the executor appends it, + // but the started event beats the fs). + const dir = join(root, "rSched"); + mkdirSync(dir); writeManifest(dir, "rSched"); + emit({ kind: "queued", queueId: "q1", position: 0 }); // logged, no throw + emit({ kind: "started", queueId: "q1", runId: "rSched", runDir: dir }); + expect(m.selectedRun).toBe("rSched"); + expect(inspector.setRunLabel).toHaveBeenCalledWith("rSched"); + expect(inspector.setWarmingUp).toHaveBeenCalled(); + + // The index line landing later is a no-op (registration is idempotent). + appendFileSync(join(root, "index"), "rSched\t2026-07-03T00:00:00Z\t/s.jl\n"); + tick(m); + expect(m.runs().filter(r => r.runId === "rSched")).toHaveLength(1); + m.dispose(); + }); + + it("demo replay: a finished run registers quietly; EXPLICIT selection renders it, promote suppressed", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + stageRun(root, "rDemo", { + finished: "completed", fidelity: 0.9998, + log: META_LINE + "AMICODE_PULSE iter=60 dt=0.2 a=0.1,0.2\nAMICODE_ITER iter=60 f=2e-3 inf_pr=1e-9 inf_du=1e-6\n", + }); + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + m.pokeDiscovery(); // same-tick registration… + expect(inspector.postCompletion).not.toHaveBeenCalled(); // …but no auto-display + m.selectRun("rDemo"); // the replayDemo command's path + expect(inspector.setRunLabel).toHaveBeenCalledWith("rDemo"); + expect(inspector.postPulse).toHaveBeenCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) })); + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9998); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // finished — never "warming" + expect(promote).not.toHaveBeenCalled(); // finished-at-discovery: no prompt + promote.mockRestore(); + m.dispose(); + }); +}); + +// Review #70 findings — one test per fix (jack-champagne's static/design pass). +describe("RunsManager review-#70 fixes", () => { + beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + + it("#1 explicit selection is PINNED — a new live run registering does not steal the view", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + stageRun(root, "rA", { finished: "completed", fidelity: 0.9 }); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + m.selectRun("rA"); // the user deliberately opens rA + expect(m.selectedRun).toBe("rA"); + inspector.setRunLabel.mockClear(); + + stageRun(root, "rB"); // background solve starts + tick(m); + expect(m.selectedRun).toBe("rA"); // auto-follow deferred to the pin + expect(inspector.setRunLabel).not.toHaveBeenCalledWith("rB"); + expect(m.runs().find(r => r.runId === "rB")?.phase).toBe("live"); // …but rB IS tracked + + m.selectRun("rB"); // explicit switch still works + expect(m.selectedRun).toBe("rB"); + m.dispose(); + }); + + it("#1 auto-follow still applies while nothing was explicitly selected (β latest-follow parity)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + stageRun(root, "rA"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + stageRun(root, "rB"); + tick(m); + expect(m.selectedRun).toBe("rB"); // no pin → newest live run wins + m.dispose(); + }); + + it("#2 a torn/invalid FINISHED at discovery is retried, not finalized as status:undefined", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const dir = join(root, "rTorn"); + mkdirSync(dir, { recursive: true }); + writeManifest(dir, "rTorn"); + writeFileSync(join(dir, "result.toml"), 'schema_version = "1"\nfidelity = 0.9997\niterations = 5\n'); + writeFileSync(join(dir, "FINISHED"), 'status = "comp'); // torn mid-write: invalid TOML + appendFileSync(join(root, "index"), "rTorn\t2026-07-04T00:00:00Z\t/s.jl\n"); + + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + // NOT finalized with an undefined status — held live so the retry lane owns it. + expect(m.runs().find(r => r.runId === "rTorn")).toMatchObject({ phase: "live" }); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // FINISHED exists on disk — never "warming" + + writeFileSync(join(dir, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); // the write completes + tick(m); + expect(m.runs().find(r => r.runId === "rTorn")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9997 }); + expect(promote).not.toHaveBeenCalled(); // still a launch replay — promote suppressed + promote.mockRestore(); + m.dispose(); + }); + + it("#4 discovery ingests the run dir ONCE — no second display pass (registration replay feeds the display)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + stageRun(root, "rOne", { log: META_LINE + "AMICODE_PULSE iter=3 dt=0.2 a=0.1,0.2\n" }); + // The old shape ran ingestRunDir twice per discovery: a pipelineSink state + // pass, then auto-follow's selectRun → a displaySink DISPLAY pass over the + // same run.log. displaySink now only backs EXPLICIT selection replays — its + // absence during discovery is the single-pass property. + const displayPass = vi.spyOn(RunsManager.prototype as never as { displaySink(): unknown }, "displaySink"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(displayPass).not.toHaveBeenCalled(); // was 1 per discovery + // …and the single pass still displayed the history (meta + newest record): + expect(inspector.postPulse).toHaveBeenCalledTimes(2); + displayPass.mockRestore(); + m.dispose(); + }); +}); diff --git a/packages/extension/test/watcher_statemachine.test.ts b/packages/extension/test/watcher_statemachine.test.ts deleted file mode 100644 index 8826f7c2..00000000 --- a/packages/extension/test/watcher_statemachine.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { appendFileSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -// Drive the live RunsRootWatcher state machine over a temp run dir and assert the -// inspector calls — the poll backstop + idle-on-finished baseline + warming→frame -// transition that the SinkDedup unit test does NOT cover (Jack's #23 [important]). -// -// The inspector is mocked (so getInspector() returns spies); `vscode` is the -// aliased stub (vitest.config.ts). We call the private tick() directly so the -// poll path is exercised deterministically instead of racing the 700ms timer. - -const { inspector } = vi.hoisted(() => ({ - inspector: { - setWarmingUp: vi.fn(), - postCompletion: vi.fn(), - postIterationRecord: vi.fn(), - postPulse: vi.fn(), - setRunLabel: vi.fn(), - reveal: vi.fn(), - }, -})); -vi.mock("../src/run_inspector", () => ({ getInspector: () => inspector })); - -import { RunsRootWatcher } from "../src/file_watcher"; - -const channel = { appendLine() {}, append() {} } as never; - -function writeManifest(dir: string, runId: string): void { - writeFileSync(join(dir, "run.toml"), - `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\n` + - `lab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`); -} -function setLatest(root: string, target: string): void { - const link = join(root, "latest"); - try { rmSync(link); } catch { /* none */ } - symlinkSync(target, link); -} -const tick = (w: RunsRootWatcher): void => (w as unknown as { tick(): void }).tick(); -const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n'; - -describe("RunsRootWatcher state machine", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); - - it("a run already FINISHED at launch stays idle — nothing re-rendered", () => { - const root = mkdtempSync(join(tmpdir(), "runs-")); - const run = join(root, "r1"); mkdirSync(run); - writeManifest(run, "r1"); - writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); - setLatest(root, run); - - const w = new RunsRootWatcher({ runsRoot: root, channel }); - w.start(); - tick(w); // even after a poll, a finished-at-launch run must render nothing - expect(inspector.postPulse).not.toHaveBeenCalled(); - expect(inspector.postCompletion).not.toHaveBeenCalled(); - expect(inspector.setWarmingUp).not.toHaveBeenCalled(); - w.dispose(); - }); - - it("fresh run → warming-up → completion (plot arrives via pulse routing, not frames)", () => { - const root = mkdtempSync(join(tmpdir(), "runs-")); - const run = join(root, "r2"); mkdirSync(run); - writeManifest(run, "r2"); // manifest only, no data yet - setLatest(root, run); - - const w = new RunsRootWatcher({ runsRoot: root, channel }); - w.start(); - // fresh run with no data → warming, not idle - expect(inspector.setWarmingUp).toHaveBeenCalledTimes(1); - expect(inspector.setRunLabel).toHaveBeenCalledWith("r2"); - - // FINISHED + result → terminal completion delivered once - writeFileSync(join(run, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 18\n'); - writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); - tick(w); - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); - w.dispose(); - }); -}); - -// #66 AC6 — live-tail pulse routing. On a live run the tailer is the ONLY -// carrier of meta (ingest sees an empty log at run start), so the tail path -// must forward meta AND each record, in order. -describe("pulse-line routing (#66)", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); - - it("live tail forwards meta and each record in order as they land", () => { - const root = mkdtempSync(join(tmpdir(), "runs-")); - const run = join(root, "p1"); mkdirSync(run); - writeManifest(run, "p1"); - setLatest(root, run); - const w = new RunsRootWatcher({ runsRoot: root, channel }); - w.start(); // fresh run, empty log → warming - expect(inspector.postPulse).not.toHaveBeenCalled(); - - writeFileSync(join(run, "run.log"), META_LINE + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n"); - tick(w); // poll poke drains the tailer - expect(inspector.postPulse).toHaveBeenCalledTimes(2); - expect(inspector.postPulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "meta" })); - expect(inspector.postPulse).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); - - appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n"); - tick(w); - expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); - w.dispose(); - }); - - it("replay-seeded meta arms the live stream: tailed records flow without a re-sent meta", () => { - const root = mkdtempSync(join(tmpdir(), "runs-")); - const run = join(root, "p2"); mkdirSync(run); - writeManifest(run, "p2"); - // Mid-flight switch: meta + one record ALREADY on disk, run not finished. - writeFileSync(join(run, "run.log"), META_LINE + "AMICODE_PULSE iter=3 dt=0.2 a=0.1,0.2\n"); - setLatest(root, run); - const w = new RunsRootWatcher({ runsRoot: root, channel }); - w.start(); // ingest replays meta + newest record - expect(inspector.postPulse).toHaveBeenCalledTimes(2); - - // a record tailed AFTER attach, with no meta line in the tailed region - appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=4 dt=0.2 a=0.5,0.6\n"); - tick(w); - expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); - w.dispose(); - }); -}); From d393e3ec92eb047a47da65fd8a10797d9853b2eb Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Sun, 5 Jul 2026 23:14:38 -0400 Subject: [PATCH 05/50] =?UTF-8?q?1.3:=20Run=20Inspector=20single=E2=86=92m?= =?UTF-8?q?ulti-run=20(runId-keyed=20protocol,=20per-run=20panes)=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(1.3): Run Inspector single→multi-run (runId-keyed protocol, per-run panes) Freeze-2 reshape (#58): the host↔webview message protocol is now runId-keyed and both the host and the webview fan into per-run panes. Single→multi only — the pane markup stays the current pulseplot (design lane, UX4 #49). Host (run_inspector.ts): a PaneBuffer per runId + activeRunId; runId-keyed surface postPulse/postIterationRecord/postCompletion/setWarmingUp/setRunLabel + new activate(runId). Per-run 5 Hz pulse throttle. resolveWebviewView replays EVERY pane from its buffer (S36) with positional ordering, then posts activate last. setWarmingUp guarded from clobbering a pane that already has data/terminal state. pulse stays plot-only (deliberately does not clear warming). Webview (media/ui/views/inspector.ts): createPanel() instances the former single-run view per runId (no shared globals); a router keys panels by runId, activate toggles the one visible pane, background/late messages only touch their own pane. Pane-hiding uses two-class selectors so it wins over layout.css `.stack` on specificity, not stylesheet order. RunsManager (runs_manager.ts): fans every run's live events into the inspector runId-tagged (routeIter/routePulse ungated); registration replay is state-only so the selected run never double-posts; selectRun adds activate; the single status bar stays selection-gated; completion + promote still fire per-run. Tests: runs_manager + inspector_view_contract updated to the runId-keyed API and fan-out semantics; added per-run isolation, per-run-throttle independence, S36 reopen, activate-last, warming-guard. New happy-dom webview test covers the router itself (per-run isolation, activate toggle, empty-state, plot-only pulse) — closes the coverage gap flagged in adversarial review. All invariants mutation-verified. 121 tests pass; typecheck + build clean. S6 (formulation preview) deferred — no formulation-emit in the frozen contract. Co-Authored-By: Claude Fable 5 * refactor(1.3): flow RunCompletion whole through completeRun — the #84/#81 seam Jack's #72 merge-seam heads-up: #81 adds `formulation?` to RunCompletion, and completeRun was the third completion path cherry-picking fields positionally (runId/status/fidelity) — once #81 landed, live-completed runs would carry formulation: undefined while replayed runs got it (the exact bug Kate caught on onFinished, reintroduced here). completeRun now takes the WHOLE RunCompletion; both feeders (ingestRunDir's sink verbatim, checkFinished via {runId, runDir, ...readTerminalState()}) funnel the object from the one shared read. An additive field is now a one-place edit (RunCompletion + readTerminalState) and reaches every consumer by construction — consumers cherry-pick at the leaf. Documented as the #84 funnel on both the type and completeRun; the full N-reader consolidation (catalog hydrator etc.) stays #84. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../extension/media/ui/views/inspector.ts | 118 ++++++--- packages/extension/package.json | 3 +- packages/extension/src/run_dir_reader.ts | 4 + packages/extension/src/run_inspector.ts | 236 ++++++++++-------- packages/extension/src/runs_manager.ts | 168 ++++++++----- .../test/inspector_view_contract.test.ts | 146 ++++++++--- .../test/inspector_webview_view.test.ts | 87 +++++++ packages/extension/test/runs_manager.test.ts | 92 ++++--- pnpm-lock.yaml | 69 ++++- 9 files changed, 638 insertions(+), 285 deletions(-) create mode 100644 packages/extension/test/inspector_webview_view.test.ts diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index 8ab276b6..9dfb4446 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,6 +21,10 @@ 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”."; @@ -28,7 +36,15 @@ 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; +} + +function createPanel(): Panel { const status = pill("idle"); const runLabel = text("mono small dim"); const pulse = pulseplot(IDLE_HINT); @@ -37,10 +53,7 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView const feasibility = metric("feasibility"); const optimality = metric("optimality"); const metrics = [hero, iteration, feasibility, optimality]; - - /** 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"; @@ -56,24 +69,18 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView grid.append(...metrics.map((m) => m.el)); 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); return { el, - onMessage(msg: any): void { - if (!msg || typeof msg !== "object") return; + 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 "iteration": { + case "iteration": hero.label("objective"); hero.value((msg.f_val as number).toExponential(4)); iteration.value(String(msg.iter)); @@ -81,42 +88,77 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView optimality.value((msg.kkt_error as number).toExponential(2)); status.set("running", "running"); 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"); break; - } case "completed": { - // Authoritative terminal state from the watcher (FINISHED on disk). const ok = msg.status === "completed"; status.set(ok ? "done" : "failed", ok ? "converged" : String(msg.status)); - // Promote the hero card to the final fidelity — the number that matters. if (ok && typeof msg.fidelity === "number") { hero.label("fidelity"); hero.value((msg.fidelity as number).toFixed(5)); } - if (!gotPulse) pulse.waiting(NO_DATA_HINT); // old runs / non-emitting scripts + if (!gotPulse) pulse.waiting(NO_DATA_HINT); 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; - pulse.update({ iter: msg.iter, dt: msg.dt, values: msg.values }); + 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(); + 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); + 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/package.json b/packages/extension/package.json index 70e3c4ff..3c52b323 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,7 @@ { "name": "amicode-v2", "displayName": "Amicode v2", - "description": "Amico research IDE \u2014 opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", + "description": "Amico research IDE — opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", "version": "0.0.1", "publisher": "harmoniqs", "license": "Apache-2.0", @@ -149,6 +149,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" diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index bfd81ae7..9055a445 100644 --- a/packages/extension/src/run_dir_reader.ts +++ b/packages/extension/src/run_dir_reader.ts @@ -105,6 +105,10 @@ export class PulseStream { } export interface IterRecord { iter: number; f_val: number; inf_pr: number; inf_du: number } +/** Terminal completion, built by readTerminalState and flowed WHOLE to every + * consumer (never exploded into positional args mid-pipe) — the #84 funnel. + * Additive contract fields join HERE + readTerminalState and reach all paths + * by construction: #81's `formulation?` next, then #64 hashing / #41 usage. */ export interface RunCompletion { runId: string; runDir: string; status: RunStatus; fidelity?: number } export interface PromoteInfo { runId: string; runDir: string; fidelity: number } diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 846f0527..bd85a02a 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -4,44 +4,59 @@ import { inspectorResourceRootDirs } from "./opencode_paths"; import type { PulseEvent, PulseMeta, PulseRecord } from "./run_dir_reader"; // ============================================================================ -// Run Inspector — bottom-panel webview that shows the live pulse-plot stream -// from spike_solve.jl plus a stats row driven by AMICODE_ITER parsing. +// Run Inspector — bottom-panel webview showing the live pulse-plot stream + +// an AMICODE_ITER stats row. // -// Ported from amicode/src/spikes/spike_b_inspector.ts with the simulation -// experiments stripped (we don't need ping/sim now that we have a real run -// path). Keeps the throttled image swap + setImageSource / postIteration API. +// 1.3 (#58): multi-run. The host↔webview message protocol is now runId-keyed +// (freeze 2 = the protocol, not the DOM): every message carries `runId`, the +// host keeps ONE buffer per runId, and the webview keeps ONE pane per runId. +// RunsManager fans ALL runs in here runId-tagged and calls activate(runId) to +// pick the visible pane. Per-run buffers make reopen (S36 buffer+replay) rebuild +// EVERY pane, not just the active one; the 5 Hz pulse throttle is per-run so a +// fast background run can't starve the foreground. +// +// Ported from the β single-run view (throttled record swap + native pulse #66). // ============================================================================ -const REFRESH_INTERVAL_MS = 200; // 5 Hz cap on pulse-record refresh +const REFRESH_INTERVAL_MS = 200; // 5 Hz cap on pulse-record refresh (per run) let INSPECTOR: InspectorView | undefined; +/** Everything replayable about one run's pane. Kept current whether or not the + * webview exists, so resolveWebviewView can rebuild the pane on reopen (S36). + * `pulseTimer`/`pendingPulse` are live-only throttle state (per run). */ +interface PaneBuffer { + runId: string; + runLabel?: string; + warming: boolean; + completion?: { status: string; fidelity?: number }; + pulseMeta?: PulseMeta; + pulseRecord?: PulseRecord; // newest record (throttle coalesces to this) + iterRecord?: { iter: number; f_val: number; kkt_error: number; eq_viol: number; ineq_viol: number; rho: number }; + pulseTimer?: NodeJS.Timeout; + pendingPulse?: PulseRecord; +} + class InspectorView implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; - /** Terminal state that arrived before the webview existed (e.g. on launch the - * watcher follows `latest` → a finished run completes before the panel is - * opened). Replayed after buffered pulse data so the badge isn't stuck "running". */ - private bufferedCompletion?: { status: string; fidelity?: number }; - /** A run started but hasn't emitted its first frame yet (Julia warming up). - * Buffered so the warming state shows even if the panel opens late. */ - private bufferedWarming = false; - /** Run label (runId) for the topbar — buffered so it shows even if the panel - * opens after the run was selected. */ - private bufferedRunLabel?: string; - /** Pulse events that arrived before the webview existed (#66 AC7). Unlike - * PNGs (re-offered by the poll forever), the log line is the canonical - * signal — dropped means gone. Meta + NEWEST record only. */ - private bufferedPulseMeta?: PulseMeta; - private bufferedPulseRecord?: PulseRecord; - /** 5 Hz throttle for live pulse records — same REFRESH_INTERVAL_MS policy as - * PNG frames: leading edge posts, the window coalesces (newest wins), the - * trailing edge flushes. Meta is never throttled (once per run). */ - private pulseTimer?: NodeJS.Timeout; - private pendingPulse?: PulseRecord; + /** One buffer per runId — the multi-run state. */ + private readonly panes = new Map(); + /** The run whose pane is visible. Buffered until the webview materializes so + * a late-opened panel still lands on the right pane. */ + private activeRunId?: string; constructor(private readonly ctx: vscode.ExtensionContext) {} + private paneFor(runId: string): PaneBuffer { + let p = this.panes.get(runId); + if (!p) { + p = { runId, warming: false }; + this.panes.set(runId, p); + } + return p; + } + resolveWebviewView(view: vscode.WebviewView): void { this.view = view; view.webview.options = { @@ -51,106 +66,106 @@ class InspectorView implements vscode.WebviewViewProvider { localResourceRoots: inspectorResourceRootDirs(this.ctx.extensionUri.fsPath).map((d) => vscode.Uri.file(d)), }; view.webview.html = this.renderHtml(view.webview); - view.onDidDispose(() => { this.view = undefined; this.clearPulseTimer(); }); + view.onDidDispose(() => { this.view = undefined; this.clearAllTimers(); }); + + // S36 replay: rebuild EVERY pane from its buffer (not just the active one), + // so switching to a background run after reopen shows its state too. Per + // pane the order mirrors the live stream: runlabel → warming → pulsemeta → + // pulse → iteration → completed (terminal state stays the last word). + for (const p of this.panes.values()) this.replayPane(view, p); + // Then pick the visible pane. activate is idempotent and last, so it wins + // regardless of pane-replay order above. + if (this.activeRunId) view.webview.postMessage({ type: "activate", runId: this.activeRunId }); + } - // Topbar run label — replay first so it's set regardless of run state. - if (this.bufferedRunLabel) { - view.webview.postMessage({ type: "runlabel", text: this.bufferedRunLabel }); - this.bufferedRunLabel = undefined; - } - // A run is warming up (no data yet) — show that until the first record. - if (this.bufferedWarming) { - this.bufferedWarming = false; - view.webview.postMessage({ type: "warming" }); - } - // Replay buffered pulse events (#66): meta first, then the newest record — - // and BEFORE the buffered completion below, so terminal state stays the - // last word the webview hears. - if (this.bufferedPulseMeta) { - view.webview.postMessage({ type: "pulsemeta", ...this.bufferedPulseMeta }); - this.bufferedPulseMeta = undefined; - } - if (this.bufferedPulseRecord) { - view.webview.postMessage({ type: "pulse", ...this.bufferedPulseRecord }); - this.bufferedPulseRecord = undefined; - } - // Then replay a terminal state if the run already finished — after the - // pulse data so "converged"/"failed" is the last word the webview hears. - if (this.bufferedCompletion) { - const c = this.bufferedCompletion; - this.bufferedCompletion = undefined; - view.webview.postMessage({ type: "completed", status: c.status, fidelity: c.fidelity }); - } + private replayPane(view: vscode.WebviewView, p: PaneBuffer): void { + const rid = p.runId; + if (p.runLabel !== undefined) view.webview.postMessage({ type: "runlabel", runId: rid, text: p.runLabel }); + if (p.warming) view.webview.postMessage({ type: "warming", runId: rid }); + if (p.pulseMeta) view.webview.postMessage({ type: "pulsemeta", runId: rid, ...p.pulseMeta }); + if (p.pulseRecord) view.webview.postMessage({ type: "pulse", runId: rid, ...p.pulseRecord }); + if (p.iterRecord) view.webview.postMessage({ type: "iteration", runId: rid, ...p.iterRecord, t_post: Date.now() }); + if (p.completion) view.webview.postMessage({ type: "completed", runId: rid, status: p.completion.status, fidelity: p.completion.fidelity }); } - // -------- public surface used by RunsManager -------- + // -------- public surface used by RunsManager (all runId-keyed) -------- - postIterationRecord(rec: { iter: number; f_val: number; inf_pr: number; inf_du: number }): void { - // Ok to drop iter records pre-materialization: the stats row refreshes on - // the next record (seconds away), and switchToRun's log replay re-ingests - // history whenever the run is re-selected. (Pulse events, by contrast, are - // buffered above — the log line is their only delivery.) - if (!this.view) return; - this.view.webview.postMessage({ - type: "iteration", - iter: rec.iter, - f_val: rec.f_val, - kkt_error: rec.inf_du, - eq_viol: rec.inf_pr, - ineq_viol: 0, - rho: 1.0, - t_post: Date.now(), - }); + postIterationRecord(runId: string, rec: { iter: number; f_val: number; inf_pr: number; inf_du: number }): void { + const p = this.paneFor(runId); + p.warming = false; + p.iterRecord = { iter: rec.iter, f_val: rec.f_val, kkt_error: rec.inf_du, eq_viol: rec.inf_pr, ineq_viol: 0, rho: 1.0 }; + if (!this.view) return; // buffered above; reopen replays it + this.view.webview.postMessage({ type: "iteration", runId, ...p.iterRecord, t_post: Date.now() }); } - /** Terminal-state signal so the badge stops saying "running" — on live - * finish AND when switching to an already-finished run. */ - postCompletion(status: string, fidelity?: number): void { - if (!this.view) { - // Panel not open yet — stash; resolveWebviewView replays it after pulse data. - this.bufferedCompletion = { status, fidelity }; - return; - } - this.view.webview.postMessage({ type: "completed", status, fidelity }); + /** Terminal-state signal (badge stops saying "running") — on live finish AND + * when a run is selected already-finished. Buffered per run for reopen. */ + postCompletion(runId: string, status: string, fidelity?: number): void { + const p = this.paneFor(runId); + p.warming = false; + p.completion = { status, fidelity }; + if (!this.view) return; + this.view.webview.postMessage({ type: "completed", runId, status, fidelity }); } - /** A run started but has no data yet (Julia warming up) — show that instead - * of an idle panel, so a ~minute of cold start doesn't read as frozen. */ - setWarmingUp(): void { + /** A run started but has no data yet (Julia warming up) — show that instead of + * an idle pane, so a ~minute of cold start doesn't read as frozen. */ + setWarmingUp(runId: string): void { + const p = this.paneFor(runId); + // Warming means "no data yet". Never clobber a pane that already has terminal + // state or streamed data (a run selected after its pipeline fanned events in, + // or the stale-warming-after-completion race). + if (p.completion || p.iterRecord || p.pulseRecord) return; + p.warming = true; if (!this.view) { - this.bufferedWarming = true; vscode.commands.executeCommand("amicode.runInspector.focus").then(undefined, () => undefined); return; } - this.view.webview.postMessage({ type: "warming" }); + this.view.webview.postMessage({ type: "warming", runId }); } - /** Pulse-stream event (#66): forwarded to the view as pulsemeta/pulse - * messages. Buffering for late materialization lands with AC7. */ - postPulse(e: PulseEvent): void { - if (!this.view) { - // Buffer (newest record wins) — replayed in resolveWebviewView. - if (e.type === "meta") this.bufferedPulseMeta = e.meta; - else this.bufferedPulseRecord = e.record; + /** Pulse-stream event (#66) → pulsemeta/pulse messages, runId-tagged. Meta is + * posted once; records are throttled to 5 Hz PER RUN (leading edge posts, the + * window coalesces newest-wins, trailing edge flushes). The newest record is + * always kept in the buffer for reopen even while the window is open. */ + postPulse(runId: string, e: PulseEvent): void { + const p = this.paneFor(runId); + // NB: unlike postIterationRecord/postCompletion this deliberately does NOT + // clear p.warming — pulse is plot-only (#67), and the warming badge is + // iter-driven. setWarmingUp already treats a present pulseRecord as "has + // data", so a pulse still blocks a re-warm; the flag just isn't flipped here. + if (e.type === "meta") { + p.pulseMeta = e.meta; + if (this.view) this.view.webview.postMessage({ type: "pulsemeta", runId, ...e.meta }); return; } - if (e.type === "meta") { this.view.webview.postMessage({ type: "pulsemeta", ...e.meta }); return; } - if (this.pulseTimer) { this.pendingPulse = e.record; return; } // window open — coalesce, newest wins - this.view.webview.postMessage({ type: "pulse", ...e.record }); - this.pulseTimer = setTimeout(() => { - this.pulseTimer = undefined; - if (this.pendingPulse && this.view) { - const rec = this.pendingPulse; - this.pendingPulse = undefined; - this.postPulse({ type: "record", record: rec }); + // record + p.pulseRecord = e.record; // newest wins for reopen replay + if (!this.view) return; + if (p.pulseTimer) { p.pendingPulse = e.record; return; } // window open — coalesce + this.view.webview.postMessage({ type: "pulse", runId, ...e.record }); + p.pulseTimer = setTimeout(() => { + p.pulseTimer = undefined; + if (p.pendingPulse && this.view) { + const rec = p.pendingPulse; + p.pendingPulse = undefined; + this.postPulse(runId, { type: "record", record: rec }); } }, REFRESH_INTERVAL_MS); } - /** Set the topbar run label (runId). Buffered until the webview materializes. */ - setRunLabel(label: string): void { - if (!this.view) { this.bufferedRunLabel = label; return; } - this.view.webview.postMessage({ type: "runlabel", text: label }); + /** Set a run's topbar label (runId). Buffered per run until materialize. */ + setRunLabel(runId: string, label: string): void { + this.paneFor(runId).runLabel = label; + if (this.view) this.view.webview.postMessage({ type: "runlabel", runId, text: label }); + } + + /** Make `runId` the visible pane (1.3 selection seam). Buffered until the + * webview materializes; resolveWebviewView replays it last. */ + activate(runId: string): void { + this.paneFor(runId); // ensure a pane exists even before any data + this.activeRunId = runId; + if (this.view) this.view.webview.postMessage({ type: "activate", runId }); } reveal(): void { @@ -162,12 +177,11 @@ class InspectorView implements vscode.WebviewViewProvider { // -------- internal -------- - private clearPulseTimer(): void { - if (this.pulseTimer) { - clearTimeout(this.pulseTimer); - this.pulseTimer = undefined; + private clearAllTimers(): void { + for (const p of this.panes.values()) { + if (p.pulseTimer) { clearTimeout(p.pulseTimer); p.pulseTimer = undefined; } + p.pendingPulse = undefined; } - this.pendingPulse = undefined; } private renderHtml(webview: vscode.Webview): string { diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index 75eb6df6..0a5b2e42 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -173,30 +173,52 @@ export class RunsManager implements vscode.Disposable { }); } - /** EXPLICIT selection (demo replay command; 1.3's user clicks): routes the - * single-run Inspector/StatusBar at a run AND PINS the selection — after - * this, auto-follow never steals the view (see `pinned`). Replays the run - * dir for display, then live events flow. */ + /** EXPLICIT selection (demo replay command; 1.3's user clicks): makes + * `runId` the inspector's visible pane (`activate`) AND PINS the selection — + * after this, auto-follow never steals the view (see `pinned`). + * + * Display: a run WITH a live pipeline was already fanned in runId-tagged + * (registration replay + live tail), so its pane is current — no re-ingest + * (review #70 #4). A run with NO pipeline (finished at discovery, or + * completed + torn down) was never fanned — replay it from disk into its + * pane. If FINISHED landed inside the ≤700ms poll window, the same-tick + * checkFinished below turns it into a real completion (badge + status bar), + * never a stale "running"/"warming". */ selectRun(runId: string): void { const rec = this.registry.get(runId); if (!rec) return; this.pinned = true; if (this.selected === runId) return; this.selected = runId; - getInspector()?.reveal(); - getInspector()?.setRunLabel(runId); - // Display replay (late-join safe): full history from disk → inspector. - // Promote inside the replay stays guarded by promotedRuns, so re-selecting - // a finished run never re-pops the prompt. - try { ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); } - catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } - // Fresh/live run → Julia warming up (the view swaps the hint when the first - // pulse record arrives). Same post-replay order as β's switchToRun — and, - // like β, re-check DISK (not the registry phase): FINISHED may have landed - // inside the ≤700ms poll window, and warming-after-completion would invert - // the terminal badge until the next tick. - if (rec.phase !== "finished" && !fs.existsSync(path.join(rec.runDir, "FINISHED"))) { - getInspector()?.setWarmingUp(); + const ins = getInspector(); + ins?.reveal(); + ins?.setRunLabel(runId, runId); + ins?.activate(runId); // 1.3: switch the visible pane + const p = this.pipelines.get(runId); + if (p) { + // FINISHED may have landed inside the poll window — complete it NOW, + // through the one completion mechanism, so the badge can't sit stale. + this.checkFinished(p); + // Point the single status bar at the selected run from registry state + // (routeIter/completeRun keep it current from here). + const r = this.registry.get(runId)!; + this.opts.statusBar?.setRun({ + runId, outputDir: r.runDir, startedAt: 0, + status: r.phase === "finished" ? (r.status ?? "completed") : "running", + latestIter: r.latestIter, fidelity: r.fidelity, + }); + } else { + // Never fanned (no pipeline) — display replay from disk (late-join safe). + // Promote inside the replay stays guarded by promotedRuns, so + // re-selecting a finished run never re-pops the prompt. + try { ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); } + catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + } + // Fresh/live run → Julia warming up. Disk-checked (FINISHED may exist while + // the registry still says live); the host's setWarmingUp also no-ops if the + // pane already carries data/terminal state (host guard). + if (this.registry.get(runId)?.phase !== "finished" && !fs.existsSync(path.join(rec.runDir, "FINISHED"))) { + ins?.setWarmingUp(runId); } } @@ -253,20 +275,22 @@ export class RunsManager implements vscode.Disposable { // Auto-follow BEFORE the replay (β latest-follow parity: a newly REGISTERED // live run is by definition the newest start) — unless an explicit selection - // is pinned. Deciding first lets the ONE ingest below both seed pipeline - // state and feed the display through routeIter/routePulse's selection gate - // (review #70: the old shape parsed the whole run.log twice per discovery — - // a state pass, then selectRun's display pass). + // is pinned. The single ingest below fans the run's history into ITS pane + // regardless (1.3 fan-out); following just decides which pane is visible + // (review #70 #4: the old shape parsed the whole run.log twice per + // discovery — a state pass, then selectRun's display pass). const follow = !this.pinned; if (follow && this.selected !== runId) { this.selected = runId; - getInspector()?.reveal(); - getInspector()?.setRunLabel(runId); + const ins = getInspector(); + ins?.reveal(); + ins?.setRunLabel(runId, runId); + ins?.activate(runId); } // Single replay: arms the pipeline's pulse stream (meta), seeds iter - // high-water, routes to the inspector iff selected above, and yields the - // byte offset the live tail starts from. + // high-water, fans history runId-tagged, and yields the byte offset the + // live tail starts from. let logBytes = 0; try { logBytes = ingestRunDir(runDir, this.pipelineSink(p), this.opts.promoteThreshold ?? 0.99); } catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } @@ -298,45 +322,45 @@ export class RunsManager implements vscode.Disposable { // Fresh/live run with no data yet → Julia warming up (post-replay, β order). // Disk-checked: a torn FINISHED (fall-through above) must not read "warming". if (follow && !fs.existsSync(path.join(runDir, "FINISHED"))) { - getInspector()?.setWarmingUp(); + getInspector()?.setWarmingUp(runId); } } /** Sink for a pipeline's SINGLE registration replay: seeds registry/pulse - * state and — because auto-follow assigns selection BEFORE the replay — - * feeds the display through routeIter/routePulse's selection gate in the - * same pass (review #70: no second display ingest). */ + * state AND fans the history into the inspector runId-tagged through + * routeIter/routePulse (1.3 fan-out — the run's pane buffers it even while + * hidden), so no second display ingest is needed (review #70 #4). */ private pipelineSink(p: RunPipeline): RunSink { return { iter: (rec: IterRecord) => this.routeIter(p, rec), // A FINISHED that landed between the existsSync check and this replay — - // rare race; treat exactly like a live completion. - run: (c: RunCompletion) => this.completeRun(p.runId, c.status, c.fidelity), - pulse: (e: PulseEvent) => { - if (e.type === "meta") p.pulses.arm(e.meta); - this.routePulse(p.runId, e); - }, + // rare race; treat exactly like a live completion (fans out + promotes). + run: (c: RunCompletion) => this.completeRun(c), // whole object — see completeRun (#84 seam) + pulse: (e: PulseEvent) => { if (e.type === "meta") p.pulses.arm(e.meta); this.routePulse(p.runId, e); }, promote: (info: PromoteInfo) => this.promptPromote(info), }; } - /** Display sink for selection replays: inspector + status bar; promote stays - * guarded. For a still-live run, meta also re-arms the pipeline stream. */ + /** Display sink for a selection replay: posts the run's history into ITS pane + * (runId-tagged) + points the status bar at it; promote stays guarded. For a + * still-live run, meta also re-arms the pipeline stream (redundant with + * registration but harmless). */ private displaySink(rec: RunRecord): RunSink { - const p = this.pipelines.get(rec.runId); + const rid = rec.runId; + const p = this.pipelines.get(rid); return { iter: (r: IterRecord) => { - this.registry.noteIter(rec.runId, r.iter); - getInspector()?.postIterationRecord(r); - this.opts.statusBar?.setRun({ runId: rec.runId, outputDir: rec.runDir, startedAt: 0, status: "running", latestIter: r.iter }); + this.registry.noteIter(rid, r.iter); + getInspector()?.postIterationRecord(rid, r); + this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: "running", latestIter: r.iter }); }, run: (c: RunCompletion) => { - getInspector()?.postCompletion(c.status, c.fidelity); - this.opts.statusBar?.setRun({ runId: c.runId, outputDir: c.runDir, startedAt: 0, status: c.status, latestIter: this.registry.get(rec.runId)?.latestIter, fidelity: c.fidelity }); + getInspector()?.postCompletion(rid, c.status, c.fidelity); + this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: c.status, latestIter: this.registry.get(rid)?.latestIter, fidelity: c.fidelity }); }, pulse: (e: PulseEvent) => { if (e.type === "meta") p?.pulses.arm(e.meta); - getInspector()?.postPulse(e); + getInspector()?.postPulse(rid, e); }, promote: (info: PromoteInfo) => this.promptPromote(info), }; @@ -345,15 +369,20 @@ export class RunsManager implements vscode.Disposable { private routeIter(p: RunPipeline, rec: IterRecord): void { p.dedup.noteIter(rec.iter); this.registry.noteIter(p.runId, rec.iter); - if (this.selected !== p.runId) return; - getInspector()?.postIterationRecord(rec); - // Live status-bar update — "running · iter N" as it solves (#5 AC3). - this.opts.statusBar?.setRun({ runId: p.runId, outputDir: p.runDir, startedAt: 0, status: "running", latestIter: rec.iter }); + // 1.3 fan-out: every run's iters go to the inspector runId-tagged (the + // webview updates that run's pane; only the active pane is visible — no + // cross-talk). The single status bar tracks the SELECTED run only. + getInspector()?.postIterationRecord(p.runId, rec); + if (this.selected === p.runId) { + // Live status-bar update — "running · iter N" as it solves (#5 AC3). + this.opts.statusBar?.setRun({ runId: p.runId, outputDir: p.runDir, startedAt: 0, status: "running", latestIter: rec.iter }); + } } private routePulse(runId: string, e: PulseEvent): void { - if (this.selected !== runId) return; - getInspector()?.postPulse(e); + // Fan out to every run's pane (runId-tagged); the webview shows only the + // active pane. A background run's pulse never touches the visible plot. + getInspector()?.postPulse(runId, e); } private checkFinished(p: RunPipeline): void { @@ -362,26 +391,35 @@ export class RunsManager implements vscode.Disposable { const t = this.readTerminal(p.runDir); if (!t) return; // torn/invalid FINISHED — next tick retries p.finishedSeen = true; - this.completeRun(p.runId, t.status, t.fidelity); + this.completeRun({ runId: p.runId, runDir: p.runDir, ...t }); } /** Terminal handling for ANY run, selected or not: registry, teardown, - * channel, inspector/status-bar (selected only), promote (any run, once). */ - private completeRun(runId: string, status: RunStatus, fidelity?: number): void { - const rec = this.registry.get(runId); + * channel, inspector/status-bar (selected only), promote (any run, once). + * + * Takes the WHOLE RunCompletion (never exploded into positional fields) — + * this is the #84 seam: every completion path (ingestRunDir replay, live + * checkFinished) funnels the object built by ONE shared read, so an + * additive contract field (#81's `formulation` next, then #64 hashing / + * #41 usage) reaches every consumer by construction instead of being + * re-plumbed per path. Consumers cherry-pick at the leaf, not mid-pipe. */ + private completeRun(c: RunCompletion): void { + const rec = this.registry.get(c.runId); if (!rec || rec.phase === "finished") return; // idempotent (watch + poll can both fire) - this.registry.markFinished(runId, status, fidelity); - const p = this.pipelines.get(runId); + this.registry.markFinished(c.runId, c.status, c.fidelity); + const p = this.pipelines.get(c.runId); p?.dispose(); - this.pipelines.delete(runId); - this.opts.channel.appendLine(`[runs] ${runId} ${status}${fidelity !== undefined ? ` F=${fidelity.toFixed(6)}` : ""}`); - if (status !== "completed") this.opts.channel.appendLine(`[runs] see ${path.join(rec.runDir, "run.log")}`); - if (this.selected === runId) { - getInspector()?.postCompletion(status, fidelity); - this.opts.statusBar?.setRun({ runId, outputDir: rec.runDir, startedAt: 0, status, latestIter: rec.latestIter, fidelity }); + this.pipelines.delete(c.runId); + this.opts.channel.appendLine(`[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`); + if (c.status !== "completed") this.opts.channel.appendLine(`[runs] see ${path.join(rec.runDir, "run.log")}`); + // Terminal state to the inspector for EVERY run (its pane's badge stops + // saying "running" even in the background); status bar for the selected run. + getInspector()?.postCompletion(c.runId, c.status, c.fidelity); + if (this.selected === c.runId) { + this.opts.statusBar?.setRun({ runId: c.runId, outputDir: rec.runDir, startedAt: 0, status: c.status, latestIter: rec.latestIter, fidelity: c.fidelity }); } - if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) { - this.promptPromote({ runId, runDir: rec.runDir, fidelity }); + if (c.status === "completed" && c.fidelity !== undefined && c.fidelity >= (this.opts.promoteThreshold ?? 0.99)) { + this.promptPromote({ runId: c.runId, runDir: rec.runDir, fidelity: c.fidelity }); } } diff --git a/packages/extension/test/inspector_view_contract.test.ts b/packages/extension/test/inspector_view_contract.test.ts index c996a796..0fba455d 100644 --- a/packages/extension/test/inspector_view_contract.test.ts +++ b/packages/extension/test/inspector_view_contract.test.ts @@ -10,9 +10,9 @@ import { registerRunInspector } from "../src/run_inspector"; // 1. the shell links brand.css + layout.css and the dist view bundle; // 2. the CSP authorizes every grant the view depends on; // 3. the design-owned stylesheets exist and brand.css carries the brand token. -// The message protocol (runlabel/iteration/warming/completed/refresh/ping) is -// exercised end-to-end by the watcher tests; ids/classes are now internal to -// the view and free to change. +// The message protocol (runId-keyed: runlabel/iteration/warming/completed/ +// pulsemeta/pulse/activate/ping) is exercised end-to-end by the watcher tests; +// ids/classes are now internal to the view and free to change. const PKG_ROOT = join(__dirname, ".."); @@ -64,40 +64,53 @@ describe("Run Inspector shell contract (plumbing ⇄ TS-composed view)", () => { // #66 AC7 — pulse events posted before the webview materializes must not be // lost: the log line is the canonical signal (nothing re-delivers it, unlike // PNGs which the poll re-offers). The host buffers meta + the NEWEST record -// and replays them on resolve, BEFORE any buffered terminal state. -describe("Run Inspector host buffering (#66 pulse events)", () => { +// per run and replays them on resolve, BEFORE any buffered terminal state. +// +// 1.3 (#58): every message is runId-keyed. Each run gets its own buffer + +// throttle; resolve replays EVERY pane (S36) and activate names the visible one. +describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { const META = { drives: 1, knots: 2, labels: ["a_1"], bounds: [[-0.2, 0.2]] as [number, number][] }; + const rec = (iter: number): { iter: number; dt: number; values: number[][] } => ({ iter, dt: 0.2, values: [[iter / 10, iter / 5]] }); function harness() { const ctx = { extensionUri: { fsPath: PKG_ROOT }, subscriptions: [] as unknown[] }; const inspector = registerRunInspector(ctx as never); - const posted: Array> = []; - const view = { - webview: { - options: {}, - cspSource: "vscode-webview://unit", - asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), - postMessage: (m: Record) => { posted.push(m); }, - set html(_v: string) { /* ignore */ }, - get html() { return ""; }, - }, - onDidDispose: () => ({ dispose() {} }), + /** Build an independent webview target (its own capture buffer + dispose + * hook) — lets a single inspector be resolved twice for the reopen path. */ + const makeView = () => { + const posted: Array> = []; + let disposeCb: () => void = () => undefined; + const view = { + webview: { + options: {}, + cspSource: "vscode-webview://unit", + asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), + postMessage: (m: Record) => { posted.push(m); }, + set html(_v: string) { /* ignore */ }, + get html() { return ""; }, + }, + onDidDispose: (cb: () => void) => { disposeCb = cb; return { dispose() {} }; }, + }; + return { view, posted, dispose: () => disposeCb() }; }; - return { inspector, view, posted }; + const first = makeView(); + return { inspector, makeView, view: first.view, posted: first.posted, dispose: first.dispose }; } - it("buffers meta + NEWEST record pre-materialization, replays them before a buffered completion", () => { + it("buffers meta + NEWEST record pre-materialization, replays them (runId-tagged) before a buffered completion", () => { const { inspector, view, posted } = harness(); - inspector.postPulse({ type: "meta", meta: META }); - inspector.postPulse({ type: "record", record: { iter: 1, dt: 0.2, values: [[0.1, 0.2]] } }); - inspector.postPulse({ type: "record", record: { iter: 2, dt: 0.2, values: [[0.3, 0.4]] } }); - inspector.postCompletion("completed", 0.9999); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r1", { type: "record", record: rec(1) }); + inspector.postPulse("r1", { type: "record", record: rec(2) }); + inspector.postCompletion("r1", "completed", 0.9999); inspector.resolveWebviewView(view as never); - const types = posted.map((m) => m.type); + const r1 = posted.filter((m) => m.runId === "r1"); + expect(r1.every((m) => m.runId === "r1")).toBe(true); // every message carries the runId + const types = r1.map((m) => m.type); expect(types).toContain("pulsemeta"); expect(types.filter((t) => t === "pulse")).toHaveLength(1); // newest-wins: iter 1 dropped - expect(posted.find((m) => m.type === "pulse")).toMatchObject({ iter: 2 }); + expect(r1.find((m) => m.type === "pulse")).toMatchObject({ iter: 2 }); expect(types.indexOf("pulsemeta")).toBeLessThan(types.indexOf("pulse")); expect(types.indexOf("pulse")).toBeLessThan(types.indexOf("completed")); // terminal state stays the last word }); @@ -106,25 +119,96 @@ describe("Run Inspector host buffering (#66 pulse events)", () => { vi.useFakeTimers(); const { inspector, view, posted } = harness(); inspector.resolveWebviewView(view as never); - inspector.postPulse({ type: "meta", meta: META }); + inspector.postPulse("r1", { type: "meta", meta: META }); posted.length = 0; - inspector.postPulse({ type: "record", record: { iter: 1, dt: 0.2, values: [[0.1, 0.2]] } }); + inspector.postPulse("r1", { type: "record", record: rec(1) }); expect(posted.map((m) => m.type)).toEqual(["pulse"]); // leading edge posts immediately - inspector.postPulse({ type: "record", record: { iter: 2, dt: 0.2, values: [[0.3, 0.4]] } }); - inspector.postPulse({ type: "record", record: { iter: 3, dt: 0.2, values: [[0.5, 0.6]] } }); + inspector.postPulse("r1", { type: "record", record: rec(2) }); + inspector.postPulse("r1", { type: "record", record: rec(3) }); expect(posted).toHaveLength(1); // inside the window: coalesced vi.advanceTimersByTime(200); expect(posted).toHaveLength(2); // trailing edge: exactly one flush - expect(posted[1]).toMatchObject({ type: "pulse", iter: 3 }); // …carrying the newest + expect(posted[1]).toMatchObject({ type: "pulse", iter: 3, runId: "r1" }); // …carrying the newest }); it("posts straight through once the webview is live", () => { const { inspector, view, posted } = harness(); inspector.resolveWebviewView(view as never); posted.length = 0; - inspector.postPulse({ type: "meta", meta: META }); - inspector.postPulse({ type: "record", record: { iter: 3, dt: 0.2, values: [[0.5, 0.6]] } }); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r1", { type: "record", record: rec(3) }); expect(posted.map((m) => m.type)).toEqual(["pulsemeta", "pulse"]); + expect(posted.every((m) => m.runId === "r1")).toBe(true); + }); + + it("keeps a separate pane buffer per run — a background run's record never lands under another runId", () => { + const { inspector, view, posted } = harness(); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r1", { type: "record", record: rec(1) }); + inspector.postPulse("r2", { type: "meta", meta: META }); + inspector.postPulse("r2", { type: "record", record: rec(7) }); + inspector.resolveWebviewView(view as never); + + // r1's pulse is iter 1 under r1; r2's is iter 7 under r2. No cross-key leak. + expect(posted.filter((m) => m.type === "pulse" && m.runId === "r1")).toMatchObject([{ iter: 1 }]); + expect(posted.filter((m) => m.type === "pulse" && m.runId === "r2")).toMatchObject([{ iter: 7 }]); + }); + + it("per-run throttle windows are independent — r2's leading edge is not coalesced by r1's open window", () => { + vi.useFakeTimers(); + const { inspector, view, posted } = harness(); + inspector.resolveWebviewView(view as never); + posted.length = 0; + inspector.postPulse("r1", { type: "record", record: rec(1) }); // opens r1's window (posts) + inspector.postPulse("r2", { type: "record", record: rec(1) }); // r2 has its OWN window (posts) + expect(posted.filter((m) => m.type === "pulse")).toHaveLength(2); + expect(posted.map((m) => m.runId).sort()).toEqual(["r1", "r2"]); + }); + + it("activate is replayed LAST on materialize and names the visible pane", () => { + const { inspector, view, posted } = harness(); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r2", { type: "meta", meta: META }); + inspector.activate("r2"); + inspector.resolveWebviewView(view as never); + + const activate = posted.filter((m) => m.type === "activate"); + expect(activate).toHaveLength(1); + expect(activate[0]).toMatchObject({ runId: "r2" }); + expect(posted.indexOf(activate[0])).toBe(posted.length - 1); // last word = the visible pane + }); + + it("rebuilds EVERY pane on reopen (S36) — dispose then re-resolve replays all runs", () => { + const { inspector, makeView } = harness(); + const a = makeView(); + inspector.resolveWebviewView(a.view as never); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r1", { type: "record", record: rec(4) }); + inspector.postCompletion("r1", "completed", 0.99); + inspector.postPulse("r2", { type: "meta", meta: META }); + inspector.postPulse("r2", { type: "record", record: rec(9) }); + inspector.activate("r2"); + a.dispose(); // user closes the panel + + const b = makeView(); + inspector.resolveWebviewView(b.view as never); // reopen — fresh DOM + // Both panes rebuilt from buffers, each with its newest record, r1 terminal. + expect(b.posted.filter((m) => m.type === "pulse" && m.runId === "r1")).toMatchObject([{ iter: 4 }]); + expect(b.posted.filter((m) => m.type === "completed" && m.runId === "r1")).toHaveLength(1); + expect(b.posted.filter((m) => m.type === "pulse" && m.runId === "r2")).toMatchObject([{ iter: 9 }]); + expect(b.posted[b.posted.length - 1]).toMatchObject({ type: "activate", runId: "r2" }); + }); + + it("setWarmingUp no-ops once the pane has data or terminal state (no clobber of a fanned-in run)", () => { + const { inspector, view, posted } = harness(); + inspector.resolveWebviewView(view as never); + inspector.postPulse("r1", { type: "record", record: rec(1) }); // r1 has data + inspector.postCompletion("r2", "completed", 0.99); // r2 is terminal + posted.length = 0; + inspector.setWarmingUp("r1"); + inspector.setWarmingUp("r2"); + inspector.setWarmingUp("r3"); // fresh run → warming IS shown + expect(posted.filter((m) => m.type === "warming")).toMatchObject([{ runId: "r3" }]); }); }); diff --git a/packages/extension/test/inspector_webview_view.test.ts b/packages/extension/test/inspector_webview_view.test.ts new file mode 100644 index 00000000..757fa8a4 --- /dev/null +++ b/packages/extension/test/inspector_webview_view.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { createInspectorView } from "../media/ui/views/inspector"; + +// Coverage for the 1.3 webview ROUTER (freeze-2, runId-keyed protocol): per-run +// pane isolation, `activate` pane-toggling, the empty-state hint, and #67's +// plot-only pulse (a pulse must never touch the badge). Closes the gap the +// adversarial review flagged — half the slice's guarantee lives here and was +// asserted only by comments. +// +// The pane MARKUP is the design lane (UX4 #49): these assertions target the +// router CONTRACT + observable badge/visibility, not class names beyond +// .pane/.active/.pill. Runs under happy-dom because the atoms inject styles via +// constructable stylesheets (`new CSSStyleSheet()`), which jsdom can't model. + +const iter = (runId: string, n: number) => ({ type: "iteration", runId, iter: n, f_val: 1e-2, eq_viol: 1e-8, kkt_error: 1e-6 }); +const panes = (v: { el: HTMLElement }) => [...v.el.querySelectorAll(".pane")]; +const activePane = (v: { el: HTMLElement }) => v.el.querySelector(".pane.active"); +const pillText = (pane: Element | null | undefined) => pane?.querySelector(".pill")?.textContent; + +describe("Inspector webview router (1.3 per-run panes)", () => { + it("activate shows exactly one pane and hides the empty-state hint", () => { + const v = createInspectorView(() => {}); + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); + const emptyHint = v.el.firstElementChild as HTMLElement; // the idle hint, appended first + expect(emptyHint.style.display).not.toBe("none"); + + v.onMessage(iter("r1", 3)); + v.onMessage(iter("r2", 4)); + expect(panes(v)).toHaveLength(2); + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); // panes exist but none shown yet + + v.onMessage({ type: "activate", runId: "r2" }); + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(1); + expect(panes(v)[1].classList.contains("active")).toBe(true); // r2 = 2nd-created pane + expect(panes(v)[0].classList.contains("active")).toBe(false); + expect(emptyHint.style.display).toBe("none"); + }); + + it("activate before any data still creates and shows the pane", () => { + const v = createInspectorView(() => {}); + v.onMessage({ type: "activate", runId: "rX" }); + expect(panes(v)).toHaveLength(1); + expect(panes(v)[0].classList.contains("active")).toBe(true); + expect((v.el.firstElementChild as HTMLElement).style.display).toBe("none"); + }); + + it("a background run's iteration never mutates the active pane (no cross-talk)", () => { + const v = createInspectorView(() => {}); + v.onMessage({ type: "activate", runId: "r1" }); + v.onMessage(iter("r1", 3)); + const r1 = activePane(v)!; + expect(pillText(r1)).toBe("running"); + + // r2 is a background run — its iteration must land in ITS pane, not r1's. + v.onMessage(iter("r2", 99)); + expect(pillText(activePane(v))).toBe("running"); // r1 badge unchanged + const r2 = panes(v).find((p) => !p.classList.contains("active"))!; + expect(pillText(r2)).toBe("running"); // r2 has its OWN running badge + expect(r1.textContent).toContain("3"); // r1 still reads iter 3… + expect(r1.textContent).not.toContain("99"); // …not r2's 99 (no value bleed) + }); + + it("pulse is plot-only — it never touches the active pane's badge (#67)", () => { + const v = createInspectorView(() => {}); + v.onMessage({ type: "activate", runId: "r1" }); + const r1 = activePane(v)!; + expect(pillText(r1)).toBe("idle"); + v.onMessage({ type: "pulsemeta", runId: "r1", drives: 1, knots: 2, labels: ["a_1"], bounds: [[-0.2, 0.2]] }); + v.onMessage({ type: "pulse", runId: "r1", iter: 1, dt: 0.2, values: [[0.1, 0.2]] }); + expect(pillText(r1)).toBe("idle"); // pulse did NOT flip the badge + }); + + it("switching activate moves the visible pane, each pane keeps its own state", () => { + const v = createInspectorView(() => {}); + v.onMessage(iter("r1", 1)); + v.onMessage({ type: "completed", runId: "r1", status: "completed", fidelity: 0.999 }); // hidden pane still updates + v.onMessage(iter("r2", 2)); + v.onMessage({ type: "activate", runId: "r1" }); + expect(pillText(activePane(v))).toBe("converged"); // r1 terminal badge shows on activate + v.onMessage({ type: "activate", runId: "r2" }); + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(1); + expect(pillText(activePane(v))).toBe("running"); // now r2 is visible + const r1 = panes(v).find((p) => !p.classList.contains("active"))!; + expect(pillText(r1)).toBe("converged"); // r1 untouched by the switch + }); +}); diff --git a/packages/extension/test/runs_manager.test.ts b/packages/extension/test/runs_manager.test.ts index 3e1fa499..91dd8eab 100644 --- a/packages/extension/test/runs_manager.test.ts +++ b/packages/extension/test/runs_manager.test.ts @@ -4,12 +4,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import * as vscodeMock from "vscode"; -// Drive the live RunsManager (1.2, #57) over a temp runs root and assert the -// inspector calls. Ports the RunsRootWatcher state-machine coverage (idle-on- -// finished baseline, warming→completion, #66 pulse routing) onto index-driven -// discovery, and adds the multi-run behaviors: concurrent runs all tracked, -// selection routing, background completion/promote, the Scheduler seam, and -// the explicit-selection demo-replay path. +// Drive the live RunsManager (1.2 #57 / 1.3 #58) over a temp runs root and +// assert the inspector calls. Ports the RunsRootWatcher state-machine coverage +// (idle-on-finished baseline, warming→completion, #66 pulse routing) onto +// index-driven discovery, and adds the multi-run behaviors: concurrent runs all +// tracked, selection routing, background completion/promote, the Scheduler seam, +// and the explicit-selection demo-replay path. +// +// 1.3: the inspector protocol is runId-keyed and the manager FANS every run's +// events into it runId-tagged (the webview shows only the active pane). The +// single status bar stays selection-gated — so background-vs-foreground is now +// asserted on the status bar, not on whether the inspector was called. // // The inspector is mocked (getInspector() returns spies); `vscode` is the // aliased stub. tick() is called directly so the poll path is deterministic. @@ -21,6 +26,7 @@ const { inspector } = vi.hoisted(() => ({ postIterationRecord: vi.fn(), postPulse: vi.fn(), setRunLabel: vi.fn(), + activate: vi.fn(), reveal: vi.fn(), }, })); @@ -31,6 +37,9 @@ import { RunsManager, type SchedulerLifecycleEvent, type SchedulerLike } from ". const channel = { appendLine() {}, append() {} } as never; const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n'; +/** Minimal StatusBarManager spy — only setRun is exercised. */ +function statusBarSpy() { return { setRun: vi.fn(), clear: vi.fn(), dispose: vi.fn() }; } + function writeManifest(dir: string, runId: string): void { writeFileSync(join(dir, "run.toml"), `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\n` + @@ -71,8 +80,9 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { const run = stageRun(root, "r2"); // manifest only, no data yet const m = new RunsManager({ runsRoot: root, channel }); m.start(); - expect(inspector.setWarmingUp).toHaveBeenCalledTimes(1); - expect(inspector.setRunLabel).toHaveBeenCalledWith("r2"); + expect(inspector.setWarmingUp).toHaveBeenCalledWith("r2"); + expect(inspector.setRunLabel).toHaveBeenCalledWith("r2", "r2"); + expect(inspector.activate).toHaveBeenCalledWith("r2"); // 1.3: selection = activate the pane expect(m.selectedRun).toBe("r2"); // result.toml alone must NOT complete the run (FINISHED is authoritative). @@ -82,11 +92,11 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); tick(m); - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(inspector.postCompletion).toHaveBeenCalledWith("r2", "completed", 0.9999); m.dispose(); }); - it("live tail forwards meta and each record in order as they land (#66)", () => { + it("live tail forwards meta and each record in order as they land (#66), runId-tagged", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); const run = stageRun(root, "p1"); const m = new RunsManager({ runsRoot: root, channel }); @@ -96,13 +106,13 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { writeFileSync(join(run, "run.log"), META_LINE + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n"); tick(m); expect(inspector.postPulse).toHaveBeenCalledTimes(2); - expect(inspector.postPulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "meta" })); - expect(inspector.postPulse).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); + expect(inspector.postPulse).toHaveBeenNthCalledWith(1, "p1", expect.objectContaining({ type: "meta" })); + expect(inspector.postPulse).toHaveBeenNthCalledWith(2, "p1", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n"); tick(m); expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); + expect(inspector.postPulse).toHaveBeenLastCalledWith("p1", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); m.dispose(); }); @@ -117,18 +127,19 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=4 dt=0.2 a=0.3,0.4\n"); tick(m); // record parses against the armed meta expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); + expect(inspector.postPulse).toHaveBeenLastCalledWith("p2", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); m.dispose(); }); }); -describe("RunsManager multi-run (#57)", () => { +describe("RunsManager multi-run (#57 / #58 fan-out)", () => { beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); - it("two concurrent live runs: newest auto-selected, BOTH tracked to completion", () => { + it("two concurrent live runs: newest auto-selected; both fanned to the inspector, status bar tracks the selected only", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); const a = stageRun(root, "rA"); - const m = new RunsManager({ runsRoot: root, channel }); + const statusBar = statusBarSpy(); + const m = new RunsManager({ runsRoot: root, channel, statusBar: statusBar as never }); m.start(); expect(m.selectedRun).toBe("rA"); @@ -136,27 +147,33 @@ describe("RunsManager multi-run (#57)", () => { tick(m); // index tail discovers it expect(m.selectedRun).toBe("rB"); // auto-follow the newest start inspector.postIterationRecord.mockClear(); + statusBar.setRun.mockClear(); - // Background run A keeps streaming — tracked (registry) but NOT displayed. + // Background run A keeps streaming — FANNED to the inspector runId-tagged (the + // webview keeps it in rA's hidden pane) but the single status bar is untouched. appendFileSync(join(a, "run.log"), "AMICODE_ITER iter=7 f=0.1 inf_pr=1e-8 inf_du=1e-6\n"); tick(m); - expect(inspector.postIterationRecord).not.toHaveBeenCalled(); + expect(inspector.postIterationRecord).toHaveBeenCalledWith("rA", expect.objectContaining({ iter: 7 })); + expect(statusBar.setRun).not.toHaveBeenCalled(); // selection-gated: rB is selected expect(m.runs().find(r => r.runId === "rA")?.latestIter).toBe(7); - // A finishes in the background: registry terminal, inspector untouched… + // A finishes in the background: registry terminal, completion fanned to the + // inspector (rA's pane badge), status bar still untouched… writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9995\niterations = 7\n'); writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); tick(m); - expect(inspector.postCompletion).not.toHaveBeenCalled(); // rB is selected + expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9995); + expect(statusBar.setRun).not.toHaveBeenCalled(); // still rB selected expect(m.runs().find(r => r.runId === "rA")).toMatchObject({ phase: "finished", fidelity: 0.9995 }); - // …but the promote prompt STILL fires (fan-out is per-run, not per-selection). + // …and the promote prompt STILL fires (fan-out is per-run, not per-selection). expect(promote).toHaveBeenCalledTimes(1); - // B completes while selected → completion reaches the inspector. + // B completes while selected → completion + status bar both fire. writeFileSync(join(b, "FINISHED"), 'status = "failed"\nexit_code = 3\n'); tick(m); - expect(inspector.postCompletion).toHaveBeenCalledWith("failed", undefined); + expect(inspector.postCompletion).toHaveBeenCalledWith("rB", "failed", undefined); + expect(statusBar.setRun).toHaveBeenCalledWith(expect.objectContaining({ runId: "rB", status: "failed" })); promote.mockRestore(); m.dispose(); }); @@ -176,13 +193,14 @@ describe("RunsManager multi-run (#57)", () => { const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); inspector.postCompletion.mockClear(); m.selectRun("rA"); // user switches back (1.3 seam) - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(inspector.activate).toHaveBeenCalledWith("rA"); + expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9999); expect(promote).not.toHaveBeenCalled(); // promote-once held promote.mockRestore(); m.dispose(); }); - it("PULSE events are gated on selection too (not just iter) — background run's plot never reaches the inspector", () => { + it("PULSE events are fanned to the inspector runId-tagged even for a background run (webview shows only the active pane)", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); const a = stageRun(root, "rA", { log: META_LINE }); // rA armed with meta const m = new RunsManager({ runsRoot: root, channel }); @@ -192,10 +210,11 @@ describe("RunsManager multi-run (#57)", () => { expect(m.selectedRun).toBe("rB"); // rA now background inspector.postPulse.mockClear(); - // A background pulse RECORD on rA must not reach the inspector (rB selected). + // A background pulse RECORD on rA reaches the inspector TAGGED "rA" — the + // webview routes it to rA's hidden pane, never the visible rB plot. appendFileSync(join(a, "run.log"), "AMICODE_PULSE iter=5 dt=0.2 a=0.1,0.2\n"); tick(m); - expect(inspector.postPulse).not.toHaveBeenCalled(); + expect(inspector.postPulse).toHaveBeenCalledWith("rA", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 5 }) })); m.dispose(); }); @@ -214,7 +233,7 @@ describe("RunsManager multi-run (#57)", () => { writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); m.selectRun("rA"); // user switches back BEFORE the tick // selectRun re-checks disk → completion, NOT warming (no terminal-badge inversion). - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9999); expect(inspector.setWarmingUp).not.toHaveBeenCalled(); m.dispose(); }); @@ -245,8 +264,9 @@ describe("RunsManager multi-run (#57)", () => { emit({ kind: "queued", queueId: "q1", position: 0 }); // logged, no throw emit({ kind: "started", queueId: "q1", runId: "rSched", runDir: dir }); expect(m.selectedRun).toBe("rSched"); - expect(inspector.setRunLabel).toHaveBeenCalledWith("rSched"); - expect(inspector.setWarmingUp).toHaveBeenCalled(); + expect(inspector.setRunLabel).toHaveBeenCalledWith("rSched", "rSched"); + expect(inspector.activate).toHaveBeenCalledWith("rSched"); + expect(inspector.setWarmingUp).toHaveBeenCalledWith("rSched"); // The index line landing later is a no-op (registration is idempotent). appendFileSync(join(root, "index"), "rSched\t2026-07-03T00:00:00Z\t/s.jl\n"); @@ -267,9 +287,10 @@ describe("RunsManager multi-run (#57)", () => { m.pokeDiscovery(); // same-tick registration… expect(inspector.postCompletion).not.toHaveBeenCalled(); // …but no auto-display m.selectRun("rDemo"); // the replayDemo command's path - expect(inspector.setRunLabel).toHaveBeenCalledWith("rDemo"); - expect(inspector.postPulse).toHaveBeenCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) })); - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9998); + expect(inspector.setRunLabel).toHaveBeenCalledWith("rDemo", "rDemo"); + expect(inspector.activate).toHaveBeenCalledWith("rDemo"); + expect(inspector.postPulse).toHaveBeenCalledWith("rDemo", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) })); + expect(inspector.postCompletion).toHaveBeenCalledWith("rDemo", "completed", 0.9998); expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // finished — never "warming" expect(promote).not.toHaveBeenCalled(); // finished-at-discovery: no prompt promote.mockRestore(); @@ -293,7 +314,8 @@ describe("RunsManager review-#70 fixes", () => { stageRun(root, "rB"); // background solve starts tick(m); expect(m.selectedRun).toBe("rA"); // auto-follow deferred to the pin - expect(inspector.setRunLabel).not.toHaveBeenCalledWith("rB"); + expect(inspector.setRunLabel).not.toHaveBeenCalledWith("rB", "rB"); + expect(inspector.activate).not.toHaveBeenCalledWith("rB"); // visible pane untouched expect(m.runs().find(r => r.runId === "rB")?.phase).toBe("live"); // …but rB IS tracked m.selectRun("rB"); // explicit switch still works diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22c76a57..34e9be59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19) + version: 2.1.9(@types/node@22.19.19)(happy-dom@20.10.6) packages/extension: devDependencies: @@ -50,6 +50,9 @@ importers: esbuild: specifier: ^0.24.0 version: 0.24.2 + happy-dom: + specifier: ^20.10.6 + version: 20.10.6 smol-toml: specifier: ^1.3.0 version: 1.6.1 @@ -58,7 +61,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19) + version: 2.1.9(@types/node@22.19.19)(happy-dom@20.10.6) packages/schema: dependencies: @@ -83,7 +86,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19) + version: 2.1.9(@types/node@22.19.19)(happy-dom@20.10.6) packages: @@ -652,6 +655,12 @@ packages: '@types/vscode@1.120.0': resolution: {integrity: sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typespec/ts-http-runtime@0.3.6': resolution: {integrity: sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==} engines: {node: '>=20.0.0'} @@ -820,6 +829,10 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1087,6 +1100,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1757,6 +1774,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -1769,6 +1790,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -2239,6 +2272,12 @@ snapshots: '@types/vscode@1.120.0': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.19 + '@typespec/ts-http-runtime@0.3.6': dependencies: http-proxy-agent: 7.0.2 @@ -2432,6 +2471,10 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 22.19.19 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -2767,6 +2810,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.10.6: + dependencies: + '@types/node': 22.19.19 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3436,7 +3492,7 @@ snapshots: '@types/node': 22.19.19 fsevents: 2.3.3 - vitest@2.1.9(@types/node@22.19.19): + vitest@2.1.9(@types/node@22.19.19)(happy-dom@20.10.6): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) @@ -3460,6 +3516,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 + happy-dom: 20.10.6 transitivePeerDependencies: - less - lightningcss @@ -3475,6 +3532,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} why-is-node-running@2.3.0: @@ -3485,6 +3544,8 @@ snapshots: wrappy@1.0.2: optional: true + ws@8.21.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 From 598658142d7ea3e836d4f3e7bc3e0af7379823a0 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Sun, 5 Jul 2026 23:15:53 -0400 Subject: [PATCH 06/50] =?UTF-8?q?1.4a:=20smoke=20corpus=20=E2=80=94=20Sche?= =?UTF-8?q?duler=20=E2=86=92=20executor=20=E2=86=92=20RunsManager=20?= =?UTF-8?q?=E2=86=92=20Inspector=20e2e=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(1.3): Run Inspector single→multi-run (runId-keyed protocol, per-run panes) Freeze-2 reshape (#58): the host↔webview message protocol is now runId-keyed and both the host and the webview fan into per-run panes. Single→multi only — the pane markup stays the current pulseplot (design lane, UX4 #49). Host (run_inspector.ts): a PaneBuffer per runId + activeRunId; runId-keyed surface postPulse/postIterationRecord/postCompletion/setWarmingUp/setRunLabel + new activate(runId). Per-run 5 Hz pulse throttle. resolveWebviewView replays EVERY pane from its buffer (S36) with positional ordering, then posts activate last. setWarmingUp guarded from clobbering a pane that already has data/terminal state. pulse stays plot-only (deliberately does not clear warming). Webview (media/ui/views/inspector.ts): createPanel() instances the former single-run view per runId (no shared globals); a router keys panels by runId, activate toggles the one visible pane, background/late messages only touch their own pane. Pane-hiding uses two-class selectors so it wins over layout.css `.stack` on specificity, not stylesheet order. RunsManager (runs_manager.ts): fans every run's live events into the inspector runId-tagged (routeIter/routePulse ungated); registration replay is state-only so the selected run never double-posts; selectRun adds activate; the single status bar stays selection-gated; completion + promote still fire per-run. Tests: runs_manager + inspector_view_contract updated to the runId-keyed API and fan-out semantics; added per-run isolation, per-run-throttle independence, S36 reopen, activate-last, warming-guard. New happy-dom webview test covers the router itself (per-run isolation, activate toggle, empty-state, plot-only pulse) — closes the coverage gap flagged in adversarial review. All invariants mutation-verified. 121 tests pass; typecheck + build clean. S6 (formulation preview) deferred — no formulation-emit in the frozen contract. Co-Authored-By: Claude Fable 5 * refactor(1.3): flow RunCompletion whole through completeRun — the #84/#81 seam Jack's #72 merge-seam heads-up: #81 adds `formulation?` to RunCompletion, and completeRun was the third completion path cherry-picking fields positionally (runId/status/fidelity) — once #81 landed, live-completed runs would carry formulation: undefined while replayed runs got it (the exact bug Kate caught on onFinished, reintroduced here). completeRun now takes the WHOLE RunCompletion; both feeders (ingestRunDir's sink verbatim, checkFinished via {runId, runDir, ...readTerminalState()}) funnel the object from the one shared read. An additive field is now a one-place edit (RunCompletion + readTerminalState) and reaches every consumer by construction — consumers cherry-pick at the leaf. Documented as the #84 funnel on both the type and completeRun; the full N-reader consolidation (catalog hydrator etc.) stays #84. Co-Authored-By: Claude Fable 5 * test(1.4a): smoke corpus — seconds-scale end-to-end fixtures, Scheduler → executor → run-dir → RunsManager → inspector (#61) Two corpus fixtures (transmon X, cavity displacement — distinct telemetry profiles: 2×8 vs 1×6, 4 vs 3 iters) + test/corpus/fake-julia, a node stand-in the executor spawns exactly like julia (last-argv script, cwd=runDir). It reads each fixture's AMICODE_SMOKE directive and emits the template's telemetry grammar (PULSE_META / ITER / PULSE → run.log via the executor's tail) with a small inter-iter delay so the LIVE tail path is exercised, then writes a schema-conformant result.toml. Zero Julia/Piccolo cost: full chain in ~0.5s. The end-to-end test pins what the unit suites can't — that the pieces AGREE: - Scheduler (#56) lifecycle is strictly serial (B starts only after A's finished event) and satisfies RunsManager's structural seam; - the executor's run-dir writes (run.toml/index/run.log/result.toml/FINISHED) are exactly what the manager's tailer/registry read back (fidelity + iter high-water land in the registry); - telemetry reaches the inspector runId-keyed per run with no cross-tagging (asserted by per-run record dims, completion fidelity, iter records). Runs in the regular vitest suite → already in CI's fast job; #62 promotes it to a named required gate. Mutation-verified: a wrong result.toml fidelity reds the registry + completion assertions. Branch note: contains merge of rchari/56-scheduler (the corpus drives the real Scheduler); diff collapses once #68/#70/#72 land. Co-Authored-By: Claude Fable 5 * test(1.4a): review #78 — failure-lane fixture, throwing pumpUntil, wiring-vs-format scope note 1. failing_solve.jl (exit=1): the previously-dead `exit=` directive support now has a fixture — a solve that emits two iterations then dies. Asserts the full failure path end-to-end: executor writes FINISHED{failed}, no result.toml, registry terminal with fidelity undefined but latestIter=2 (pre-crash telemetry tracked), completion fans runId-keyed, promote never fires. 2. pumpUntil now THROWS on timeout with a named condition — a wiring regression fails fast at the offending await instead of an opaque hang. 3. Scope note in the suite header: this guards WIRING (fake ↔ parser), not FORMAT (template ↔ parser) — that boundary is #83's. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../test/corpus/cavity_displacement.jl | 16 ++ .../extension/test/corpus/failing_solve.jl | 11 ++ packages/extension/test/corpus/fake-julia | 64 +++++++ packages/extension/test/corpus/transmon_x.jl | 21 +++ packages/extension/test/smoke_corpus.test.ts | 162 ++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 packages/extension/test/corpus/cavity_displacement.jl create mode 100644 packages/extension/test/corpus/failing_solve.jl create mode 100755 packages/extension/test/corpus/fake-julia create mode 100644 packages/extension/test/corpus/transmon_x.jl create mode 100644 packages/extension/test/smoke_corpus.test.ts diff --git a/packages/extension/test/corpus/cavity_displacement.jl b/packages/extension/test/corpus/cavity_displacement.jl new file mode 100644 index 00000000..1000aae7 --- /dev/null +++ b/packages/extension/test/corpus/cavity_displacement.jl @@ -0,0 +1,16 @@ +# Smoke-corpus fixture (1.4a, #61) — bosonic cavity displacement, minimal. +# Second platform so the corpus isn't shaped by one telemetry profile: a +# single-drive, shorter solve (different drives/knots/iters than transmon_x). +# +# CI FIXTURE, not runnable physics — see transmon_x.jl for the mechanism +# (test/corpus/fake-julia interprets the directive below). +# +# AMICODE_SMOKE iters=3 drives=1 knots=6 fidelity=0.9981 dt=0.5 delay_ms=40 + +# -- template-shaped parameter block (documentation of the modeled solve) -- +# levels = 8 # cavity Fock truncation (smoke-scale) +# drive_max = 0.2 # drive bound +# T = 30.0 # pulse time (ns) +# N = 6 # spline knots (smoke-scale) +# max_iter = 3 # smoke-scale +# target = displacement |0⟩ → |α⟩, α = 1.0 diff --git a/packages/extension/test/corpus/failing_solve.jl b/packages/extension/test/corpus/failing_solve.jl new file mode 100644 index 00000000..9efcbca3 --- /dev/null +++ b/packages/extension/test/corpus/failing_solve.jl @@ -0,0 +1,11 @@ +# Smoke-corpus fixture (1.4a, #61) — FAILURE LANE: a solve that dies mid-run. +# Exercises the path a crashing Julia process takes end-to-end: nonzero exit → +# executor writes FINISHED{failed} → RunsManager registers terminal WITHOUT a +# fidelity (no result.toml is written on failure) → completion fans to the +# inspector runId-tagged → promote never fires. +# +# CI FIXTURE, not runnable physics — see transmon_x.jl for the mechanism +# (test/corpus/fake-julia interprets the directive below; exit=1 makes it +# emit two iterations of telemetry and then die, like a real mid-solve crash). +# +# AMICODE_SMOKE iters=2 drives=1 knots=4 exit=1 dt=0.2 delay_ms=40 diff --git a/packages/extension/test/corpus/fake-julia b/packages/extension/test/corpus/fake-julia new file mode 100755 index 00000000..5d40f65d --- /dev/null +++ b/packages/extension/test/corpus/fake-julia @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Smoke-corpus "julia" (1.4a, #61) — a seconds-scale stand-in for the real +// solver so CI can exercise the FULL pipeline (Scheduler → LocalExecutor → +// run-dir contract → RunsManager → RunInspector) with zero Julia/Piccolo cost. +// +// Invoked exactly like julia: `fake-julia [--project=…] ` with +// cwd = the run dir (executor contract). It reads the corpus script (LAST +// argv, julia-style), parses its AMICODE_SMOKE directive, and then behaves +// like an instrumented solve_template.jl run: +// - AMICODE_PULSE_META once (shape + bounds), then per iteration one +// AMICODE_ITER + one AMICODE_PULSE line on stdout (→ run.log via the +// executor's tail), with a small delay so the LIVE tail path is exercised +// (not just a single replay drain); +// - result.toml into cwd (the run dir), matching the result schema; +// - exit 0 (the executor writes FINISHED{completed} — never the script). +// +// Directive grammar (one line in the corpus script): +// # AMICODE_SMOKE iters= drives= knots= fidelity= [dt=] [delay_ms=] [exit=] +// exit≠0 makes a failure-lane fixture (no result.toml, nonzero exit). + +'use strict'; +const fs = require('node:fs'); + +const script = process.argv[process.argv.length - 1]; +const src = fs.readFileSync(script, 'utf8'); +const m = src.match(/^#\s*AMICODE_SMOKE\s+(.+)$/m); +if (!m) { process.stderr.write(`fake-julia: no AMICODE_SMOKE directive in ${script}\n`); process.exit(2); } +const d = {}; +for (const kv of m[1].trim().split(/\s+/)) { + const [k, v] = kv.split('='); + d[k] = Number(v); +} +const iters = d.iters ?? 3, drives = d.drives ?? 1, knots = d.knots ?? 4; +const fidelity = d.fidelity ?? 0.999, dt = d.dt ?? 0.2; +const delayMs = d.delay_ms ?? 40, exitCode = d.exit ?? 0; +const bound = 0.2; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const say = (line) => process.stdout.write(line + '\n'); + +(async () => { + const labels = Array.from({ length: drives }, (_, i) => `"a_${i + 1}"`).join(','); + const bounds = Array.from({ length: drives }, () => `${-bound}:${bound}`).join(','); + say(`AMICODE_PULSE_META drives=${drives} knots=${knots} labels=${labels} bounds=${bounds}`); + + for (let k = 0; k <= iters; k++) { + // Deterministic, iteration-varying values inside the bounds band. + const row = (di) => + Array.from({ length: knots }, (_, j) => + (bound * 0.9 * Math.sin((j + 1) * (k + 1) + di)).toFixed(6)).join(','); + const vals = Array.from({ length: drives }, (_, di) => row(di)).join(';'); + const f = 50 * Math.exp(-k) + (1 - fidelity); + say(`AMICODE_ITER iter=${k} f=${f.toExponential(6)} inf_pr=${(1e-3 * Math.exp(-k)).toExponential(3)} inf_du=${(1e-2 * Math.exp(-k)).toExponential(3)}`); + say(`AMICODE_PULSE iter=${k} dt=${dt} a=${vals}`); + await sleep(delayMs); + } + + if (exitCode === 0) { + // Same shape the template writes (result schema: schema_version/fidelity/iterations). + fs.writeFileSync('result.toml', + `schema_version = "1"\nfidelity = ${fidelity}\niterations = ${iters}\n`); + } + process.exit(exitCode); +})(); diff --git a/packages/extension/test/corpus/transmon_x.jl b/packages/extension/test/corpus/transmon_x.jl new file mode 100644 index 00000000..5931b1c8 --- /dev/null +++ b/packages/extension/test/corpus/transmon_x.jl @@ -0,0 +1,21 @@ +# Smoke-corpus fixture (1.4a, #61) — transmon single-qubit X gate, minimal. +# +# This file is a CI FIXTURE, not runnable physics: in the smoke suite it is +# "solved" by test/corpus/fake-julia, which reads the directive below and +# emits the same telemetry stream (AMICODE_PULSE_META / AMICODE_ITER / +# AMICODE_PULSE → run.log) + result.toml that an instrumented +# templates/solve_template.jl run produces. The parameter block mirrors the +# template's transmon-X defaults so the fixture stays recognizably that +# platform — if the telemetry contract changes, change the template, the +# emitter, and this directive together. +# +# AMICODE_SMOKE iters=4 drives=2 knots=8 fidelity=0.9993 dt=0.2 delay_ms=40 + +# -- template-shaped parameter block (documentation of the modeled solve) -- +# levels = 3 # computational + 1 leakage +# delta = 0.2 # anharmonicity (GHz), positive convention +# drive_max = 0.2 # per-quadrature bound (GHz) +# T = 10.0 # gate time (ns) +# N = 8 # spline knots (smoke-scale; template default is 50) +# max_iter = 4 # smoke-scale; template default is 60 +# gate = :X # EmbeddedOperator(:X, sys) on the computational subspace diff --git a/packages/extension/test/smoke_corpus.test.ts b/packages/extension/test/smoke_corpus.test.ts new file mode 100644 index 00000000..c70d740e --- /dev/null +++ b/packages/extension/test/smoke_corpus.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as vscodeMock from "vscode"; +import { LocalExecutor, Scheduler, type SchedulerEvent } from "@amicode/amico-run"; + +// Smoke corpus (1.4a, #61) — the END-TO-END lane: real Scheduler (#56) → real +// LocalExecutor → real run-dir contract on disk → real RunsManager (#57) +// tailing runs/index → inspector host surface (#58, runId-keyed). The only +// stand-in is the solver binary: test/corpus/fake-julia interprets each corpus +// fixture's AMICODE_SMOKE directive and emits the template's telemetry stream +// at seconds-scale, so this runs in the fast CI tier with zero Julia cost. +// +// What this pins that the unit suites can't: the pieces AGREE — the executor's +// run.log/index/FINISHED writes are exactly what the manager's tailer/registry +// read, scheduler lifecycle events satisfy the manager's structural seam, and +// per-run telemetry lands runId-keyed on the inspector with no cross-tagging. +// (#62 wires this into CI as a required gate.) +// +// SCOPE — don't over-trust (review #78): fake-julia is an independent encoding +// of the AMICODE_* grammar, so this suite guards WIRING (fake ↔ parser), NOT +// FORMAT (template ↔ parser). A solve_template.jl emit-format change stays +// green here while real solves break; that boundary is #83's format guard. + +const { inspector } = vi.hoisted(() => ({ + inspector: { + setWarmingUp: vi.fn(), + postCompletion: vi.fn(), + postIterationRecord: vi.fn(), + postPulse: vi.fn(), + setRunLabel: vi.fn(), + activate: vi.fn(), + reveal: vi.fn(), + }, +})); +vi.mock("../src/run_inspector", () => ({ getInspector: () => inspector })); + +import { RunsManager } from "../src/runs_manager"; + +const channel = { appendLine() {}, append() {} } as never; +const CORPUS = join(__dirname, "corpus"); +const EMITTER = join(CORPUS, "fake-julia"); + +const tick = (m: RunsManager): void => (m as unknown as { tick(): void }).tick(); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/** Pump the manager's poll path until `pred` holds (the 700ms wall-clock poll + * is too slow for a test — tick() is the same code path, deterministic). + * THROWS on timeout (review #78): a wiring regression must fail fast at the + * offending await, not surface as an opaque suite hang. */ +async function pumpUntil(m: RunsManager, pred: () => boolean, what: string, ms = 8000): Promise { + const t0 = Date.now(); + while (!pred() && Date.now() - t0 < ms) { tick(m); await sleep(25); } + tick(m); + if (!pred()) throw new Error(`pumpUntil timed out after ${ms}ms waiting for: ${what}`); +} + +describe("smoke corpus — Scheduler → executor → run-dir → RunsManager → inspector", () => { + beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + + it("runs the corpus serially end-to-end; both runs tracked, runId-keyed, correct fidelity", async () => { + const runsRoot = mkdtempSync(join(tmpdir(), "smoke-corpus-")); + const m = new RunsManager({ runsRoot, channel }); + m.start(); + const scheduler = new Scheduler(new LocalExecutor()); + m.attachScheduler(scheduler); + const events: SchedulerEvent[] = []; + scheduler.onEvent((e) => events.push(e)); + + // Enqueue the WHOLE corpus up front — the second entry must wait (serial). + const opts = { runsRoot, julia: { julia: EMITTER } }; + const a = scheduler.enqueue({ scriptPath: join(CORPUS, "transmon_x.jl"), opts }); + const b = scheduler.enqueue({ scriptPath: join(CORPUS, "cavity_displacement.jl"), opts }); + + const ha = await a.handle; // head of queue — starts immediately + await pumpUntil(m, () => existsSync(join(ha.runDir, "FINISHED")), "run A FINISHED on disk"); + expect((await ha.finished).status).toBe("completed"); + + const hb = await b.handle; // resolves only after A finished (serial) + await pumpUntil(m, () => existsSync(join(hb.runDir, "FINISHED")), "run B FINISHED on disk"); + expect((await hb.finished).status).toBe("completed"); + + // --- scheduler lifecycle: strict serial ordering, queueIds line up --- + const seq = events.map((e) => `${e.kind}:${e.queueId}`); + expect(seq.indexOf("finished:q1")).toBeGreaterThan(seq.indexOf("started:q1")); + expect(seq.indexOf("started:q2")).toBeGreaterThan(seq.indexOf("finished:q1")); // B started strictly after A finished + expect(seq.indexOf("finished:q2")).toBeGreaterThan(seq.indexOf("started:q2")); + + // --- run-dir contract on disk for BOTH runs (what the executor wrote is + // exactly what the manager read) --- + for (const h of [ha, hb]) { + for (const f of ["run.toml", "run.log", "result.toml", "FINISHED"]) { + expect(existsSync(join(h.runDir, f)), `${f} missing in ${h.runDir}`).toBe(true); + } + expect(readFileSync(join(runsRoot, "index"), "utf8")).toContain(h.runId); + } + + // --- registry: both finished, fidelity + iter high-water from the stream --- + await pumpUntil(m, () => m.runs().filter((r) => r.phase === "finished").length === 2, "both runs terminal in the registry"); + expect(m.runs().find((r) => r.runId === ha.runId)).toMatchObject({ + phase: "finished", status: "completed", fidelity: 0.9993, latestIter: 4, + }); + expect(m.runs().find((r) => r.runId === hb.runId)).toMatchObject({ + phase: "finished", status: "completed", fidelity: 0.9981, latestIter: 3, + }); + + // --- inspector fan-out: per-run, runId-keyed, no cross-tagging --- + // Scheduler `started` selected each run as it began (auto-follow). + expect(inspector.activate).toHaveBeenCalledWith(ha.runId); + expect(inspector.activate).toHaveBeenCalledWith(hb.runId); + // Completion runId-keyed with each fixture's fidelity. + expect(inspector.postCompletion).toHaveBeenCalledWith(ha.runId, "completed", 0.9993); + expect(inspector.postCompletion).toHaveBeenCalledWith(hb.runId, "completed", 0.9981); + // Pulse stream per run: meta + records with the fixture's shape, and every + // record tagged with ITS run — dims prove no stream-crossing (A is 2×8, B is 1×6). + const pulses = (rid: string) => inspector.postPulse.mock.calls.filter((c) => c[0] === rid).map((c) => c[1]); + const lastA = pulses(ha.runId).filter((e) => e.type === "record").at(-1); + const lastB = pulses(hb.runId).filter((e) => e.type === "record").at(-1); + expect(pulses(ha.runId).some((e) => e.type === "meta" && e.meta.drives === 2 && e.meta.knots === 8)).toBe(true); + expect(pulses(hb.runId).some((e) => e.type === "meta" && e.meta.drives === 1 && e.meta.knots === 6)).toBe(true); + expect(lastA.record).toMatchObject({ iter: 4 }); + expect(lastA.record.values).toHaveLength(2); + expect(lastA.record.values[0]).toHaveLength(8); + expect(lastB.record).toMatchObject({ iter: 3 }); + expect(lastB.record.values).toHaveLength(1); + expect(lastB.record.values[0]).toHaveLength(6); + // Iter telemetry keyed per run too. + expect(inspector.postIterationRecord).toHaveBeenCalledWith(ha.runId, expect.objectContaining({ iter: 4 })); + expect(inspector.postIterationRecord).toHaveBeenCalledWith(hb.runId, expect.objectContaining({ iter: 3 })); + + m.dispose(); + }, 20000); + + it("failure lane: a crashing solve lands FINISHED{failed}, no result.toml, no fidelity, promote suppressed", async () => { + const runsRoot = mkdtempSync(join(tmpdir(), "smoke-corpus-fail-")); + const m = new RunsManager({ runsRoot, channel }); + m.start(); + const scheduler = new Scheduler(new LocalExecutor()); + m.attachScheduler(scheduler); + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + + const f = scheduler.enqueue({ scriptPath: join(CORPUS, "failing_solve.jl"), opts: { runsRoot, julia: { julia: EMITTER } } }); + const hf = await f.handle; + await pumpUntil(m, () => existsSync(join(hf.runDir, "FINISHED")), "failing run FINISHED on disk"); + expect((await hf.finished).status).toBe("failed"); + + // Run-dir contract for the failure lane: FINISHED written by the EXECUTOR + // (never the script), and no result.toml (the emitter dies before it). + expect(existsSync(join(hf.runDir, "result.toml"))).toBe(false); + await pumpUntil(m, () => m.runs().find((r) => r.runId === hf.runId)?.phase === "finished", "failed run terminal in the registry"); + expect(m.runs().find((r) => r.runId === hf.runId)).toMatchObject({ phase: "finished", status: "failed" }); + expect(m.runs().find((r) => r.runId === hf.runId)?.fidelity).toBeUndefined(); + // …but the telemetry it emitted BEFORE dying was tracked (iters 0-2). + expect(m.runs().find((r) => r.runId === hf.runId)?.latestIter).toBe(2); + + // Completion fans runId-keyed with no fidelity; promote never fires. + expect(inspector.postCompletion).toHaveBeenCalledWith(hf.runId, "failed", undefined); + expect(promote).not.toHaveBeenCalled(); + promote.mockRestore(); + m.dispose(); + }, 20000); +}); From 83c799813f9a193f8f2efcf2d01168528f613c7a Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 6 Jul 2026 12:00:10 -0400 Subject: [PATCH 07/50] =?UTF-8?q?feat:=20theme-calculated=20Harmoniqs=20ye?= =?UTF-8?q?llow=20=E2=80=94=20OKLCH-solved=20brand=20accent=20(brand-wide)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brand_accent.ts computes the deployed accent from the active theme at webview boot: the canonical #FFF676 ships EXACTLY wherever contrast vs the theme's editor background clears 3:1 (all dark themes); light themes get the closest-to-brand gold by binary-searching lightness with hue + chroma held (gamut-clamped). Two tokens with different jobs: lines (--color-accent, contrast-solved: borders/rings/marks) and fills (--color-accent-fill, always the brand lemon — black text on it ≈ 19:1; a 3:1-darkened gold passes WCAG math but reads muddy under text). --color-on-accent is contrast-picked; yellow is never text. Recomputed live on theme switch. Inspector + catalog-card webviews apply at boot; brand.css statics remain the no-JS fallback. Pill atom gains a dot-less badge variant (dot = process state; badges describe things). Co-Authored-By: Claude Fable 5 --- packages/extension/media/ui/atoms/pill.ts | 14 +- packages/extension/media/ui/brand_accent.ts | 141 ++++++++++++++++++ .../extension/src/catalog_card_webview.ts | 3 + packages/extension/src/inspector_webview.ts | 3 + packages/extension/test/brand_accent.test.ts | 60 ++++++++ 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 packages/extension/media/ui/brand_accent.ts create mode 100644 packages/extension/test/brand_accent.test.ts diff --git a/packages/extension/media/ui/atoms/pill.ts b/packages/extension/media/ui/atoms/pill.ts index d7b3ab26..c150ab9c 100644 --- a/packages/extension/media/ui/atoms/pill.ts +++ b/packages/extension/media/ui/atoms/pill.ts @@ -1,4 +1,7 @@ // 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"; @@ -10,6 +13,7 @@ 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; } @@ -20,15 +24,21 @@ defineStyle("pill", ` 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/brand_accent.ts b/packages/extension/media/ui/brand_accent.ts new file mode 100644 index 00000000..b4e95d63 --- /dev/null +++ b/packages/extension/media/ui/brand_accent.ts @@ -0,0 +1,141 @@ +// 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/src/catalog_card_webview.ts b/packages/extension/src/catalog_card_webview.ts index fa4f26ed..a8df0376 100644 --- a/packages/extension/src/catalog_card_webview.ts +++ b/packages/extension/src/catalog_card_webview.ts @@ -2,8 +2,11 @@ // (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 } } } diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index 47ed85e5..7066d0ce 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -2,8 +2,11 @@ // inspector.ts). No static markup: the view builds its own DOM from atoms/ // components; brand.css + layout.css are linked by the shell (run_inspector.ts). +import { applyBrandAccent } from "../media/ui/brand_accent"; import { createInspectorView } from "../media/ui/views/inspector"; +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + declare function acquireVsCodeApi(): { postMessage(msg: unknown): void; }; diff --git a/packages/extension/test/brand_accent.test.ts b/packages/extension/test/brand_accent.test.ts new file mode 100644 index 00000000..1e18ea23 --- /dev/null +++ b/packages/extension/test/brand_accent.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { parseColor, srgbToOklch, oklchToSrgb, contrast, solveBrandAccent } from "../media/ui/brand_accent"; + +// Theme-calculated Harmoniqs yellow: brand-exact wherever the theme allows +// (dark), contrast-solved to the closest-to-brand gold where it doesn't +// (light). Yellow is never text: on-accent is picked by contrast on the fill. + +describe("solveBrandAccent — the theme-calculated Harmoniqs yellow", () => { + it("dark themes ship the canonical hex EXACTLY", () => { + for (const bg of ["#1e1e1e", "#000000", "rgb(30, 30, 30)"]) { + const r = solveBrandAccent(bg); + expect(r.accent).toBe("#FFF676"); + expect(r.brandExact).toBe(true); + } + }); + + it("light themes get a contrast-solved gold LINE: ≥3:1, brand hue held, lightness reduced", () => { + const r = solveBrandAccent("#ffffff"); + expect(r.brandExact).toBe(false); + const solved = parseColor(r.accent)!; + expect(contrast(solved, parseColor("#ffffff")!)).toBeGreaterThanOrEqual(2.98); // binary-search tolerance + const brand = srgbToOklch(parseColor("#FFF676")!); + const got = srgbToOklch(solved); + expect(Math.abs(got.h - brand.h)).toBeLessThan(8); // hue is the brand carrier + expect(got.L).toBeLessThan(brand.L); + }); + + it("the FILL stays brand lemon on every theme — text readability beats fill-vs-bg contrast", () => { + for (const bg of ["#1e1e1e", "#ffffff", "#f3f3f3"]) { + const r = solveBrandAccent(bg); + expect(r.accentFill).toBe("#FFF676"); + // black text on the lemon fill is always high-contrast (~19:1) + expect(contrast(parseColor(r.onAccent)!, parseColor(r.accentFill)!)).toBeGreaterThan(4.5); + } + }); + + it("on-accent text is picked by contrast on the fill (black on the lemon)", () => { + expect(solveBrandAccent("#1e1e1e").onAccent).toBe("#000000"); + expect(solveBrandAccent("#ffffff").onAccent).toBe("#000000"); + }); + + it("mid-gray themes that already clear 3:1 stay brand-exact", () => { + expect(solveBrandAccent("#808080").brandExact).toBe(true); + }); + + it("parses the color formats getComputedStyle actually returns", () => { + expect(parseColor("#FFF676")).toBeDefined(); + expect(parseColor("rgb(255, 246, 118)")).toBeDefined(); + expect(parseColor("rgba(255, 246, 118, 1)")).toBeDefined(); + expect(parseColor("")).toBeUndefined(); + // garbage input falls back inside solveBrandAccent rather than throwing + expect(() => solveBrandAccent("not-a-color")).not.toThrow(); + }); + + it("OKLCH round-trips the brand hex within a hair", () => { + const rgb = parseColor("#FFF676")!; + const back = oklchToSrgb(srgbToOklch(rgb)); + back.forEach((c, i) => expect(Math.abs(c - rgb[i])).toBeLessThan(0.005)); + }); +}); From 9634f863f596a93a67bb66b7740f480c948b449a Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 6 Jul 2026 16:17:07 -0400 Subject: [PATCH 08/50] workbench: run picker + pane-ticker pause + runId-tagged controls (+ Kate's theme accent picked) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - amicode.selectRun: QuickPick over the registry (newest first; live/completed/ stopped/failed icons, iter + fidelity + script). Picking pins; "Follow latest" releases the pin via RunsManager.resumeAutoFollow() (jumps to the newest live run). Pre-UX4 utility — unblocks real multi-run testing. - Hidden panes pause their 1 Hz elapsed-strip ticker (Panel.setActive from the router's activate); resumes with a fresh render on re-activation. - Control-row messages carry their pane's runId (post-UX4 correctness; commands still resolve the selected run today). - Cherry-picked Kate's 154650c: OKLCH theme-calculated brand accent — fixes the hardcoded #FFF676 that dies on light themes (audit P0-1, extension side). 408 tests pass; typecheck + build clean. Co-Authored-By: Claude Fable 5 --- .DS_Store | Bin 6148 -> 6148 bytes packages/.DS_Store | Bin 6148 -> 6148 bytes packages/extension/.DS_Store | Bin 10244 -> 10244 bytes .../extension/media/ui/views/inspector.ts | 25 +++++++++++++----- packages/extension/package.json | 24 ++++++++++++----- packages/extension/src/extension.ts | 22 +++++++++++++++ packages/extension/src/runs_manager.ts | 15 +++++++++++ 7 files changed, 73 insertions(+), 13 deletions(-) diff --git a/.DS_Store b/.DS_Store index dca287d0a7be4cd02fbbcb94a6820b7da7c4945c..8722cf59eb5d0e3d06d5c45d00a540112860fea5 100644 GIT binary patch delta 130 zcmZoMXffEJ$`Tir%FDpOz`~%%kj{|FP?DSP;*yk;p9B=+;7yWqQu=q?5mi0~uY5s< uVQ_MOZUIma1H9%MSoNLL{UB diff --git a/packages/.DS_Store b/packages/.DS_Store index a00014fa0eefe470a64e7ce5eba693b1cf60f4f3..71e649abc416adb41ee8542e2015a8aae35676cd 100644 GIT binary patch delta 27 jcmZoMXffDuo{9N#TIl3IOcIk{F~u|8ez4h_d8Y^fq45ii delta 27 jcmZoMXffDuo{9MqQ~TsUOcIk{F~u|8D%kAJyi)`Kn}G`H diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index 1d83c24db3c81a1726fc51107600db74e9011f7d..e97ec0b52fea1f0840adfa8e8e7db42d7618658b 100644 GIT binary patch delta 343 zcmZn(XbIR*E68locW82hU?h{ph0V7GZ5Wvqc-tpS2`4Zvm|QKa!x%NWMp$O^5#a(R z*{4nn3=GT+#SBFZ$+`J1E=f80Nk9>f>pD_SN|OV{Bw3XDG=eARiODc3Ozsxj9jgMA zWnf__VMqmPDnZur!vLt|-*HD&`4qU3*yIZ`41<&Na|=L*GO#ghUMxP5Z;-iZ@?!Bs E0L#W&0RR91 delta 323 zcmZn(XbIR*E68jf$1=G=Fp|l9!RFh7HjK>jA2cUR2`4Z%Os*EzVPu+IBP_G|h;RWD z|LuGR1_ow^Vum7y): 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): Panel { +function createPanel(post: (msg: unknown) => void, runId?: string): Panel { const status = pill("idle"); const runLabel = text("mono small dim"); const pulse = pulseplot(IDLE_HINT); @@ -80,9 +83,9 @@ function createPanel(post: (msg: unknown) => void): Panel { // 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); @@ -127,6 +130,13 @@ function createPanel(post: (msg: unknown) => void): Panel { return { el, + 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 "runlabel": @@ -215,7 +225,7 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView const panelFor = (runId: string): Panel => { let p = panels.get(runId); if (!p) { - p = createPanel(post); + p = createPanel(post, runId); panels.set(runId, p); el.append(p.el); } @@ -225,7 +235,10 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView 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); + 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 }; diff --git a/packages/extension/package.json b/packages/extension/package.json index 32937841..3a23c5a0 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,7 @@ { "name": "amicode-v2", "displayName": "Amicode v2", - "description": "Amico research IDE — opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", + "description": "Amico research IDE \u2014 opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", "version": "0.0.1", "publisher": "harmoniqs", "license": "Apache-2.0", @@ -76,6 +76,10 @@ "command": "amicode.openInspector", "title": "Amicode: Open Run Inspector" }, + { + "command": "amicode.selectRun", + "title": "Amicode: Select run to inspect\u2026" + }, { "command": "amicode.restartServer", "title": "Amicode: Restart opencode server" @@ -140,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." + "description": "Platform-skill names indexed from the central library (public). Empty = atoms, transmon, fluxonium, ions, bosonic. Only listed names are indexed \u2014 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." }, @@ -169,7 +179,7 @@ "amicode.veloce": { "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." + "description": "Amico Veloce: start sessions with autonomy on \u2014 auto-accept high-confidence downstream recommendations without asking. Resource gates (solve launch, hardware) always confirm; any interruption drops veloce. Off by default." } } }, @@ -217,4 +227,4 @@ "dependencies": { "yaml": "^2.9.0" } -} \ No newline at end of file +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index cbaf848e..870e27d3 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -289,6 +289,28 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.registerCommand("amicode.openInspector", async () => { await vscode.commands.executeCommand("amicode.runInspector.focus"); }), + // Run picker (pre-UX4 utility): switch the inspector between tracked runs. + // Picking pins the selection (a background solve won't steal the view); + // "Follow latest" releases the pin and resumes newest-run auto-follow. + vscode.commands.registerCommand("amicode.selectRun", async () => { + const runs = runsManager?.runs() ?? []; + if (runs.length === 0) { void vscode.window.showInformationMessage("Amicode: no runs tracked yet."); return; } + const items: (vscode.QuickPickItem & { runId?: string; follow?: boolean })[] = [ + { label: "$(radio-tower) Follow latest", description: "auto-follow the newest run (release pin)", follow: true }, + ...[...runs].reverse().map((r) => ({ + label: `${r.phase === "live" ? "$(pulse)" : r.status === "completed" ? "$(pass)" : r.status === "stopped" ? "$(debug-pause)" : "$(error)"} ${r.runId}`, + description: [r.phase === "live" ? `live · iter ${r.latestIter ?? 0}` : r.status, + r.fidelity !== undefined ? `F=${r.fidelity.toFixed(5)}` : undefined, + r.scriptPath ? path.basename(r.scriptPath) : undefined].filter(Boolean).join(" · "), + runId: r.runId, + })), + ]; + const pick = await vscode.window.showQuickPick(items, { placeHolder: "Amicode: select the run to inspect" }); + if (!pick) return; + if (pick.follow) runsManager?.resumeAutoFollow(); + else if (pick.runId) runsManager?.selectRun(pick.runId); + await vscode.commands.executeCommand("amicode.runInspector.focus"); + }), vscode.commands.registerCommand("amicode.stopRun", () => { const dir = runsManager?.getActiveRunDir(); if (!dir) { vscode.window.showWarningMessage("Amicode: no active run to stop."); return; } diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index 73a8897d..6244e5bc 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -249,6 +249,21 @@ export class RunsManager implements vscode.Disposable { return this.selected ? this.registry.get(this.selected)?.runDir : undefined; } + /** Release an explicit pin and resume latest-follow: jump to the newest LIVE + * run if one exists (registration order = creation order), else stay put. + * Backs the run picker's "Follow latest" entry. */ + resumeAutoFollow(): void { + this.pinned = false; + const live = this.registry.all().filter((r) => r.phase === "live"); + const newest = live[live.length - 1]; + if (newest && this.selected !== newest.runId) { + // Route through selectRun for the full display path, then re-release the + // pin it sets (this is the auto lane, not an explicit selection). + this.selectRun(newest.runId); + this.pinned = false; + } + } + // -------- internal -------- private registerRun(runId: string, runDir: string, createdAt?: string, scriptPath?: string): void { From 9d2a564186ca10528b1b6d6cfe3565c32d9a13ed Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 6 Jul 2026 16:44:56 -0400 Subject: [PATCH 09/50] =?UTF-8?q?workbench:=20wire=20catalog=20what-next?= =?UTF-8?q?=20=E2=80=94=20tune/warm-start=20stage=20a=20concrete=20chat=20?= =?UTF-8?q?prompt=20(clipboard=20+=20open=20chat);=20promote=20says=20Phas?= =?UTF-8?q?e-3=20honestly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .DS_Store | Bin 6148 -> 6148 bytes packages/.DS_Store | Bin 6148 -> 6148 bytes packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/catalog_card_shell.ts | 18 +++++++++++++++++- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.DS_Store b/.DS_Store index 8722cf59eb5d0e3d06d5c45d00a540112860fea5..ca78d7b7093694918cbc422f5ec34af432c14929 100644 GIT binary patch delta 127 zcmZoMXffEJ#u9tmi-CcGg+Y%YogtH+iErGLjAQRP$c$`@o9 r1}Ep|76A1yFl@Q7xtXPyiAmwiWCeDy$$acFj0+|v&e_b)@s}R}hyZwsDbWHg#wBP_G|h;RWD(<_tBW@7*NA@n&$0Ns8P AyZ`_I delta 48 zcmZn(XbIR*C&<)yXmW#K6yt@>w*}8IGDc0V5ti9}M7V&7>8aCZGqHdC5c(V=0NBkE Awg3PC diff --git a/packages/extension/src/catalog_card_shell.ts b/packages/extension/src/catalog_card_shell.ts index 20c1f394..a631c202 100644 --- a/packages/extension/src/catalog_card_shell.ts +++ b/packages/extension/src/catalog_card_shell.ts @@ -42,7 +42,23 @@ export function registerCatalogCard(ctx: vscode.ExtensionContext): void { open.set(key, panel); panel.onDidDispose(() => open.delete(key), null, ctx.subscriptions); panel.webview.onDidReceiveMessage((m) => { - if (m?.type === "whatnext") vscode.window.showInformationMessage(`what-next → ${m.id} (stub)`); + 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); From 96f5e44c79d2dd6719accb09eb1cdf9cf476c130 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 6 Jul 2026 16:49:41 -0400 Subject: [PATCH 10/50] =?UTF-8?q?workbench:=20chat=20theme=20bridge=20?= =?UTF-8?q?=E2=80=94=20iframe=20boots=20with=20=3FcolorScheme=3D=20from=20?= =?UTF-8?q?the=20editor=20theme;=20live=20re-theme=20via=20onDidChangeActi?= =?UTF-8?q?veColorTheme=20=E2=86=92=20two-lane=20relay=20(origin-pinned)?= =?UTF-8?q?=20=E2=86=92=20app's=20setColorScheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .DS_Store | Bin 6148 -> 6148 bytes packages/.DS_Store | Bin 6148 -> 6148 bytes packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/chat_panel.ts | 39 ++++++++++++++++++++++++--- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.DS_Store b/.DS_Store index ca78d7b7093694918cbc422f5ec34af432c14929..369a9522a2b46aa25ed89a2f6de2d7b0ab83af5a 100644 GIT binary patch delta 127 zcmZoMXffEJ#uEEuAp-*g3xgg*IzuKyNp8N2OHxjL5>SjIupwxP>c8WTsPZXzw delta 127 zcmZoMXffEJ#u9tmi-CcGg+Y%YogtH+iErGLjAQRP$c$`@o9 r1}Ep|76A1yFl@Q7xtXPyiAmwiWCeDy$$acFj0+|v&e_b)@s}R}hyZwsDbWHg#wBP_G|h;RWD(<_tBW@7*NA@n&$0Ns8P AyZ`_I diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index f82c40a5..747a662d 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -20,13 +20,28 @@ const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ "amicode.openInspector", ]); + +/** 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) { 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 @@ -88,6 +103,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 +120,7 @@ export class ChatPanel { - + `; - })); + }, + ), + ); } /** 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 { +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; @@ -110,11 +131,15 @@ export function hydrateFromRunDir(runDir: string, systemName?: string, tags?: st 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 (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 */ } + } 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 index a8df0376..ab937b8b 100644 --- a/packages/extension/src/catalog_card_webview.ts +++ b/packages/extension/src/catalog_card_webview.ts @@ -5,10 +5,14 @@ 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) +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 } } } +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 @@ -26,17 +30,27 @@ const ENTRY: CatalogEntry = { }; const PULSE: CardPulse = { - meta: { drives: 2, knots: 25, labels: ["u_1", "u_2"], bounds: [[-0.2, 0.2], [-0.2, 0.2]] }, + 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.100, -0.089, -0.065, -0.031, 0.009, 0.049, - 0.084, 0.109, 0.121, 0.118, 0.100, 0.070, 0.032, -0.009, -0.048, -0.079, - -0.098, -0.102, -0.089, -0.061, -0.024], + [ + 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, + ], ], }, }; @@ -51,7 +65,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 + 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"; diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index d70d32b5..94cf6ee7 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -24,7 +24,6 @@ const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ "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"; @@ -34,15 +33,21 @@ 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) }), + (t) => + void this.panel.webview.postMessage({ + source: "amicode", + kind: "theme", + colorScheme: themeKindToScheme(t.kind), + }), null, this.disposables, ); @@ -59,7 +64,7 @@ export class ChatPanel { (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) + /^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. @@ -79,9 +84,16 @@ export class ChatPanel { // 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 }), - ); + void vscode.env.clipboard + .readText() + .then((text) => + this.panel.webview.postMessage({ + source: "amicode", + kind: "clipboard", + nonce: (msg as { nonce?: string }).nonce, + text, + }), + ); return; } if ( @@ -107,19 +119,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; @@ -189,7 +196,9 @@ export class ChatPanel { dispose(): void { for (const d of this.disposables) { - try { d.dispose(); } catch {} + try { + d.dispose(); + } catch {} } this.disposables.length = 0; if (ChatPanel.current === this) ChatPanel.current = undefined; diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 1b60fd30..92682a59 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -39,7 +39,6 @@ let opencodeReadyUrl: URL | undefined; * and the distillNow command read it lazily (undefined = distiller disabled). */ let distillerSetup: DistillerSetup | undefined; - /** Run dirs with a cooperative stop in flight (escalation timer armed) — a * second Stop click must not stack a second dialog. */ const pendingStops = new Set(); @@ -55,7 +54,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 1. UI surfaces const trees = registerTrees(ctx); registerRunInspector(ctx); - registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow + registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow ctx.subscriptions.push( // #47 session catalog: record the save (workspaceState + tree), then open // the card. Both prompts (demo replay, live promote) route through here. @@ -79,7 +78,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { prompt: "Tags (comma-separated, optional)", placeHolder: "e.g. high-R, T=8, fast", }); - const tags = tagsRaw?.split(",").map((t) => t.trim()).filter(Boolean) ?? []; + const tags = + tagsRaw + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean) ?? []; await trees.catalog.save({ run_id: String(manifest.run_id ?? path.basename(runDir)), runDir, @@ -146,9 +149,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const opencodeProject = prepareOpencodeProject({ agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), templateSrc: path.resolve(ctx.extensionPath, "templates", "solve_template.jl"), - juliaProject: resolveJuliaProject( - vscode.workspace.getConfiguration("amicode").get("juliaProject", ""), - ), + juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), platformSkills: cfgArr("platformSkills"), skillLibraryRoots: cfgArr("skillLibraryRoots"), @@ -188,7 +189,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // gets the Julia project from AGENTS.md (substituted at session-copy time) // and passes it as `--project`. PATH just needs to resolve the launcher. if (amicoRunBinDir === undefined) { - opencodeChannel.appendLine(`[boot] WARNING: amico-run launcher not found — chat can author but solves won't run (build amico-run or check the VSIX)`); + opencodeChannel.appendLine( + `[boot] WARNING: amico-run launcher not found — chat can author but solves won't run (build amico-run or check the VSIX)`, + ); } // opencode owns the LLM credential (0.3): amico injects NO key into the // spawn env — opencode resolves its provider from its own env / config / @@ -209,7 +212,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // config, so the model/provider are preserved. This is what makes the // chat actually author + run solves instead of behaving like vanilla // opencode (the session cwd is the workspace, not opencodeProject.projectDir). - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(opencodeProject.agentsPath, opencodeProject.templatePath, runsRoot, undefined, undefined, opencodeProject.skillPaths, opencodeProject.skillsStageDir, opencodeProject.vaultDir), + OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + opencodeProject.agentsPath, + opencodeProject.templatePath, + runsRoot, + undefined, + undefined, + opencodeProject.skillPaths, + opencodeProject.skillsStageDir, + opencodeProject.vaultDir, + ), }, channel: opencodeChannel, }); @@ -232,7 +244,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { model: vscode.workspace.getConfiguration("amicode").get("distillerModel", "opencode/big-pickle"), }; initDistillerTransport(distillerSetup); - opencodeChannel.appendLine(`[boot] distiller armed (vault: ${opencodeProject.vaultDir}, model: ${distillerSetup.model})`); + opencodeChannel.appendLine( + `[boot] distiller armed (vault: ${opencodeProject.vaultDir}, model: ${distillerSetup.model})`, + ); } catch (e) { opencodeChannel.appendLine(`[boot] distiller transport failed (memory disabled this session): ${e}`); distillerSetup = undefined; @@ -280,7 +294,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // openOrReveal as undefined (or reveal a panel bound to a stale server). const readyUrl = opencodeReadyUrl; if (!readyUrl) { - vscode.window.showWarningMessage("Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel."); + vscode.window.showWarningMessage( + "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", + ); return; } // Creds gate — opencode serves HTTP 200 (→ "ready") even with zero @@ -303,18 +319,33 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // "Follow latest" releases the pin and resumes newest-run auto-follow. vscode.commands.registerCommand("amicode.selectRun", async () => { const runs = runsManager?.runs() ?? []; - if (runs.length === 0) { void vscode.window.showInformationMessage("Amicode: no runs tracked yet."); return; } + if (runs.length === 0) { + void vscode.window.showInformationMessage("Amicode: no runs tracked yet."); + return; + } const items: (vscode.QuickPickItem & { runId?: string; follow?: boolean })[] = [ - { label: "$(radio-tower) Follow latest", description: "auto-follow the newest run (release pin)", follow: true }, + { + label: "$(radio-tower) Follow latest", + description: "auto-follow the newest run (release pin)", + follow: true, + }, ...[...runs].reverse().map((r) => { // A "live" run whose log has gone cold is stalled — the picker must // agree with the status bar, not advertise a wedge as live. const stalled = r.phase === "live" && stopPlan(r.runDir) === "force"; return { label: `${r.phase === "live" ? (stalled ? "$(warning)" : "$(pulse)") : r.status === "completed" ? "$(pass)" : r.status === "stopped" ? "$(debug-pause)" : "$(error)"} ${r.runId}`, - description: [r.phase === "live" ? (stalled ? `stalled · iter ${r.latestIter ?? 0}` : `live · iter ${r.latestIter ?? 0}`) : r.status, - r.fidelity !== undefined ? `F=${r.fidelity.toFixed(5)}` : undefined, - r.scriptPath ? path.basename(r.scriptPath) : undefined].filter(Boolean).join(" · "), + description: [ + r.phase === "live" + ? stalled + ? `stalled · iter ${r.latestIter ?? 0}` + : `live · iter ${r.latestIter ?? 0}` + : r.status, + r.fidelity !== undefined ? `F=${r.fidelity.toFixed(5)}` : undefined, + r.scriptPath ? path.basename(r.scriptPath) : undefined, + ] + .filter(Boolean) + .join(" · "), runId: r.runId, }; }), @@ -327,12 +358,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), vscode.commands.registerCommand("amicode.stopRun", async () => { const dir = runsManager?.getActiveRunDir(); - if (!dir) { vscode.window.showWarningMessage("Amicode: no active run to stop."); return; } + if (!dir) { + vscode.window.showWarningMessage("Amicode: no active run to stop."); + return; + } // Escalation ladder: cooperative STOP only works while a solver is alive // to poll it — a stalled run gets the hard path immediately, a healthy // one gets a grace window and then an explicit Force-stop offer (never a // silent kill: one long Ipopt iteration can look wedged). - const label = path.basename(dir); // every toast names the run — stop A, start B, a nameless dialog at t+120s reads as "B is wedged" + const label = path.basename(dir); // every toast names the run — stop A, start B, a nameless dialog at t+120s reads as "B is wedged" if (pendingStops.has(dir)) { vscode.window.showInformationMessage(`Amicode: stop already in progress for ${label}.`); return; @@ -344,19 +378,25 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } // Best-effort: a deleted run dir throws ENOENT here, and the force path // below must still be reachable to clear the registry/UI entry. - try { writeStopFile(dir); } catch { /* dir gone — force path handles it */ } + try { + writeStopFile(dir); + } catch { + /* dir gone — force path handles it */ + } if (plan === "force") { await forceStop(dir); vscode.window.showInformationMessage(`Amicode: run ${label} was stalled — force-stopped and marked aborted.`); return; } - vscode.window.showInformationMessage(`Amicode: stop requested for ${label} — the solve will halt at the next iteration.`); + vscode.window.showInformationMessage( + `Amicode: stop requested for ${label} — the solve will halt at the next iteration.`, + ); const mtimeAtStop = runLogMtime(dir); pendingStops.add(dir); const timer = setTimeout(async () => { pendingStops.delete(dir); - if (stopPlan(dir) === "already-finished") return; // cooperative stop landed - if (runLogMtime(dir) !== mtimeAtStop) return; // still iterating — let it reach the callback + if (stopPlan(dir) === "already-finished") return; // cooperative stop landed + if (runLogMtime(dir) !== mtimeAtStop) return; // still iterating — let it reach the callback const pick = await vscode.window.showWarningMessage( `Amicode: run ${label} hasn't responded to stop.`, "Force stop", @@ -367,24 +407,38 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.window.showInformationMessage(`Amicode: run ${label} force-stopped and marked aborted.`); } }, 120_000); - ctx.subscriptions.push({ dispose: () => { clearTimeout(timer); pendingStops.delete(dir); } }); + ctx.subscriptions.push({ + dispose: () => { + clearTimeout(timer); + pendingStops.delete(dir); + }, + }); }), vscode.commands.registerCommand("amicode.openRunDir", async () => { const dir = runsManager?.getActiveRunDir(); - if (!dir) { vscode.window.showWarningMessage("Amicode: no active run."); return; } + if (!dir) { + vscode.window.showWarningMessage("Amicode: no active run."); + return; + } // revealFileInOS wants a FILE (a bare directory errors on macOS) — reveal // the manifest, which every run dir has from birth; fall back to opening // the folder externally if the reveal still fails. const manifest = path.join(dir, "run.toml"); try { - await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(fs.existsSync(manifest) ? manifest : dir)); + await vscode.commands.executeCommand( + "revealFileInOS", + vscode.Uri.file(fs.existsSync(manifest) ? manifest : dir), + ); } catch { await vscode.env.openExternal(vscode.Uri.file(dir)); } }), vscode.commands.registerCommand("amicode.savePulse", async () => { const dir = runsManager?.getActiveRunDir(); - if (!dir) { vscode.window.showWarningMessage("Amicode: no active run."); return; } + if (!dir) { + vscode.window.showWarningMessage("Amicode: no active run."); + return; + } const catalog = catalogPulsesDir(); const picks = [catalog ? "Save to catalog" : undefined, "Save to file…"].filter(Boolean) as string[]; const choice = await vscode.window.showQuickPick(picks, { title: "Save pulse" }); @@ -399,7 +453,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { filters: { JLD2: ["jld2"] }, defaultUri: vscode.Uri.file(path.join(dir, "pulse.jld2")), }); - if (uri) { savePulseTo(dir, uri.fsPath); vscode.window.showInformationMessage("Amicode: pulse saved."); } + if (uri) { + savePulseTo(dir, uri.fsPath); + vscode.window.showInformationMessage("Amicode: pulse saved."); + } } } catch (e) { vscode.window.showErrorMessage(`Amicode: ${(e as Error).message}`); @@ -450,7 +507,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (fid >= 0.99) { const choice = await vscode.window.showInformationMessage( `Amicode: demo solve converged (F=${fid.toFixed(4)}). Save to catalog?`, - "Save to catalog", "Not now", + "Save to catalog", + "Not now", ); if (choice === "Save to catalog") await vscode.commands.executeCommand("amicode.catalog.save", runDir); } @@ -472,4 +530,3 @@ export function deactivate(): void { runsManager?.dispose(); statusBar?.dispose(); } - diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index 7066d0ce..1f9fdfa4 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -5,7 +5,7 @@ import { applyBrandAccent } from "../media/ui/brand_accent"; import { createInspectorView } from "../media/ui/views/inspector"; -applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) declare function acquireVsCodeApi(): { postMessage(msg: unknown): void; diff --git a/packages/extension/src/log_tailer.ts b/packages/extension/src/log_tailer.ts index 319f81c4..15749863 100644 --- a/packages/extension/src/log_tailer.ts +++ b/packages/extension/src/log_tailer.ts @@ -8,7 +8,12 @@ import * as vscode from "vscode"; // live run's run.log PLUS one on the append-only runs/index (discovery). // =========================================================================== -export interface LogTailerOptions { path: string; channel: vscode.OutputChannel; onLine: (line: string) => void; startOffset?: number } +export interface LogTailerOptions { + path: string; + channel: vscode.OutputChannel; + onLine: (line: string) => void; + startOffset?: number; +} export class LogTailer implements vscode.Disposable { private watcher?: fs.FSWatcher; @@ -42,7 +47,11 @@ export class LogTailer implements vscode.Disposable { dispose(): void { this.disposed = true; if (this.pollTimer) clearTimeout(this.pollTimer); - try { this.watcher?.close(); } catch { /* noop */ } + try { + this.watcher?.close(); + } catch { + /* noop */ + } this.watcher = undefined; } @@ -69,10 +78,17 @@ export class LogTailer implements vscode.Disposable { private drain(): void { if (this.disposed) return; let fd: number; - try { fd = fs.openSync(this.opts.path, "r"); } catch { return; } + try { + fd = fs.openSync(this.opts.path, "r"); + } catch { + return; + } try { const size = fs.fstatSync(fd).size; - if (size < this.offset) { this.offset = 0; this.buf = ""; } + if (size < this.offset) { + this.offset = 0; + this.buf = ""; + } if (size === this.offset) return; const chunk = Buffer.allocUnsafe(size - this.offset); const read = fs.readSync(fd, chunk, 0, chunk.length, this.offset); @@ -82,10 +98,18 @@ export class LogTailer implements vscode.Disposable { while ((nl = this.buf.indexOf("\n")) >= 0) { const line = this.buf.slice(0, nl); this.buf = this.buf.slice(nl + 1); - try { this.opts.onLine(line); } catch (e) { this.opts.channel.appendLine(`[runs] onLine threw: ${String(e)}`); } + try { + this.opts.onLine(line); + } catch (e) { + this.opts.channel.appendLine(`[runs] onLine threw: ${String(e)}`); + } } } finally { - try { fs.closeSync(fd); } catch { /* noop */ } + try { + fs.closeSync(fd); + } catch { + /* noop */ + } } } } diff --git a/packages/extension/src/run_controls.ts b/packages/extension/src/run_controls.ts index a12bb8c8..1fada267 100644 --- a/packages/extension/src/run_controls.ts +++ b/packages/extension/src/run_controls.ts @@ -27,7 +27,11 @@ export const STALL_AFTER_MS = 10 * 60 * 1000; /** run.log mtime, or undefined before the log exists. */ export function runLogMtime(runDir: string): number | undefined { - try { return fs.statSync(path.join(runDir, "run.log")).mtimeMs; } catch { return undefined; } + try { + return fs.statSync(path.join(runDir, "run.log")).mtimeMs; + } catch { + return undefined; + } } /** What stopping this run requires right now: nothing (already terminal), the @@ -52,7 +56,11 @@ export function runScriptPath(runDir: string): string | undefined { try { const m = /^script_path\s*=\s*(".*")\s*$/m.exec(fs.readFileSync(path.join(runDir, "run.toml"), "utf8")); if (!m) return undefined; - try { return JSON.parse(m[1]) as string; } catch { return m[1].slice(1, -1); } + try { + return JSON.parse(m[1]) as string; + } catch { + return m[1].slice(1, -1); + } } catch { return undefined; } @@ -68,7 +76,11 @@ function lsofPath(): string { } function realpathOr(p: string): string { - try { return fs.realpathSync(p); } catch { return path.resolve(p); } + try { + return fs.realpathSync(p); + } catch { + return path.resolve(p); + } } /** PIDs belonging to THIS run: command line references the run's solve script @@ -84,7 +96,11 @@ export function findRunPids( execFileSync(cmd, args, { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }), ): number[] { let psOut = ""; - try { psOut = exec("/bin/ps", ["-A", "-o", "pid=,args="]); } catch { return []; } + try { + psOut = exec("/bin/ps", ["-A", "-o", "pid=,args="]); + } catch { + return []; + } const candidates: number[] = []; for (const line of psOut.split("\n")) { const m = /^\s*(\d+)\s+(.*)$/.exec(line); @@ -116,8 +132,13 @@ export function forceFinalize(runDir: string): void { fs.writeFileSync(tmp, 'status = "aborted"\nexit_code = -1\n'); fs.renameSync(tmp, path.join(runDir, "FINISHED")); try { - fs.appendFileSync(path.join(runDir, "run.log"), "\nAMICODE_ABORTED force-stopped by user (solver not responding)\n"); - } catch { /* log breadcrumb is best-effort */ } + fs.appendFileSync( + path.join(runDir, "run.log"), + "\nAMICODE_ABORTED force-stopped by user (solver not responding)\n", + ); + } catch { + /* log breadcrumb is best-effort */ + } } /** The hard path: TERM any live solver process provably tied to this run dir, @@ -126,14 +147,28 @@ export function forceFinalize(runDir: string): void { * fully dead run — the pid scan just comes back empty. */ export async function forceStop(runDir: string): Promise { const pids = findRunPids(runDir, runScriptPath(runDir)); - for (const pid of pids) { try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ } } + for (const pid of pids) { + try { + process.kill(pid, "SIGTERM"); + } catch { + /* already gone */ + } + } if (pids.length > 0) { await new Promise((r) => setTimeout(r, 1500)); // Re-prove ownership before the KILL sweep — a pid that exited on TERM can // be reused by an unrelated process inside the window, and the two-key // safety property is the whole point of this module. const survivors = new Set(findRunPids(runDir, runScriptPath(runDir))); - for (const pid of pids) { if (survivors.has(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* exited */ } } } + for (const pid of pids) { + if (survivors.has(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + /* exited */ + } + } + } } // The orchestrator (cwd ≠ run dir, outside the kill set) may observe the // child's death and write its own truthful FINISHED (e.g. failed/143) during @@ -141,7 +176,9 @@ export async function forceStop(runDir: string): Promise { // Best-effort on a DELETED run dir (nothing to finalize, nothing to crash). try { if (!fs.existsSync(path.join(runDir, "FINISHED"))) forceFinalize(runDir); - } catch { /* run dir removed underneath us */ } + } catch { + /* run dir removed underneath us */ + } } /** Copy the run's pulse.jld2 to an absolute destination path. */ diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index 44ff2b83..79f908e6 100644 --- a/packages/extension/src/run_dir_reader.ts +++ b/packages/extension/src/run_dir_reader.ts @@ -34,7 +34,12 @@ export const AMICODE_PULSE_META_RE = new RegExp( String.raw`^AMICODE_PULSE_META\s+drives=(\d+)\s+knots=(\d+)\s+labels=((?:"[^",]*")(?:,"[^",]*")*)\s+bounds=(${NUM}:${NUM}(?:,${NUM}:${NUM})*)\s*$`, ); -export interface PulseMeta { drives: number; knots: number; labels: string[]; bounds: [number, number][] } +export interface PulseMeta { + drives: number; + knots: number; + labels: string[]; + bounds: [number, number][]; +} /** Parse an AMICODE_PULSE_META line. Returns undefined for anything malformed. */ export function parsePulseMetaLine(line: string): PulseMeta | undefined { @@ -52,7 +57,11 @@ export const AMICODE_PULSE_RE = new RegExp( String.raw`^AMICODE_PULSE\s+iter=(\d+)\s+dt=(${NUM})\s+a=(${NUM}(?:,${NUM})*(?:;${NUM}(?:,${NUM})*)*)\s*$`, ); -export interface PulseRecord { iter: number; dt: number; values: number[][] } +export interface PulseRecord { + iter: number; + dt: number; + values: number[][]; +} /** Parse an AMICODE_PULSE record line. Returns undefined for anything malformed. */ export function parsePulseRecordLine(line: string): PulseRecord | undefined { @@ -62,9 +71,7 @@ export function parsePulseRecordLine(line: string): PulseRecord | undefined { return { iter: parseInt(m[1], 10), dt: parseAmicoNum(m[2]), values }; } -export type PulseEvent = - | { type: "meta"; meta: PulseMeta } - | { type: "record"; record: PulseRecord }; +export type PulseEvent = { type: "meta"; meta: PulseMeta } | { type: "record"; record: PulseRecord }; /** Cross-line policy for the pulse stream — the single gate BOTH delivery * paths (replay ingest, live tail) feed lines through. Policy (#66 AC4): @@ -95,7 +102,7 @@ export class PulseStream { } const record = parsePulseRecordLine(line); if (record) { - if (!this.meta) return undefined; // record before meta — nothing to interpret it against + if (!this.meta) return undefined; // record before meta — nothing to interpret it against if (record.values.length !== this.meta.drives) return undefined; if (record.values.some((d) => d.length !== this.meta!.knots)) return undefined; return { type: "record", record }; @@ -104,13 +111,27 @@ export class PulseStream { } } -export interface IterRecord { iter: number; f_val: number; inf_pr: number; inf_du: number } +export interface IterRecord { + iter: number; + f_val: number; + inf_pr: number; + inf_du: number; +} /** Terminal completion, built by readTerminalState and flowed WHOLE to every * consumer (never exploded into positional args mid-pipe) — the #84 funnel. * Additive contract fields join HERE + readTerminalState and reach all paths * by construction: #81's `formulation?` next, then #64 hashing / #41 usage. */ -export interface RunCompletion { runId: string; runDir: string; status: RunStatus; fidelity?: number } -export interface PromoteInfo { runId: string; runDir: string; fidelity: number } +export interface RunCompletion { + runId: string; + runDir: string; + status: RunStatus; + fidelity?: number; +} +export interface PromoteInfo { + runId: string; + runDir: string; + fidelity: number; +} /** Where ingestRunDir routes its findings. The live impl carries the * newest-wins + promote-once guards; the test impl is plain spies. */ @@ -132,12 +153,17 @@ export class SinkDedup { if (iter > this.latestIter) this.latestIter = iter; } /** Highest iteration seen. */ - get high(): number { return this.latestIter; } + get high(): number { + return this.latestIter; + } } export function readTomlSafe(fp: string): Record | undefined { - try { return parse(fs.readFileSync(fp, "utf8")) as Record; } - catch { return undefined; } + try { + return parse(fs.readFileSync(fp, "utf8")) as Record; + } catch { + return undefined; + } } /** spec C promote gate: rendering is tier-blind, PROMOTION is not. A `free`-tier @@ -151,8 +177,11 @@ export function readTomlSafe(fp: string): Record | undefined { export type PromoteEligibility = "eligible" | "pending_verification" | "suppressed"; export function promoteEligibility(runDir: string): PromoteEligibility { let spec: Record | undefined; - try { spec = JSON.parse(fs.readFileSync(path.join(runDir, "solvespec.json"), "utf8")); } - catch { return "eligible"; } // no/unreadable spec → a bare run, unchanged behavior + try { + spec = JSON.parse(fs.readFileSync(path.join(runDir, "solvespec.json"), "utf8")); + } catch { + return "eligible"; + } // no/unreadable spec → a bare run, unchanged behavior if (spec?.tier !== "free") return "eligible"; const verification = readTomlSafe(path.join(runDir, "verification.toml")); if (!verification) return "pending_verification"; @@ -218,12 +247,16 @@ export function readTerminalState( * appended after the read are tailed) and no overlap (already-replayed lines). */ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0.99): number { const manifest = readTomlSafe(path.join(runDir, "run.toml")); - if (!manifest || !validateManifest(manifest).ok) return 0; // no valid manifest → not a run dir yet + if (!manifest || !validateManifest(manifest).ok) return 0; // no valid manifest → not a run dir yet const runId = String(manifest.run_id); // run.log body → iter records (replay; the live tailer handles appended lines) let logBody: string | undefined; - try { logBody = fs.readFileSync(path.join(runDir, "run.log"), "utf8"); } catch { /* none yet */ } + try { + logBody = fs.readFileSync(path.join(runDir, "run.log"), "utf8"); + } catch { + /* none yet */ + } let logBytes = 0; if (logBody) { logBytes = Buffer.byteLength(logBody, "utf8"); @@ -235,9 +268,20 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0 let newestPulse: PulseEvent | undefined; for (const line of logBody.split("\n")) { const m = AMICODE_ITER_RE.exec(line); - if (m) { sink.iter({ iter: +m[1], f_val: parseAmicoNum(m[2]), inf_pr: parseAmicoNum(m[3]), inf_du: parseAmicoNum(m[4]) }); continue; } + if (m) { + sink.iter({ + iter: +m[1], + f_val: parseAmicoNum(m[2]), + inf_pr: parseAmicoNum(m[3]), + inf_du: parseAmicoNum(m[4]), + }); + continue; + } const e = pulses.onLine(line); - if (e?.type === "meta") { pulseMeta = e; newestPulse = undefined; } // new meta governs; stale records don't cross it + if (e?.type === "meta") { + pulseMeta = e; + newestPulse = undefined; + } // new meta governs; stale records don't cross it else if (e?.type === "record") newestPulse = e; } if (pulseMeta) sink.pulse(pulseMeta); @@ -251,7 +295,7 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0 if (!t) return logBytes; sink.run({ runId, runDir, ...t }); if (t.status === "completed" && t.fidelity !== undefined && t.fidelity >= promoteThreshold) { - const eligibility = promoteEligibility(runDir); // tier-blind render, tier-aware promote (spec C) + const eligibility = promoteEligibility(runDir); // tier-blind render, tier-aware promote (spec C) if (eligibility === "eligible") sink.promote({ runId, runDir, fidelity: t.fidelity }); else console.warn(`[amico] promote skipped for ${runId}: free-tier verification ${eligibility}`); } diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 6c9050bc..91495f5b 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -22,10 +22,10 @@ const REFRESH_INTERVAL_MS = 200; // 5 Hz cap on pulse-record refresh (per run) /** Timing payload for the elapsed/rate/ETA strip. */ export interface TimingInfo { - createdAtMs?: number; // run.toml created_at → live elapsed base - maxIter?: number; // parsed from the run script → ETA - wallSeconds?: number; // result.toml wall_seconds → frozen elapsed on finish - terminal?: boolean; // true once the run is finished + createdAtMs?: number; // run.toml created_at → live elapsed base + maxIter?: number; // parsed from the run script → ETA + wallSeconds?: number; // result.toml wall_seconds → frozen elapsed on finish + terminal?: boolean; // true once the run is finished } let INSPECTOR: InspectorView | undefined; @@ -39,9 +39,9 @@ interface PaneBuffer { warming: boolean; completion?: { status: string; fidelity?: number }; pulseMeta?: PulseMeta; - pulseRecord?: PulseRecord; // newest record (throttle coalesces to this) + pulseRecord?: PulseRecord; // newest record (throttle coalesces to this) iterRecord?: { iter: number; f_val: number; kkt_error: number; eq_viol: number; ineq_viol: number; rho: number }; - timing?: TimingInfo; // elapsed/rate/ETA strip state (his run_timing UI) + timing?: TimingInfo; // elapsed/rate/ETA strip state (his run_timing UI) pulseTimer?: NodeJS.Timeout; pendingPulse?: PulseRecord; } @@ -81,10 +81,16 @@ class InspectorView implements vscode.WebviewViewProvider { // the target run from the manager's selected run. const msgSub = view.webview.onDidReceiveMessage((msg: { type?: string; action?: string }) => { if (msg?.type !== "control") return; - const cmd = ({ stop: "amicode.stopRun", save: "amicode.savePulse", open: "amicode.openRunDir" } as Record)[msg.action ?? ""]; + const cmd = ( + { stop: "amicode.stopRun", save: "amicode.savePulse", open: "amicode.openRunDir" } as Record + )[msg.action ?? ""]; if (cmd) void vscode.commands.executeCommand(cmd); }); - view.onDidDispose(() => { this.view = undefined; this.clearAllTimers(); msgSub.dispose(); }); + view.onDidDispose(() => { + this.view = undefined; + this.clearAllTimers(); + msgSub.dispose(); + }); // S36 replay: rebuild EVERY pane from its buffer (not just the active one), // so switching to a background run after reopen shows its state too. Per @@ -104,7 +110,13 @@ class InspectorView implements vscode.WebviewViewProvider { if (p.pulseMeta) view.webview.postMessage({ type: "pulsemeta", runId: rid, ...p.pulseMeta }); if (p.pulseRecord) view.webview.postMessage({ type: "pulse", runId: rid, ...p.pulseRecord }); if (p.iterRecord) view.webview.postMessage({ type: "iteration", runId: rid, ...p.iterRecord, t_post: Date.now() }); - if (p.completion) view.webview.postMessage({ type: "completed", runId: rid, status: p.completion.status, fidelity: p.completion.fidelity }); + if (p.completion) + view.webview.postMessage({ + type: "completed", + runId: rid, + status: p.completion.status, + fidelity: p.completion.fidelity, + }); } // -------- public surface used by RunsManager (all runId-keyed) -------- @@ -112,8 +124,15 @@ class InspectorView implements vscode.WebviewViewProvider { postIterationRecord(runId: string, rec: { iter: number; f_val: number; inf_pr: number; inf_du: number }): void { const p = this.paneFor(runId); p.warming = false; - p.iterRecord = { iter: rec.iter, f_val: rec.f_val, kkt_error: rec.inf_du, eq_viol: rec.inf_pr, ineq_viol: 0, rho: 1.0 }; - if (!this.view) return; // buffered above; reopen replays it + p.iterRecord = { + iter: rec.iter, + f_val: rec.f_val, + kkt_error: rec.inf_du, + eq_viol: rec.inf_pr, + ineq_viol: 0, + rho: 1.0, + }; + if (!this.view) return; // buffered above; reopen replays it this.view.webview.postMessage({ type: "iteration", runId, ...p.iterRecord, t_post: Date.now() }); } @@ -163,9 +182,12 @@ class InspectorView implements vscode.WebviewViewProvider { return; } // record - p.pulseRecord = e.record; // newest wins for reopen replay + p.pulseRecord = e.record; // newest wins for reopen replay if (!this.view) return; - if (p.pulseTimer) { p.pendingPulse = e.record; return; } // window open — coalesce + if (p.pulseTimer) { + p.pendingPulse = e.record; + return; + } // window open — coalesce this.view.webview.postMessage({ type: "pulse", runId, ...e.record }); p.pulseTimer = setTimeout(() => { p.pulseTimer = undefined; @@ -194,7 +216,7 @@ class InspectorView implements vscode.WebviewViewProvider { /** Make `runId` the visible pane (1.3 selection seam). Buffered until the * webview materializes; resolveWebviewView replays it last. */ activate(runId: string): void { - this.paneFor(runId); // ensure a pane exists even before any data + this.paneFor(runId); // ensure a pane exists even before any data this.activeRunId = runId; if (this.view) this.view.webview.postMessage({ type: "activate", runId }); } @@ -204,22 +226,23 @@ class InspectorView implements vscode.WebviewViewProvider { // default so a starting solve never steals focus; the status-bar item and // the explicit open command (which bypasses reveal) remain available. if (!autoOpenEnabled()) return; - vscode.commands.executeCommand("amicode.runInspector.focus") - .then(undefined, () => undefined); + vscode.commands.executeCommand("amicode.runInspector.focus").then(undefined, () => undefined); } // -------- internal -------- private clearAllTimers(): void { for (const p of this.panes.values()) { - if (p.pulseTimer) { clearTimeout(p.pulseTimer); p.pulseTimer = undefined; } + if (p.pulseTimer) { + clearTimeout(p.pulseTimer); + p.pulseTimer = undefined; + } p.pendingPulse = undefined; } } private renderHtml(webview: vscode.Webview): string { - const uri = (...parts: string[]) => - webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, ...parts)); + const uri = (...parts: string[]) => webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, ...parts)); const nonce = newNonce(); // The view is TS-composed (media/ui/views/inspector.ts → dist bundle): the // script builds its own DOM from atoms/components and injects their styles diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index f27feaa3..fd2fad87 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -8,8 +8,18 @@ import { parseMaxIter } from "./run_timing"; import type { StatusBarManager } from "./status_bar"; import type { RunStatus } from "./types"; import { - AMICODE_ITER_RE, ingestRunDir, readTerminalState, readTomlSafe, parseAmicoNum, PulseStream, SinkDedup, - type IterRecord, type PulseEvent, type RunCompletion, type PromoteInfo, type RunSink, + AMICODE_ITER_RE, + ingestRunDir, + readTerminalState, + readTomlSafe, + parseAmicoNum, + PulseStream, + SinkDedup, + type IterRecord, + type PulseEvent, + type RunCompletion, + type PromoteInfo, + type RunSink, } from "./run_dir_reader"; import { STALL_AFTER_MS } from "./run_controls"; @@ -82,10 +92,17 @@ class RunPipeline implements vscode.Disposable { dirWatcher?: fs.FSWatcher; tailer?: LogTailer; - constructor(readonly runId: string, readonly runDir: string) {} + constructor( + readonly runId: string, + readonly runDir: string, + ) {} dispose(): void { - try { this.dirWatcher?.close(); } catch { /* noop */ } + try { + this.dirWatcher?.close(); + } catch { + /* noop */ + } this.tailer?.dispose(); this.dirWatcher = undefined; this.tailer = undefined; @@ -133,7 +150,7 @@ export class RunsManager implements vscode.Disposable { }, }); this.booting = true; - this.indexTailer.start(); // synchronous initial drain — boot replay + this.indexTailer.start(); // synchronous initial drain — boot replay this.booting = false; this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => { if (filename === "index") this.indexTailer?.poke(); @@ -142,7 +159,9 @@ export class RunsManager implements vscode.Disposable { // host (e.g. the watched dir deleted). The poll backstop keeps us live. this.rootWatcher.on("error", (e) => this.opts.channel.appendLine(`[runs] root watch error: ${String(e)}`)); this.poll = setInterval(() => this.tick(), RunsManager.POLL_MS); - this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot}/index (fs.watch + ${RunsManager.POLL_MS}ms poll)`); + this.opts.channel.appendLine( + `[runs] watching ${this.opts.runsRoot}/index (fs.watch + ${RunsManager.POLL_MS}ms poll)`, + ); } /** Poll backstop — macOS FSEvents coalesces/drops events, so re-poke the @@ -160,16 +179,33 @@ export class RunsManager implements vscode.Disposable { // wedges mid-watch would keep "running · iter N" forever without this. // DOWNGRADE only (never stamps "running": warming/iter flow owns that). const sel = this.selected ? this.registry.get(this.selected) : undefined; - if (sel && sel.phase !== "finished" && sel.latestIter !== undefined && this.liveStatus(sel.runDir) === "stalled") { - this.opts.statusBar?.setRun({ runId: sel.runId, outputDir: sel.runDir, startedAt: 0, status: "stalled", latestIter: sel.latestIter }); + if ( + sel && + sel.phase !== "finished" && + sel.latestIter !== undefined && + this.liveStatus(sel.runDir) === "stalled" + ) { + this.opts.statusBar?.setRun({ + runId: sel.runId, + outputDir: sel.runDir, + startedAt: 0, + status: "stalled", + latestIter: sel.latestIter, + }); } - } catch { /* transient fs race — next tick retries */ } + } catch { + /* transient fs race — next tick retries */ + } } dispose(): void { if (this.poll) clearInterval(this.poll); this.poll = undefined; - try { this.rootWatcher?.close(); } catch { /* noop */ } + try { + this.rootWatcher?.close(); + } catch { + /* noop */ + } this.rootWatcher = undefined; this.indexTailer?.dispose(); this.indexTailer = undefined; @@ -189,7 +225,9 @@ export class RunsManager implements vscode.Disposable { this.registerRun(e.runId, e.runDir); return; } - this.opts.channel.appendLine(`[runs] scheduler ${e.kind} ${e.runId ?? e.queueId}${e.message ? `: ${e.message}` : ""}`); + this.opts.channel.appendLine( + `[runs] scheduler ${e.kind} ${e.runId ?? e.queueId}${e.message ? `: ${e.message}` : ""}`, + ); }); } @@ -213,7 +251,7 @@ export class RunsManager implements vscode.Disposable { const ins = getInspector(); ins?.reveal(); ins?.setRunLabel(runId, runId); - ins?.activate(runId); // 1.3: switch the visible pane + ins?.activate(runId); // 1.3: switch the visible pane const p = this.pipelines.get(runId); if (p) { // FINISHED may have landed inside the poll window — complete it NOW, @@ -223,16 +261,22 @@ export class RunsManager implements vscode.Disposable { // (routeIter/completeRun keep it current from here). const r = this.registry.get(runId)!; this.opts.statusBar?.setRun({ - runId, outputDir: r.runDir, startedAt: 0, + runId, + outputDir: r.runDir, + startedAt: 0, status: r.phase === "finished" ? (r.status ?? "completed") : this.liveStatus(r.runDir), - latestIter: r.latestIter, fidelity: r.fidelity, + latestIter: r.latestIter, + fidelity: r.fidelity, }); } else { // Never fanned (no pipeline) — display replay from disk (late-join safe). // Promote inside the replay stays guarded by promotedRuns, so // re-selecting a finished run never re-pops the prompt. - try { ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); } - catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + try { + ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); + } catch (err) { + this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); + } } // Fresh/live run → Julia warming up. Disk-checked (FINISHED may exist while // the registry still says live); the host's setWarmingUp also no-ops if the @@ -299,7 +343,15 @@ export class RunsManager implements vscode.Disposable { if (t) { // Terminal at discovery: record it (status/fidelity for the registry) but // render nothing and never re-pop the promote prompt (β launch parity). - this.registry.register({ runId, runDir, createdAt, scriptPath, phase: "finished", status: t.status, fidelity: t.fidelity }); + this.registry.register({ + runId, + runDir, + createdAt, + scriptPath, + phase: "finished", + status: t.status, + fidelity: t.fidelity, + }); this.promotedRuns.add(runId); return; } @@ -320,8 +372,16 @@ export class RunsManager implements vscode.Disposable { const manifest = readTomlSafe(path.join(runDir, "run.toml")); const createdAtMs = manifest?.created_at ? Date.parse(String(manifest.created_at)) : NaN; let maxIter: number | undefined; - try { if (manifest?.script_path) maxIter = parseMaxIter(fs.readFileSync(String(manifest.script_path), "utf8")); } catch { /* script gone */ } - getInspector()?.postTiming(runId, { createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : undefined, maxIter, terminal: false }); + try { + if (manifest?.script_path) maxIter = parseMaxIter(fs.readFileSync(String(manifest.script_path), "utf8")); + } catch { + /* script gone */ + } + getInspector()?.postTiming(runId, { + createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : undefined, + maxIter, + terminal: false, + }); } // Auto-follow BEFORE the replay (β latest-follow parity: a newly REGISTERED @@ -334,7 +394,7 @@ export class RunsManager implements vscode.Disposable { if (follow && this.selected !== runId) { this.selected = runId; const ins = getInspector(); - if (!this.booting) ins?.reveal(); // boot replay must not steal focus + if (!this.booting) ins?.reveal(); // boot replay must not steal focus ins?.setRunLabel(runId, runId); ins?.activate(runId); } @@ -343,8 +403,11 @@ export class RunsManager implements vscode.Disposable { // high-water, fans history runId-tagged, and yields the byte offset the // live tail starts from. let logBytes = 0; - try { logBytes = ingestRunDir(runDir, this.pipelineSink(p), this.opts.promoteThreshold ?? 0.99); } - catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + try { + logBytes = ingestRunDir(runDir, this.pipelineSink(p), this.opts.promoteThreshold ?? 0.99); + } catch (err) { + this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); + } // FINISHED landed between the existsSync check and the replay (rare race): // completeRun already tore the pipeline down (and — selection was assigned @@ -363,7 +426,15 @@ export class RunsManager implements vscode.Disposable { channel: this.opts.channel, onLine: (line) => { const m = AMICODE_ITER_RE.exec(line); - if (m) { this.routeIter(p, { iter: +m[1], f_val: parseAmicoNum(m[2]), inf_pr: parseAmicoNum(m[3]), inf_du: parseAmicoNum(m[4]) }); return; } + if (m) { + this.routeIter(p, { + iter: +m[1], + f_val: parseAmicoNum(m[2]), + inf_pr: parseAmicoNum(m[3]), + inf_du: parseAmicoNum(m[4]), + }); + return; + } const e = p.pulses.onLine(line); if (e) this.routePulse(p.runId, e); }, @@ -386,8 +457,11 @@ export class RunsManager implements vscode.Disposable { iter: (rec: IterRecord) => this.routeIter(p, rec), // A FINISHED that landed between the existsSync check and this replay — // rare race; treat exactly like a live completion (fans out + promotes). - run: (c: RunCompletion) => this.completeRun(c), // whole object — see completeRun (#84 seam) - pulse: (e: PulseEvent) => { if (e.type === "meta") p.pulses.arm(e.meta); this.routePulse(p.runId, e); }, + run: (c: RunCompletion) => this.completeRun(c), // whole object — see completeRun (#84 seam) + pulse: (e: PulseEvent) => { + if (e.type === "meta") p.pulses.arm(e.meta); + this.routePulse(p.runId, e); + }, promote: (info: PromoteInfo) => this.promptPromote(info), }; } @@ -406,12 +480,25 @@ export class RunsManager implements vscode.Disposable { // A finished run's replay must not stamp running/stalled per line — its // completion event (below) sets the bar exactly once at the end. if (this.registry.get(rid)?.phase !== "finished") { - this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: this.liveStatus(rec.runDir), latestIter: r.iter }); + this.opts.statusBar?.setRun({ + runId: rid, + outputDir: rec.runDir, + startedAt: 0, + status: this.liveStatus(rec.runDir), + latestIter: r.iter, + }); } }, run: (c: RunCompletion) => { getInspector()?.postCompletion(rid, c.status, c.fidelity); - this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: c.status, latestIter: this.registry.get(rid)?.latestIter, fidelity: c.fidelity }); + this.opts.statusBar?.setRun({ + runId: rid, + outputDir: rec.runDir, + startedAt: 0, + status: c.status, + latestIter: this.registry.get(rid)?.latestIter, + fidelity: c.fidelity, + }); }, pulse: (e: PulseEvent) => { if (e.type === "meta") p?.pulses.arm(e.meta); @@ -421,7 +508,6 @@ export class RunsManager implements vscode.Disposable { }; } - /** "running" only if run.log is actually moving. A FINISHED-less run whose * log has been silent >10 min is wedged (OOM, killed host) — never let a * boot replay of its old iter lines stamp "running · iter N" on the status @@ -436,7 +522,9 @@ export class RunsManager implements vscode.Disposable { let val: "running" | "stalled" = "running"; try { if (now - fs.statSync(path.join(runDir, "run.log")).mtimeMs > STALL_AFTER_MS) val = "stalled"; - } catch { /* no run.log yet — brand-new run, trust the tailer */ } + } catch { + /* no run.log yet — brand-new run, trust the tailer */ + } this.liveStatusCache.set(runDir, { at: now, val }); return val; } @@ -450,7 +538,13 @@ export class RunsManager implements vscode.Disposable { getInspector()?.postIterationRecord(p.runId, rec); if (this.selected === p.runId) { // Live status-bar update — "running · iter N" as it solves (#5 AC3). - this.opts.statusBar?.setRun({ runId: p.runId, outputDir: p.runDir, startedAt: 0, status: this.liveStatus(p.runDir), latestIter: rec.iter }); + this.opts.statusBar?.setRun({ + runId: p.runId, + outputDir: p.runDir, + startedAt: 0, + status: this.liveStatus(p.runDir), + latestIter: rec.iter, + }); } } @@ -464,7 +558,7 @@ export class RunsManager implements vscode.Disposable { if (p.finishedSeen) return; if (!fs.existsSync(path.join(p.runDir, "FINISHED"))) return; const t = this.readTerminal(p.runDir); - if (!t) return; // torn/invalid FINISHED — next tick retries + if (!t) return; // torn/invalid FINISHED — next tick retries p.finishedSeen = true; this.completeRun({ runId: p.runId, runDir: p.runDir, ...t }); } @@ -480,12 +574,14 @@ export class RunsManager implements vscode.Disposable { * re-plumbed per path. Consumers cherry-pick at the leaf, not mid-pipe. */ private completeRun(c: RunCompletion): void { const rec = this.registry.get(c.runId); - if (!rec || rec.phase === "finished") return; // idempotent (watch + poll can both fire) + if (!rec || rec.phase === "finished") return; // idempotent (watch + poll can both fire) this.registry.markFinished(c.runId, c.status, c.fidelity); const p = this.pipelines.get(c.runId); p?.dispose(); this.pipelines.delete(c.runId); - this.opts.channel.appendLine(`[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`); + this.opts.channel.appendLine( + `[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`, + ); if (c.status !== "completed") this.opts.channel.appendLine(`[runs] see ${path.join(rec.runDir, "run.log")}`); // Terminal state to the inspector for EVERY run (its pane's badge stops // saying "running" even in the background); status bar for the selected run. @@ -497,7 +593,14 @@ export class RunsManager implements vscode.Disposable { getInspector()?.postTiming(c.runId, { wallSeconds, terminal: true }); this.opts.onRunFinished?.({ runId: c.runId, runDir: rec.runDir, status: c.status }); if (this.selected === c.runId) { - this.opts.statusBar?.setRun({ runId: c.runId, outputDir: rec.runDir, startedAt: 0, status: c.status, latestIter: rec.latestIter, fidelity: c.fidelity }); + this.opts.statusBar?.setRun({ + runId: c.runId, + outputDir: rec.runDir, + startedAt: 0, + status: c.status, + latestIter: rec.latestIter, + fidelity: c.fidelity, + }); } if (c.status === "completed" && c.fidelity !== undefined && c.fidelity >= (this.opts.promoteThreshold ?? 0.99)) { this.promptPromote({ runId: c.runId, runDir: rec.runDir, fidelity: c.fidelity }); @@ -520,7 +623,8 @@ export class RunsManager implements vscode.Disposable { void (async () => { const choice = await vscode.window.showInformationMessage( `Amicode: solve converged (F=${info.fidelity.toFixed(4)}). Promote pulse to catalog?`, - "Yes — promote", "No — keep local only", + "Yes — promote", + "No — keep local only", ); if (choice === "Yes — promote") { // #47: record in the session catalog + open the card (store persistence diff --git a/packages/extension/src/status_bar.ts b/packages/extension/src/status_bar.ts index f7e64e92..4cb1efb1 100644 --- a/packages/extension/src/status_bar.ts +++ b/packages/extension/src/status_bar.ts @@ -11,17 +11,30 @@ export function statusBarLabel(serverReady: boolean, run?: RunState): { text: st if (!serverReady) return { text: "$(loading~spin) Amicode (booting)", tooltip: "Spawning opencode server…" }; const dir = run?.outputDir ?? ""; switch (run?.status) { - case "starting": return { text: "$(sync~spin) Amicode · warming…", tooltip: `Julia warming up in ${dir}` }; - case "running": return { text: `$(gear~spin) Amicode · iter ${run.latestIter ?? "—"}`, tooltip: `Solve running in ${dir}` }; - case "stalled": return { text: "$(warning) Amicode · stalled", tooltip: `No progress for 10+ min in ${dir} — run may be wedged (OOM?)` }; + case "starting": + return { text: "$(sync~spin) Amicode · warming…", tooltip: `Julia warming up in ${dir}` }; + case "running": + return { text: `$(gear~spin) Amicode · iter ${run.latestIter ?? "—"}`, tooltip: `Solve running in ${dir}` }; + case "stalled": + return { + text: "$(warning) Amicode · stalled", + tooltip: `No progress for 10+ min in ${dir} — run may be wedged (OOM?)`, + }; case "completed": { const f = run.fidelity; - return { text: `$(check) Amicode · F=${f !== undefined ? f.toFixed(4) : "—"}`, tooltip: `Last solve completed in ${dir}` }; + return { + text: `$(check) Amicode · F=${f !== undefined ? f.toFixed(4) : "—"}`, + tooltip: `Last solve completed in ${dir}`, + }; } - case "stopped": return { text: "$(circle-slash) Amicode · stopped", tooltip: `Solve stopped in ${dir}` }; - case "failed": return { text: "$(error) Amicode · solve failed", tooltip: `Solve failed in ${dir} — see run.log` }; - case "aborted": return { text: "$(circle-slash) Amicode · aborted", tooltip: `Solve aborted in ${dir}` }; - default: return { text: "$(comment-discussion) Amicode", tooltip: "Open the Run Inspector" }; + case "stopped": + return { text: "$(circle-slash) Amicode · stopped", tooltip: `Solve stopped in ${dir}` }; + case "failed": + return { text: "$(error) Amicode · solve failed", tooltip: `Solve failed in ${dir} — see run.log` }; + case "aborted": + return { text: "$(circle-slash) Amicode · aborted", tooltip: `Solve aborted in ${dir}` }; + default: + return { text: "$(comment-discussion) Amicode", tooltip: "Open the Run Inspector" }; } } diff --git a/packages/extension/src/trees.ts b/packages/extension/src/trees.ts index 56b385d2..2c21a099 100644 --- a/packages/extension/src/trees.ts +++ b/packages/extension/src/trees.ts @@ -21,7 +21,9 @@ class PlaceholderTree implements vscode.TreeDataProvider { getChildren(): string[] { return [this.hint]; } - refresh(): void { this._onDidChange.fire(); } + refresh(): void { + this._onDidChange.fire(); + } } /** A saved session-catalog entry (#47). Promote-shaped, mirrors the card's @@ -62,7 +64,10 @@ export class SessionCatalogTree implements vscode.TreeDataProvider { - await this.ctx.workspaceState.update(CATALOG_KEY, this.entries().filter((e) => e.run_id !== run_id)); + await this.ctx.workspaceState.update( + CATALOG_KEY, + this.entries().filter((e) => e.run_id !== run_id), + ); this._onDidChange.fire(); } @@ -77,8 +82,12 @@ export class SessionCatalogTree implements vscode.TreeDataProvider ({ dispose() {} }), }, revealCount: 0, - reveal() { this.revealCount += 1; }, - onDidDispose(cb: () => void, _thisArg?: unknown, _subs?: unknown) { disposeCbs.push(cb); return { dispose() {} }; }, - dispose() { for (const cb of disposeCbs) cb(); }, + reveal() { + this.revealCount += 1; + }, + onDidDispose(cb: () => void, _thisArg?: unknown, _subs?: unknown) { + disposeCbs.push(cb); + return { dispose() {} }; + }, + dispose() { + for (const cb of disposeCbs) cb(); + }, }; }, }; @@ -28,7 +35,11 @@ const registeredCommands = new Map unknown>(); export const commands = { registerCommand: (id: string, fn: (...a: unknown[]) => unknown) => { registeredCommands.set(id, fn); - return { dispose() { registeredCommands.delete(id); } }; + return { + dispose() { + registeredCommands.delete(id); + }, + }; }, executeCommand: (id: string, ...a: unknown[]) => Promise.resolve(registeredCommands.get(id)?.(...a)), }; @@ -40,7 +51,7 @@ export const workspace = { export const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }), joinPath: (base: { fsPath?: string } | string, ...parts: string[]) => { - const root = typeof base === "string" ? base : base.fsPath ?? ""; + const root = typeof base === "string" ? base : (base.fsPath ?? ""); const full = [root, ...parts].join("/"); return { fsPath: full, toString: () => full }; }, @@ -57,6 +68,9 @@ export class TreeItem { description?: string; tooltip?: string; command?: unknown; - constructor(public label: string, public collapsibleState?: number) {} + constructor( + public label: string, + public collapsibleState?: number, + ) {} } export const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; diff --git a/packages/extension/test/brand_accent.test.ts b/packages/extension/test/brand_accent.test.ts index 1e18ea23..d4916fac 100644 --- a/packages/extension/test/brand_accent.test.ts +++ b/packages/extension/test/brand_accent.test.ts @@ -18,10 +18,10 @@ describe("solveBrandAccent — the theme-calculated Harmoniqs yellow", () => { const r = solveBrandAccent("#ffffff"); expect(r.brandExact).toBe(false); const solved = parseColor(r.accent)!; - expect(contrast(solved, parseColor("#ffffff")!)).toBeGreaterThanOrEqual(2.98); // binary-search tolerance + expect(contrast(solved, parseColor("#ffffff")!)).toBeGreaterThanOrEqual(2.98); // binary-search tolerance const brand = srgbToOklch(parseColor("#FFF676")!); const got = srgbToOklch(solved); - expect(Math.abs(got.h - brand.h)).toBeLessThan(8); // hue is the brand carrier + expect(Math.abs(got.h - brand.h)).toBeLessThan(8); // hue is the brand carrier expect(got.L).toBeLessThan(brand.L); }); diff --git a/packages/extension/test/catalog_shell.test.ts b/packages/extension/test/catalog_shell.test.ts index 1e0155b6..78f5c285 100644 --- a/packages/extension/test/catalog_shell.test.ts +++ b/packages/extension/test/catalog_shell.test.ts @@ -12,16 +12,22 @@ import { SessionCatalogTree, type SessionCatalogEntry } from "../src/trees"; function stageRun(opts: { pulseLines?: string; gate?: string; system?: string }): string { const dir = mkdtempSync(join(tmpdir(), "card-run-")); - writeFileSync(join(dir, "run.toml"), + writeFileSync( + join(dir, "run.toml"), 'schema_version = "1"\nrun_id = "r20260703-000000Z-cafe"\nlab_id = "default"\nscript_path = "/s.jl"\n' + - 'lab = "default"\ncreated_at = "2026-07-03T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n'); + 'lab = "default"\ncreated_at = "2026-07-03T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n', + ); const params = [ opts.system ? `system = "${opts.system}"` : "", opts.gate ? `gate = "${opts.gate}"` : "", "levels = 3", - ].filter(Boolean).join("\n"); - writeFileSync(join(dir, "result.toml"), - `schema_version = "1"\nfidelity = 0.9998\niterations = 60\nwall_seconds = 41.5\n[params]\n${params}\n`); + ] + .filter(Boolean) + .join("\n"); + writeFileSync( + join(dir, "result.toml"), + `schema_version = "1"\nfidelity = 0.9998\niterations = 60\nwall_seconds = 41.5\n[params]\n${params}\n`, + ); if (opts.pulseLines !== undefined) writeFileSync(join(dir, "run.log"), opts.pulseLines); return dir; } @@ -29,7 +35,8 @@ function stageRun(opts: { pulseLines?: string; gate?: string; system?: string }) describe("hydrateFromRunDir — entry from real run artifacts", () => { it("maps identity, fidelity, params (gate lifted to top level), proposed block, and the newest pulse", () => { const dir = stageRun({ - gate: "X", system: "transmon", + gate: "X", + system: "transmon", pulseLines: 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n' + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n" + @@ -44,7 +51,7 @@ describe("hydrateFromRunDir — entry from real run artifacts", () => { proposed: { iterations: 60, wall_seconds: 41.5 }, }); expect((data.entry.params as Record).system).toBe("transmon"); - expect(data.pulse).toMatchObject({ record: { iter: 2 } }); // newest record, not the first + expect(data.pulse).toMatchObject({ record: { iter: 2 } }); // newest record, not the first }); it("degrades: no run.log → no pulse; missing result.toml → undefined", () => { @@ -64,13 +71,13 @@ describe("registerCatalogCard — reveal-or-create panel dedupe", () => { await vscode.commands.executeCommand("amicode.catalogCard.open", dir); await vscode.commands.executeCommand("amicode.catalogCard.open", dir); - expect(spy).toHaveBeenCalledTimes(1); // one panel per run_id + expect(spy).toHaveBeenCalledTimes(1); // one panel per run_id const panel = spy.mock.results[0].value as { revealCount: number; dispose: () => void }; - expect(panel.revealCount).toBe(1); // second click re-focuses + expect(panel.revealCount).toBe(1); // second click re-focuses - panel.dispose(); // user closes the tab + panel.dispose(); // user closes the tab await vscode.commands.executeCommand("amicode.catalogCard.open", dir); - expect(spy).toHaveBeenCalledTimes(2); // closed → fresh panel + expect(spy).toHaveBeenCalledTimes(2); // closed → fresh panel spy.mockRestore(); }); }); @@ -81,20 +88,29 @@ describe("SessionCatalogTree — pointer records, newest first", () => { return { workspaceState: { get: (k: string, d: unknown) => (store.has(k) ? store.get(k) : d), - update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); }, + update: (k: string, v: unknown) => { + store.set(k, v); + return Promise.resolve(); + }, }, } as never; } const entry = (run_id: string, over: Partial = {}): SessionCatalogEntry => ({ - run_id, runDir: `/runs/${run_id}`, lab_id: "default", fidelity: 0.999, - gate: "X", system: "transmon", saved_at: "2026-07-03T00:00:00Z", ...over, + run_id, + runDir: `/runs/${run_id}`, + lab_id: "default", + fidelity: 0.999, + gate: "X", + system: "transmon", + saved_at: "2026-07-03T00:00:00Z", + ...over, }); it("saves newest-first, dedupes by run_id, and rows open the card for the run dir", async () => { const tree = new SessionCatalogTree(makeCtx()); await tree.save(entry("r1")); await tree.save(entry("r2")); - await tree.save(entry("r1", { fidelity: 0.5 })); // re-save moves to front, replaces + await tree.save(entry("r1", { fidelity: 0.5 })); // re-save moves to front, replaces const rows = tree.getChildren() as SessionCatalogEntry[]; expect(rows.map((r) => r.run_id)).toEqual(["r1", "r2"]); expect(rows[0].fidelity).toBe(0.5); @@ -102,7 +118,7 @@ describe("SessionCatalogTree — pointer records, newest first", () => { const item = tree.getTreeItem(rows[1]) as { label: string; command?: { command: string; arguments: unknown[] } }; expect(item.label).toContain("transmon"); expect(item.command?.command).toBe("amicode.catalogCard.open"); - expect(item.command?.arguments).toEqual(["/runs/r2", "transmon", undefined]); // runDir + name + tags → card + expect(item.command?.arguments).toEqual(["/runs/r2", "transmon", undefined]); // runDir + name + tags → card }); it("remove() unsaves the pointer only — remaining entries and order survive", async () => { @@ -113,7 +129,7 @@ describe("SessionCatalogTree — pointer records, newest first", () => { await tree.remove("r2"); const rows = tree.getChildren() as SessionCatalogEntry[]; expect(rows.map((r) => r.run_id)).toEqual(["r3", "r1"]); - await tree.remove("r2"); // idempotent — removing a gone entry is a no-op + await tree.remove("r2"); // idempotent — removing a gone entry is a no-op expect((tree.getChildren() as SessionCatalogEntry[]).length).toBe(2); }); diff --git a/packages/extension/test/inspector_view_contract.test.ts b/packages/extension/test/inspector_view_contract.test.ts index 287fbc92..67643fed 100644 --- a/packages/extension/test/inspector_view_contract.test.ts +++ b/packages/extension/test/inspector_view_contract.test.ts @@ -24,11 +24,17 @@ function renderInspectorHtml(): string { webview: { options: {}, cspSource: "vscode-webview://unit", - asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), + asWebviewUri: (u: { fsPath?: string }) => ({ + toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)), + }), postMessage: () => undefined, onDidReceiveMessage: () => ({ dispose() {} }), - set html(v: string) { captured = v; }, - get html() { return captured; }, + set html(v: string) { + captured = v; + }, + get html() { + return captured; + }, }, onDidDispose: () => ({ dispose() {} }), }; @@ -48,7 +54,9 @@ describe("Run Inspector shell contract (plumbing ⇄ TS-composed view)", () => { it("keeps the CSP authorizing every grant the view depends on", () => { // Pin grants to their directive, not just "appears somewhere in the CSP". const styleSrc = html.match(/style-src([^;]*)/)?.[1] ?? ""; - expect(styleSrc, "style-src must grant the webview source for the linked stylesheets").toContain("vscode-webview://unit"); + expect(styleSrc, "style-src must grant the webview source for the linked stylesheets").toContain( + "vscode-webview://unit", + ); expect(styleSrc, "style-src keeps 'unsafe-inline' for design-lane static style attrs").toContain("'unsafe-inline'"); expect(html, "no image grants — the view renders from message data (#66)").not.toMatch(/img-src/); @@ -76,7 +84,11 @@ describe("Run Inspector shell contract (plumbing ⇄ TS-composed view)", () => { // throttle; resolve replays EVERY pane (S36) and activate names the visible one. describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { const META = { drives: 1, knots: 2, labels: ["a_1"], bounds: [[-0.2, 0.2]] as [number, number][] }; - const rec = (iter: number): { iter: number; dt: number; values: number[][] } => ({ iter, dt: 0.2, values: [[iter / 10, iter / 5]] }); + const rec = (iter: number): { iter: number; dt: number; values: number[][] } => ({ + iter, + dt: 0.2, + values: [[iter / 10, iter / 5]], + }); function harness() { const ctx = { extensionUri: { fsPath: PKG_ROOT }, subscriptions: [] as unknown[] }; @@ -90,13 +102,24 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { webview: { options: {}, cspSource: "vscode-webview://unit", - asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), - postMessage: (m: Record) => { posted.push(m); }, + asWebviewUri: (u: { fsPath?: string }) => ({ + toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)), + }), + postMessage: (m: Record) => { + posted.push(m); + }, onDidReceiveMessage: () => ({ dispose() {} }), - set html(_v: string) { /* ignore */ }, - get html() { return ""; }, + set html(_v: string) { + /* ignore */ + }, + get html() { + return ""; + }, + }, + onDidDispose: (cb: () => void) => { + disposeCb = cb; + return { dispose() {} }; }, - onDidDispose: (cb: () => void) => { disposeCb = cb; return { dispose() {} }; }, }; return { view, posted, dispose: () => disposeCb() }; }; @@ -113,10 +136,10 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { inspector.resolveWebviewView(view as never); const r1 = posted.filter((m) => m.runId === "r1"); - expect(r1.every((m) => m.runId === "r1")).toBe(true); // every message carries the runId + expect(r1.every((m) => m.runId === "r1")).toBe(true); // every message carries the runId const types = r1.map((m) => m.type); expect(types).toContain("pulsemeta"); - expect(types.filter((t) => t === "pulse")).toHaveLength(1); // newest-wins: iter 1 dropped + expect(types.filter((t) => t === "pulse")).toHaveLength(1); // newest-wins: iter 1 dropped expect(r1.find((m) => m.type === "pulse")).toMatchObject({ iter: 2 }); expect(types.indexOf("pulsemeta")).toBeLessThan(types.indexOf("pulse")); expect(types.indexOf("pulse")).toBeLessThan(types.indexOf("completed")); // terminal state stays the last word @@ -129,13 +152,13 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { inspector.postPulse("r1", { type: "meta", meta: META }); posted.length = 0; inspector.postPulse("r1", { type: "record", record: rec(1) }); - expect(posted.map((m) => m.type)).toEqual(["pulse"]); // leading edge posts immediately + expect(posted.map((m) => m.type)).toEqual(["pulse"]); // leading edge posts immediately inspector.postPulse("r1", { type: "record", record: rec(2) }); inspector.postPulse("r1", { type: "record", record: rec(3) }); - expect(posted).toHaveLength(1); // inside the window: coalesced + expect(posted).toHaveLength(1); // inside the window: coalesced vi.advanceTimersByTime(200); - expect(posted).toHaveLength(2); // trailing edge: exactly one flush - expect(posted[1]).toMatchObject({ type: "pulse", iter: 3, runId: "r1" }); // …carrying the newest + expect(posted).toHaveLength(2); // trailing edge: exactly one flush + expect(posted[1]).toMatchObject({ type: "pulse", iter: 3, runId: "r1" }); // …carrying the newest }); it("posts straight through once the webview is live", () => { @@ -166,8 +189,8 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { const { inspector, view, posted } = harness(); inspector.resolveWebviewView(view as never); posted.length = 0; - inspector.postPulse("r1", { type: "record", record: rec(1) }); // opens r1's window (posts) - inspector.postPulse("r2", { type: "record", record: rec(1) }); // r2 has its OWN window (posts) + inspector.postPulse("r1", { type: "record", record: rec(1) }); // opens r1's window (posts) + inspector.postPulse("r2", { type: "record", record: rec(1) }); // r2 has its OWN window (posts) expect(posted.filter((m) => m.type === "pulse")).toHaveLength(2); expect(posted.map((m) => m.runId).sort()).toEqual(["r1", "r2"]); }); @@ -182,7 +205,7 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { const activate = posted.filter((m) => m.type === "activate"); expect(activate).toHaveLength(1); expect(activate[0]).toMatchObject({ runId: "r2" }); - expect(posted.indexOf(activate[0])).toBe(posted.length - 1); // last word = the visible pane + expect(posted.indexOf(activate[0])).toBe(posted.length - 1); // last word = the visible pane }); it("rebuilds EVERY pane on reopen (S36) — dispose then re-resolve replays all runs", () => { @@ -195,10 +218,10 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { inspector.postPulse("r2", { type: "meta", meta: META }); inspector.postPulse("r2", { type: "record", record: rec(9) }); inspector.activate("r2"); - a.dispose(); // user closes the panel + a.dispose(); // user closes the panel const b = makeView(); - inspector.resolveWebviewView(b.view as never); // reopen — fresh DOM + inspector.resolveWebviewView(b.view as never); // reopen — fresh DOM // Both panes rebuilt from buffers, each with its newest record, r1 terminal. expect(b.posted.filter((m) => m.type === "pulse" && m.runId === "r1")).toMatchObject([{ iter: 4 }]); expect(b.posted.filter((m) => m.type === "completed" && m.runId === "r1")).toHaveLength(1); @@ -209,14 +232,16 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { it("setWarmingUp no-ops once the pane has data or terminal state (no clobber of a fanned-in run)", () => { const { inspector, view, posted } = harness(); inspector.resolveWebviewView(view as never); - inspector.postPulse("r1", { type: "record", record: rec(1) }); // r1 has data - inspector.postCompletion("r2", "completed", 0.99); // r2 is terminal + inspector.postPulse("r1", { type: "record", record: rec(1) }); // r1 has data + inspector.postCompletion("r2", "completed", 0.99); // r2 is terminal posted.length = 0; inspector.setWarmingUp("r1"); inspector.setWarmingUp("r2"); - inspector.setWarmingUp("r3"); // fresh run → warming IS shown + inspector.setWarmingUp("r3"); // fresh run → warming IS shown expect(posted.filter((m) => m.type === "warming")).toMatchObject([{ runId: "r3" }]); }); }); -afterEach(() => { vi.useRealTimers(); }); +afterEach(() => { + vi.useRealTimers(); +}); diff --git a/packages/extension/test/inspector_webview_view.test.ts b/packages/extension/test/inspector_webview_view.test.ts index 757fa8a4..763b5262 100644 --- a/packages/extension/test/inspector_webview_view.test.ts +++ b/packages/extension/test/inspector_webview_view.test.ts @@ -13,7 +13,14 @@ import { createInspectorView } from "../media/ui/views/inspector"; // .pane/.active/.pill. Runs under happy-dom because the atoms inject styles via // constructable stylesheets (`new CSSStyleSheet()`), which jsdom can't model. -const iter = (runId: string, n: number) => ({ type: "iteration", runId, iter: n, f_val: 1e-2, eq_viol: 1e-8, kkt_error: 1e-6 }); +const iter = (runId: string, n: number) => ({ + type: "iteration", + runId, + iter: n, + f_val: 1e-2, + eq_viol: 1e-8, + kkt_error: 1e-6, +}); const panes = (v: { el: HTMLElement }) => [...v.el.querySelectorAll(".pane")]; const activePane = (v: { el: HTMLElement }) => v.el.querySelector(".pane.active"); const pillText = (pane: Element | null | undefined) => pane?.querySelector(".pill")?.textContent; @@ -22,17 +29,17 @@ describe("Inspector webview router (1.3 per-run panes)", () => { it("activate shows exactly one pane and hides the empty-state hint", () => { const v = createInspectorView(() => {}); expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); - const emptyHint = v.el.firstElementChild as HTMLElement; // the idle hint, appended first + const emptyHint = v.el.firstElementChild as HTMLElement; // the idle hint, appended first expect(emptyHint.style.display).not.toBe("none"); v.onMessage(iter("r1", 3)); v.onMessage(iter("r2", 4)); expect(panes(v)).toHaveLength(2); - expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); // panes exist but none shown yet + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); // panes exist but none shown yet v.onMessage({ type: "activate", runId: "r2" }); expect(v.el.querySelectorAll(".pane.active")).toHaveLength(1); - expect(panes(v)[1].classList.contains("active")).toBe(true); // r2 = 2nd-created pane + expect(panes(v)[1].classList.contains("active")).toBe(true); // r2 = 2nd-created pane expect(panes(v)[0].classList.contains("active")).toBe(false); expect(emptyHint.style.display).toBe("none"); }); @@ -54,11 +61,11 @@ describe("Inspector webview router (1.3 per-run panes)", () => { // r2 is a background run — its iteration must land in ITS pane, not r1's. v.onMessage(iter("r2", 99)); - expect(pillText(activePane(v))).toBe("running"); // r1 badge unchanged + expect(pillText(activePane(v))).toBe("running"); // r1 badge unchanged const r2 = panes(v).find((p) => !p.classList.contains("active"))!; - expect(pillText(r2)).toBe("running"); // r2 has its OWN running badge - expect(r1.textContent).toContain("3"); // r1 still reads iter 3… - expect(r1.textContent).not.toContain("99"); // …not r2's 99 (no value bleed) + expect(pillText(r2)).toBe("running"); // r2 has its OWN running badge + expect(r1.textContent).toContain("3"); // r1 still reads iter 3… + expect(r1.textContent).not.toContain("99"); // …not r2's 99 (no value bleed) }); it("pulse is plot-only — it never touches the active pane's badge (#67)", () => { @@ -68,20 +75,20 @@ describe("Inspector webview router (1.3 per-run panes)", () => { expect(pillText(r1)).toBe("idle"); v.onMessage({ type: "pulsemeta", runId: "r1", drives: 1, knots: 2, labels: ["a_1"], bounds: [[-0.2, 0.2]] }); v.onMessage({ type: "pulse", runId: "r1", iter: 1, dt: 0.2, values: [[0.1, 0.2]] }); - expect(pillText(r1)).toBe("idle"); // pulse did NOT flip the badge + expect(pillText(r1)).toBe("idle"); // pulse did NOT flip the badge }); it("switching activate moves the visible pane, each pane keeps its own state", () => { const v = createInspectorView(() => {}); v.onMessage(iter("r1", 1)); - v.onMessage({ type: "completed", runId: "r1", status: "completed", fidelity: 0.999 }); // hidden pane still updates + v.onMessage({ type: "completed", runId: "r1", status: "completed", fidelity: 0.999 }); // hidden pane still updates v.onMessage(iter("r2", 2)); v.onMessage({ type: "activate", runId: "r1" }); - expect(pillText(activePane(v))).toBe("converged"); // r1 terminal badge shows on activate + expect(pillText(activePane(v))).toBe("converged"); // r1 terminal badge shows on activate v.onMessage({ type: "activate", runId: "r2" }); expect(v.el.querySelectorAll(".pane.active")).toHaveLength(1); - expect(pillText(activePane(v))).toBe("running"); // now r2 is visible + expect(pillText(activePane(v))).toBe("running"); // now r2 is visible const r1 = panes(v).find((p) => !p.classList.contains("active"))!; - expect(pillText(r1)).toBe("converged"); // r1 untouched by the switch + expect(pillText(r1)).toBe("converged"); // r1 untouched by the switch }); }); diff --git a/packages/extension/test/log_tailer.test.ts b/packages/extension/test/log_tailer.test.ts index 098185e7..72f7be37 100644 --- a/packages/extension/test/log_tailer.test.ts +++ b/packages/extension/test/log_tailer.test.ts @@ -22,12 +22,12 @@ function harness(content?: string, startOffset = 0) { describe("LogTailer", () => { it("emits complete lines once; a torn final line (no newline yet) waits and heals", () => { - const { p, t, lines } = harness("a\t1\t/s.jl\nb\t2\t/s"); // second line torn mid-write + const { p, t, lines } = harness("a\t1\t/s.jl\nb\t2\t/s"); // second line torn mid-write t.poke(); - expect(lines).toEqual(["a\t1\t/s.jl"]); // torn tail NOT emitted - appendFileSync(p, ".jl\nc\t3\t/t.jl\n"); // writer finishes + appends + expect(lines).toEqual(["a\t1\t/s.jl"]); // torn tail NOT emitted + appendFileSync(p, ".jl\nc\t3\t/t.jl\n"); // writer finishes + appends t.poke(); - expect(lines).toEqual(["a\t1\t/s.jl", "b\t2\t/s.jl", "c\t3\t/t.jl"]); // healed, no split + expect(lines).toEqual(["a\t1\t/s.jl", "b\t2\t/s.jl", "c\t3\t/t.jl"]); // healed, no split t.dispose(); }); @@ -35,9 +35,9 @@ describe("LogTailer", () => { const { p, t, lines } = harness("one\ntwo\n"); t.poke(); expect(lines).toEqual(["one", "two"]); - writeFileSync(p, "one\n"); // file shrank (rewrite) + writeFileSync(p, "one\n"); // file shrank (rewrite) t.poke(); - expect(lines).toEqual(["one", "two", "one"]); // full re-read from 0 + expect(lines).toEqual(["one", "two", "one"]); // full re-read from 0 t.dispose(); }); @@ -50,11 +50,11 @@ describe("LogTailer", () => { }); it("poke() self-attaches when the file appears after start()", () => { - const { p, t, lines } = harness(undefined); // file doesn't exist yet + const { p, t, lines } = harness(undefined); // file doesn't exist yet t.poke(); expect(lines).toEqual([]); writeFileSync(p, "late\n"); - t.poke(); // attaches + drains + t.poke(); // attaches + drains expect(lines).toEqual(["late"]); t.dispose(); }); diff --git a/packages/extension/test/run_controls.test.ts b/packages/extension/test/run_controls.test.ts index f084abb0..52422933 100644 --- a/packages/extension/test/run_controls.test.ts +++ b/packages/extension/test/run_controls.test.ts @@ -90,7 +90,8 @@ describe("forceFinalize", () => { describe("findRunPids (two-key match: cmdline AND cwd)", () => { const RUN_DIR = "/fake/runs/default/r1"; const SCRIPT = "/fake/problems/x/solve.jl"; - const fakeExec = (psLines: string, cwdByPid: Record) => + const fakeExec = + (psLines: string, cwdByPid: Record) => (cmd: string, args: string[]): string => { if (cmd === "/bin/ps") return psLines; const pid = args[args.indexOf("-p") + 1]; @@ -100,18 +101,26 @@ describe("findRunPids (two-key match: cmdline AND cwd)", () => { it("kills only processes running the script FROM this run dir", () => { const ps = [ - ` 101 julia --project=/x ${SCRIPT}`, // ours: script + cwd match - ` 202 julia --project=/x ${SCRIPT}`, // sibling run, other cwd - ` 303 vim ${RUN_DIR}/run.log`, // references dir, wrong cwd + ` 101 julia --project=/x ${SCRIPT}`, // ours: script + cwd match + ` 202 julia --project=/x ${SCRIPT}`, // sibling run, other cwd + ` 303 vim ${RUN_DIR}/run.log`, // references dir, wrong cwd " 404 unrelated", ].join("\n"); - const pids = findRunPids(RUN_DIR, SCRIPT, fakeExec(ps, { "101": RUN_DIR, "202": "/fake/runs/default/r2", "303": "/home" })); + const pids = findRunPids( + RUN_DIR, + SCRIPT, + fakeExec(ps, { "101": RUN_DIR, "202": "/fake/runs/default/r2", "303": "/home" }), + ); expect(pids).toEqual([101]); }); it("returns [] when nothing matches or lsof cannot prove ownership", () => { const ps = ` 505 julia ${SCRIPT}\n`; expect(findRunPids(RUN_DIR, SCRIPT, fakeExec(ps, {}))).toEqual([]); - expect(findRunPids(RUN_DIR, SCRIPT, () => { throw new Error("ps down"); })).toEqual([]); + expect( + findRunPids(RUN_DIR, SCRIPT, () => { + throw new Error("ps down"); + }), + ).toEqual([]); }); }); diff --git a/packages/extension/test/run_registry.test.ts b/packages/extension/test/run_registry.test.ts index d501c91c..ca2daa6a 100644 --- a/packages/extension/test/run_registry.test.ts +++ b/packages/extension/test/run_registry.test.ts @@ -8,14 +8,16 @@ import { parseIndexLine, RunRegistry } from "../src/run_registry"; describe("parseIndexLine — runs/index grammar", () => { it("parses the writer's TSV line", () => { expect(parseIndexLine("r20260703-010203Z-ab12\t2026-07-03T01:02:03Z\t/tmp/solve.jl")).toEqual({ - runId: "r20260703-010203Z-ab12", createdAt: "2026-07-03T01:02:03Z", scriptPath: "/tmp/solve.jl", + runId: "r20260703-010203Z-ab12", + createdAt: "2026-07-03T01:02:03Z", + scriptPath: "/tmp/solve.jl", }); }); it("rejects blank and malformed lines (torn final line heals on next drain)", () => { expect(parseIndexLine("")).toBeUndefined(); expect(parseIndexLine(" ")).toBeUndefined(); expect(parseIndexLine("r1\tonly-two-fields")).toBeUndefined(); - expect(parseIndexLine("\t\t/s.jl")).toBeUndefined(); // empty runId + expect(parseIndexLine("\t\t/s.jl")).toBeUndefined(); // empty runId }); it("re-joins extra tabs into the path (defensive — the writer sanitizes)", () => { expect(parseIndexLine("r1\t2026-01-01T00:00:00Z\t/a\tb.jl")?.scriptPath).toBe("/a\tb.jl"); @@ -27,16 +29,16 @@ describe("RunRegistry", () => { const reg = new RunRegistry(); expect(reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" })).toBe(true); expect(reg.register({ runId: "r1", runDir: "/elsewhere", phase: "finished" })).toBe(false); - expect(reg.get("r1")?.runDir).toBe("/runs/r1"); // first registration wins + expect(reg.get("r1")?.runDir).toBe("/runs/r1"); // first registration wins expect(reg.get("r1")?.phase).toBe("live"); }); it("noteIter is a monotonic high-water mark", () => { const reg = new RunRegistry(); reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); reg.noteIter("r1", 5); - reg.noteIter("r1", 3); // out-of-order (poll double-delivery) + reg.noteIter("r1", 3); // out-of-order (poll double-delivery) expect(reg.get("r1")?.latestIter).toBe(5); - reg.noteIter("nope", 9); // unknown run — no throw + reg.noteIter("nope", 9); // unknown run — no throw }); it("markFinished sets phase/status/fidelity and keeps latestIter", () => { const reg = new RunRegistry(); @@ -49,12 +51,12 @@ describe("RunRegistry", () => { const reg = new RunRegistry(); reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); reg.markFinished("r1", "completed", 0.999); - reg.markFinished("r1", "failed"); // stray second call (public surface, 1.3 consumers) + reg.markFinished("r1", "failed"); // stray second call (public surface, 1.3 consumers) expect(reg.get("r1")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.999 }); }); it("backfill fills ONLY missing metadata (scheduler-registered run gains createdAt/scriptPath from a later index line)", () => { const reg = new RunRegistry(); - reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); // scheduler path: no metadata + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); // scheduler path: no metadata expect(reg.get("r1")?.createdAt).toBeUndefined(); expect(reg.get("r1")?.scriptPath).toBeUndefined(); reg.backfill("r1", { createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); @@ -62,7 +64,7 @@ describe("RunRegistry", () => { // never overwrites a present value (first registration wins for everything) reg.backfill("r1", { createdAt: "2099-01-01T00:00:00Z", scriptPath: "/other.jl" }); expect(reg.get("r1")).toMatchObject({ createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); - reg.backfill("nope", { createdAt: "x" }); // unknown run — no throw + reg.backfill("nope", { createdAt: "x" }); // unknown run — no throw }); it("all() returns COPIES — callers can't mutate registry state", () => { const reg = new RunRegistry(); diff --git a/packages/extension/test/runs_manager.test.ts b/packages/extension/test/runs_manager.test.ts index 5ca07e61..b2d781ef 100644 --- a/packages/extension/test/runs_manager.test.ts +++ b/packages/extension/test/runs_manager.test.ts @@ -39,20 +39,29 @@ const channel = { appendLine() {}, append() {} } as never; const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n'; /** Minimal StatusBarManager spy — only setRun is exercised. */ -function statusBarSpy() { return { setRun: vi.fn(), clear: vi.fn(), dispose: vi.fn() }; } +function statusBarSpy() { + return { setRun: vi.fn(), clear: vi.fn(), dispose: vi.fn() }; +} function writeManifest(dir: string, runId: string): void { - writeFileSync(join(dir, "run.toml"), + writeFileSync( + join(dir, "run.toml"), `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\n` + - `lab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`); + `lab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`, + ); } /** Stage a run dir + its index line (the amico-run writer's TSV format). */ -function stageRun(root: string, runId: string, opts: { finished?: string; fidelity?: number; log?: string } = {}): string { +function stageRun( + root: string, + runId: string, + opts: { finished?: string; fidelity?: number; log?: string } = {}, +): string { const dir = join(root, runId); mkdirSync(dir, { recursive: true }); writeManifest(dir, runId); if (opts.log !== undefined) writeFileSync(join(dir, "run.log"), opts.log); - if (opts.fidelity !== undefined) writeFileSync(join(dir, "result.toml"), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = 9\n`); + if (opts.fidelity !== undefined) + writeFileSync(join(dir, "result.toml"), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = 9\n`); if (opts.finished) writeFileSync(join(dir, "FINISHED"), `status = "${opts.finished}"\nexit_code = 0\n`); appendFileSync(join(root, "index"), `${runId}\t2026-07-03T00:00:00Z\t/s.jl\n`); return dir; @@ -60,7 +69,9 @@ function stageRun(root: string, runId: string, opts: { finished?: string; fideli const tick = (m: RunsManager): void => (m as unknown as { tick(): void }).tick(); describe("RunsManager state machine (ported from RunsRootWatcher)", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + beforeEach(() => { + for (const f of Object.values(inspector)) f.mockClear(); + }); it("a run already FINISHED at launch stays idle — nothing re-rendered", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); @@ -71,7 +82,7 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { expect(inspector.postPulse).not.toHaveBeenCalled(); expect(inspector.postCompletion).not.toHaveBeenCalled(); expect(inspector.setWarmingUp).not.toHaveBeenCalled(); - expect(m.runs()).toHaveLength(1); // …but it IS registered + expect(m.runs()).toHaveLength(1); // …but it IS registered expect(m.runs()[0]).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9999 }); m.dispose(); }); @@ -82,11 +93,11 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { m.start(); // Registered AFTER boot (a run that STARTS while the user works) — the // boot-replay path is warming-quiet by design (see the boot test below). - const run = stageRun(root, "r2"); // manifest only, no data yet + const run = stageRun(root, "r2"); // manifest only, no data yet tick(m); expect(inspector.setWarmingUp).toHaveBeenCalledWith("r2"); expect(inspector.setRunLabel).toHaveBeenCalledWith("r2", "r2"); - expect(inspector.activate).toHaveBeenCalledWith("r2"); // 1.3: selection = activate the pane + expect(inspector.activate).toHaveBeenCalledWith("r2"); // 1.3: selection = activate the pane expect(m.selectedRun).toBe("r2"); // result.toml alone must NOT complete the run (FINISHED is authoritative). @@ -102,12 +113,12 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { it("BOOT replay is warming/reveal-quiet: a live run discovered at start() is tracked but never steals focus", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); - stageRun(root, "rBoot"); // live run exists BEFORE start + stageRun(root, "rBoot"); // live run exists BEFORE start const m = new RunsManager({ runsRoot: root, channel }); m.start(); - expect(m.selectedRun).toBe("rBoot"); // state still selects it… - expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // …but no warming focus - expect(inspector.reveal).not.toHaveBeenCalled(); // …and no reveal at boot + expect(m.selectedRun).toBe("rBoot"); // state still selects it… + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // …but no warming focus + expect(inspector.reveal).not.toHaveBeenCalled(); // …and no reveal at boot m.dispose(); }); @@ -122,12 +133,19 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { tick(m); expect(inspector.postPulse).toHaveBeenCalledTimes(2); expect(inspector.postPulse).toHaveBeenNthCalledWith(1, "p1", expect.objectContaining({ type: "meta" })); - expect(inspector.postPulse).toHaveBeenNthCalledWith(2, "p1", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); + expect(inspector.postPulse).toHaveBeenNthCalledWith( + 2, + "p1", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) }), + ); appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n"); tick(m); expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith("p1", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); + expect(inspector.postPulse).toHaveBeenLastCalledWith( + "p1", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) }), + ); m.dispose(); }); @@ -136,19 +154,24 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { // Mid-flight discovery: meta + one record ALREADY on disk, run not finished. const run = stageRun(root, "p2", { log: META_LINE + "AMICODE_PULSE iter=3 dt=0.2 a=0.1,0.2\n" }); const m = new RunsManager({ runsRoot: root, channel }); - m.start(); // display replay → meta + newest record + m.start(); // display replay → meta + newest record expect(inspector.postPulse).toHaveBeenCalledTimes(2); appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=4 dt=0.2 a=0.3,0.4\n"); - tick(m); // record parses against the armed meta + tick(m); // record parses against the armed meta expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith("p2", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); + expect(inspector.postPulse).toHaveBeenLastCalledWith( + "p2", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) }), + ); m.dispose(); }); }); describe("RunsManager multi-run (#57 / #58 fan-out)", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + beforeEach(() => { + for (const f of Object.values(inspector)) f.mockClear(); + }); it("two concurrent live runs: newest auto-selected; both fanned to the inspector, status bar tracks the selected only", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); @@ -158,9 +181,9 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { m.start(); expect(m.selectedRun).toBe("rA"); - const b = stageRun(root, "rB"); // second solve starts - tick(m); // index tail discovers it - expect(m.selectedRun).toBe("rB"); // auto-follow the newest start + const b = stageRun(root, "rB"); // second solve starts + tick(m); // index tail discovers it + expect(m.selectedRun).toBe("rB"); // auto-follow the newest start inspector.postIterationRecord.mockClear(); statusBar.setRun.mockClear(); @@ -169,8 +192,8 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { appendFileSync(join(a, "run.log"), "AMICODE_ITER iter=7 f=0.1 inf_pr=1e-8 inf_du=1e-6\n"); tick(m); expect(inspector.postIterationRecord).toHaveBeenCalledWith("rA", expect.objectContaining({ iter: 7 })); - expect(statusBar.setRun).not.toHaveBeenCalled(); // selection-gated: rB is selected - expect(m.runs().find(r => r.runId === "rA")?.latestIter).toBe(7); + expect(statusBar.setRun).not.toHaveBeenCalled(); // selection-gated: rB is selected + expect(m.runs().find((r) => r.runId === "rA")?.latestIter).toBe(7); // A finishes in the background: registry terminal, completion fanned to the // inspector (rA's pane badge), status bar still untouched… @@ -179,8 +202,8 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); tick(m); expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9995); - expect(statusBar.setRun).not.toHaveBeenCalled(); // still rB selected - expect(m.runs().find(r => r.runId === "rA")).toMatchObject({ phase: "finished", fidelity: 0.9995 }); + expect(statusBar.setRun).not.toHaveBeenCalled(); // still rB selected + expect(m.runs().find((r) => r.runId === "rA")).toMatchObject({ phase: "finished", fidelity: 0.9995 }); // …and the promote prompt STILL fires (fan-out is per-run, not per-selection). expect(promote).toHaveBeenCalledTimes(1); @@ -200,53 +223,56 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { m.start(); writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 3\n'); writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); - tick(m); // live completion (promotes once) + tick(m); // live completion (promotes once) stageRun(root, "rB"); - tick(m); // selection moves to rB + tick(m); // selection moves to rB expect(m.selectedRun).toBe("rB"); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); inspector.postCompletion.mockClear(); - m.selectRun("rA"); // user switches back (1.3 seam) + m.selectRun("rA"); // user switches back (1.3 seam) expect(inspector.activate).toHaveBeenCalledWith("rA"); expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9999); - expect(promote).not.toHaveBeenCalled(); // promote-once held + expect(promote).not.toHaveBeenCalled(); // promote-once held promote.mockRestore(); m.dispose(); }); it("PULSE events are fanned to the inspector runId-tagged even for a background run (webview shows only the active pane)", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); - const a = stageRun(root, "rA", { log: META_LINE }); // rA armed with meta + const a = stageRun(root, "rA", { log: META_LINE }); // rA armed with meta const m = new RunsManager({ runsRoot: root, channel }); m.start(); stageRun(root, "rB"); tick(m); - expect(m.selectedRun).toBe("rB"); // rA now background + expect(m.selectedRun).toBe("rB"); // rA now background inspector.postPulse.mockClear(); // A background pulse RECORD on rA reaches the inspector TAGGED "rA" — the // webview routes it to rA's hidden pane, never the visible rB plot. appendFileSync(join(a, "run.log"), "AMICODE_PULSE iter=5 dt=0.2 a=0.1,0.2\n"); tick(m); - expect(inspector.postPulse).toHaveBeenCalledWith("rA", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 5 }) })); + expect(inspector.postPulse).toHaveBeenCalledWith( + "rA", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 5 }) }), + ); m.dispose(); }); it("selecting a run whose FINISHED landed inside the poll window shows completion, never warming", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); - const a = stageRun(root, "rA"); // live at discovery → pipeline + selected + const a = stageRun(root, "rA"); // live at discovery → pipeline + selected const m = new RunsManager({ runsRoot: root, channel }); m.start(); stageRun(root, "rB"); - tick(m); // selection moves to rB (rA still "live" in registry) + tick(m); // selection moves to rB (rA still "live" in registry) inspector.setWarmingUp.mockClear(); inspector.postCompletion.mockClear(); // rA finishes on disk but the poll hasn't ticked (registry still says live). writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 3\n'); writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); - m.selectRun("rA"); // user switches back BEFORE the tick + m.selectRun("rA"); // user switches back BEFORE the tick // selectRun re-checks disk → completion, NOT warming (no terminal-badge inversion). expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9999); expect(inspector.setWarmingUp).not.toHaveBeenCalled(); @@ -269,14 +295,22 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { m.start(); let emit!: (e: SchedulerLifecycleEvent) => void; - const scheduler: SchedulerLike = { onEvent: (l) => { emit = l; return () => { /* dispose */ }; } }; + const scheduler: SchedulerLike = { + onEvent: (l) => { + emit = l; + return () => { + /* dispose */ + }; + }, + }; m.attachScheduler(scheduler); // A scheduler-launched run — no index line yet (the executor appends it, // but the started event beats the fs). const dir = join(root, "rSched"); - mkdirSync(dir); writeManifest(dir, "rSched"); - emit({ kind: "queued", queueId: "q1", position: 0 }); // logged, no throw + mkdirSync(dir); + writeManifest(dir, "rSched"); + emit({ kind: "queued", queueId: "q1", position: 0 }); // logged, no throw emit({ kind: "started", queueId: "q1", runId: "rSched", runDir: dir }); expect(m.selectedRun).toBe("rSched"); expect(inspector.setRunLabel).toHaveBeenCalledWith("rSched", "rSched"); @@ -286,7 +320,7 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { // The index line landing later is a no-op (registration is idempotent). appendFileSync(join(root, "index"), "rSched\t2026-07-03T00:00:00Z\t/s.jl\n"); tick(m); - expect(m.runs().filter(r => r.runId === "rSched")).toHaveLength(1); + expect(m.runs().filter((r) => r.runId === "rSched")).toHaveLength(1); m.dispose(); }); @@ -295,19 +329,23 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { const m = new RunsManager({ runsRoot: root, channel }); m.start(); stageRun(root, "rDemo", { - finished: "completed", fidelity: 0.9998, + finished: "completed", + fidelity: 0.9998, log: META_LINE + "AMICODE_PULSE iter=60 dt=0.2 a=0.1,0.2\nAMICODE_ITER iter=60 f=2e-3 inf_pr=1e-9 inf_du=1e-6\n", }); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); - m.pokeDiscovery(); // same-tick registration… - expect(inspector.postCompletion).not.toHaveBeenCalled(); // …but no auto-display - m.selectRun("rDemo"); // the replayDemo command's path + m.pokeDiscovery(); // same-tick registration… + expect(inspector.postCompletion).not.toHaveBeenCalled(); // …but no auto-display + m.selectRun("rDemo"); // the replayDemo command's path expect(inspector.setRunLabel).toHaveBeenCalledWith("rDemo", "rDemo"); expect(inspector.activate).toHaveBeenCalledWith("rDemo"); - expect(inspector.postPulse).toHaveBeenCalledWith("rDemo", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) })); + expect(inspector.postPulse).toHaveBeenCalledWith( + "rDemo", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) }), + ); expect(inspector.postCompletion).toHaveBeenCalledWith("rDemo", "completed", 0.9998); - expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // finished — never "warming" - expect(promote).not.toHaveBeenCalled(); // finished-at-discovery: no prompt + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // finished — never "warming" + expect(promote).not.toHaveBeenCalled(); // finished-at-discovery: no prompt promote.mockRestore(); m.dispose(); }); @@ -315,25 +353,27 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { // Review #70 findings — one test per fix (jack-champagne's static/design pass). describe("RunsManager review-#70 fixes", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + beforeEach(() => { + for (const f of Object.values(inspector)) f.mockClear(); + }); it("#1 explicit selection is PINNED — a new live run registering does not steal the view", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); stageRun(root, "rA", { finished: "completed", fidelity: 0.9 }); const m = new RunsManager({ runsRoot: root, channel }); m.start(); - m.selectRun("rA"); // the user deliberately opens rA + m.selectRun("rA"); // the user deliberately opens rA expect(m.selectedRun).toBe("rA"); inspector.setRunLabel.mockClear(); - stageRun(root, "rB"); // background solve starts + stageRun(root, "rB"); // background solve starts tick(m); - expect(m.selectedRun).toBe("rA"); // auto-follow deferred to the pin + expect(m.selectedRun).toBe("rA"); // auto-follow deferred to the pin expect(inspector.setRunLabel).not.toHaveBeenCalledWith("rB", "rB"); expect(inspector.activate).not.toHaveBeenCalledWith("rB"); // visible pane untouched - expect(m.runs().find(r => r.runId === "rB")?.phase).toBe("live"); // …but rB IS tracked + expect(m.runs().find((r) => r.runId === "rB")?.phase).toBe("live"); // …but rB IS tracked - m.selectRun("rB"); // explicit switch still works + m.selectRun("rB"); // explicit switch still works expect(m.selectedRun).toBe("rB"); m.dispose(); }); @@ -345,7 +385,7 @@ describe("RunsManager review-#70 fixes", () => { m.start(); stageRun(root, "rB"); tick(m); - expect(m.selectedRun).toBe("rB"); // no pin → newest live run wins + expect(m.selectedRun).toBe("rB"); // no pin → newest live run wins m.dispose(); }); @@ -355,20 +395,24 @@ describe("RunsManager review-#70 fixes", () => { mkdirSync(dir, { recursive: true }); writeManifest(dir, "rTorn"); writeFileSync(join(dir, "result.toml"), 'schema_version = "1"\nfidelity = 0.9997\niterations = 5\n'); - writeFileSync(join(dir, "FINISHED"), 'status = "comp'); // torn mid-write: invalid TOML + writeFileSync(join(dir, "FINISHED"), 'status = "comp'); // torn mid-write: invalid TOML appendFileSync(join(root, "index"), "rTorn\t2026-07-04T00:00:00Z\t/s.jl\n"); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); const m = new RunsManager({ runsRoot: root, channel }); m.start(); // NOT finalized with an undefined status — held live so the retry lane owns it. - expect(m.runs().find(r => r.runId === "rTorn")).toMatchObject({ phase: "live" }); - expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // FINISHED exists on disk — never "warming" + expect(m.runs().find((r) => r.runId === "rTorn")).toMatchObject({ phase: "live" }); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // FINISHED exists on disk — never "warming" - writeFileSync(join(dir, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); // the write completes + writeFileSync(join(dir, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); // the write completes tick(m); - expect(m.runs().find(r => r.runId === "rTorn")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9997 }); - expect(promote).not.toHaveBeenCalled(); // still a launch replay — promote suppressed + expect(m.runs().find((r) => r.runId === "rTorn")).toMatchObject({ + phase: "finished", + status: "completed", + fidelity: 0.9997, + }); + expect(promote).not.toHaveBeenCalled(); // still a launch replay — promote suppressed promote.mockRestore(); m.dispose(); }); @@ -383,7 +427,7 @@ describe("RunsManager review-#70 fixes", () => { const displayPass = vi.spyOn(RunsManager.prototype as never as { displaySink(): unknown }, "displaySink"); const m = new RunsManager({ runsRoot: root, channel }); m.start(); - expect(displayPass).not.toHaveBeenCalled(); // was 1 per discovery + expect(displayPass).not.toHaveBeenCalled(); // was 1 per discovery // …and the single pass still displayed the history (meta + newest record): expect(inspector.postPulse).toHaveBeenCalledTimes(2); displayPass.mockRestore(); @@ -398,7 +442,7 @@ describe("mid-session stall surfaces on the status bar", () => { const m = new RunsManager({ runsRoot: root, channel, statusBar: statusBar as never }); m.start(); const dir = stageRun(root, "r-wedge", { log: "AMICODE_ITER iter=8 f=1.07e+01 inf_pr=1e-3 inf_du=1e-2\n" }); - tick(m); // registers + replays → status bar sees running/iter 8 via routeIter + tick(m); // registers + replays → status bar sees running/iter 8 via routeIter statusBar.setRun.mockClear(); // age run.log past the stall threshold, then let the poll backstop fire diff --git a/packages/extension/test/slow/scores_e2e.test.ts b/packages/extension/test/slow/scores_e2e.test.ts index 422f5e20..b8d41301 100644 --- a/packages/extension/test/slow/scores_e2e.test.ts +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect, afterAll } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir, homedir } from 'node:os' -import { join } from 'node:path' -import { spawn, type ChildProcess } from 'node:child_process' -import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from '../../src/opencode_config' -import { loadState } from '../../src/scores/interview_state' -import { readUsage, reconstructTraversal } from '../../src/scores/usage' +import { describe, it, expect, afterAll } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from "../../src/opencode_config"; +import { loadState } from "../../src/scores/interview_state"; +import { readUsage, reconstructTraversal } from "../../src/scores/usage"; // ============================================================================ // Scores-runtime e2e — router → score #0 → pinned interview_state + usage funnel. @@ -19,134 +19,151 @@ import { readUsage, reconstructTraversal } from '../../src/scores/usage' // assertable. No solve is run here — tier D of the night e2e owns that. // ============================================================================ -const EXT = join(__dirname, '..', '..') -const OC_BIN = join(EXT, 'vendor', 'opencode', `${process.platform}-${process.arch}`, 'opencode') +const EXT = join(__dirname, "..", ".."); +const OC_BIN = join(EXT, "vendor", "opencode", `${process.platform}-${process.arch}`, "opencode"); // Spec A: the manifest lives at the problems ROOT (the guard's manifestDir), but // interview_state.json / usage.jsonl live in the ACTIVE problem's workspace. The // plugin auto-creates an untitled problem on the first tool call; resolve it via // the `active` pointer. function activeStateDir(problemsRoot: string): string | undefined { - const activeFile = join(problemsRoot, 'active') - if (!existsSync(activeFile)) return undefined - const slug = readFileSync(activeFile, 'utf8').trim() - if (!slug) return undefined - return join(problemsRoot, slug) + const activeFile = join(problemsRoot, "active"); + if (!existsSync(activeFile)) return undefined; + const slug = readFileSync(activeFile, "utf8").trim(); + if (!slug) return undefined; + return join(problemsRoot, slug); } -const AUTH_JSON = join(homedir(), '.local', 'share', 'opencode', 'auth.json') +const AUTH_JSON = join(homedir(), ".local", "share", "opencode", "auth.json"); function hasCreds(): boolean { - if (process.env.AMICODE_E2E_LIVE === '1') return true - if (process.env.ANTHROPIC_API_KEY) return true + if (process.env.AMICODE_E2E_LIVE === "1") return true; + if (process.env.ANTHROPIC_API_KEY) return true; try { - return Object.keys(JSON.parse(readFileSync(AUTH_JSON, 'utf8'))).length > 0 + return Object.keys(JSON.parse(readFileSync(AUTH_JSON, "utf8"))).length > 0; } catch { - return false + return false; } } -const PROBLEMS = mkdtempSync(join(tmpdir(), 'scores-e2e-problems-')) -const servers: ChildProcess[] = [] +const PROBLEMS = mkdtempSync(join(tmpdir(), "scores-e2e-problems-")); +const servers: ChildProcess[] = []; afterAll(() => { - for (const c of servers) c.kill('SIGTERM') -}) + for (const c of servers) c.kill("SIGTERM"); +}); async function serveWithScores(port: number) { // problems root must match between the extension-side builder (permission grant + // manifest transport) and the Bun-side plugin — pin it before either runs. - process.env.AMICODE_PROBLEMS_DIR = PROBLEMS + process.env.AMICODE_PROBLEMS_DIR = PROBLEMS; const project = prepareOpencodeProject({ - agentsSrc: join(EXT, 'AGENTS.md'), - templateSrc: join(EXT, 'templates', 'solve_template.jl'), - juliaProject: resolveJuliaProject(''), - entitlementsDir: mkdtempSync(join(tmpdir(), 'scores-e2e-noents-')), // no code → public repertoire - }) - const env = { ...process.env, AMICODE_PROBLEMS_DIR: PROBLEMS } - env.OPENCODE_CONFIG_CONTENT = buildOpencodeConfigContent(project.agentsPath, join(EXT, 'templates', 'solve_template.jl'), join(homedir(), '.amico', 'runs', 'default')) - let buf = '' - const child = spawn(OC_BIN, ['serve', '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] }) - servers.push(child) - child.stdout!.on('data', (c) => (buf += c)) - child.stderr!.on('data', (c) => (buf += c)) - const url = `http://127.0.0.1:${port}` - const deadline = Date.now() + 30_000 + agentsSrc: join(EXT, "AGENTS.md"), + templateSrc: join(EXT, "templates", "solve_template.jl"), + juliaProject: resolveJuliaProject(""), + entitlementsDir: mkdtempSync(join(tmpdir(), "scores-e2e-noents-")), // no code → public repertoire + }); + const env = { ...process.env, AMICODE_PROBLEMS_DIR: PROBLEMS }; + env.OPENCODE_CONFIG_CONTENT = buildOpencodeConfigContent( + project.agentsPath, + join(EXT, "templates", "solve_template.jl"), + join(homedir(), ".amico", "runs", "default"), + ); + let buf = ""; + const child = spawn(OC_BIN, ["serve", "--port", String(port)], { env, stdio: ["ignore", "pipe", "pipe"] }); + servers.push(child); + child.stdout!.on("data", (c) => (buf += c)); + child.stderr!.on("data", (c) => (buf += c)); + const url = `http://127.0.0.1:${port}`; + const deadline = Date.now() + 30_000; for (;;) { try { - const r = await fetch(url + '/', { signal: AbortSignal.timeout(1000) }) - if (r.ok) break - } catch { /* not up yet */ } - if (Date.now() > deadline) throw new Error(`serve not ready in 30s; log:\n${buf.slice(0, 2000)}`) - await new Promise((r) => setTimeout(r, 300)) + const r = await fetch(url + "/", { signal: AbortSignal.timeout(1000) }); + if (r.ok) break; + } catch { + /* not up yet */ + } + if (Date.now() > deadline) throw new Error(`serve not ready in 30s; log:\n${buf.slice(0, 2000)}`); + await new Promise((r) => setTimeout(r, 300)); } - return { url, log: () => buf, agentsPath: project.agentsPath } + return { url, log: () => buf, agentsPath: project.agentsPath }; } -describe.skipIf(!existsSync(OC_BIN) || !hasCreds())('scores runtime live e2e (creds required)', () => { - it('router opens, score #0 interview starts, state pinned + usage funnel recorded', { timeout: 300_000 }, async () => { - const s = await serveWithScores(14320) - - // Sanity: the session prep actually compiled the score (not the fallback). - const agents = readFileSync(s.agentsPath, 'utf8') - expect(agents).toContain('## Onset router') - // Version-agnostic: SCORE.md version bumps must not rot this pin (it sat - // hardcoded at v1 while the score reached v3 — red on every creds machine). - expect(agents).toMatch(/Compiled from score `pulse-designer` v\d+/) - - const ses = (await ( - await fetch(s.url + '/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) - ).json()) as { id: string } - const turn = async (text: string): Promise => { - const r = await fetch(`${s.url}/session/${ses.id}/message`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ agent: 'pulse-designer', parts: [{ type: 'text', text }] }), - }) - expect(r.ok, `message POST ${r.status}`).toBe(true) - const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> } - return (msg.parts ?? []).filter((p) => p.type === 'text').map((p) => p.text).join('\n') - } - - const transcript: string[] = [] - - // Turn 1: open-ended → the onset router's options (or a proactive stage-1 kickoff — - // both are protocol-legal; what matters is it offers a way in, one question only). - const t1 = await turn('hi — what can I do here?') - transcript.push(`## turn 1 (hi — what can I do here?)\n\n${t1}`) - expect(t1.toLowerCase()).toMatch(/start from a system|design.*pulse|what do you want to do|platform|system/) - expect(t1.toLowerCase(), 'no stage-batching in turn 1').not.toMatch(/max_iter|timestep|objective|constraint|drive_max/) - - // Turn 2: choose the system-first path → the PLATFORM question, alone. - const t2 = await turn('start from a system — walk me through designing a pulse') - transcript.push(`## turn 2 (start from a system)\n\n${t2}`) - expect(t2.toLowerCase()).toMatch(/system|platform/) - expect(t2.toLowerCase(), 'no stage-batching in turn 2').not.toMatch(/max_iter|timestep|objective|constraint|drive_max/) - - // Turn 3: answer → LaTeX confirm + amicode_pick_system records stage/platform. - const t3 = await turn('transmon') - transcript.push(`## turn 3 (transmon)\n\n${t3}`) - expect(t3).toMatch(/\\hat|H\s*\/\s*\\hbar|hamiltonian/i) - - // The guard state is written by the plugin when the tool fires; free-tier models - // occasionally skip the tool call — one explicit nudge turn is allowed before - // the hard assertion (rerun-once policy covers residual sampling noise). State - // now lives in the ACTIVE problem workspace, not the problems root. - const stateDir0 = activeStateDir(PROBLEMS) - if (!stateDir0 || !loadState(stateDir0)) { - const t4 = await turn('please record that with your amicode tools before we continue') - transcript.push(`## turn 4 (nudge)\n\n${t4}`) - } - writeFileSync(join(tmpdir(), `scores-e2e-transcript-${Date.now()}.md`), transcript.join('\n\n')) - - // Success criterion 1+8 (scores spec §10): pinned state + reconstructable funnel. - const stateDir = activeStateDir(PROBLEMS) - expect(stateDir, 'active problem workspace exists').toBeDefined() - const state = loadState(stateDir!) - expect(state, 'interview_state.json written by the guard').toBeDefined() - expect(state!.score_id).toBe('pulse-designer') - expect(state!.score_version).toBe(1) - - const traversal = reconstructTraversal(readUsage(stateDir!)) - expect(traversal.score_id).toBe('pulse-designer') - expect(traversal.funnel.map((f) => f.stage)).toContain('platform') - }) -}) +describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("scores runtime live e2e (creds required)", () => { + it( + "router opens, score #0 interview starts, state pinned + usage funnel recorded", + { timeout: 300_000 }, + async () => { + const s = await serveWithScores(14320); + + // Sanity: the session prep actually compiled the score (not the fallback). + const agents = readFileSync(s.agentsPath, "utf8"); + expect(agents).toContain("## Onset router"); + // Version-agnostic: SCORE.md version bumps must not rot this pin (it sat + // hardcoded at v1 while the score reached v3 — red on every creds machine). + expect(agents).toMatch(/Compiled from score `pulse-designer` v\d+/); + + const ses = (await ( + await fetch(s.url + "/session", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }) + ).json()) as { id: string }; + const turn = async (text: string): Promise => { + const r = await fetch(`${s.url}/session/${ses.id}/message`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), + }); + expect(r.ok, `message POST ${r.status}`).toBe(true); + const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> }; + return (msg.parts ?? []) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("\n"); + }; + + const transcript: string[] = []; + + // Turn 1: open-ended → the onset router's options (or a proactive stage-1 kickoff — + // both are protocol-legal; what matters is it offers a way in, one question only). + const t1 = await turn("hi — what can I do here?"); + transcript.push(`## turn 1 (hi — what can I do here?)\n\n${t1}`); + expect(t1.toLowerCase()).toMatch(/start from a system|design.*pulse|what do you want to do|platform|system/); + expect(t1.toLowerCase(), "no stage-batching in turn 1").not.toMatch( + /max_iter|timestep|objective|constraint|drive_max/, + ); + + // Turn 2: choose the system-first path → the PLATFORM question, alone. + const t2 = await turn("start from a system — walk me through designing a pulse"); + transcript.push(`## turn 2 (start from a system)\n\n${t2}`); + expect(t2.toLowerCase()).toMatch(/system|platform/); + expect(t2.toLowerCase(), "no stage-batching in turn 2").not.toMatch( + /max_iter|timestep|objective|constraint|drive_max/, + ); + + // Turn 3: answer → LaTeX confirm + amicode_pick_system records stage/platform. + const t3 = await turn("transmon"); + transcript.push(`## turn 3 (transmon)\n\n${t3}`); + expect(t3).toMatch(/\\hat|H\s*\/\s*\\hbar|hamiltonian/i); + + // The guard state is written by the plugin when the tool fires; free-tier models + // occasionally skip the tool call — one explicit nudge turn is allowed before + // the hard assertion (rerun-once policy covers residual sampling noise). State + // now lives in the ACTIVE problem workspace, not the problems root. + const stateDir0 = activeStateDir(PROBLEMS); + if (!stateDir0 || !loadState(stateDir0)) { + const t4 = await turn("please record that with your amicode tools before we continue"); + transcript.push(`## turn 4 (nudge)\n\n${t4}`); + } + writeFileSync(join(tmpdir(), `scores-e2e-transcript-${Date.now()}.md`), transcript.join("\n\n")); + + // Success criterion 1+8 (scores spec §10): pinned state + reconstructable funnel. + const stateDir = activeStateDir(PROBLEMS); + expect(stateDir, "active problem workspace exists").toBeDefined(); + const state = loadState(stateDir!); + expect(state, "interview_state.json written by the guard").toBeDefined(); + expect(state!.score_id).toBe("pulse-designer"); + expect(state!.score_version).toBe(1); + + const traversal = reconstructTraversal(readUsage(stateDir!)); + expect(traversal.score_id).toBe("pulse-designer"); + expect(traversal.funnel.map((f) => f.stage)).toContain("platform"); + }, + ); +}); diff --git a/packages/extension/test/smoke_corpus.test.ts b/packages/extension/test/smoke_corpus.test.ts index 32f43ce7..15f85960 100644 --- a/packages/extension/test/smoke_corpus.test.ts +++ b/packages/extension/test/smoke_corpus.test.ts @@ -51,13 +51,18 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); * offending await, not surface as an opaque suite hang. */ async function pumpUntil(m: RunsManager, pred: () => boolean, what: string, ms = 8000): Promise { const t0 = Date.now(); - while (!pred() && Date.now() - t0 < ms) { tick(m); await sleep(25); } + while (!pred() && Date.now() - t0 < ms) { + tick(m); + await sleep(25); + } tick(m); if (!pred()) throw new Error(`pumpUntil timed out after ${ms}ms waiting for: ${what}`); } describe("smoke corpus — Scheduler → executor → run-dir → RunsManager → inspector", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + beforeEach(() => { + for (const f of Object.values(inspector)) f.mockClear(); + }); it("runs the corpus serially end-to-end; both runs tracked, runId-keyed, correct fidelity", async () => { const runsRoot = mkdtempSync(join(tmpdir(), "smoke-corpus-")); @@ -73,18 +78,18 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager const a = scheduler.enqueue({ scriptPath: join(CORPUS, "transmon_x.jl"), opts }); const b = scheduler.enqueue({ scriptPath: join(CORPUS, "cavity_displacement.jl"), opts }); - const ha = await a.handle; // head of queue — starts immediately + const ha = await a.handle; // head of queue — starts immediately await pumpUntil(m, () => existsSync(join(ha.runDir, "FINISHED")), "run A FINISHED on disk"); expect((await ha.finished).status).toBe("completed"); - const hb = await b.handle; // resolves only after A finished (serial) + const hb = await b.handle; // resolves only after A finished (serial) await pumpUntil(m, () => existsSync(join(hb.runDir, "FINISHED")), "run B FINISHED on disk"); expect((await hb.finished).status).toBe("completed"); // --- scheduler lifecycle: strict serial ordering, queueIds line up --- const seq = events.map((e) => `${e.kind}:${e.queueId}`); expect(seq.indexOf("finished:q1")).toBeGreaterThan(seq.indexOf("started:q1")); - expect(seq.indexOf("started:q2")).toBeGreaterThan(seq.indexOf("finished:q1")); // B started strictly after A finished + expect(seq.indexOf("started:q2")).toBeGreaterThan(seq.indexOf("finished:q1")); // B started strictly after A finished expect(seq.indexOf("finished:q2")).toBeGreaterThan(seq.indexOf("started:q2")); // --- run-dir contract on disk for BOTH runs (what the executor wrote is @@ -97,12 +102,22 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager } // --- registry: both finished, fidelity + iter high-water from the stream --- - await pumpUntil(m, () => m.runs().filter((r) => r.phase === "finished").length === 2, "both runs terminal in the registry"); + await pumpUntil( + m, + () => m.runs().filter((r) => r.phase === "finished").length === 2, + "both runs terminal in the registry", + ); expect(m.runs().find((r) => r.runId === ha.runId)).toMatchObject({ - phase: "finished", status: "completed", fidelity: 0.9993, latestIter: 4, + phase: "finished", + status: "completed", + fidelity: 0.9993, + latestIter: 4, }); expect(m.runs().find((r) => r.runId === hb.runId)).toMatchObject({ - phase: "finished", status: "completed", fidelity: 0.9981, latestIter: 3, + phase: "finished", + status: "completed", + fidelity: 0.9981, + latestIter: 3, }); // --- inspector fan-out: per-run, runId-keyed, no cross-tagging --- @@ -115,8 +130,12 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager // Pulse stream per run: meta + records with the fixture's shape, and every // record tagged with ITS run — dims prove no stream-crossing (A is 2×8, B is 1×6). const pulses = (rid: string) => inspector.postPulse.mock.calls.filter((c) => c[0] === rid).map((c) => c[1]); - const lastA = pulses(ha.runId).filter((e) => e.type === "record").at(-1); - const lastB = pulses(hb.runId).filter((e) => e.type === "record").at(-1); + const lastA = pulses(ha.runId) + .filter((e) => e.type === "record") + .at(-1); + const lastB = pulses(hb.runId) + .filter((e) => e.type === "record") + .at(-1); expect(pulses(ha.runId).some((e) => e.type === "meta" && e.meta.drives === 2 && e.meta.knots === 8)).toBe(true); expect(pulses(hb.runId).some((e) => e.type === "meta" && e.meta.drives === 1 && e.meta.knots === 6)).toBe(true); expect(lastA.record).toMatchObject({ iter: 4 }); @@ -140,7 +159,10 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager m.attachScheduler(scheduler); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); - const f = scheduler.enqueue({ scriptPath: join(CORPUS, "failing_solve.jl"), opts: { runsRoot, julia: { julia: EMITTER } } }); + const f = scheduler.enqueue({ + scriptPath: join(CORPUS, "failing_solve.jl"), + opts: { runsRoot, julia: { julia: EMITTER } }, + }); const hf = await f.handle; await pumpUntil(m, () => existsSync(join(hf.runDir, "FINISHED")), "failing run FINISHED on disk"); expect((await hf.finished).status).toBe("failed"); @@ -148,7 +170,11 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager // Run-dir contract for the failure lane: FINISHED written by the EXECUTOR // (never the script), and no result.toml (the emitter dies before it). expect(existsSync(join(hf.runDir, "result.toml"))).toBe(false); - await pumpUntil(m, () => m.runs().find((r) => r.runId === hf.runId)?.phase === "finished", "failed run terminal in the registry"); + await pumpUntil( + m, + () => m.runs().find((r) => r.runId === hf.runId)?.phase === "finished", + "failed run terminal in the registry", + ); expect(m.runs().find((r) => r.runId === hf.runId)).toMatchObject({ phase: "finished", status: "failed" }); expect(m.runs().find((r) => r.runId === hf.runId)?.fidelity).toBeUndefined(); // …but the telemetry it emitted BEFORE dying was tracked (iters 0-2). diff --git a/packages/extension/test/watcher_contract.test.ts b/packages/extension/test/watcher_contract.test.ts index e6c57588..7085f380 100644 --- a/packages/extension/test/watcher_contract.test.ts +++ b/packages/extension/test/watcher_contract.test.ts @@ -1,215 +1,277 @@ -import { describe, it, expect, vi } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { ingestRunDir, promoteEligibility, AMICODE_ITER_RE, parseAmicoNum, parsePulseMetaLine, parsePulseRecordLine, PulseStream, SinkDedup } from '../src/run_dir_reader' // pure β.1-contract reader (vscode-free) +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ingestRunDir, + promoteEligibility, + AMICODE_ITER_RE, + parseAmicoNum, + parsePulseMetaLine, + parsePulseRecordLine, + PulseStream, + SinkDedup, +} from "../src/run_dir_reader"; // pure β.1-contract reader (vscode-free) -function stageRun(opts: { status: string; exit: number; iters: number[]; fidelity?: number; tier?: string; agree?: boolean }): string { - const root = mkdtempSync(join(tmpdir(), 'runs-')) - const runId = 'r20260615-000000Z-ab12' - const dir = join(root, runId); mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'run.toml'), - `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`) - writeFileSync(join(dir, 'run.log'), opts.iters.map(k => `AMICODE_ITER iter=${k} f=0.1 inf_pr=1e-8 inf_du=1e-6`).join('\n') + '\n') - if (opts.fidelity !== undefined) writeFileSync(join(dir, 'result.toml'), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = ${Math.max(...opts.iters, 0)}\n`) +function stageRun(opts: { + status: string; + exit: number; + iters: number[]; + fidelity?: number; + tier?: string; + agree?: boolean; +}): string { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const runId = "r20260615-000000Z-ab12"; + const dir = join(root, runId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "run.toml"), + `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`, + ); + writeFileSync( + join(dir, "run.log"), + opts.iters.map((k) => `AMICODE_ITER iter=${k} f=0.1 inf_pr=1e-8 inf_du=1e-6`).join("\n") + "\n", + ); + if (opts.fidelity !== undefined) + writeFileSync( + join(dir, "result.toml"), + `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = ${Math.max(...opts.iters, 0)}\n`, + ); // spec C: a --spec launch persists solvespec.json; free tier gates promotion - if (opts.tier !== undefined) writeFileSync(join(dir, 'solvespec.json'), JSON.stringify({ schema_version: '2', script_path: '/s.jl', lab_id: 'default', tier: opts.tier })) - if (opts.agree !== undefined) writeFileSync(join(dir, 'verification.toml'), `schema_version = "1"\nagree = ${opts.agree}\n`) - writeFileSync(join(dir, 'FINISHED'), `status = "${opts.status}"\nexit_code = ${opts.exit}\n`) - return dir + if (opts.tier !== undefined) + writeFileSync( + join(dir, "solvespec.json"), + JSON.stringify({ schema_version: "2", script_path: "/s.jl", lab_id: "default", tier: opts.tier }), + ); + if (opts.agree !== undefined) + writeFileSync(join(dir, "verification.toml"), `schema_version = "1"\nagree = ${opts.agree}\n`); + writeFileSync(join(dir, "FINISHED"), `status = "${opts.status}"\nexit_code = ${opts.exit}\n`); + return dir; } -const fakeSink = () => ({ iter: vi.fn(), run: vi.fn(), promote: vi.fn(), pulse: vi.fn() }) +const fakeSink = () => ({ iter: vi.fn(), run: vi.fn(), promote: vi.fn(), pulse: vi.fn() }); -describe('ingestRunDir — β.1 contract reading (replay)', () => { - it('completed run: identity from manifest, run.log→iter, FINISHED→completed, promote on F≥0.99', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1, 7, 142], fidelity: 0.9991 }), sink) - expect(sink.iter).toHaveBeenCalledWith(expect.objectContaining({ iter: 142 })) // run.log parsed on REPLAY - expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', fidelity: 0.9991 })) - expect(sink.promote).toHaveBeenCalled() - }) - it('failed run: FINISHED→failed, no promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'failed', exit: 3, iters: [1], fidelity: 0.4 }), sink) - expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed' })) - expect(sink.promote).not.toHaveBeenCalled() - }) - it('aborted run: FINISHED→aborted, no promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'aborted', exit: 143, iters: [] }), sink) - expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: 'aborted' })) - expect(sink.promote).not.toHaveBeenCalled() - }) - it('completed but F<0.99: no promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.5 }), sink) - expect(sink.promote).not.toHaveBeenCalled() - }) +describe("ingestRunDir — β.1 contract reading (replay)", () => { + it("completed run: identity from manifest, run.log→iter, FINISHED→completed, promote on F≥0.99", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1, 7, 142], fidelity: 0.9991 }), sink); + expect(sink.iter).toHaveBeenCalledWith(expect.objectContaining({ iter: 142 })); // run.log parsed on REPLAY + expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: "completed", fidelity: 0.9991 })); + expect(sink.promote).toHaveBeenCalled(); + }); + it("failed run: FINISHED→failed, no promote", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "failed", exit: 3, iters: [1], fidelity: 0.4 }), sink); + expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: "failed" })); + expect(sink.promote).not.toHaveBeenCalled(); + }); + it("aborted run: FINISHED→aborted, no promote", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "aborted", exit: 143, iters: [] }), sink); + expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: "aborted" })); + expect(sink.promote).not.toHaveBeenCalled(); + }); + it("completed but F<0.99: no promote", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.5 }), sink); + expect(sink.promote).not.toHaveBeenCalled(); + }); // spec C: rendering stays tier-blind (sink.run always fires); promotion is gated - describe('free-tier verification gates promotion (spec C)', () => { - it('(a) no solvespec.json (bare run) → promote fires, unchanged', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999 }), sink) - expect(sink.promote).toHaveBeenCalled() - }) - it('(b) tier=free, no verification.toml → NO promote, but run STILL rendered (tier-blind)', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free' }), sink) - expect(sink.run).toHaveBeenCalled() - expect(sink.promote).not.toHaveBeenCalled() - }) - it('(c) tier=free + agree=true → promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free', agree: true }), sink) - expect(sink.promote).toHaveBeenCalled() - }) - it('(d) tier=free + agree=false → NO promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free', agree: false }), sink) - expect(sink.promote).not.toHaveBeenCalled() - }) - it('(e) tier=vetted, no verification → promote (only free is gated)', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'vetted' }), sink) - expect(sink.promote).toHaveBeenCalled() - }) - it('promoteEligibility: eligible / pending_verification / suppressed / eligible-when-agree', () => { - expect(promoteEligibility(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999 }))).toBe('eligible') - expect(promoteEligibility(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free' }))).toBe('pending_verification') - expect(promoteEligibility(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free', agree: false }))).toBe('suppressed') - expect(promoteEligibility(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free', agree: true }))).toBe('eligible') - }) - }) - it('returns the run.log byte offset (so the live tailer attaches without skipping iters)', () => { - const sink = fakeSink() - const bytes = ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1, 2], fidelity: 0.999 }), sink) - expect(bytes).toBeGreaterThan(0) // = byte length of run.log consumed during replay - }) + describe("free-tier verification gates promotion (spec C)", () => { + it("(a) no solvespec.json (bare run) → promote fires, unchanged", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999 }), sink); + expect(sink.promote).toHaveBeenCalled(); + }); + it("(b) tier=free, no verification.toml → NO promote, but run STILL rendered (tier-blind)", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free" }), sink); + expect(sink.run).toHaveBeenCalled(); + expect(sink.promote).not.toHaveBeenCalled(); + }); + it("(c) tier=free + agree=true → promote", () => { + const sink = fakeSink(); + ingestRunDir( + stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free", agree: true }), + sink, + ); + expect(sink.promote).toHaveBeenCalled(); + }); + it("(d) tier=free + agree=false → NO promote", () => { + const sink = fakeSink(); + ingestRunDir( + stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free", agree: false }), + sink, + ); + expect(sink.promote).not.toHaveBeenCalled(); + }); + it("(e) tier=vetted, no verification → promote (only free is gated)", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "vetted" }), sink); + expect(sink.promote).toHaveBeenCalled(); + }); + it("promoteEligibility: eligible / pending_verification / suppressed / eligible-when-agree", () => { + expect(promoteEligibility(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999 }))).toBe( + "eligible", + ); + expect( + promoteEligibility(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free" })), + ).toBe("pending_verification"); + expect( + promoteEligibility( + stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free", agree: false }), + ), + ).toBe("suppressed"); + expect( + promoteEligibility( + stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free", agree: true }), + ), + ).toBe("eligible"); + }); + }); + it("returns the run.log byte offset (so the live tailer attaches without skipping iters)", () => { + const sink = fakeSink(); + const bytes = ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1, 2], fidelity: 0.999 }), sink); + expect(bytes).toBeGreaterThan(0); // = byte length of run.log consumed during replay + }); - it('pulse lines on replay: forwards the meta plus ONLY the newest record (no history burst at the webview)', () => { - const sink = fakeSink() - const dir = stageRun({ status: 'completed', exit: 0, iters: [1, 2, 3], fidelity: 0.999 }) - writeFileSync(join(dir, 'run.log'), + it("pulse lines on replay: forwards the meta plus ONLY the newest record (no history burst at the webview)", () => { + const sink = fakeSink(); + const dir = stageRun({ status: "completed", exit: 0, iters: [1, 2, 3], fidelity: 0.999 }); + writeFileSync( + join(dir, "run.log"), 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n' + - 'AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n' + - 'AMICODE_ITER iter=1 f=0.1 inf_pr=1e-8 inf_du=1e-6\n' + - 'AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n' + - 'AMICODE_PULSE iter=3 dt=0.2 a=0.5,0.6\n') - ingestRunDir(dir, sink) - expect(sink.pulse).toHaveBeenCalledTimes(2) - expect(sink.pulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: 'meta' })) - expect(sink.pulse).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: 'record', record: expect.objectContaining({ iter: 3 }) })) - }) + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n" + + "AMICODE_ITER iter=1 f=0.1 inf_pr=1e-8 inf_du=1e-6\n" + + "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n" + + "AMICODE_PULSE iter=3 dt=0.2 a=0.5,0.6\n", + ); + ingestRunDir(dir, sink); + expect(sink.pulse).toHaveBeenCalledTimes(2); + expect(sink.pulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "meta" })); + expect(sink.pulse).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 3 }) }), + ); + }); - it('pulse-less run.log: sink.pulse never fires (runs render exactly as today)', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999 }), sink) - expect(sink.pulse).not.toHaveBeenCalled() - }) -}) + it("pulse-less run.log: sink.pulse never fires (runs render exactly as today)", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999 }), sink); + expect(sink.pulse).not.toHaveBeenCalled(); + }); +}); -describe('AMICODE_ITER parsing — Inf/NaN are kept, not dropped', () => { - it('matches blow-up / stagnation iters (Inf, -Inf, NaN), matching amico-run', () => { - expect(AMICODE_ITER_RE.test('AMICODE_ITER iter=3 f=Inf inf_pr=NaN inf_du=-Inf')).toBe(true) - expect(AMICODE_ITER_RE.test('AMICODE_ITER iter=4 f=1.2e-03 inf_pr=5e-9 inf_du=2.3')).toBe(true) - }) - it('parseAmicoNum maps Julia Inf/NaN to JS values', () => { - expect(parseAmicoNum('Inf')).toBe(Infinity) - expect(parseAmicoNum('-Inf')).toBe(-Infinity) - expect(Number.isNaN(parseAmicoNum('NaN'))).toBe(true) - expect(parseAmicoNum('1.5e-3')).toBeCloseTo(0.0015) - }) -}) +describe("AMICODE_ITER parsing — Inf/NaN are kept, not dropped", () => { + it("matches blow-up / stagnation iters (Inf, -Inf, NaN), matching amico-run", () => { + expect(AMICODE_ITER_RE.test("AMICODE_ITER iter=3 f=Inf inf_pr=NaN inf_du=-Inf")).toBe(true); + expect(AMICODE_ITER_RE.test("AMICODE_ITER iter=4 f=1.2e-03 inf_pr=5e-9 inf_du=2.3")).toBe(true); + }); + it("parseAmicoNum maps Julia Inf/NaN to JS values", () => { + expect(parseAmicoNum("Inf")).toBe(Infinity); + expect(parseAmicoNum("-Inf")).toBe(-Infinity); + expect(Number.isNaN(parseAmicoNum("NaN"))).toBe(true); + expect(parseAmicoNum("1.5e-3")).toBeCloseTo(0.0015); + }); +}); // Pulse-line grammar (#66) — the candidate GA format for client-side pulse // rendering. Additive to the run.log stdout tee: consumers that don't know // these lines ignore them (anchored regex no-match), so the β contract freeze // is untouched. -describe('AMICODE_PULSE_META parsing (#66 pinned grammar)', () => { - it('parses drives/knots/labels/bounds from a well-formed meta line', () => { - const m = parsePulseMetaLine('AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2') +describe("AMICODE_PULSE_META parsing (#66 pinned grammar)", () => { + it("parses drives/knots/labels/bounds from a well-formed meta line", () => { + const m = parsePulseMetaLine('AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2'); expect(m).toEqual({ drives: 2, knots: 50, - labels: ['u_1', 'u_2'], - bounds: [[-0.2, 0.2], [-0.2, 0.2]], - }) - }) -}) + labels: ["u_1", "u_2"], + bounds: [ + [-0.2, 0.2], + [-0.2, 0.2], + ], + }); + }); +}); -describe('AMICODE_PULSE record parsing (#66 pinned grammar)', () => { - it('parses iter/dt and per-drive value lists (drives ;-separated, values ,-separated)', () => { - const r = parsePulseRecordLine('AMICODE_PULSE iter=6 dt=0.204082 a=0.021,-0.013,1.2e-3;0.008,0.031,-4e-2') +describe("AMICODE_PULSE record parsing (#66 pinned grammar)", () => { + it("parses iter/dt and per-drive value lists (drives ;-separated, values ,-separated)", () => { + const r = parsePulseRecordLine("AMICODE_PULSE iter=6 dt=0.204082 a=0.021,-0.013,1.2e-3;0.008,0.031,-4e-2"); expect(r).toEqual({ iter: 6, dt: 0.204082, - values: [[0.021, -0.013, 0.0012], [0.008, 0.031, -0.04]], - }) - }) - it('keeps Inf/NaN values, matching the stats parser', () => { - const r = parsePulseRecordLine('AMICODE_PULSE iter=3 dt=0.2 a=Inf,-Inf;NaN,0.5') - expect(r!.values[0]).toEqual([Infinity, -Infinity]) - expect(Number.isNaN(r!.values[1][0])).toBe(true) - expect(r!.values[1][1]).toBe(0.5) - }) -}) + values: [ + [0.021, -0.013, 0.0012], + [0.008, 0.031, -0.04], + ], + }); + }); + it("keeps Inf/NaN values, matching the stats parser", () => { + const r = parsePulseRecordLine("AMICODE_PULSE iter=3 dt=0.2 a=Inf,-Inf;NaN,0.5"); + expect(r!.values[0]).toEqual([Infinity, -Infinity]); + expect(Number.isNaN(r!.values[1][0])).toBe(true); + expect(r!.values[1][1]).toBe(0.5); + }); +}); // PulseStream — the cross-line policy both delivery paths (replay ingest, live // tail) feed lines through. Policy per #66 AC4: records before any meta are // dropped; the last meta wins and resets state; count-mismatched records and // internally-inconsistent metas are ignored. -describe('PulseStream — cross-line policy (#66)', () => { - const META = 'AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2' - const REC = 'AMICODE_PULSE iter=6 dt=0.2 a=0.1,0.2,0.3;0.4,0.5,0.6' +describe("PulseStream — cross-line policy (#66)", () => { + const META = 'AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2'; + const REC = "AMICODE_PULSE iter=6 dt=0.2 a=0.1,0.2,0.3;0.4,0.5,0.6"; - it('drops records that arrive before any meta', () => { - const ps = new PulseStream() - expect(ps.onLine(REC)).toBeUndefined() - expect(ps.onLine(META)).toMatchObject({ type: 'meta' }) - expect(ps.onLine(REC)).toMatchObject({ type: 'record', record: { iter: 6 } }) - }) + it("drops records that arrive before any meta", () => { + const ps = new PulseStream(); + expect(ps.onLine(REC)).toBeUndefined(); + expect(ps.onLine(META)).toMatchObject({ type: "meta" }); + expect(ps.onLine(REC)).toMatchObject({ type: "record", record: { iter: 6 } }); + }); - it('ignores records whose drive count or knot count disagree with the current meta', () => { - const ps = new PulseStream() - ps.onLine(META) // drives=2 knots=3 - expect(ps.onLine('AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2,0.3')).toBeUndefined() // 1 drive ≠ 2 - expect(ps.onLine('AMICODE_PULSE iter=2 dt=0.2 a=0.1,0.2;0.3,0.4')).toBeUndefined() // 2 knots ≠ 3 - expect(ps.onLine(REC)).toMatchObject({ type: 'record' }) // conformant still flows - }) + it("ignores records whose drive count or knot count disagree with the current meta", () => { + const ps = new PulseStream(); + ps.onLine(META); // drives=2 knots=3 + expect(ps.onLine("AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2,0.3")).toBeUndefined(); // 1 drive ≠ 2 + expect(ps.onLine("AMICODE_PULSE iter=2 dt=0.2 a=0.1,0.2;0.3,0.4")).toBeUndefined(); // 2 knots ≠ 3 + expect(ps.onLine(REC)).toMatchObject({ type: "record" }); // conformant still flows + }); - it('treats a meta whose label or bounds count disagrees with drives= as malformed (no state change)', () => { - const ps = new PulseStream() - expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1" bounds=-0.2:0.2,-0.2:0.2')).toBeUndefined() // 1 label ≠ 2 drives - expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2')).toBeUndefined() // 1 bound ≠ 2 drives - expect(ps.onLine(REC)).toBeUndefined() // bad metas did NOT arm the stream - }) + it("treats a meta whose label or bounds count disagrees with drives= as malformed (no state change)", () => { + const ps = new PulseStream(); + expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1" bounds=-0.2:0.2,-0.2:0.2')).toBeUndefined(); // 1 label ≠ 2 drives + expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2')).toBeUndefined(); // 1 bound ≠ 2 drives + expect(ps.onLine(REC)).toBeUndefined(); // bad metas did NOT arm the stream + }); - it('last meta wins: a re-read meta (tailer truncation re-read) re-arms cleanly and its shape governs', () => { - const ps = new PulseStream() - ps.onLine(META) - ps.onLine(REC) + it("last meta wins: a re-read meta (tailer truncation re-read) re-arms cleanly and its shape governs", () => { + const ps = new PulseStream(); + ps.onLine(META); + ps.onLine(REC); // duplicate meta (offset-0 re-read) — same shape, still fine - expect(ps.onLine(META)).toMatchObject({ type: 'meta' }) - expect(ps.onLine(REC)).toMatchObject({ type: 'record' }) + expect(ps.onLine(META)).toMatchObject({ type: "meta" }); + expect(ps.onLine(REC)).toMatchObject({ type: "record" }); // a NEW meta with a different shape governs subsequent records - expect(ps.onLine('AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.1:0.1')).toMatchObject({ type: 'meta' }) - expect(ps.onLine(REC)).toBeUndefined() // old-shape record now ignored - expect(ps.onLine('AMICODE_PULSE iter=9 dt=0.2 a=0.1,0.2')).toMatchObject({ type: 'record', record: { iter: 9 } }) - }) -}) + expect(ps.onLine('AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.1:0.1')).toMatchObject({ + type: "meta", + }); + expect(ps.onLine(REC)).toBeUndefined(); // old-shape record now ignored + expect(ps.onLine("AMICODE_PULSE iter=9 dt=0.2 a=0.1,0.2")).toMatchObject({ type: "record", record: { iter: 9 } }); + }); +}); // SinkDedup — the live sink's iteration high-water mark (status bar / completion). -describe('SinkDedup — iteration high-water mark', () => { - it('high() tracks the max iter seen; out-of-order notes never regress it', () => { - const d = new SinkDedup() - expect(d.high).toBe(-1) - d.noteIter(42) - expect(d.high).toBe(42) - d.noteIter(7) - expect(d.high).toBe(42) - d.noteIter(60) - expect(d.high).toBe(60) - }) -}) +describe("SinkDedup — iteration high-water mark", () => { + it("high() tracks the max iter seen; out-of-order notes never regress it", () => { + const d = new SinkDedup(); + expect(d.high).toBe(-1); + d.noteIter(42); + expect(d.high).toBe(42); + d.noteIter(7); + expect(d.high).toBe(42); + d.noteIter(60); + expect(d.high).toBe(60); + }); +}); From 754bf085a305461ba01fc9f4d50ec226c1e279ac Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 05:26:51 -0400 Subject: [PATCH 41/50] style: prettier over the branch's touched amico-run files (missed in the previous style commit) Co-Authored-By: Claude Fable 5 --- packages/amico-run/.DS_Store | Bin 6148 -> 6148 bytes packages/amico-run/src/index.ts | 14 +- packages/amico-run/src/scheduler.ts | 133 ++++--- packages/amico-run/test/scheduler.test.ts | 429 ++++++++++++---------- 4 files changed, 306 insertions(+), 270 deletions(-) diff --git a/packages/amico-run/.DS_Store b/packages/amico-run/.DS_Store index b67d98eb715c6c2905f5e0ba3612aed949170f5e..6d06e1ee76f216b280050d72bf98f6715cb04e25 100644 GIT binary patch delta 22 ecmZoMXffFEhL!2I?c{f?1x)f=HYczx5d;8fmI%E7 delta 22 ecmZoMXffFEhL!2o%*pRq3z+2YZcboZA_xF)BMCqN diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index 7fd1e8e4..dcd0ab44 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -1,7 +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 './scheduler.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/scheduler.ts b/packages/amico-run/src/scheduler.ts index dd1153bf..82d434ff 100644 --- a/packages/amico-run/src/scheduler.ts +++ b/packages/amico-run/src/scheduler.ts @@ -1,4 +1,4 @@ -import { ConfigError, type Executor, type RunHandle, type RunStatus, type SubmitOpts } from './types.js' +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 @@ -26,140 +26,155 @@ import { ConfigError, type Executor, type RunHandle, type RunStatus, type Submit /** What to run when this entry reaches the head of the queue. */ export interface SubmitSpec { - scriptPath: string + scriptPath: string; /** Passed to Executor.submit verbatim (lab pointer, runsRoot, julia opts…). */ - opts?: SubmitOpts + opts?: SubmitOpts; } export interface EnqueueOpts { /** Phase-4 seam (opt-in parallel lane) — NOT implemented; throws ConfigError. */ - concurrent?: boolean + 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 } + | { 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 + 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 + 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 + cancel(): boolean; } interface Entry { - queueId: string - spec: SubmitSpec - resolve: (h: RunHandle) => void - reject: (e: Error) => void + 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>() + 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) } + 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) + 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') + 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 }) + 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() + 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 + 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 */ } + 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 + if (this.running) return; + const entry = this.queue.shift(); + if (!entry) return; + this.running = true; try { - let handle: RunHandle + let handle: RunHandle; try { - handle = await this.executor.submit(entry.spec.scriptPath, entry.spec.opts) + 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 + 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) + 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 }) + 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}` }) + const msg = e instanceof Error ? e.message : String(e); + this.emit({ kind: "error", queueId: entry.queueId, message: `finished rejected: ${msg}` }); } } finally { - this.running = false + 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()) + queueMicrotask(() => void this.pump()); } } } diff --git a/packages/amico-run/test/scheduler.test.ts b/packages/amico-run/test/scheduler.test.ts index 4bf78981..be538dab 100644 --- a/packages/amico-run/test/scheduler.test.ts +++ b/packages/amico-run/test/scheduler.test.ts @@ -1,7 +1,14 @@ -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' +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: @@ -14,207 +21,218 @@ import { EventQueue } from '../src/event_queue.js' /** 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[] }> = [] + 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() + 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[] = [] + 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 + abort: async () => { + aborted.push(true); + await finished; + }, + }; + this.handles.push({ handle, finish, aborted }); + return handle; } } -const tick = () => new Promise(r => setTimeout(r, 0)) +const tick = () => new Promise((r) => setTimeout(r, 0)); function collect(s: Scheduler): SchedulerEvent[] { - const seen: SchedulerEvent[] = [] - s.onEvent(e => seen.push(e)) - return seen + 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') - }) +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("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("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("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() + 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 }, - ]) - }) + { 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() 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("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("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("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("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("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 () => { + 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 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') - }) + 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 () => { + 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); @@ -222,52 +240,55 @@ describe('Scheduler — serial queue (#56)', () => { // 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() + 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 + 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/) - }) + 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 () => { + 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) + 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([]) + 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) + process.off("unhandledRejection", trap); } - }) + }); - it('contract (c): the Scheduler owns no timers (no warming timeout to hard-code)', async () => { + 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/) - }) -}) + 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/); + }); +}); From 56910aff7af6113ceeae61b3782103853f683b22 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 05:41:00 -0400 Subject: [PATCH 42/50] =?UTF-8?q?style:=20prettier=20repo-wide=20(new=20.p?= =?UTF-8?q?rettierrc/.prettierignore)=20=E2=80=94=20one-time=20full-repo?= =?UTF-8?q?=20normalization=20so=20the=20formatter=20is=20enforceable=20fr?= =?UTF-8?q?om=20here?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .prettierignore | 8 + packages/.DS_Store | Bin 6148 -> 6148 bytes packages/amico-run/.DS_Store | Bin 6148 -> 6148 bytes packages/amico-run/esbuild.config.mjs | 22 +- packages/amico-run/src/authoring.ts | 60 +- packages/amico-run/src/baseline.ts | 39 +- packages/amico-run/src/catalog.ts | 170 ++- packages/amico-run/src/cli.ts | 227 ++-- packages/amico-run/src/event_queue.ts | 26 +- packages/amico-run/src/gate.ts | 126 +- packages/amico-run/src/import_scan.ts | 77 +- packages/amico-run/src/local_executor.ts | 213 +-- packages/amico-run/src/run_dir.ts | 93 +- packages/amico-run/src/subcommands.ts | 122 +- packages/amico-run/src/telemetry.ts | 20 +- packages/amico-run/src/types.ts | 55 +- packages/amico-run/src/verify.ts | 49 +- packages/amico-run/test/abort.test.ts | 74 +- packages/amico-run/test/authoring.test.ts | 82 +- packages/amico-run/test/baseline.test.ts | 50 +- packages/amico-run/test/catalog.test.ts | 127 +- packages/amico-run/test/cli.test.ts | 324 +++-- packages/amico-run/test/failure_lanes.test.ts | 221 ++-- packages/amico-run/test/gate.test.ts | 154 +-- packages/amico-run/test/helpers.ts | 20 +- packages/amico-run/test/import_scan.test.ts | 42 +- .../amico-run/test/local_executor.test.ts | 124 +- packages/amico-run/test/run_dir.test.ts | 185 +-- packages/amico-run/test/s31.test.ts | 23 +- packages/amico-run/test/schemas.test.ts | 97 +- .../amico-run/test/slow/integration.test.ts | 57 +- packages/amico-run/test/subcommands.test.ts | 166 +-- packages/amico-run/test/telemetry.test.ts | 54 +- packages/amico-run/test/verify.test.ts | 104 +- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/AGENTS.md | 17 +- packages/extension/CONTRACT.md | 20 +- packages/extension/DEMO_CHECKLIST.md | 18 +- packages/extension/DISTILLER.md | 34 +- packages/extension/RUNBOOK.md | 17 +- packages/extension/TESTING.md | 2 +- .../dev/pulseplot_harness/index.html | 100 +- .../extension/dev/pulseplot_harness/main.ts | 70 +- packages/extension/julia/README.md | 2 + packages/extension/media/brand.css | 4 +- packages/extension/media/layout.css | 61 +- packages/extension/media/ui/atoms/button.ts | 18 +- packages/extension/media/ui/atoms/text.ts | 14 +- .../extension/media/ui/components/metric.ts | 7 +- .../media/ui/components/pulseplot.ts | 40 +- .../media/ui/components/sparkline.ts | 43 +- .../media/vendor/katex/katex.min.css | 1163 ++++++++++++++++- .../opencode-plugin/amicode_tools.ts | 73 +- .../opencode-plugin/distill_queue.ts | 11 +- .../extension/opencode-plugin/entities.ts | 8 +- .../extension/opencode-plugin/onboarding.ts | 5 +- .../extension/opencode-plugin/problems.ts | 15 +- .../extension/opencode-plugin/score_guard.ts | 7 +- packages/extension/opencode.lock.json | 10 +- packages/extension/scores/README.md | 31 +- .../scores/memory/confidence-rubric.md | 13 +- packages/extension/scores/overture/SCORE.md | 20 +- .../extension/scripts/build_exemplars.mjs | 101 +- packages/extension/scripts/distill_batch.mjs | 31 +- packages/extension/scripts/fetch_opencode.mjs | 167 ++- packages/extension/scripts/healthcheck.mjs | 115 +- packages/extension/scripts/opencode_probe.mjs | 25 +- packages/extension/scripts/plugin_exercise.ts | 39 +- packages/extension/src/chat_panel.ts | 18 +- packages/extension/src/executor_check.ts | 10 +- packages/extension/src/llm_creds.d.mts | 12 +- packages/extension/src/llm_creds.mjs | 3 +- packages/extension/src/opencode_binary.ts | 5 +- packages/extension/src/opencode_config.ts | 61 +- packages/extension/src/scores/compiler.ts | 10 +- .../extension/src/scores/package_skills.ts | 20 +- packages/extension/src/scores/schema.ts | 13 +- packages/extension/src/server_manager.ts | 27 +- packages/extension/src/sse_client.ts | 19 +- packages/extension/src/substrate/distiller.ts | 10 +- packages/extension/test/agents_md.test.ts | 256 ++-- packages/extension/test/amicode_tools.test.ts | 519 ++++---- packages/extension/test/boot_smoke.mjs | 25 +- packages/extension/test/corpus/fake-julia | 41 +- packages/extension/test/demo_replay.test.ts | 64 +- .../extension/test/fetch_opencode.test.ts | 184 +-- packages/extension/test/hashes.test.ts | 26 +- packages/extension/test/healthcheck.test.ts | 41 +- packages/extension/test/lab_config.test.ts | 44 +- packages/extension/test/llm_creds.test.ts | 179 +-- .../extension/test/opencode_binary.test.ts | 60 +- .../extension/test/opencode_config.test.ts | 313 +++-- .../extension/test/opencode_paths.test.ts | 82 +- packages/extension/test/packaging.test.ts | 86 +- packages/extension/test/problems.test.ts | 430 +++--- .../test/run_dir_reader_stopped.test.ts | 10 +- .../test/scores/allowlist_production.test.ts | 6 +- .../test/scores/entitlements_router.test.ts | 26 +- packages/extension/test/scores/guard.test.ts | 24 +- .../test/scores/overture_routing.test.ts | 24 +- .../test/scores/package_skills.test.ts | 18 +- .../test/scores/prep_integration.test.ts | 23 +- .../test/scores/repertoire_lint.test.ts | 14 +- packages/extension/test/scores/schema.test.ts | 50 +- .../extension/test/slow/interview_e2e.test.ts | 306 +++-- packages/extension/test/slow/template.test.ts | 51 +- .../test/slow/verify_harness.test.ts | 59 +- .../slow/verify_spline_free_phase.test.ts | 64 +- packages/extension/test/sparkline.test.ts | 4 +- .../test/substrate/user_splice.test.ts | 10 +- .../test/substrate/vault_store.test.ts | 5 +- packages/schema/.DS_Store | Bin 8196 -> 8196 bytes packages/schema/esbuild.config.mjs | 18 +- packages/schema/package.json | 8 +- .../schema/schemas/catalog-entry.schema.json | 12 +- packages/schema/schemas/lab.schema.json | 28 +- packages/schema/schemas/result.schema.json | 13 +- packages/schema/schemas/run.schema.json | 21 +- packages/schema/schemas/solvespec.schema.json | 32 +- packages/schema/src/cli.ts | 26 +- packages/schema/src/index.ts | 19 +- packages/schema/test/cli.test.ts | 14 +- packages/schema/test/validate.test.ts | 146 ++- 123 files changed, 5676 insertions(+), 3425 deletions(-) create mode 100644 .prettierignore 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/packages/.DS_Store b/packages/.DS_Store index 015dc36fbe238f788cbdde2bead17e26a3037b47..46610f70a413a5f7b0e6ae8a0a6ff16e404b0539 100644 GIT binary patch delta 51 zcmZoMXffDez{HfxI5~z%VzL)g0%OAD)l4ePSN`pu{0AuViYcDy?t;nQ%<@b=jhl0r HXNUj*z_t4S^?m(VN3zD2MGNSJ5v*E 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/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/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/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 index 77a0145d3a4010916414f37f4bf7593d505e8905..b1828c242a460f3c314e9c06329c0bb46e65d60f 100644 GIT binary patch delta 183 zcmZn(XbIS$BES^nJy}iQ8IwfoWIn+VIRgeDU|~pM$YdyHD9K4T3{K9^EdU8JSUT8E zZV-%OY?ypoa37<^ObS7hrGyh0H%zV;)?u`tTq7(q`H1iWrf%lRb3{}ocZtko zJU3ZIG>!4W7Dn~4c9GPzyb>>+-eml%bUJ;e6_0Bol@ A>Hq)$ delta 183 zcmZn(XbIS$BES^TJXuZP8I!o!WIn+VIRyqFU|~pM$YdyHD9K4T3{K9^EdU8JSjyQ< zZV-%OOqhIIa37=DOhQ_drGyh07fh}e)?o~sTq7(q`H1iWrY^b3b3{}ocZtko z)SRp$n#TBHa<`}=Q-|5)b)q?p7bZ)KNivx|-E1Z%z{un/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/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/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/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/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/vendor/katex/katex.min.css b/packages/extension/media/vendor/katex/katex.min.css index 71298b5b..317de128 100644 --- a/packages/extension/media/vendor/katex/katex.min.css +++ b/packages/extension/media/vendor/katex/katex.min.css @@ -1 +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:.2777777778em;margin-right:-.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:.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:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.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:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.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:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.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:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.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:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.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:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.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:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.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:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.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:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.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:.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 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding: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% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .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} +@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..8e581aad 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -3,7 +3,13 @@ "repo": "harmoniqs/opencode", "tag": "v1.17.3-amicode.1", "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": "f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455" + }, + "linux-x64": { + "asset": "opencode-linux-x64.tar.gz", + "sha256": "268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10" + } } } 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/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/chat_panel.ts b/packages/extension/src/chat_panel.ts index 94cf6ee7..1a3f72c3 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -84,16 +84,14 @@ export class ChatPanel { // 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, - }), - ); + void vscode.env.clipboard.readText().then((text) => + this.panel.webview.postMessage({ + source: "amicode", + kind: "clipboard", + nonce: (msg as { nonce?: string }).nonce, + text, + }), + ); return; } if ( diff --git a/packages/extension/src/executor_check.ts b/packages/extension/src/executor_check.ts index ebf5abea..57a0e2ce 100644 --- a/packages/extension/src/executor_check.ts +++ b/packages/extension/src/executor_check.ts @@ -1,9 +1,9 @@ // Type-level contract check (spec §9 AC): the extension consumes the β.1 library API. // β.5 replaces this with the real RunsManager integration. -import type { Executor, RunHandle, RunEvent } from '@amicode/amico-run' +import type { Executor, RunHandle, RunEvent } from "@amicode/amico-run"; export type _ExecutorContract = { - submit: Executor['submit'] - handle: Pick - event: RunEvent['kind'] -} + submit: Executor["submit"]; + handle: Pick; + event: RunEvent["kind"]; +}; diff --git a/packages/extension/src/llm_creds.d.mts b/packages/extension/src/llm_creds.d.mts index 503f667e..14f2978f 100644 --- a/packages/extension/src/llm_creds.d.mts +++ b/packages/extension/src/llm_creds.d.mts @@ -4,23 +4,23 @@ /** A key-free provider entry from opencode's /config/providers (post-strip). */ export interface ProviderEntry { - id: string - source?: string + id: string; + source?: string; } /** ok | not-ok signal shared by the healthcheck and the chat-not-ready gate. */ export type LlmCredsSignal = | { ok: true; provider: string; source?: string } - | { ok: false; reason: string; fix: string } + | { ok: false; reason: string; fix: string }; /** PURE: compute the signal from opencode's resolved providers + configured model. */ -export function resolveLlmCreds(args: { providers: ProviderEntry[]; model?: string }): LlmCredsSignal +export function resolveLlmCreds(args: { providers: ProviderEntry[]; model?: string }): LlmCredsSignal; /** No-leak boundary: strip the raw /config/providers JSON to key-free {id, source}. */ -export function stripProviders(providersJson: unknown): ProviderEntry[] +export function stripProviders(providersJson: unknown): ProviderEntry[]; /** Async: query a running opencode server for the provider signal (no key ever returned). */ export function fetchProviderSignal( baseUrl: string, opts?: { fetchImpl?: typeof fetch; timeoutMs?: number }, -): Promise +): Promise; diff --git a/packages/extension/src/llm_creds.mjs b/packages/extension/src/llm_creds.mjs index 40a5398f..abafceba 100644 --- a/packages/extension/src/llm_creds.mjs +++ b/packages/extension/src/llm_creds.mjs @@ -60,8 +60,7 @@ export function resolveLlmCreds({ providers, model }) { * carries provider keys) is touched; nothing but {id, source} escapes. */ export function stripProviders(providersJson) { - const arr = - providersJson && Array.isArray(providersJson.providers) ? providersJson.providers : []; + const arr = providersJson && Array.isArray(providersJson.providers) ? providersJson.providers : []; return arr .map((p) => ({ id: p && p.id, source: p && p.source })) .filter((p) => typeof p.id === "string" && p.id.length > 0); diff --git a/packages/extension/src/opencode_binary.ts b/packages/extension/src/opencode_binary.ts index 126949a0..b6f96c82 100644 --- a/packages/extension/src/opencode_binary.ts +++ b/packages/extension/src/opencode_binary.ts @@ -3,7 +3,10 @@ import { join } from "node:path"; export class OpencodeMissingError extends Error {} -export interface ResolvedBinary { path: string; source: "config-override" | "vendored" } +export interface ResolvedBinary { + path: string; + source: "config-override" | "vendored"; +} const SUPPORTED = ["darwin-arm64", "linux-x64"] as const; diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index bb1c6a5b..9d329091 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -7,9 +7,21 @@ import { readLocalEntitlements, filterRepertoire, packageAllowlist } from "./sco import { buildRouterSection } from "./scores/router"; import { compileScore, spliceIntoAgentsMd, compileChainedScore, chainManifest } from "./scores/compiler"; import { - resolveLibrarySkills, resolvePackageSkills, buildSkillIndexSection, stageOpencodeSkills, type SkillIndexEntry, + resolveLibrarySkills, + resolvePackageSkills, + buildSkillIndexSection, + stageOpencodeSkills, + type SkillIndexEntry, } from "./scores/package_skills"; -import { resolvePersonalVault, defaultVaultsRoot, readProfileMd, readKnowledgeLines, readDemoLines, hasOnboardingCompleted, onboardingDir } from "./substrate/vault_store"; +import { + resolvePersonalVault, + defaultVaultsRoot, + readProfileMd, + readKnowledgeLines, + readDemoLines, + hasOnboardingCompleted, + onboardingDir, +} from "./substrate/vault_store"; import { buildAboutUserSection, buildRecentProblemsSection, buildReferenceDemosSection } from "./substrate/user_splice"; // ============================================================================ @@ -98,7 +110,7 @@ export function resolveJuliaProject(configValue: string): string { * plugin wrote (the plugin's own fs writes are host-process calls and need * no grant). Must stay derivation-identical to problemsDir() in * opencode-plugin/problems.ts. */ -const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 +const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 /** Root of the amicode_* Problem workspaces — MUST match problemsDir() in * opencode-plugin/problems.ts ($AMICODE_PROBLEMS_DIR override included, so the @@ -164,12 +176,27 @@ export function writeAuthoringConfig( const registry = AUTHORING_ASSETS.registry; const allowlist = packageAllowlist(entitlementsTablePath(scoresRoot), ents.entitlements); let tolerance = 0.01; - let support: string[] = ["JLD2", "CairoMakie", "Makie", "TOML", "Printf", "LinearAlgebra", "Random", "Statistics", "SparseArrays"]; + let support: string[] = [ + "JLD2", + "CairoMakie", + "Makie", + "TOML", + "Printf", + "LinearAlgebra", + "Random", + "Statistics", + "SparseArrays", + ]; try { - const reg = parseToml(fs.readFileSync(registry, "utf8")) as { verify_tolerance?: number; support?: { packages?: string[] } }; + const reg = parseToml(fs.readFileSync(registry, "utf8")) as { + verify_tolerance?: number; + support?: { packages?: string[] }; + }; if (typeof reg.verify_tolerance === "number") tolerance = reg.verify_tolerance; if (Array.isArray(reg.support?.packages)) support = reg.support!.packages!; - } catch { /* keep defaults */ } + } catch { + /* keep defaults */ + } const file = authoringFilePath(); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( @@ -236,14 +263,14 @@ export function buildOpencodeConfigContent( bash: "allow", edit: "allow", external_directory: { - [templatePath]: "allow", // exact template file the agent reads - [`${templatesDir}/**`]: "allow", // (belt-and-suspenders for the dir) - [`${SCRATCH_DIR}/**`]: "allow", // solve.jl + solve.log it writes - [`/private${SCRATCH_DIR}/**`]: "allow", // macOS: /tmp → /private/tmp - [`${runsRoot}/**`]: "allow", // run read-backs: FINISHED/result.toml/run.log - [`${problemsRoot()}/**`]: "allow", // amicode_* problem workspaces the agent reads back - [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads - ...skillGrants, // per-indexed-skill dirs (spec §3, least-privilege) + [templatePath]: "allow", // exact template file the agent reads + [`${templatesDir}/**`]: "allow", // (belt-and-suspenders for the dir) + [`${SCRATCH_DIR}/**`]: "allow", // solve.jl + solve.log it writes + [`/private${SCRATCH_DIR}/**`]: "allow", // macOS: /tmp → /private/tmp + [`${runsRoot}/**`]: "allow", // run read-backs: FINISHED/result.toml/run.log + [`${problemsRoot()}/**`]: "allow", // amicode_* problem workspaces the agent reads back + [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads + ...skillGrants, // per-indexed-skill dirs (spec §3, least-privilege) // User-memory substrate (spec-20260705-002847 §6): the interview reads // problem/environment cards on demand. Read-only BY CONTRACT — vault // writes are distiller-only (its own config); the permission surface @@ -254,7 +281,6 @@ export function buildOpencodeConfigContent( }); } - export interface OpencodeConfigOptions { /** Absolute path to packages/extension/AGENTS.md to substitute + write into the project dir. */ agentsSrc: string; @@ -380,7 +406,10 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro const scoresRoot = opts.scoresRoot ?? DEFAULT_SCORES_ROOT; const allow = packageAllowlist(entitlementsTablePath(scoresRoot), readLocalEntitlements(entsDir).entitlements); skillEntries = [ - ...resolveLibrarySkills(opts.platformSkills ?? DEFAULT_PLATFORM_SKILLS, opts.skillLibraryRoots ?? DEFAULT_LIBRARY_ROOTS), + ...resolveLibrarySkills( + opts.platformSkills ?? DEFAULT_PLATFORM_SKILLS, + opts.skillLibraryRoots ?? DEFAULT_LIBRARY_ROOTS, + ), ...resolvePackageSkills(allow, opts.skillRoots ?? DEFAULT_SKILL_ROOTS), ]; const section = buildSkillIndexSection(skillEntries); diff --git a/packages/extension/src/scores/compiler.ts b/packages/extension/src/scores/compiler.ts index 29837a1d..b7a2d513 100644 --- a/packages/extension/src/scores/compiler.ts +++ b/packages/extension/src/scores/compiler.ts @@ -10,7 +10,7 @@ import { ScoreManifest, Stage } from "./schema"; const INTERVIEW_CONTRACT = [ "**Interview contract:** ONE question at a time — never batch. Ask, wait, record,", "advance. Questions with an options list go through `amicode_ask` (options in the", - "given order, default first and marked \"(recommended)\"); free-form questions stay", + 'given order, default first and marked "(recommended)"); free-form questions stay', "plain text. A stage marked *(optional)* may be skipped. A stage with a gate must", "not be entered until the gate's checks pass.", ]; @@ -20,7 +20,10 @@ const INTERVIEW_CONTRACT = [ function renderStages(stages: Stage[], dir: string, start: number): string[] { const lines: string[] = []; stages.forEach((s, i) => { - const flags = [s.optional ? "(optional)" : "", s.gate ? `🔒 gate: ${s.gate} — checks must pass before entering` : ""] + const flags = [ + s.optional ? "(optional)" : "", + s.gate ? `🔒 gate: ${s.gate} — checks must pass before entering` : "", + ] .filter(Boolean) .join(" "); lines.push(`${start + i + 1}. **${s.id}**${flags ? " " + flags : ""}`); @@ -35,7 +38,8 @@ function renderStages(stages: Stage[], dir: string, start: number): string[] { : ""; lines.push(` - Q \`${q.id}\`: "${q.prompt}"${choices}`); if (q.skip_if) lines.push(` - skip if: ${q.skip_if}`); - if (q.memory_hooks?.length) lines.push(` - [Why?] hooks: ${q.memory_hooks.join(", ")} (read \`scores/memory/.md\` on request)`); + if (q.memory_hooks?.length) + lines.push(` - [Why?] hooks: ${q.memory_hooks.join(", ")} (read \`scores/memory/.md\` on request)`); } }); return lines; diff --git a/packages/extension/src/scores/package_skills.ts b/packages/extension/src/scores/package_skills.ts index b7c94198..db5c88d6 100644 --- a/packages/extension/src/scores/package_skills.ts +++ b/packages/extension/src/scores/package_skills.ts @@ -12,7 +12,7 @@ import { parse as parseYaml } from "yaml"; // same parser as scores/loader.ts // .vsix. Errors mirror the entitlements philosophy: skip + warn, never throw. export interface SkillIndexEntry { source: "library" | "package"; // platform library (public) vs co-located package skill (gated) - package?: string; // absent for library entries (spec §3) + package?: string; // absent for library entries (spec §3) name: string; description: string; path: string; // absolute SKILL.md path @@ -42,10 +42,20 @@ export function resolvePackageSkills(allowlist: string[], roots: string[]): Skil for (const pkg of allowlist) { const skillsDir = roots .map((r) => path.join(expandHome(r), `${pkg}.jl`, "skills")) - .find((d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } }); + .find((d) => { + try { + return fs.statSync(d).isDirectory(); + } catch { + return false; + } + }); if (!skillsDir) continue; // no repo / no skills — silently skipped (spec §9) let names: string[] = []; - try { names = fs.readdirSync(skillsDir); } catch { continue; } + try { + names = fs.readdirSync(skillsDir); + } catch { + continue; + } for (const name of names.sort()) { const skillPath = path.join(skillsDir, name, "SKILL.md"); if (!fs.existsSync(skillPath)) continue; @@ -68,9 +78,7 @@ export function resolvePackageSkills(allowlist: string[], roots: string[]): Skil export function resolveLibrarySkills(names: string[], roots: string[]): SkillIndexEntry[] { const out: SkillIndexEntry[] = []; for (const name of names) { - const skillPath = roots - .map((r) => path.join(expandHome(r), name, "SKILL.md")) - .find((p) => fs.existsSync(p)); + const skillPath = roots.map((r) => path.join(expandHome(r), name, "SKILL.md")).find((p) => fs.existsSync(p)); if (!skillPath) continue; // configured-but-absent — silently skipped try { const fm = readFrontmatter(skillPath); diff --git a/packages/extension/src/scores/schema.ts b/packages/extension/src/scores/schema.ts index 419921a0..94130ff9 100644 --- a/packages/extension/src/scores/schema.ts +++ b/packages/extension/src/scores/schema.ts @@ -1,7 +1,15 @@ // Score manifest schema — spec §3 (spec-20260703-025314-amicode-scores-front-of-chain). // Additive policy (spec §8): unknown fields are ignored; validation only rejects what is // present-and-wrong or required-and-missing, so older runtimes tolerate newer scores. -export const KNOWN_ENTITIES = ["circuit", "system", "formulation", "pulse", "run", "device_session", "knowledge"] as const; +export const KNOWN_ENTITIES = [ + "circuit", + "system", + "formulation", + "pulse", + "run", + "device_session", + "knowledge", +] as const; export const GATE_CLASSES = ["light", "heavy"] as const; export const SUPPORTED_SCHEMA_VERSIONS = [1] as const; @@ -67,7 +75,8 @@ export function validateScoreManifest(raw: unknown): string[] { seen.add(s.id); for (const e of s.emits ?? []) if (!(KNOWN_ENTITIES as readonly string[]).includes(e)) errs.push(`stage ${s.id}: unknown entity in emits: ${e}`); - if (s.gate && !(GATE_CLASSES as readonly string[]).includes(s.gate)) errs.push(`stage ${s.id}: unknown gate class: ${s.gate}`); + if (s.gate && !(GATE_CLASSES as readonly string[]).includes(s.gate)) + errs.push(`stage ${s.id}: unknown gate class: ${s.gate}`); for (const q of s.questions ?? []) { if (!q.id) errs.push(`stage ${s.id}: question missing id`); if (!q.prompt) errs.push(`stage ${s.id}: question ${q.id ?? "?"} missing prompt`); diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 0f37304a..2b805251 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -40,9 +40,15 @@ export class ServerManager { constructor(private readonly opts: ServerOptions) {} - get port(): number | undefined { return this._port; } - get url(): URL | undefined { return this._port ? new URL(`http://127.0.0.1:${this._port}`) : undefined; } - get ready(): boolean { return this._ready; } + get port(): number | undefined { + return this._port; + } + get url(): URL | undefined { + return this._port ? new URL(`http://127.0.0.1:${this._port}`) : undefined; + } + get ready(): boolean { + return this._ready; + } async start(): Promise { if (this.child) { @@ -91,13 +97,20 @@ export class ServerManager { this._ready = false; return new Promise((resolve) => { const killTimer = setTimeout(() => { - try { c.kill("SIGKILL"); } catch {} + try { + c.kill("SIGKILL"); + } catch {} }, 3_000); c.once("exit", () => { clearTimeout(killTimer); resolve(); }); - try { c.kill("SIGTERM"); } catch { clearTimeout(killTimer); resolve(); } + try { + c.kill("SIGTERM"); + } catch { + clearTimeout(killTimer); + resolve(); + } }); } } @@ -145,4 +158,6 @@ async function fetchWithTimeout(url: string, ms: number): Promise { } } -function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/packages/extension/src/sse_client.ts b/packages/extension/src/sse_client.ts index 9c21cb81..30673edf 100644 --- a/packages/extension/src/sse_client.ts +++ b/packages/extension/src/sse_client.ts @@ -43,8 +43,16 @@ export class OpencodeEventClient implements vscode.Disposable { dispose(): void { this.disposed = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - try { this.req?.destroy(); } catch { /* noop */ } - try { this.res?.destroy(); } catch { /* noop */ } + try { + this.req?.destroy(); + } catch { + /* noop */ + } + try { + this.res?.destroy(); + } catch { + /* noop */ + } } private openOnce(): void { @@ -113,8 +121,11 @@ export class OpencodeEventClient implements vscode.Disposable { if (dataLines.length === 0) return; const payload = dataLines.join("\n"); let event: { type?: string; properties?: Record }; - try { event = JSON.parse(payload); } - catch { return; /* opencode sometimes sends ping/comment-only blocks */ } + try { + event = JSON.parse(payload); + } catch { + return; /* opencode sometimes sends ping/comment-only blocks */ + } this.dispatch(event); } diff --git a/packages/extension/src/substrate/distiller.ts b/packages/extension/src/substrate/distiller.ts index 2fe7b228..9ec604fa 100644 --- a/packages/extension/src/substrate/distiller.ts +++ b/packages/extension/src/substrate/distiller.ts @@ -47,12 +47,12 @@ export function buildDistillerConfigContent(s: DistillerSetup): Record { - it('teaches the tiered resolve → author → --spec launch (spec C), not a single bundled template', () => { - expect(AGENTS).toMatch(/amico-run resolve/) // tier resolution step - expect(AGENTS).toMatch(/amico-run --spec/) // the gated invocation it teaches - expect(AGENTS).toMatch(/solve\.jl/) - expect(AGENTS).toMatch(/vetted/) // the three tiers named - expect(AGENTS).toMatch(/composed/) - expect(AGENTS).toMatch(/free/) - }) - it('authors into the workspace-owned solve.jl (spec A), never /tmp', () => { - expect(AGENTS).toMatch(/~\/\.amico\/problems\/\/solve\.jl/) // workspace-owned - expect(AGENTS).not.toMatch(/\/tmp\/amicode-work/) // the old scratch path is gone - expect(AGENTS).not.toMatch(/in this project dir/) - }) - it('teaches the portable detached launch (nohup + & in a subshell + watch inspector), not setsid', () => { - expect(AGENTS).toMatch(/nohup/) - expect(AGENTS).toMatch(/&\s*\)/) // backgrounded inside a subshell - expect(AGENTS).toMatch(/Run Inspector/) - expect(AGENTS).not.toMatch(/setsid/) // Linux-only; would silently break the macOS demo - }) - it('does not tell the agent to block on the solve', () => { - expect(AGENTS).not.toMatch(/wait for (the )?solve to finish/i) - }) - it('author-first: multi-qubit transmon ROUTES to the free-tier offer (unvetted, verified), never a flat decline', () => { - expect(AGENTS).toMatch(/single[- ]qubit/i) - expect(AGENTS).toMatch(/multi-qubit|two-qubit|2-qubit|CNOT/i) +describe("AGENTS.md teaches the D9/D10 script-authoring workflow", () => { + it("teaches the tiered resolve → author → --spec launch (spec C), not a single bundled template", () => { + expect(AGENTS).toMatch(/amico-run resolve/); // tier resolution step + expect(AGENTS).toMatch(/amico-run --spec/); // the gated invocation it teaches + expect(AGENTS).toMatch(/solve\.jl/); + expect(AGENTS).toMatch(/vetted/); // the three tiers named + expect(AGENTS).toMatch(/composed/); + expect(AGENTS).toMatch(/free/); + }); + it("authors into the workspace-owned solve.jl (spec A), never /tmp", () => { + expect(AGENTS).toMatch(/~\/\.amico\/problems\/\/solve\.jl/); // workspace-owned + expect(AGENTS).not.toMatch(/\/tmp\/amicode-work/); // the old scratch path is gone + expect(AGENTS).not.toMatch(/in this project dir/); + }); + it("teaches the portable detached launch (nohup + & in a subshell + watch inspector), not setsid", () => { + expect(AGENTS).toMatch(/nohup/); + expect(AGENTS).toMatch(/&\s*\)/); // backgrounded inside a subshell + expect(AGENTS).toMatch(/Run Inspector/); + expect(AGENTS).not.toMatch(/setsid/); // Linux-only; would silently break the macOS demo + }); + it("does not tell the agent to block on the solve", () => { + expect(AGENTS).not.toMatch(/wait for (the )?solve to finish/i); + }); + it("author-first: multi-qubit transmon ROUTES to the free-tier offer (unvetted, verified), never a flat decline", () => { + expect(AGENTS).toMatch(/single[- ]qubit/i); + expect(AGENTS).toMatch(/multi-qubit|two-qubit|2-qubit|CNOT/i); // spec-20260704-113005 §5: "no template → decline" is retired — it routes to // the free-tier offer with an honest unvetted caveat, not a stop. - expect(AGENTS).toMatch(/free[- ]tier/i) - expect(AGENTS).toMatch(/unvetted/i) - expect(AGENTS).not.toMatch(/say so plainly and stop/i) + expect(AGENTS).toMatch(/free[- ]tier/i); + expect(AGENTS).toMatch(/unvetted/i); + expect(AGENTS).not.toMatch(/say so plainly and stop/i); // the reconciliation: 2-qubit Rydberg CZ IS supported (composed exemplar / Piccolissimo path). // whitespace-tolerant: markdown reflow may wrap any gap in the phrase. - expect(AGENTS).toMatch(/Rydberg\s+CZ\s+is\s+the\s+exception/i) - }) - it('author-first PLATFORM intake: no coercion, records the actual platform, offers free-tier (spec §5)', () => { - expect(AGENTS).toMatch(/as stated/i) // acknowledge the platform as itself - expect(AGENTS).toMatch(/actual platform string/i) // record the real string, not "other" - expect(AGENTS).toMatch(/never coerce/i) - expect(AGENTS).toMatch(/## Skill index/) // routing keys off the dual-source index - expect(AGENTS).toMatch(/free-phase CZ path/i) // the issimo Piccolissimo recommendation - }) - it('gives regime guidance (level cap + scale N with gate time)', () => { - expect(AGENTS).toMatch(/levels/i) - expect(AGENTS).toMatch(/steps\/ns|timesteps/i) - }) - it('documents the run-dir contract the script must emit', () => { - expect(AGENTS).toMatch(/AMICODE_ITER/) - expect(AGENTS).toMatch(/iter_.*\.png/) - expect(AGENTS).toMatch(/result\.toml/) - expect(AGENTS).toMatch(/load_traj/) // corrected warm-start idiom (not load_pulse) - }) - it('does NOT teach the deleted pre-D9 flag CLI', () => { - expect(AGENTS).not.toMatch(/--gate\b/) - expect(AGENTS).not.toMatch(/--system\b/) - expect(AGENTS).not.toMatch(/load_pulse/) - }) -}) + expect(AGENTS).toMatch(/Rydberg\s+CZ\s+is\s+the\s+exception/i); + }); + it("author-first PLATFORM intake: no coercion, records the actual platform, offers free-tier (spec §5)", () => { + expect(AGENTS).toMatch(/as stated/i); // acknowledge the platform as itself + expect(AGENTS).toMatch(/actual platform string/i); // record the real string, not "other" + expect(AGENTS).toMatch(/never coerce/i); + expect(AGENTS).toMatch(/## Skill index/); // routing keys off the dual-source index + expect(AGENTS).toMatch(/free-phase CZ path/i); // the issimo Piccolissimo recommendation + }); + it("gives regime guidance (level cap + scale N with gate time)", () => { + expect(AGENTS).toMatch(/levels/i); + expect(AGENTS).toMatch(/steps\/ns|timesteps/i); + }); + it("documents the run-dir contract the script must emit", () => { + expect(AGENTS).toMatch(/AMICODE_ITER/); + expect(AGENTS).toMatch(/iter_.*\.png/); + expect(AGENTS).toMatch(/result\.toml/); + expect(AGENTS).toMatch(/load_traj/); // corrected warm-start idiom (not load_pulse) + }); + it("does NOT teach the deleted pre-D9 flag CLI", () => { + expect(AGENTS).not.toMatch(/--gate\b/); + expect(AGENTS).not.toMatch(/--system\b/); + expect(AGENTS).not.toMatch(/load_pulse/); + }); +}); -describe('AGENTS.md pulse-designer interview (Layer 0)', () => { - it('scopes the interview to the pulse-designer persona and never forces it on a specific ask', () => { - expect(AGENTS).toMatch(/pulse-designer/) - expect(AGENTS).toMatch(/skip straight to\s+the\s+workflow/i) - expect(AGENTS).toMatch(/fast-forward/i) - }) - it('capabilities question has a curated answer: no webfetch, no engine talk', () => { - expect(AGENTS).toMatch(/## Answering "What can Amicode do\?"/) - expect(AGENTS).toMatch(/never webfetch/i) - expect(AGENTS).toMatch(/never describe the underlying engine/i) - expect(AGENTS).toMatch(/How I work \(author-first\)/) // the curated scope statement (renamed from "Today's scope" in §5) - }) - it('identity: Amico/Amicode, never self-describes as opencode; interview kicks off proactively on greetings', () => { - expect(AGENTS).toMatch(/You are \*\*Amico\*\*/) - expect(AGENTS).toMatch(/NOT "opencode"/) - expect(AGENTS).toMatch(/never describe yourself as an interactive CLI tool/i) - expect(AGENTS).toMatch(/\*\*proactively\*\*/i) - expect(AGENTS).toMatch(/greeting or no specific request/i) - }) - it('enforces one-question-at-a-time cadence', () => { - expect(AGENTS).toMatch(/ONE question at a time/) - expect(AGENTS).toMatch(/Never batch/i) - }) - it('walks the stage chain in order', () => { - const stages = ['PLATFORM', 'MODEL', 'MODE', 'PROBLEM', 'FORMULATION', 'SOLVE PARAMS', 'INSPECT', 'HARDWARE / CALIBRATE'] +describe("AGENTS.md pulse-designer interview (Layer 0)", () => { + it("scopes the interview to the pulse-designer persona and never forces it on a specific ask", () => { + expect(AGENTS).toMatch(/pulse-designer/); + expect(AGENTS).toMatch(/skip straight to\s+the\s+workflow/i); + expect(AGENTS).toMatch(/fast-forward/i); + }); + it("capabilities question has a curated answer: no webfetch, no engine talk", () => { + expect(AGENTS).toMatch(/## Answering "What can Amicode do\?"/); + expect(AGENTS).toMatch(/never webfetch/i); + expect(AGENTS).toMatch(/never describe the underlying engine/i); + expect(AGENTS).toMatch(/How I work \(author-first\)/); // the curated scope statement (renamed from "Today's scope" in §5) + }); + it("identity: Amico/Amicode, never self-describes as opencode; interview kicks off proactively on greetings", () => { + expect(AGENTS).toMatch(/You are \*\*Amico\*\*/); + expect(AGENTS).toMatch(/NOT "opencode"/); + expect(AGENTS).toMatch(/never describe yourself as an interactive CLI tool/i); + expect(AGENTS).toMatch(/\*\*proactively\*\*/i); + expect(AGENTS).toMatch(/greeting or no specific request/i); + }); + it("enforces one-question-at-a-time cadence", () => { + expect(AGENTS).toMatch(/ONE question at a time/); + expect(AGENTS).toMatch(/Never batch/i); + }); + it("walks the stage chain in order", () => { + const stages = [ + "PLATFORM", + "MODEL", + "MODE", + "PROBLEM", + "FORMULATION", + "SOLVE PARAMS", + "INSPECT", + "HARDWARE / CALIBRATE", + ]; // Match the bold stage markers — bare indexOf collides on prefixes (MODE ⊂ MODEL). - const idx = stages.map((s) => AGENTS.indexOf(`**${s}**`)) - idx.forEach((i, k) => expect(i, `stage ${stages[k]} present`).toBeGreaterThan(-1)) - for (let k = 1; k < idx.length; k++) expect(idx[k], `${stages[k]} after ${stages[k - 1]}`).toBeGreaterThan(idx[k - 1]) - }) - it('shows the transmon Hamiltonian in LaTeX and is honest about the Rydberg tier', () => { - expect(AGENTS).toContain('\\hat H/\\hbar') - expect(AGENTS).toMatch(/rydberg/i) + const idx = stages.map((s) => AGENTS.indexOf(`**${s}**`)); + idx.forEach((i, k) => expect(i, `stage ${stages[k]} present`).toBeGreaterThan(-1)); + for (let k = 1; k < idx.length; k++) + expect(idx[k], `${stages[k]} after ${stages[k - 1]}`).toBeGreaterThan(idx[k - 1]); + }); + it("shows the transmon Hamiltonian in LaTeX and is honest about the Rydberg tier", () => { + expect(AGENTS).toContain("\\hat H/\\hbar"); + expect(AGENTS).toMatch(/rydberg/i); // Rydberg authoring IS wired (composed tier, experimental) — the stale // "not wired / transmon-only follow-up" narrative must stay gone. - expect(AGENTS).toMatch(/composed/i) - expect(AGENTS).toMatch(/experimental/i) - expect(AGENTS).not.toMatch(/Rydberg solve authoring is not wired/i) - }) - it('names the amicode_* recording tools as bookkeeping, not gates, with bash still the launch mechanism', () => { + expect(AGENTS).toMatch(/composed/i); + expect(AGENTS).toMatch(/experimental/i); + expect(AGENTS).not.toMatch(/Rydberg solve authoring is not wired/i); + }); + it("names the amicode_* recording tools as bookkeeping, not gates, with bash still the launch mechanism", () => { for (const t of [ - 'amicode_ask', - 'amicode_pick_system', - 'amicode_set_model', - 'amicode_formulate', - 'amicode_solve', - 'amicode_to_hardware', - 'amicode_calibrate', + "amicode_ask", + "amicode_pick_system", + "amicode_set_model", + "amicode_formulate", + "amicode_solve", + "amicode_to_hardware", + "amicode_calibrate", ]) { - expect(AGENTS).toContain(t) + expect(AGENTS).toContain(t); } - expect(AGENTS).toMatch(/bookkeeping, not gates/) - expect(AGENTS).toMatch(/they never replace the bash launch/i) - }) - it('teaches the free-tier verification recording (amicode_verify) and untrusted-until-agree rule', () => { - expect(AGENTS).toContain('amicode_verify') - expect(AGENTS).toMatch(/verification\.toml/) - expect(AGENTS).toMatch(/cannot be promoted[\s\S]*until verification/i) - }) - it('keeps the guardrails: T-vs-N convention and no silent global co-optimization', () => { - expect(AGENTS).toMatch(/`T` = scalar gate time/) - expect(AGENTS).toMatch(/`N` = number of timesteps/) - expect(AGENTS).toMatch(/Never silently\s+co-optimize/i) - }) - it('leaves no unknown {{...}} placeholder after session-prep substitution', () => { - const substituted = AGENTS.replace(/\{\{TEMPLATE_PATH\}\}/g, '/abs/solve_template.jl').replace( + expect(AGENTS).toMatch(/bookkeeping, not gates/); + expect(AGENTS).toMatch(/they never replace the bash launch/i); + }); + it("teaches the free-tier verification recording (amicode_verify) and untrusted-until-agree rule", () => { + expect(AGENTS).toContain("amicode_verify"); + expect(AGENTS).toMatch(/verification\.toml/); + expect(AGENTS).toMatch(/cannot be promoted[\s\S]*until verification/i); + }); + it("keeps the guardrails: T-vs-N convention and no silent global co-optimization", () => { + expect(AGENTS).toMatch(/`T` = scalar gate time/); + expect(AGENTS).toMatch(/`N` = number of timesteps/); + expect(AGENTS).toMatch(/Never silently\s+co-optimize/i); + }); + it("leaves no unknown {{...}} placeholder after session-prep substitution", () => { + const substituted = AGENTS.replace(/\{\{TEMPLATE_PATH\}\}/g, "/abs/solve_template.jl").replace( /\{\{JULIA_PROJECT\}\}/g, - '/abs/julia', - ) - expect(substituted).not.toMatch(/\{\{[A-Z_]+\}\}/) - }) -}) + "/abs/julia", + ); + expect(substituted).not.toMatch(/\{\{[A-Z_]+\}\}/); + }); +}); diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts index a36441ae..c0c16f50 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -12,8 +12,8 @@ // export (opencode's getLegacyPlugins throws on any extra export). Its runtime // loading is verified against the real binary (see the night-build handoff), not // in vitest. -import { describe, it, expect } from 'vitest' -import { parse } from 'smol-toml' +import { describe, it, expect } from "vitest"; +import { parse } from "smol-toml"; import { systemToml, formulationToml, @@ -32,273 +32,292 @@ import { type SystemEntity, type FormulationEntity, type ProblemMeta, -} from '../opencode-plugin/entities' +} from "../opencode-plugin/entities"; const SYS: SystemEntity = { - platform: 'transmon', + platform: "transmon", levels: 3, params: { omega: 4.8, delta: -0.2 }, -} +}; const FORM: FormulationEntity = { - problem: 'gate_synthesis', - target: 'X', - objective: 'unitary infidelity', - constraints: ['amplitude bound (drive_max)', 'smoothness'], -} + problem: "gate_synthesis", + target: "X", + objective: "unitary infidelity", + constraints: ["amplitude bound (drive_max)", "smoothness"], +}; -describe('systemToml', () => { - it('emits valid TOML that round-trips through smol-toml (the repo parser)', () => { - const doc = parse(systemToml(SYS)) as any - expect(doc.system).toBeDefined() // [system] header - expect(doc.system.platform).toBe('transmon') - expect(doc.system.levels).toBe(3) - expect(doc.system.params.omega).toBeCloseTo(4.8) - expect(doc.system.params.delta).toBeCloseTo(-0.2) - }) - it('stamps an ISO-8601 `recorded` field (quoted string — parseable, no TomlDate surprises)', () => { - const doc = parse(systemToml(SYS)) as any - expect(typeof doc.system.recorded).toBe('string') - expect(Number.isNaN(Date.parse(doc.system.recorded))).toBe(false) - }) - it('accepts the levels boundary values 2 and 6', () => { - expect(() => systemToml({ ...SYS, levels: 2 })).not.toThrow() - expect(() => systemToml({ ...SYS, levels: 6 })).not.toThrow() - }) - it('accepts an arbitrary platform, rejects an empty one (opened model, spec A)', () => { - expect(() => systemToml({ ...SYS, platform: 'gkp-cavity' })).not.toThrow() - expect(() => systemToml({ ...SYS, platform: '' })).toThrow(/platform/) - }) - it('rejects levels < 2 and non-integers, but allows levels > 6 (warning, not error)', () => { - expect(() => systemToml({ ...SYS, levels: 1 })).toThrow(/levels/) - expect(() => systemToml({ ...SYS, levels: 3.5 })).toThrow(/levels/) - expect(() => systemToml({ ...SYS, levels: 7 })).not.toThrow() - }) - it('rejects non-finite param values (NaN/Infinity have no TOML representation)', () => { - expect(() => systemToml({ ...SYS, params: { omega: NaN } })).toThrow(/param/) - expect(() => systemToml({ ...SYS, params: { omega: Infinity } })).toThrow(/param/) - }) - it('quotes param keys that are not TOML bare keys', () => { - const doc = parse(systemToml({ ...SYS, params: { 'drive max': 0.2 } })) as any - expect(doc.system.params['drive max']).toBeCloseTo(0.2) - }) -}) +describe("systemToml", () => { + it("emits valid TOML that round-trips through smol-toml (the repo parser)", () => { + const doc = parse(systemToml(SYS)) as any; + expect(doc.system).toBeDefined(); // [system] header + expect(doc.system.platform).toBe("transmon"); + expect(doc.system.levels).toBe(3); + expect(doc.system.params.omega).toBeCloseTo(4.8); + expect(doc.system.params.delta).toBeCloseTo(-0.2); + }); + it("stamps an ISO-8601 `recorded` field (quoted string — parseable, no TomlDate surprises)", () => { + const doc = parse(systemToml(SYS)) as any; + expect(typeof doc.system.recorded).toBe("string"); + expect(Number.isNaN(Date.parse(doc.system.recorded))).toBe(false); + }); + it("accepts the levels boundary values 2 and 6", () => { + expect(() => systemToml({ ...SYS, levels: 2 })).not.toThrow(); + expect(() => systemToml({ ...SYS, levels: 6 })).not.toThrow(); + }); + it("accepts an arbitrary platform, rejects an empty one (opened model, spec A)", () => { + expect(() => systemToml({ ...SYS, platform: "gkp-cavity" })).not.toThrow(); + expect(() => systemToml({ ...SYS, platform: "" })).toThrow(/platform/); + }); + it("rejects levels < 2 and non-integers, but allows levels > 6 (warning, not error)", () => { + expect(() => systemToml({ ...SYS, levels: 1 })).toThrow(/levels/); + expect(() => systemToml({ ...SYS, levels: 3.5 })).toThrow(/levels/); + expect(() => systemToml({ ...SYS, levels: 7 })).not.toThrow(); + }); + it("rejects non-finite param values (NaN/Infinity have no TOML representation)", () => { + expect(() => systemToml({ ...SYS, params: { omega: NaN } })).toThrow(/param/); + expect(() => systemToml({ ...SYS, params: { omega: Infinity } })).toThrow(/param/); + }); + it("quotes param keys that are not TOML bare keys", () => { + const doc = parse(systemToml({ ...SYS, params: { "drive max": 0.2 } })) as any; + expect(doc.system.params["drive max"]).toBeCloseTo(0.2); + }); +}); -describe('formulationToml', () => { - it('round-trips problem/target/objective/constraints under [formulation]', () => { - const doc = parse(formulationToml(FORM)) as any - expect(doc.formulation.problem).toBe('gate_synthesis') - expect(doc.formulation.target).toBe('X') - expect(doc.formulation.objective).toBe('unitary infidelity') - expect(doc.formulation.constraints).toEqual(FORM.constraints) - expect(Number.isNaN(Date.parse(doc.formulation.recorded))).toBe(false) - }) - it('escapes quotes, backslashes, and newlines in string values (round-trip exact)', () => { - const nasty = 'say "hi" \\ then\nnewline\ttab' - const doc = parse(formulationToml({ ...FORM, target: nasty, constraints: [nasty] })) as any - expect(doc.formulation.target).toBe(nasty) - expect(doc.formulation.constraints).toEqual([nasty]) - }) - it('rejects an empty or whitespace-only target', () => { - expect(() => formulationToml({ ...FORM, target: '' })).toThrow(/target/) - expect(() => formulationToml({ ...FORM, target: ' ' })).toThrow(/target/) - }) - it('rejects an empty problem', () => { - expect(() => formulationToml({ ...FORM, problem: '' })).toThrow(/problem/) - }) -}) +describe("formulationToml", () => { + it("round-trips problem/target/objective/constraints under [formulation]", () => { + const doc = parse(formulationToml(FORM)) as any; + expect(doc.formulation.problem).toBe("gate_synthesis"); + expect(doc.formulation.target).toBe("X"); + expect(doc.formulation.objective).toBe("unitary infidelity"); + expect(doc.formulation.constraints).toEqual(FORM.constraints); + expect(Number.isNaN(Date.parse(doc.formulation.recorded))).toBe(false); + }); + it("escapes quotes, backslashes, and newlines in string values (round-trip exact)", () => { + const nasty = 'say "hi" \\ then\nnewline\ttab'; + const doc = parse(formulationToml({ ...FORM, target: nasty, constraints: [nasty] })) as any; + expect(doc.formulation.target).toBe(nasty); + expect(doc.formulation.constraints).toEqual([nasty]); + }); + it("rejects an empty or whitespace-only target", () => { + expect(() => formulationToml({ ...FORM, target: "" })).toThrow(/target/); + expect(() => formulationToml({ ...FORM, target: " " })).toThrow(/target/); + }); + it("rejects an empty problem", () => { + expect(() => formulationToml({ ...FORM, problem: "" })).toThrow(/problem/); + }); +}); -describe('validateSystem / validateFormulation', () => { - it('return [] for valid entities', () => { - expect(validateSystem(SYS)).toEqual([]) - expect(validateFormulation(FORM)).toEqual([]) - }) - it('name the offending field in each problem message', () => { - expect(validateSystem({ ...SYS, platform: '' as any }).join(' ')).toMatch(/platform/) - expect(validateSystem({ ...SYS, levels: 1 }).join(' ')).toMatch(/levels/) - expect(validateFormulation({ ...FORM, target: '' }).join(' ')).toMatch(/target/) - }) -}) +describe("validateSystem / validateFormulation", () => { + it("return [] for valid entities", () => { + expect(validateSystem(SYS)).toEqual([]); + expect(validateFormulation(FORM)).toEqual([]); + }); + it("name the offending field in each problem message", () => { + expect(validateSystem({ ...SYS, platform: "" as any }).join(" ")).toMatch(/platform/); + expect(validateSystem({ ...SYS, levels: 1 }).join(" ")).toMatch(/levels/); + expect(validateFormulation({ ...FORM, target: "" }).join(" ")).toMatch(/target/); + }); +}); -describe('updateSystem (the amicode_set_model merge)', () => { - it('merges levels and params, preserving untouched params and the platform', () => { - const merged = updateSystem(SYS, { levels: 4, params: { drive_max: 0.2, delta: -0.25 } }) - expect(merged.platform).toBe('transmon') - expect(merged.levels).toBe(4) - expect(merged.params.omega).toBeCloseTo(4.8) // untouched param preserved - expect(merged.params.delta).toBeCloseTo(-0.25) // overwritten - expect(merged.params.drive_max).toBeCloseTo(0.2) // added - }) - it('does not mutate the input entity', () => { - const before = JSON.parse(JSON.stringify(SYS)) - updateSystem(SYS, { levels: 5, params: { omega: 5.1 } }) - expect(SYS).toEqual(before) - }) - it('leaves levels alone when the patch omits it', () => { - expect(updateSystem(SYS, { params: { drive_max: 0.3 } }).levels).toBe(3) - }) - it('throws when the merge would produce an invalid entity', () => { - expect(() => updateSystem(SYS, { levels: 1 })).toThrow(/levels/) - expect(() => updateSystem(SYS, { params: { omega: NaN } })).toThrow(/param/) - }) -}) +describe("updateSystem (the amicode_set_model merge)", () => { + it("merges levels and params, preserving untouched params and the platform", () => { + const merged = updateSystem(SYS, { levels: 4, params: { drive_max: 0.2, delta: -0.25 } }); + expect(merged.platform).toBe("transmon"); + expect(merged.levels).toBe(4); + expect(merged.params.omega).toBeCloseTo(4.8); // untouched param preserved + expect(merged.params.delta).toBeCloseTo(-0.25); // overwritten + expect(merged.params.drive_max).toBeCloseTo(0.2); // added + }); + it("does not mutate the input entity", () => { + const before = JSON.parse(JSON.stringify(SYS)); + updateSystem(SYS, { levels: 5, params: { omega: 5.1 } }); + expect(SYS).toEqual(before); + }); + it("leaves levels alone when the patch omits it", () => { + expect(updateSystem(SYS, { params: { drive_max: 0.3 } }).levels).toBe(3); + }); + it("throws when the merge would produce an invalid entity", () => { + expect(() => updateSystem(SYS, { levels: 1 })).toThrow(/levels/); + expect(() => updateSystem(SYS, { params: { omega: NaN } })).toThrow(/param/); + }); +}); -describe('runStubToml (bookkeeping stub — NOT amico-run\'s run.toml)', () => { - it('round-trips refs + launched_via under [run]', () => { - const doc = parse(runStubToml({ - formulation_ref: '/home/u/.amico/runs/default/_entities/formulation.toml', - system_ref: '/home/u/.amico/runs/default/_entities/system.toml', - run_dir: '/home/u/.amico/runs/default/20260703-021500-abcd', - note: 'X gate, defaults', - })) as any - expect(doc.run.launched_via).toBe('bash amico-run') // the tool never launches — bash does - expect(doc.run.formulation_ref).toMatch(/formulation\.toml$/) - expect(doc.run.system_ref).toMatch(/system\.toml$/) - expect(doc.run.run_dir).toMatch(/20260703-021500-abcd$/) - expect(doc.run.note).toBe('X gate, defaults') - expect(Number.isNaN(Date.parse(doc.run.recorded))).toBe(false) - }) - it('omits absent optional refs instead of writing empty strings', () => { - const doc = parse(runStubToml({})) as any - expect(doc.run.launched_via).toBe('bash amico-run') - expect('formulation_ref' in doc.run).toBe(false) - expect('system_ref' in doc.run).toBe(false) - expect('note' in doc.run).toBe(false) - expect('verification' in doc.run).toBe(false) // spec C: absent until amicode_verify - }) - it('round-trips the free-tier verification sub-table (spec C)', () => { - const doc = parse(runStubToml({ - tier: 'free', - verification: { agree: false, fidelity_rerolled: 0.0004, fidelity_reported: 0.9999 }, - })) as any - expect(doc.run.tier).toBe('free') - expect(doc.run.verification.agree).toBe(false) - expect(doc.run.verification.fidelity_rerolled).toBeCloseTo(0.0004) - expect(doc.run.verification.fidelity_reported).toBeCloseTo(0.9999) - }) -}) +describe("runStubToml (bookkeeping stub — NOT amico-run's run.toml)", () => { + it("round-trips refs + launched_via under [run]", () => { + const doc = parse( + runStubToml({ + formulation_ref: "/home/u/.amico/runs/default/_entities/formulation.toml", + system_ref: "/home/u/.amico/runs/default/_entities/system.toml", + run_dir: "/home/u/.amico/runs/default/20260703-021500-abcd", + note: "X gate, defaults", + }), + ) as any; + expect(doc.run.launched_via).toBe("bash amico-run"); // the tool never launches — bash does + expect(doc.run.formulation_ref).toMatch(/formulation\.toml$/); + expect(doc.run.system_ref).toMatch(/system\.toml$/); + expect(doc.run.run_dir).toMatch(/20260703-021500-abcd$/); + expect(doc.run.note).toBe("X gate, defaults"); + expect(Number.isNaN(Date.parse(doc.run.recorded))).toBe(false); + }); + it("omits absent optional refs instead of writing empty strings", () => { + const doc = parse(runStubToml({})) as any; + expect(doc.run.launched_via).toBe("bash amico-run"); + expect("formulation_ref" in doc.run).toBe(false); + expect("system_ref" in doc.run).toBe(false); + expect("note" in doc.run).toBe(false); + expect("verification" in doc.run).toBe(false); // spec C: absent until amicode_verify + }); + it("round-trips the free-tier verification sub-table (spec C)", () => { + const doc = parse( + runStubToml({ + tier: "free", + verification: { agree: false, fidelity_rerolled: 0.0004, fidelity_reported: 0.9999 }, + }), + ) as any; + expect(doc.run.tier).toBe("free"); + expect(doc.run.verification.agree).toBe(false); + expect(doc.run.verification.fidelity_rerolled).toBeCloseTo(0.0004); + expect(doc.run.verification.fidelity_reported).toBeCloseTo(0.9999); + }); +}); -describe('deviceSessionStubToml (stage-8 guided stub — NO device I/O in this build)', () => { - it('round-trips refs + the fixed gate/checks under [device_session]', () => { - const doc = parse(deviceSessionStubToml({ - pulse_ref: '/home/u/.amico/runs/default/20260703-021500-abcd/pulse.jld2', - run_dir: '/home/u/.amico/runs/default/20260703-021500-abcd', - note: 'X gate pulse, F=0.9999', - })) as any - expect(doc.device_session.gate).toBe('pending-human-signoff') // never auto-approved - expect(doc.device_session.checks).toEqual([ // the send-to-device gate's auto checks - 'fidelity>=threshold', '|drive|<=cap', 'bandwidth', 'leakage', - ]) - expect(doc.device_session.pulse_ref).toMatch(/pulse\.jld2$/) - expect(doc.device_session.run_dir).toMatch(/20260703-021500-abcd$/) - expect(doc.device_session.note).toBe('X gate pulse, F=0.9999') - expect(Number.isNaN(Date.parse(doc.device_session.recorded))).toBe(false) - }) - it('omits absent optional refs; gate + checks are always present', () => { - const doc = parse(deviceSessionStubToml({})) as any - expect(doc.device_session.gate).toBe('pending-human-signoff') - expect(doc.device_session.checks).toHaveLength(4) - expect('pulse_ref' in doc.device_session).toBe(false) - expect('run_dir' in doc.device_session).toBe(false) - expect('note' in doc.device_session).toBe(false) - }) - it('rejects given-but-empty refs (a caller bug, not an omission)', () => { - expect(() => deviceSessionStubToml({ pulse_ref: '' })).toThrow(/pulse_ref/) - expect(() => deviceSessionStubToml({ run_dir: ' ' })).toThrow(/run_dir/) - }) -}) +describe("deviceSessionStubToml (stage-8 guided stub — NO device I/O in this build)", () => { + it("round-trips refs + the fixed gate/checks under [device_session]", () => { + const doc = parse( + deviceSessionStubToml({ + pulse_ref: "/home/u/.amico/runs/default/20260703-021500-abcd/pulse.jld2", + run_dir: "/home/u/.amico/runs/default/20260703-021500-abcd", + note: "X gate pulse, F=0.9999", + }), + ) as any; + expect(doc.device_session.gate).toBe("pending-human-signoff"); // never auto-approved + expect(doc.device_session.checks).toEqual([ + // the send-to-device gate's auto checks + "fidelity>=threshold", + "|drive|<=cap", + "bandwidth", + "leakage", + ]); + expect(doc.device_session.pulse_ref).toMatch(/pulse\.jld2$/); + expect(doc.device_session.run_dir).toMatch(/20260703-021500-abcd$/); + expect(doc.device_session.note).toBe("X gate pulse, F=0.9999"); + expect(Number.isNaN(Date.parse(doc.device_session.recorded))).toBe(false); + }); + it("omits absent optional refs; gate + checks are always present", () => { + const doc = parse(deviceSessionStubToml({})) as any; + expect(doc.device_session.gate).toBe("pending-human-signoff"); + expect(doc.device_session.checks).toHaveLength(4); + expect("pulse_ref" in doc.device_session).toBe(false); + expect("run_dir" in doc.device_session).toBe(false); + expect("note" in doc.device_session).toBe(false); + }); + it("rejects given-but-empty refs (a caller bug, not an omission)", () => { + expect(() => deviceSessionStubToml({ pulse_ref: "" })).toThrow(/pulse_ref/); + expect(() => deviceSessionStubToml({ run_dir: " " })).toThrow(/run_dir/); + }); +}); -describe('calibrationStubToml (guided follow-up stub — loop not wired in this build)', () => { - it('round-trips the ref + fixed loop/status under [calibration]', () => { - const doc = parse(calibrationStubToml({ - device_session_ref: '/home/u/.amico/runs/default/_entities/device_session.toml', - note: 'after first hardware shots', - })) as any - expect(doc.calibration.loop).toBe('ILC') // the loop that follows hardware runs - expect(doc.calibration.status).toBe('not-wired') // honest: recorded follow-up only tonight - expect(doc.calibration.device_session_ref).toMatch(/device_session\.toml$/) - expect(doc.calibration.note).toBe('after first hardware shots') - expect(Number.isNaN(Date.parse(doc.calibration.recorded))).toBe(false) - }) - it('omits absent optionals; loop + status are always present', () => { - const doc = parse(calibrationStubToml({})) as any - expect(doc.calibration.loop).toBe('ILC') - expect(doc.calibration.status).toBe('not-wired') - expect('device_session_ref' in doc.calibration).toBe(false) - expect('note' in doc.calibration).toBe(false) - }) - it('rejects a given-but-empty device_session_ref', () => { - expect(() => calibrationStubToml({ device_session_ref: '' })).toThrow(/device_session_ref/) - }) -}) +describe("calibrationStubToml (guided follow-up stub — loop not wired in this build)", () => { + it("round-trips the ref + fixed loop/status under [calibration]", () => { + const doc = parse( + calibrationStubToml({ + device_session_ref: "/home/u/.amico/runs/default/_entities/device_session.toml", + note: "after first hardware shots", + }), + ) as any; + expect(doc.calibration.loop).toBe("ILC"); // the loop that follows hardware runs + expect(doc.calibration.status).toBe("not-wired"); // honest: recorded follow-up only tonight + expect(doc.calibration.device_session_ref).toMatch(/device_session\.toml$/); + expect(doc.calibration.note).toBe("after first hardware shots"); + expect(Number.isNaN(Date.parse(doc.calibration.recorded))).toBe(false); + }); + it("omits absent optionals; loop + status are always present", () => { + const doc = parse(calibrationStubToml({})) as any; + expect(doc.calibration.loop).toBe("ILC"); + expect(doc.calibration.status).toBe("not-wired"); + expect("device_session_ref" in doc.calibration).toBe(false); + expect("note" in doc.calibration).toBe(false); + }); + it("rejects a given-but-empty device_session_ref", () => { + expect(() => calibrationStubToml({ device_session_ref: "" })).toThrow(/device_session_ref/); + }); +}); -describe('opened entity model (spec A)', () => { - it('accepts an unknown platform and optional levels', () => { - expect(validateSystem({ platform: 'gkp-cavity', params: { chi: 0.5 } } as SystemEntity)).toEqual([]) - expect(validateSystem({ platform: '', params: {} } as SystemEntity)).not.toEqual([]) - }) - it('warns but does not reject levels > 6', () => { - expect(validateSystem({ platform: 'transmon', levels: 7, params: {} } as SystemEntity)).toEqual([]) - }) - it('round-trips formulation.solve through TOML', () => { +describe("opened entity model (spec A)", () => { + it("accepts an unknown platform and optional levels", () => { + expect(validateSystem({ platform: "gkp-cavity", params: { chi: 0.5 } } as SystemEntity)).toEqual([]); + expect(validateSystem({ platform: "", params: {} } as SystemEntity)).not.toEqual([]); + }); + it("warns but does not reject levels > 6", () => { + expect(validateSystem({ platform: "transmon", levels: 7, params: {} } as SystemEntity)).toEqual([]); + }); + it("round-trips formulation.solve through TOML", () => { const f: FormulationEntity = { - problem: 'min_time', target: 'CZ', objective: 'unitary infidelity', - constraints: ['amplitude bound'], solve: { T: 10, N: 50, max_iter: 60, integrator: 'MagnusGL4' }, - } - const parsed = parse(formulationToml(f)) as any - expect(parsed.formulation.solve.T).toBe(10) - expect(parsed.formulation.solve.integrator).toBe('MagnusGL4') - }) -}) + problem: "min_time", + target: "CZ", + objective: "unitary infidelity", + constraints: ["amplitude bound"], + solve: { T: 10, N: 50, max_iter: 60, integrator: "MagnusGL4" }, + }; + const parsed = parse(formulationToml(f)) as any; + expect(parsed.formulation.solve.T).toBe(10); + expect(parsed.formulation.solve.integrator).toBe("MagnusGL4"); + }); +}); -describe('canonicalJson + hash input rules', () => { - it('sorts keys and excludes recorded/notes', () => { - expect(canonicalJson({ b: 1, a: 2, recorded: 'x', notes: 'y' })).toBe('{"a":2,"b":1}') - }) - it('is stable across key order', () => { - expect(canonicalJson({ x: { b: 1, a: [1, 2] } })).toBe(canonicalJson({ x: { a: [1, 2], b: 1 } })) - }) -}) +describe("canonicalJson + hash input rules", () => { + it("sorts keys and excludes recorded/notes", () => { + expect(canonicalJson({ b: 1, a: 2, recorded: "x", notes: "y" })).toBe('{"a":2,"b":1}'); + }); + it("is stable across key order", () => { + expect(canonicalJson({ x: { b: 1, a: [1, 2] } })).toBe(canonicalJson({ x: { a: [1, 2], b: 1 } })); + }); +}); -describe('deriveSlug', () => { - it('kebab-cases and strips punctuation', () => { - expect(deriveSlug('X gate on Q1!')).toBe('x-gate-on-q1') - expect(deriveSlug('///')).toBe('untitled') - }) -}) +describe("deriveSlug", () => { + it("kebab-cases and strips punctuation", () => { + expect(deriveSlug("X gate on Q1!")).toBe("x-gate-on-q1"); + expect(deriveSlug("///")).toBe("untitled"); + }); +}); -describe('entityDiff + sentinel truncation', () => { - it('produces dotted keys for nested params and skips recorded', () => { +describe("entityDiff + sentinel truncation", () => { + it("produces dotted keys for nested params and skips recorded", () => { const d = entityDiff( { levels: 3, params: { drive_max: 0.2 } }, - { levels: 4, params: { drive_max: 0.2 }, recorded: 'x' }, - ) - expect(d).toEqual({ levels: { from: 3, to: 4 } }) - }) - it('null from on create', () => { - expect(entityDiff(undefined, { platform: 'transmon' })).toEqual({ platform: { from: null, to: 'transmon' } }) - }) - it('keeps the sentinel line under 1 KB', () => { - const big = entityDiff(undefined, { notes2: 'z'.repeat(5000) }) - const line = JSON.stringify(truncateDiffForSentinel(big)) - expect(line.length).toBeLessThanOrEqual(1024) - expect(line).toContain('…') - }) -}) + { levels: 4, params: { drive_max: 0.2 }, recorded: "x" }, + ); + expect(d).toEqual({ levels: { from: 3, to: 4 } }); + }); + it("null from on create", () => { + expect(entityDiff(undefined, { platform: "transmon" })).toEqual({ platform: { from: null, to: "transmon" } }); + }); + it("keeps the sentinel line under 1 KB", () => { + const big = entityDiff(undefined, { notes2: "z".repeat(5000) }); + const line = JSON.stringify(truncateDiffForSentinel(big)); + expect(line.length).toBeLessThanOrEqual(1024); + expect(line).toContain("…"); + }); +}); -describe('problem + run-ref serializers', () => { - it('round-trips problem.toml', () => { +describe("problem + run-ref serializers", () => { + it("round-trips problem.toml", () => { const meta: ProblemMeta = { - name: 'X gate on Q1', slug: 'x-gate-q1', created: '2026-07-03T00:00:00Z', - status: 'designing', score: { id: 'pulse-designer', version: 3 }, env: { kind: 'provisioned' }, - } - const parsed = parse(problemToml(meta)) as any - expect(parsed.problem.slug).toBe('x-gate-q1') - expect(parsed.problem.score.id).toBe('pulse-designer') - expect(parsed.problem.env.kind).toBe('provisioned') - }) - it('round-trips runs.toml appends', () => { - const t = runRefsToml([{ run_id: 'r1', lab: 'default', tier: 'vetted', recorded: 'x' }]) - expect((parse(t) as any).runs[0].tier).toBe('vetted') - }) -}) + name: "X gate on Q1", + slug: "x-gate-q1", + created: "2026-07-03T00:00:00Z", + status: "designing", + score: { id: "pulse-designer", version: 3 }, + env: { kind: "provisioned" }, + }; + const parsed = parse(problemToml(meta)) as any; + expect(parsed.problem.slug).toBe("x-gate-q1"); + expect(parsed.problem.score.id).toBe("pulse-designer"); + expect(parsed.problem.env.kind).toBe("provisioned"); + }); + it("round-trips runs.toml appends", () => { + const t = runRefsToml([{ run_id: "r1", lab: "default", tier: "vetted", recorded: "x" }]); + expect((parse(t) as any).runs[0].tier).toBe("vetted"); + }); +}); diff --git a/packages/extension/test/boot_smoke.mjs b/packages/extension/test/boot_smoke.mjs index d5d461d2..70ee7b54 100644 --- a/packages/extension/test/boot_smoke.mjs +++ b/packages/extension/test/boot_smoke.mjs @@ -14,15 +14,20 @@ // Boot + probe logic lives in scripts/opencode_probe.mjs (shared with the // healthcheck, which derives BOTH the /event gate and the provider signal from a // single boot); this script asserts the /event gate and exits. -import { bootOpencodeAndProbe, vendoredOpencodeBin } from '../scripts/opencode_probe.mjs' +import { bootOpencodeAndProbe, vendoredOpencodeBin } from "../scripts/opencode_probe.mjs"; -const fail = (msg, code = 1) => { console.error(`[smoke] FAIL: ${msg}`); process.exit(code) } +const fail = (msg, code = 1) => { + console.error(`[smoke] FAIL: ${msg}`); + process.exit(code); +}; -const boot = await bootOpencodeAndProbe({ timeoutMs: 30_000 }) -if (boot.binMissing) fail(`vendored binary missing at ${vendoredOpencodeBin()} — run \`pnpm --filter amicode-v2 fetch:opencode\``, 10) -if (!boot.up) fail(`server not up within 30s\n--- server output ---\n${boot.log}`) -console.log(`[smoke] GET /event → ${boot.eventStatus} (${boot.eventCtype})`) -if (boot.eventStatus !== 200) fail(`/event status ${boot.eventStatus}, want 200\n--- server output ---\n${boot.log}`) -if (!(boot.eventCtype ?? '').includes('text/event-stream')) fail(`/event content-type "${boot.eventCtype}", want text/event-stream`) -console.log('[smoke] PASS') -process.exit(0) +const boot = await bootOpencodeAndProbe({ timeoutMs: 30_000 }); +if (boot.binMissing) + fail(`vendored binary missing at ${vendoredOpencodeBin()} — run \`pnpm --filter amicode-v2 fetch:opencode\``, 10); +if (!boot.up) fail(`server not up within 30s\n--- server output ---\n${boot.log}`); +console.log(`[smoke] GET /event → ${boot.eventStatus} (${boot.eventCtype})`); +if (boot.eventStatus !== 200) fail(`/event status ${boot.eventStatus}, want 200\n--- server output ---\n${boot.log}`); +if (!(boot.eventCtype ?? "").includes("text/event-stream")) + fail(`/event content-type "${boot.eventCtype}", want text/event-stream`); +console.log("[smoke] PASS"); +process.exit(0); diff --git a/packages/extension/test/corpus/fake-julia b/packages/extension/test/corpus/fake-julia index 5d40f65d..77ebdfc8 100755 --- a/packages/extension/test/corpus/fake-julia +++ b/packages/extension/test/corpus/fake-julia @@ -18,47 +18,54 @@ // # AMICODE_SMOKE iters= drives= knots= fidelity= [dt=] [delay_ms=] [exit=] // exit≠0 makes a failure-lane fixture (no result.toml, nonzero exit). -'use strict'; -const fs = require('node:fs'); +"use strict"; +const fs = require("node:fs"); const script = process.argv[process.argv.length - 1]; -const src = fs.readFileSync(script, 'utf8'); +const src = fs.readFileSync(script, "utf8"); const m = src.match(/^#\s*AMICODE_SMOKE\s+(.+)$/m); -if (!m) { process.stderr.write(`fake-julia: no AMICODE_SMOKE directive in ${script}\n`); process.exit(2); } +if (!m) { + process.stderr.write(`fake-julia: no AMICODE_SMOKE directive in ${script}\n`); + process.exit(2); +} const d = {}; for (const kv of m[1].trim().split(/\s+/)) { - const [k, v] = kv.split('='); + const [k, v] = kv.split("="); d[k] = Number(v); } -const iters = d.iters ?? 3, drives = d.drives ?? 1, knots = d.knots ?? 4; -const fidelity = d.fidelity ?? 0.999, dt = d.dt ?? 0.2; -const delayMs = d.delay_ms ?? 40, exitCode = d.exit ?? 0; +const iters = d.iters ?? 3, + drives = d.drives ?? 1, + knots = d.knots ?? 4; +const fidelity = d.fidelity ?? 0.999, + dt = d.dt ?? 0.2; +const delayMs = d.delay_ms ?? 40, + exitCode = d.exit ?? 0; const bound = 0.2; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const say = (line) => process.stdout.write(line + '\n'); +const say = (line) => process.stdout.write(line + "\n"); (async () => { - const labels = Array.from({ length: drives }, (_, i) => `"a_${i + 1}"`).join(','); - const bounds = Array.from({ length: drives }, () => `${-bound}:${bound}`).join(','); + const labels = Array.from({ length: drives }, (_, i) => `"a_${i + 1}"`).join(","); + const bounds = Array.from({ length: drives }, () => `${-bound}:${bound}`).join(","); say(`AMICODE_PULSE_META drives=${drives} knots=${knots} labels=${labels} bounds=${bounds}`); for (let k = 0; k <= iters; k++) { // Deterministic, iteration-varying values inside the bounds band. const row = (di) => - Array.from({ length: knots }, (_, j) => - (bound * 0.9 * Math.sin((j + 1) * (k + 1) + di)).toFixed(6)).join(','); - const vals = Array.from({ length: drives }, (_, di) => row(di)).join(';'); + Array.from({ length: knots }, (_, j) => (bound * 0.9 * Math.sin((j + 1) * (k + 1) + di)).toFixed(6)).join(","); + const vals = Array.from({ length: drives }, (_, di) => row(di)).join(";"); const f = 50 * Math.exp(-k) + (1 - fidelity); - say(`AMICODE_ITER iter=${k} f=${f.toExponential(6)} inf_pr=${(1e-3 * Math.exp(-k)).toExponential(3)} inf_du=${(1e-2 * Math.exp(-k)).toExponential(3)}`); + say( + `AMICODE_ITER iter=${k} f=${f.toExponential(6)} inf_pr=${(1e-3 * Math.exp(-k)).toExponential(3)} inf_du=${(1e-2 * Math.exp(-k)).toExponential(3)}`, + ); say(`AMICODE_PULSE iter=${k} dt=${dt} a=${vals}`); await sleep(delayMs); } if (exitCode === 0) { // Same shape the template writes (result schema: schema_version/fidelity/iterations). - fs.writeFileSync('result.toml', - `schema_version = "1"\nfidelity = ${fidelity}\niterations = ${iters}\n`); + fs.writeFileSync("result.toml", `schema_version = "1"\nfidelity = ${fidelity}\niterations = ${iters}\n`); } process.exit(exitCode); })(); diff --git a/packages/extension/test/demo_replay.test.ts b/packages/extension/test/demo_replay.test.ts index 7d31f7c3..7468c8dd 100644 --- a/packages/extension/test/demo_replay.test.ts +++ b/packages/extension/test/demo_replay.test.ts @@ -1,35 +1,37 @@ -import { describe, it, expect } from 'vitest' -import { mkdtempSync, writeFileSync, readFileSync, readlinkSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' -import { validateManifest, validateFinished } from '@amicode/amico-run' -import { stageDemoRun } from '../src/demo_replay' +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, readlinkSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; +import { validateManifest, validateFinished } from "@amicode/amico-run"; +import { stageDemoRun } from "../src/demo_replay"; function fakeDemo(): string { - const d = mkdtempSync(join(tmpdir(), 'demo-')) - writeFileSync(join(d, 'run.toml'), - `schema_version = "1"\nrun_id = "rDEMO"\nscript_path = "/demo.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-17T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`) - writeFileSync(join(d, 'run.log'), 'AMICODE_ITER iter=10 f=0.1 inf_pr=1e-8 inf_du=1e-6\n') - writeFileSync(join(d, 'iter_0010.png'), 'PNG') - writeFileSync(join(d, 'result.toml'), 'schema_version = "1"\nfidelity = 0.9999\niterations = 10\n') - writeFileSync(join(d, 'FINISHED'), 'status = "completed"\nexit_code = 0\n') - return d + const d = mkdtempSync(join(tmpdir(), "demo-")); + writeFileSync( + join(d, "run.toml"), + `schema_version = "1"\nrun_id = "rDEMO"\nscript_path = "/demo.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-17T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`, + ); + writeFileSync(join(d, "run.log"), "AMICODE_ITER iter=10 f=0.1 inf_pr=1e-8 inf_du=1e-6\n"); + writeFileSync(join(d, "iter_0010.png"), "PNG"); + writeFileSync(join(d, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 10\n'); + writeFileSync(join(d, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + return d; } -describe('stageDemoRun', () => { - it('stages the demo into a fresh runId, rewrites manifest run_id, swings latest', () => { - const demo = fakeDemo() - const runsRoot = mkdtempSync(join(tmpdir(), 'runs-')) - const runDir = stageDemoRun(demo, runsRoot) - const runId = runDir.split('/').pop()! - expect(runId).toMatch(/^r\d{8}-\d{6}Z-[0-9a-f]{4}$/) // β.1 runId format - expect(existsSync(join(runDir, 'iter_0010.png'))).toBe(true) - const m = parse(readFileSync(join(runDir, 'run.toml'), 'utf8')) as Record - expect(validateManifest(m).ok).toBe(true) - expect(m.run_id).toBe(runId) // rewritten to match the dir - expect(validateFinished(parse(readFileSync(join(runDir, 'FINISHED'), 'utf8'))).ok).toBe(true) - expect(readlinkSync(join(runsRoot, 'latest'))).toBe(runId) // the watcher will follow this - expect(readFileSync(join(runsRoot, 'index'), 'utf8')).toContain(runId) // appended to the index - }) -}) +describe("stageDemoRun", () => { + it("stages the demo into a fresh runId, rewrites manifest run_id, swings latest", () => { + const demo = fakeDemo(); + const runsRoot = mkdtempSync(join(tmpdir(), "runs-")); + const runDir = stageDemoRun(demo, runsRoot); + const runId = runDir.split("/").pop()!; + expect(runId).toMatch(/^r\d{8}-\d{6}Z-[0-9a-f]{4}$/); // β.1 runId format + expect(existsSync(join(runDir, "iter_0010.png"))).toBe(true); + const m = parse(readFileSync(join(runDir, "run.toml"), "utf8")) as Record; + expect(validateManifest(m).ok).toBe(true); + expect(m.run_id).toBe(runId); // rewritten to match the dir + expect(validateFinished(parse(readFileSync(join(runDir, "FINISHED"), "utf8"))).ok).toBe(true); + expect(readlinkSync(join(runsRoot, "latest"))).toBe(runId); // the watcher will follow this + expect(readFileSync(join(runsRoot, "index"), "utf8")).toContain(runId); // appended to the index + }); +}); diff --git a/packages/extension/test/fetch_opencode.test.ts b/packages/extension/test/fetch_opencode.test.ts index 5d45f4ac..5c58596e 100644 --- a/packages/extension/test/fetch_opencode.test.ts +++ b/packages/extension/test/fetch_opencode.test.ts @@ -1,100 +1,112 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fetchOpencode, loadManifest, resolvePlatform, sha256 } from '../scripts/fetch_opencode.mjs' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fetchOpencode, loadManifest, resolvePlatform, sha256 } from "../scripts/fetch_opencode.mjs"; function rootWith(manifest: unknown): string { - const root = mkdtempSync(join(tmpdir(), 'oc-test-')) - writeFileSync(join(root, 'opencode.lock.json'), JSON.stringify(manifest)) - return root + const root = mkdtempSync(join(tmpdir(), "oc-test-")); + writeFileSync(join(root, "opencode.lock.json"), JSON.stringify(manifest)); + return root; } const GOOD = { - version: '1.17.3', + version: "1.17.3", platforms: { - 'darwin-arm64': { asset: 'a.zip', sha256: 'ab'.repeat(32) }, - 'linux-x64': { asset: 'a.tar.gz', sha256: 'cd'.repeat(32) }, + "darwin-arm64": { asset: "a.zip", sha256: "ab".repeat(32) }, + "linux-x64": { asset: "a.tar.gz", sha256: "cd".repeat(32) }, }, -} +}; -describe('loadManifest', () => { - it('accepts a well-formed manifest', () => { - expect(loadManifest(rootWith(GOOD)).version).toBe('1.17.3') - }) - it('the COMMITTED manifest parses and pins exactly the two supported platforms', () => { - const m = loadManifest() // defaults to the real packages/extension root - expect(Object.keys(m.platforms).sort()).toEqual(['darwin-arm64', 'linux-x64']) - }) - it('rejects missing version and short hashes', () => { - expect(() => loadManifest(rootWith({ ...GOOD, version: '' }))).toThrow(/version/) - expect(() => loadManifest(rootWith({ - ...GOOD, platforms: { ...GOOD.platforms, 'linux-x64': { asset: 'a', sha256: 'beef' } }, - }))).toThrow(/sha256/) - }) -}) +describe("loadManifest", () => { + it("accepts a well-formed manifest", () => { + expect(loadManifest(rootWith(GOOD)).version).toBe("1.17.3"); + }); + it("the COMMITTED manifest parses and pins exactly the two supported platforms", () => { + const m = loadManifest(); // defaults to the real packages/extension root + expect(Object.keys(m.platforms).sort()).toEqual(["darwin-arm64", "linux-x64"]); + }); + it("rejects missing version and short hashes", () => { + expect(() => loadManifest(rootWith({ ...GOOD, version: "" }))).toThrow(/version/); + expect(() => + loadManifest( + rootWith({ + ...GOOD, + platforms: { ...GOOD.platforms, "linux-x64": { asset: "a", sha256: "beef" } }, + }), + ), + ).toThrow(/sha256/); + }); +}); -describe('resolvePlatform', () => { - it('honors an explicit valid key and rejects unknown ones', () => { - expect(resolvePlatform(GOOD, 'linux-x64')).toBe('linux-x64') - expect(() => resolvePlatform(GOOD, 'windows-x64')).toThrow(/supported/) - }) - it('detects the current machine when no flag given', () => { - const key = `${process.platform}-${process.arch}` - if (key in GOOD.platforms) expect(resolvePlatform(GOOD)).toBe(key) - else expect(() => resolvePlatform(GOOD)).toThrow(/supported/) - }) -}) +describe("resolvePlatform", () => { + it("honors an explicit valid key and rejects unknown ones", () => { + expect(resolvePlatform(GOOD, "linux-x64")).toBe("linux-x64"); + expect(() => resolvePlatform(GOOD, "windows-x64")).toThrow(/supported/); + }); + it("detects the current machine when no flag given", () => { + const key = `${process.platform}-${process.arch}`; + if (key in GOOD.platforms) expect(resolvePlatform(GOOD)).toBe(key); + else expect(() => resolvePlatform(GOOD)).toThrow(/supported/); + }); +}); function fixtureArchive(): { bytes: Buffer; hash: string } { - const dir = mkdtempSync(join(tmpdir(), 'oc-fixture-')) - writeFileSync(join(dir, 'opencode'), '#!/bin/sh\necho fake-opencode\n') - chmodSync(join(dir, 'opencode'), 0o755) - execFileSync('tar', ['-czf', join(dir, 'a.tar.gz'), '-C', dir, 'opencode']) - const bytes = readFileSync(join(dir, 'a.tar.gz')) - return { bytes, hash: sha256(bytes) } + const dir = mkdtempSync(join(tmpdir(), "oc-fixture-")); + writeFileSync(join(dir, "opencode"), "#!/bin/sh\necho fake-opencode\n"); + chmodSync(join(dir, "opencode"), 0o755); + execFileSync("tar", ["-czf", join(dir, "a.tar.gz"), "-C", dir, "opencode"]); + const bytes = readFileSync(join(dir, "a.tar.gz")); + return { bytes, hash: sha256(bytes) }; } -describe('fetchOpencode', () => { - it('downloads, verifies, unpacks, stamps — then skips on re-run', async () => { - const { bytes, hash } = fixtureArchive() - const root = rootWith({ version: '9.9.9', platforms: { 'linux-x64': { asset: 'a.tar.gz', sha256: hash } } }) - let calls = 0 - const download = async () => { calls++; return bytes } - const r1 = await fetchOpencode({ root, platform: 'linux-x64', download }) - expect(r1.skipped).toBe(false) - const bin = join(root, 'vendor', 'opencode', 'linux-x64', 'opencode') - expect(existsSync(bin)).toBe(true) - expect(readFileSync(join(root, 'vendor', 'opencode', 'linux-x64', '.sha256'), 'utf8').trim()).toBe(hash) - const r2 = await fetchOpencode({ root, platform: 'linux-x64', download }) - expect(r2.skipped).toBe(true) - expect(calls).toBe(1) // idempotent: no second download - }) - it('hard-fails on hash mismatch, printing expected vs actual, installing nothing', async () => { - const { bytes } = fixtureArchive() - const root = rootWith({ version: '9.9.9', platforms: { 'linux-x64': { asset: 'a.tar.gz', sha256: 'ee'.repeat(32) } } }) - await expect(fetchOpencode({ root, platform: 'linux-x64', download: async () => bytes })) - .rejects.toThrow(/expected ee.*actual/s) - expect(existsSync(join(root, 'vendor', 'opencode', 'linux-x64', 'opencode'))).toBe(false) - }) -}) +describe("fetchOpencode", () => { + it("downloads, verifies, unpacks, stamps — then skips on re-run", async () => { + const { bytes, hash } = fixtureArchive(); + const root = rootWith({ version: "9.9.9", platforms: { "linux-x64": { asset: "a.tar.gz", sha256: hash } } }); + let calls = 0; + const download = async () => { + calls++; + return bytes; + }; + const r1 = await fetchOpencode({ root, platform: "linux-x64", download }); + expect(r1.skipped).toBe(false); + const bin = join(root, "vendor", "opencode", "linux-x64", "opencode"); + expect(existsSync(bin)).toBe(true); + expect(readFileSync(join(root, "vendor", "opencode", "linux-x64", ".sha256"), "utf8").trim()).toBe(hash); + const r2 = await fetchOpencode({ root, platform: "linux-x64", download }); + expect(r2.skipped).toBe(true); + expect(calls).toBe(1); // idempotent: no second download + }); + it("hard-fails on hash mismatch, printing expected vs actual, installing nothing", async () => { + const { bytes } = fixtureArchive(); + const root = rootWith({ + version: "9.9.9", + platforms: { "linux-x64": { asset: "a.tar.gz", sha256: "ee".repeat(32) } }, + }); + await expect(fetchOpencode({ root, platform: "linux-x64", download: async () => bytes })).rejects.toThrow( + /expected ee.*actual/s, + ); + expect(existsSync(join(root, "vendor", "opencode", "linux-x64", "opencode"))).toBe(false); + }); +}); -describe('releaseCoords — fork-mirror pinning', async () => { - const { releaseCoords, assetUrl } = await import('../scripts/fetch_opencode.mjs') - const platforms = { 'linux-x64': { asset: 'opencode-linux-x64.tar.gz', sha256: 'a'.repeat(64) } } - it('defaults to upstream at v, public', () => { - const m = { version: '1.17.3', platforms } - expect(releaseCoords(m)).toEqual({ repo: 'sst/opencode', tag: 'v1.17.3', private: false }) - expect(assetUrl(m, 'linux-x64')).toBe( - 'https://github.com/sst/opencode/releases/download/v1.17.3/opencode-linux-x64.tar.gz', - ) - }) - it('repo+tag repoint to the private mirror', () => { - const m = { version: '1.17.3', repo: 'harmoniqs/opencode', tag: 'v1.17.3-amicode.1', platforms } - expect(releaseCoords(m)).toEqual({ repo: 'harmoniqs/opencode', tag: 'v1.17.3-amicode.1', private: true }) - expect(assetUrl(m, 'linux-x64')).toBe( - 'https://github.com/harmoniqs/opencode/releases/download/v1.17.3-amicode.1/opencode-linux-x64.tar.gz', - ) - }) -}) +describe("releaseCoords — fork-mirror pinning", async () => { + const { releaseCoords, assetUrl } = await import("../scripts/fetch_opencode.mjs"); + const platforms = { "linux-x64": { asset: "opencode-linux-x64.tar.gz", sha256: "a".repeat(64) } }; + it("defaults to upstream at v, public", () => { + const m = { version: "1.17.3", platforms }; + expect(releaseCoords(m)).toEqual({ repo: "sst/opencode", tag: "v1.17.3", private: false }); + expect(assetUrl(m, "linux-x64")).toBe( + "https://github.com/sst/opencode/releases/download/v1.17.3/opencode-linux-x64.tar.gz", + ); + }); + it("repo+tag repoint to the private mirror", () => { + const m = { version: "1.17.3", repo: "harmoniqs/opencode", tag: "v1.17.3-amicode.1", platforms }; + expect(releaseCoords(m)).toEqual({ repo: "harmoniqs/opencode", tag: "v1.17.3-amicode.1", private: true }); + expect(assetUrl(m, "linux-x64")).toBe( + "https://github.com/harmoniqs/opencode/releases/download/v1.17.3-amicode.1/opencode-linux-x64.tar.gz", + ); + }); +}); diff --git a/packages/extension/test/hashes.test.ts b/packages/extension/test/hashes.test.ts index ad74dfa8..e7c03461 100644 --- a/packages/extension/test/hashes.test.ts +++ b/packages/extension/test/hashes.test.ts @@ -3,19 +3,19 @@ // hashes.ts uses node:crypto — it is NOT importable into entities.ts (which is // dependency-free / dual-runtime). It follows the score_guard.ts sibling rules: // node: builtins allowed, named exports fine. Exercised here as plain functions. -import { describe, it, expect } from 'vitest' -import { sha256Hex, entityHash } from '../opencode-plugin/hashes' +import { describe, it, expect } from "vitest"; +import { sha256Hex, entityHash } from "../opencode-plugin/hashes"; -describe('sha256Hex', () => { +describe("sha256Hex", () => { it('matches the known vector for "abc"', () => { - expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad') - }) -}) + expect(sha256Hex("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + }); +}); -describe('entityHash', () => { - it('is prefixed with sha256: and stable across key order + excluded keys', () => { - const h = entityHash({ b: 1, a: 2, recorded: 'x' }) - expect(h.startsWith('sha256:')).toBe(true) - expect(entityHash({ a: 2, b: 1 })).toBe(h) - }) -}) +describe("entityHash", () => { + it("is prefixed with sha256: and stable across key order + excluded keys", () => { + const h = entityHash({ b: 1, a: 2, recorded: "x" }); + expect(h.startsWith("sha256:")).toBe(true); + expect(entityHash({ a: 2, b: 1 })).toBe(h); + }); +}); diff --git a/packages/extension/test/healthcheck.test.ts b/packages/extension/test/healthcheck.test.ts index a419f1e0..ef545bef 100644 --- a/packages/extension/test/healthcheck.test.ts +++ b/packages/extension/test/healthcheck.test.ts @@ -1,23 +1,22 @@ -import { describe, it, expect } from 'vitest' -import { resolveChecks } from '../scripts/healthcheck.mjs' +import { describe, it, expect } from "vitest"; +import { resolveChecks } from "../scripts/healthcheck.mjs"; -const ok = { ok: true } -const bad = (reason: string, fix: string) => ({ ok: false, reason, fix }) +const ok = { ok: true }; +const bad = (reason: string, fix: string) => ({ ok: false, reason, fix }); -describe('resolveChecks', () => { - it('exit 0 when all four pass', () => { - const r = resolveChecks({ julia: ok, opencode: ok, amicorun: ok, creds: ok }) - expect(r.exitCode).toBe(0) - expect(r.lines.filter((l: string) => l.startsWith('✓'))).toHaveLength(4) - }) - it('non-zero + precise line when one fails', () => { - const r = resolveChecks({ julia: ok, opencode: bad('no /event 200', 'check opencode'), - amicorun: ok, creds: ok }) - expect(r.exitCode).not.toBe(0) - expect(r.lines.join('\n')).toMatch(/✗ opencode \/event: no \/event 200 → check opencode/) - }) - it('reports every failing check, not just the first', () => { - const r = resolveChecks({ julia: bad('a', 'x'), opencode: ok, amicorun: bad('b', 'y'), creds: ok }) - expect(r.lines.filter((l: string) => l.startsWith('✗'))).toHaveLength(2) - }) -}) +describe("resolveChecks", () => { + it("exit 0 when all four pass", () => { + const r = resolveChecks({ julia: ok, opencode: ok, amicorun: ok, creds: ok }); + expect(r.exitCode).toBe(0); + expect(r.lines.filter((l: string) => l.startsWith("✓"))).toHaveLength(4); + }); + it("non-zero + precise line when one fails", () => { + const r = resolveChecks({ julia: ok, opencode: bad("no /event 200", "check opencode"), amicorun: ok, creds: ok }); + expect(r.exitCode).not.toBe(0); + expect(r.lines.join("\n")).toMatch(/✗ opencode \/event: no \/event 200 → check opencode/); + }); + it("reports every failing check, not just the first", () => { + const r = resolveChecks({ julia: bad("a", "x"), opencode: ok, amicorun: bad("b", "y"), creds: ok }); + expect(r.lines.filter((l: string) => l.startsWith("✗"))).toHaveLength(2); + }); +}); diff --git a/packages/extension/test/lab_config.test.ts b/packages/extension/test/lab_config.test.ts index 176d4f47..7e8cc55a 100644 --- a/packages/extension/test/lab_config.test.ts +++ b/packages/extension/test/lab_config.test.ts @@ -21,21 +21,18 @@ function errs(content: string): string[] { const has = (es: string[], needle: string) => es.some((e) => e.includes(needle)); describe("resolveLabTomlPath", () => { - it("defaults to ~/.amico/lab.toml", () => - expect(resolveLabTomlPath("")).toBe(join(homedir(), ".amico", "lab.toml"))); + it("defaults to ~/.amico/lab.toml", () => expect(resolveLabTomlPath("")).toBe(join(homedir(), ".amico", "lab.toml"))); it("expands a leading ~", () => { expect(resolveLabTomlPath("~")).toBe(homedir()); expect(resolveLabTomlPath("~/x/lab.toml")).toBe(join(homedir(), "x", "lab.toml")); }); - it("uses an explicit path, trimmed", () => - expect(resolveLabTomlPath(" /a/lab.toml ")).toBe("/a/lab.toml")); + it("uses an explicit path, trimmed", () => expect(resolveLabTomlPath(" /a/lab.toml ")).toBe("/a/lab.toml")); }); describe("checkLabToml", () => { it("a missing file is `absent`, not an error (a lab may be provisioned later)", () => expect(checkLabToml(join(tmpdir(), "definitely-absent-lab-dir", "lab.toml")).state).toBe("absent")); - it("a conforming lab.toml is `valid`", () => - expect(checkLabToml(writeLab(VALID)).state).toBe("valid")); + it("a conforming lab.toml is `valid`", () => expect(checkLabToml(writeLab(VALID)).state).toBe("valid")); // field-precise negative matrix (#16 ACs / S17) it("missing required key → names the absent key + path", () => @@ -49,24 +46,32 @@ describe("checkLabToml", () => { it("absent schema_version → field-precise required error", () => expect(has(errs(VALID.replace('schema_version = "1"\n', "")), 'missing required key "schema_version"')).toBe(true)); it("unrecognized schema_version → version-specific error", () => - expect(has(errs(VALID.replace('schema_version = "1"', 'schema_version = "9"')), "/schema_version: unrecognized version")).toBe(true)); + expect( + has(errs(VALID.replace('schema_version = "1"', 'schema_version = "9"')), "/schema_version: unrecognized version"), + ).toBe(true)); it("hardware range bounds are field-precise (#29: omega/drive_max/delta) + name minLength", () => { - expect(has(errs(VALID.replace("omega_GHz = 5.0", "omega_GHz = 999")), "/transmon/omega_GHz: must be <= 100")).toBe(true); - expect(has(errs(VALID.replace("drive_max_GHz = 0.2", "drive_max_GHz = 50")), "/transmon/drive_max_GHz: must be <= 10")).toBe(true); - expect(has(errs(VALID.replace("delta_GHz = 0.2", "delta_GHz = 25")), "/transmon/delta_GHz: must be <= 2")).toBe(true); // garbage anharmonicity - expect(has(errs(VALID.replace('name = "demo-lab"', 'name = ""')), "/lab/name")).toBe(true); // minLength + expect(has(errs(VALID.replace("omega_GHz = 5.0", "omega_GHz = 999")), "/transmon/omega_GHz: must be <= 100")).toBe( + true, + ); + expect( + has(errs(VALID.replace("drive_max_GHz = 0.2", "drive_max_GHz = 50")), "/transmon/drive_max_GHz: must be <= 10"), + ).toBe(true); + expect(has(errs(VALID.replace("delta_GHz = 0.2", "delta_GHz = 25")), "/transmon/delta_GHz: must be <= 2")).toBe( + true, + ); // garbage anharmonicity + expect(has(errs(VALID.replace('name = "demo-lab"', 'name = ""')), "/lab/name")).toBe(true); // minLength }); it("parity over a corpus: checkLabToml === @amicode/schema.validateFile on every input (no second path) [#16]", () => { const corpus = [ - VALID, // valid - VALID.replace("drive_max_GHz = 0.2\n", ""), // missing required - VALID.replace("levels = 3", 'levels = "three"'), // wrong type - VALID.replace("levels = 3", "levels = 99"), // out of range - VALID.replace("delta_GHz = 0.2", "delta_GHz = 25"), // out of range (delta) - VALID + "rogue = 1\n", // unknown key - VALID.replace('schema_version = "1"\n', ""), // absent version + VALID, // valid + VALID.replace("drive_max_GHz = 0.2\n", ""), // missing required + VALID.replace("levels = 3", 'levels = "three"'), // wrong type + VALID.replace("levels = 3", "levels = 99"), // out of range + VALID.replace("delta_GHz = 0.2", "delta_GHz = 25"), // out of range (delta) + VALID + "rogue = 1\n", // unknown key + VALID.replace('schema_version = "1"\n', ""), // absent version VALID.replace('schema_version = "1"', 'schema_version = "9"'), // unrecognized version ]; for (const content of corpus) { @@ -84,7 +89,8 @@ describe("valid lab profiles conform (demo + Schuster)", () => { it("a Schuster-profile lab (negative-convention δ, 4 levels) validates clean", () => { // Distinct from demo-lab: negative anharmonicity convention + a 4-level model, // exercising the schema's range tolerance on a second real-shaped profile. - const schuster = 'schema_version = "1"\n[lab]\nname = "schuster-transmon"\n' + + const schuster = + 'schema_version = "1"\n[lab]\nname = "schuster-transmon"\n' + "[transmon]\nomega_GHz = 4.8\ndelta_GHz = -0.33\nlevels = 4\ndrive_max_GHz = 0.1\n"; expect(checkLabToml(writeLab(schuster)).state).toBe("valid"); }); diff --git a/packages/extension/test/llm_creds.test.ts b/packages/extension/test/llm_creds.test.ts index aee1c442..5fa6f6d9 100644 --- a/packages/extension/test/llm_creds.test.ts +++ b/packages/extension/test/llm_creds.test.ts @@ -1,113 +1,118 @@ -import { describe, it, expect } from 'vitest' -import { resolveLlmCreds, stripProviders, fetchProviderSignal } from '../src/llm_creds.mjs' +import { describe, it, expect } from "vitest"; +import { resolveLlmCreds, stripProviders, fetchProviderSignal } from "../src/llm_creds.mjs"; // 0.3 — the LLM-provider SIGNAL: amico stores/injects no credential; opencode // owns the secret, and amico computes the configured/missing/mismatch signal // from opencode's OWN live /config/providers. Tests cover the pure signal, the // no-leak strip boundary, and the async fetch against a stubbed endpoint. -describe('resolveLlmCreds — pure signal from opencode-resolved providers', () => { - it('not configured → ONE explicit signal when opencode resolves no provider', () => { - const r = resolveLlmCreds({ providers: [] }) - expect(r.ok).toBe(false) +describe("resolveLlmCreds — pure signal from opencode-resolved providers", () => { + it("not configured → ONE explicit signal when opencode resolves no provider", () => { + const r = resolveLlmCreds({ providers: [] }); + expect(r.ok).toBe(false); if (!r.ok) { - expect(r.reason).toMatch(/not configured/i) - expect(r.fix).toMatch(/provider|RUNBOOK/i) + expect(r.reason).toMatch(/not configured/i); + expect(r.fix).toMatch(/provider|RUNBOOK/i); } - }) - it('configured → ok when a provider resolves and no model pins one', () => { - const r = resolveLlmCreds({ providers: [{ id: 'anthropic', source: 'env' }] }) - expect(r).toMatchObject({ ok: true, provider: 'anthropic', source: 'env' }) - }) - it('configured → ok when the model provider is among the resolved ones', () => { + }); + it("configured → ok when a provider resolves and no model pins one", () => { + const r = resolveLlmCreds({ providers: [{ id: "anthropic", source: "env" }] }); + expect(r).toMatchObject({ ok: true, provider: "anthropic", source: "env" }); + }); + it("configured → ok when the model provider is among the resolved ones", () => { const r = resolveLlmCreds({ - providers: [{ id: 'amazon-bedrock', source: 'config' }, { id: 'anthropic', source: 'env' }], - model: 'anthropic/claude-sonnet-4-6', - }) - expect(r).toMatchObject({ ok: true, provider: 'anthropic', source: 'env' }) - }) - it('mismatch → explicit fail when the model points at an unresolved provider', () => { + providers: [ + { id: "amazon-bedrock", source: "config" }, + { id: "anthropic", source: "env" }, + ], + model: "anthropic/claude-sonnet-4-6", + }); + expect(r).toMatchObject({ ok: true, provider: "anthropic", source: "env" }); + }); + it("mismatch → explicit fail when the model points at an unresolved provider", () => { const r = resolveLlmCreds({ - providers: [{ id: 'anthropic', source: 'env' }], - model: 'amazon-bedrock/us.anthropic.claude-sonnet-4-6', - }) - expect(r.ok).toBe(false) + providers: [{ id: "anthropic", source: "env" }], + model: "amazon-bedrock/us.anthropic.claude-sonnet-4-6", + }); + expect(r.ok).toBe(false); if (!r.ok) { - expect(r.reason).toMatch(/amazon-bedrock/) - expect(r.reason).toMatch(/no resolved credentials|resolved:/i) + expect(r.reason).toMatch(/amazon-bedrock/); + expect(r.reason).toMatch(/no resolved credentials|resolved:/i); } - }) + }); it('ignores a model with no provider prefix (falls back to "any resolved")', () => { - const r = resolveLlmCreds({ providers: [{ id: 'openai', source: 'env' }], model: 'weird-model-no-slash' }) - expect(r).toMatchObject({ ok: true, provider: 'openai' }) - }) -}) + const r = resolveLlmCreds({ providers: [{ id: "openai", source: "env" }], model: "weird-model-no-slash" }); + expect(r).toMatchObject({ ok: true, provider: "openai" }); + }); +}); -describe('stripProviders — the no-leak boundary', () => { - it('keeps only {id, source} and DROPS the plaintext key + everything else', () => { +describe("stripProviders — the no-leak boundary", () => { + it("keeps only {id, source} and DROPS the plaintext key + everything else", () => { const raw = { providers: [ - { id: 'anthropic', source: 'env', key: 'sk-ant-SECRET', models: { a: {} }, options: {} }, - { id: 'amazon-bedrock', source: 'config', env: ['AWS_ACCESS_KEY_ID'] }, + { id: "anthropic", source: "env", key: "sk-ant-SECRET", models: { a: {} }, options: {} }, + { id: "amazon-bedrock", source: "config", env: ["AWS_ACCESS_KEY_ID"] }, ], - } - const stripped = stripProviders(raw) + }; + const stripped = stripProviders(raw); expect(stripped).toEqual([ - { id: 'anthropic', source: 'env' }, - { id: 'amazon-bedrock', source: 'config' }, - ]) + { id: "anthropic", source: "env" }, + { id: "amazon-bedrock", source: "config" }, + ]); // The secret must not survive the strip — in ANY field. - expect(JSON.stringify(stripped)).not.toContain('sk-ant-SECRET') - }) - it('tolerates a missing/empty providers array', () => { - expect(stripProviders({})).toEqual([]) - expect(stripProviders(null)).toEqual([]) - expect(stripProviders({ providers: [] })).toEqual([]) - }) -}) + expect(JSON.stringify(stripped)).not.toContain("sk-ant-SECRET"); + }); + it("tolerates a missing/empty providers array", () => { + expect(stripProviders({})).toEqual([]); + expect(stripProviders(null)).toEqual([]); + expect(stripProviders({ providers: [] })).toEqual([]); + }); +}); -describe('fetchProviderSignal — async, against a stubbed opencode server', () => { - const SECRET = 'sk-ant-DO-NOT-LEAK' +describe("fetchProviderSignal — async, against a stubbed opencode server", () => { + const SECRET = "sk-ant-DO-NOT-LEAK"; const stub = (routes: Record, status = 200) => (async (url: string) => { - const path = url.replace(/^https?:\/\/[^/]+/, '') - if (!(path in routes)) return { ok: false, status: 404, json: async () => ({}) } as Response - return { ok: status < 400, status, json: async () => routes[path] } as Response - }) as unknown as typeof fetch + const path = url.replace(/^https?:\/\/[^/]+/, ""); + if (!(path in routes)) return { ok: false, status: 404, json: async () => ({}) } as Response; + return { ok: status < 400, status, json: async () => routes[path] } as Response; + }) as unknown as typeof fetch; - it('ok + which-provider when the live server resolves one, and NEVER returns a key', async () => { + it("ok + which-provider when the live server resolves one, and NEVER returns a key", async () => { const fetchImpl = stub({ - '/config/providers': { providers: [{ id: 'anthropic', source: 'env', key: SECRET }] }, - '/config': { model: 'anthropic/claude-sonnet-4-6' }, - }) - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig).toMatchObject({ ok: true, provider: 'anthropic', source: 'env' }) + "/config/providers": { providers: [{ id: "anthropic", source: "env", key: SECRET }] }, + "/config": { model: "anthropic/claude-sonnet-4-6" }, + }); + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig).toMatchObject({ ok: true, provider: "anthropic", source: "env" }); // AC6: the secret in the raw response must not appear anywhere in the signal. - expect(JSON.stringify(sig)).not.toContain(SECRET) - }) - it('not configured when the live server resolves zero providers', async () => { - const fetchImpl = stub({ '/config/providers': { providers: [] }, '/config': {} }) - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig.ok).toBe(false) - if (!sig.ok) expect(sig.reason).toMatch(/not configured/i) - }) - it('mismatch surfaces through the async path too', async () => { + expect(JSON.stringify(sig)).not.toContain(SECRET); + }); + it("not configured when the live server resolves zero providers", async () => { + const fetchImpl = stub({ "/config/providers": { providers: [] }, "/config": {} }); + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig.ok).toBe(false); + if (!sig.ok) expect(sig.reason).toMatch(/not configured/i); + }); + it("mismatch surfaces through the async path too", async () => { const fetchImpl = stub({ - '/config/providers': { providers: [{ id: 'anthropic', source: 'env' }] }, - '/config': { model: 'amazon-bedrock/x' }, - }) - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig.ok).toBe(false) - }) - it('not-ok (not a throw) when /config/providers is unreachable', async () => { - const fetchImpl = (async () => { throw new Error('ECONNREFUSED') }) as unknown as typeof fetch - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig.ok).toBe(false) - if (!sig.ok) expect(sig.reason).toMatch(/could not query|providers/i) - }) - it('still ok when /config (model) is unavailable — model check is optional', async () => { - const fetchImpl = stub({ '/config/providers': { providers: [{ id: 'openai', source: 'env' }] } }) // no /config route → 404 - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig).toMatchObject({ ok: true, provider: 'openai' }) - }) -}) + "/config/providers": { providers: [{ id: "anthropic", source: "env" }] }, + "/config": { model: "amazon-bedrock/x" }, + }); + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig.ok).toBe(false); + }); + it("not-ok (not a throw) when /config/providers is unreachable", async () => { + const fetchImpl = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig.ok).toBe(false); + if (!sig.ok) expect(sig.reason).toMatch(/could not query|providers/i); + }); + it("still ok when /config (model) is unavailable — model check is optional", async () => { + const fetchImpl = stub({ "/config/providers": { providers: [{ id: "openai", source: "env" }] } }); // no /config route → 404 + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig).toMatchObject({ ok: true, provider: "openai" }); + }); +}); diff --git a/packages/extension/test/opencode_binary.test.ts b/packages/extension/test/opencode_binary.test.ts index fe3a3af9..5276d3e0 100644 --- a/packages/extension/test/opencode_binary.test.ts +++ b/packages/extension/test/opencode_binary.test.ts @@ -1,34 +1,36 @@ -import { describe, it, expect } from 'vitest' -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resolveOpencodeBinary, OpencodeMissingError } from '../src/opencode_binary' +import { describe, it, expect } from "vitest"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveOpencodeBinary, OpencodeMissingError } from "../src/opencode_binary"; -const platformKey = `${process.platform}-${process.arch}` +const platformKey = `${process.platform}-${process.arch}`; function rootWithVendored(): string { - const root = mkdtempSync(join(tmpdir(), 'ocbin-')) - const dir = join(root, 'vendor', 'opencode', platformKey) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'opencode'), '#!/bin/sh\n') - chmodSync(join(dir, 'opencode'), 0o755) - return root + const root = mkdtempSync(join(tmpdir(), "ocbin-")); + const dir = join(root, "vendor", "opencode", platformKey); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencode"), "#!/bin/sh\n"); + chmodSync(join(dir, "opencode"), 0o755); + return root; } -describe('resolveOpencodeBinary', () => { - it('config override wins, verbatim', () => { - expect(resolveOpencodeBinary(rootWithVendored(), '/custom/opencode')) - .toEqual({ path: '/custom/opencode', source: 'config-override' }) - }) - it('falls through to the vendored binary when config is empty', () => { - const root = rootWithVendored() - const r = resolveOpencodeBinary(root, '') - expect(r.source).toBe('vendored') - expect(r.path).toBe(join(root, 'vendor', 'opencode', platformKey, 'opencode')) - }) - it('missing vendored binary → actionable hard error, never $PATH', () => { - const empty = mkdtempSync(join(tmpdir(), 'ocbin-empty-')) - expect(() => resolveOpencodeBinary(empty, '')).toThrow(OpencodeMissingError) - expect(() => resolveOpencodeBinary(empty, '')).toThrow(/fetch:opencode|reinstall/) - }) -}) +describe("resolveOpencodeBinary", () => { + it("config override wins, verbatim", () => { + expect(resolveOpencodeBinary(rootWithVendored(), "/custom/opencode")).toEqual({ + path: "/custom/opencode", + source: "config-override", + }); + }); + it("falls through to the vendored binary when config is empty", () => { + const root = rootWithVendored(); + const r = resolveOpencodeBinary(root, ""); + expect(r.source).toBe("vendored"); + expect(r.path).toBe(join(root, "vendor", "opencode", platformKey, "opencode")); + }); + it("missing vendored binary → actionable hard error, never $PATH", () => { + const empty = mkdtempSync(join(tmpdir(), "ocbin-empty-")); + expect(() => resolveOpencodeBinary(empty, "")).toThrow(OpencodeMissingError); + expect(() => resolveOpencodeBinary(empty, "")).toThrow(/fetch:opencode|reinstall/); + }); +}); diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 043cb0f5..6c2ed9f8 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -1,118 +1,128 @@ -import { describe, it, expect } from 'vitest' -import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' -import { tmpdir, homedir } from 'node:os' -import { join, isAbsolute } from 'node:path' -import { execFileSync } from 'node:child_process' -import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from '../src/opencode_config' +import { describe, it, expect } from "vitest"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join, isAbsolute } from "node:path"; +import { execFileSync } from "node:child_process"; +import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "../src/opencode_config"; function fakeExtRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'extroot-')) - writeFileSync(join(root, 'AGENTS.md'), '# A\nproject: {{JULIA_PROJECT}}\ntemplate: {{TEMPLATE_PATH}}\n') - mkdirSync(join(root, 'templates')) - writeFileSync(join(root, 'templates', 'solve_template.jl'), '# template\n') - return root + const root = mkdtempSync(join(tmpdir(), "extroot-")); + writeFileSync(join(root, "AGENTS.md"), "# A\nproject: {{JULIA_PROJECT}}\ntemplate: {{TEMPLATE_PATH}}\n"); + mkdirSync(join(root, "templates")); + writeFileSync(join(root, "templates", "solve_template.jl"), "# template\n"); + return root; } -describe('resolveJuliaProject', () => { - const def = join(homedir(), '.amico', 'julia') - it('defaults to ~/.amico/julia when empty or whitespace', () => { - expect(resolveJuliaProject('')).toBe(def) - expect(resolveJuliaProject(' ')).toBe(def) - }) - it('uses a configured value, trimmed', () => { - expect(resolveJuliaProject('/opt/piccolo')).toBe('/opt/piccolo') - expect(resolveJuliaProject(' /opt/p ')).toBe('/opt/p') - }) - it('expands a leading ~ (parity with resolveRunsRoot)', () => { - expect(resolveJuliaProject('~')).toBe(homedir()) - expect(resolveJuliaProject('~/foo/bar')).toBe(join(homedir(), 'foo', 'bar')) - }) -}) +describe("resolveJuliaProject", () => { + const def = join(homedir(), ".amico", "julia"); + it("defaults to ~/.amico/julia when empty or whitespace", () => { + expect(resolveJuliaProject("")).toBe(def); + expect(resolveJuliaProject(" ")).toBe(def); + }); + it("uses a configured value, trimmed", () => { + expect(resolveJuliaProject("/opt/piccolo")).toBe("/opt/piccolo"); + expect(resolveJuliaProject(" /opt/p ")).toBe("/opt/p"); + }); + it("expands a leading ~ (parity with resolveRunsRoot)", () => { + expect(resolveJuliaProject("~")).toBe(homedir()); + expect(resolveJuliaProject("~/foo/bar")).toBe(join(homedir(), "foo", "bar")); + }); +}); -describe('buildOpencodeConfigContent', () => { - const TPL = '/ext/templates/solve_template.jl' - it('emits valid JSON whose instructions points at the (absolute) agents file', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(cfg.instructions).toEqual(['/abs/AGENTS.md']) - }) - it('scopes external_directory to the template + scratch + runs roots (least privilege), drops webfetch', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - const ed = cfg.permission.external_directory - expect(typeof ed).toBe('object') // path-scoped, NOT a blanket "allow" - expect(ed[TPL]).toBe('allow') // the template file the agent reads - expect(ed['/ext/templates/**']).toBe('allow') // its dir (belt-and-suspenders) - expect(ed['/tmp/amicode-work/**']).toBe('allow') // scratch it writes solve.jl into - expect(ed['/private/tmp/amicode-work/**']).toBe('allow') // macOS: /tmp → /private/tmp +describe("buildOpencodeConfigContent", () => { + const TPL = "/ext/templates/solve_template.jl"; + it("emits valid JSON whose instructions points at the (absolute) agents file", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(cfg.instructions).toEqual(["/abs/AGENTS.md"]); + }); + it("scopes external_directory to the template + scratch + runs roots (least privilege), drops webfetch", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + const ed = cfg.permission.external_directory; + expect(typeof ed).toBe("object"); // path-scoped, NOT a blanket "allow" + expect(ed[TPL]).toBe("allow"); // the template file the agent reads + expect(ed["/ext/templates/**"]).toBe("allow"); // its dir (belt-and-suspenders) + expect(ed["/tmp/amicode-work/**"]).toBe("allow"); // scratch it writes solve.jl into + expect(ed["/private/tmp/amicode-work/**"]).toBe("allow"); // macOS: /tmp → /private/tmp // The runs root: AGENTS.md tells the agent to read FINISHED/result.toml for // results and run.log for tracebacks — without this grant every such read is // an external_directory "ask" prompt (one per solve, worse on failures). - expect(ed['/home/u/.amico/runs/default/**']).toBe('allow') - expect(cfg.permission.bash).toBe('allow') // runs amico-run (compound launch) - expect(cfg.permission.edit).toBe('allow') // fills the FILL-IN block - expect(cfg.permission.webfetch).toBeUndefined() // unused by the solve flow — dropped - }) - it('registers the amicode_* plugin by ABSOLUTE default path — and the file actually exists', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(Array.isArray(cfg.plugin)).toBe(true) - expect(cfg.plugin).toHaveLength(1) - expect(isAbsolute(cfg.plugin[0])).toBe(true) // opencode imports it by abs path - expect(cfg.plugin[0].endsWith(join('opencode-plugin', 'amicode_tools.ts'))).toBe(true) - expect(existsSync(cfg.plugin[0])).toBe(true) // __dirname default resolves to the real file - expect(existsSync(join(cfg.plugin[0], '..', 'entities.ts'))).toBe(true) // its relative import target too - }) - it('honors an explicit pluginPath (the follow-up extension.ts wiring)', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default', '/elsewhere/amicode_tools.ts')) - expect(cfg.plugin).toEqual(['/elsewhere/amicode_tools.ts']) - }) - it('registers skills.paths only when a stage dir is given (opencode-native skills)', () => { - const without = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(without.skills).toBeUndefined() // no stage dir → no skills key at all + expect(ed["/home/u/.amico/runs/default/**"]).toBe("allow"); + expect(cfg.permission.bash).toBe("allow"); // runs amico-run (compound launch) + expect(cfg.permission.edit).toBe("allow"); // fills the FILL-IN block + expect(cfg.permission.webfetch).toBeUndefined(); // unused by the solve flow — dropped + }); + it("registers the amicode_* plugin by ABSOLUTE default path — and the file actually exists", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(Array.isArray(cfg.plugin)).toBe(true); + expect(cfg.plugin).toHaveLength(1); + expect(isAbsolute(cfg.plugin[0])).toBe(true); // opencode imports it by abs path + expect(cfg.plugin[0].endsWith(join("opencode-plugin", "amicode_tools.ts"))).toBe(true); + expect(existsSync(cfg.plugin[0])).toBe(true); // __dirname default resolves to the real file + expect(existsSync(join(cfg.plugin[0], "..", "entities.ts"))).toBe(true); // its relative import target too + }); + it("honors an explicit pluginPath (the follow-up extension.ts wiring)", () => { + const cfg = JSON.parse( + buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default", "/elsewhere/amicode_tools.ts"), + ); + expect(cfg.plugin).toEqual(["/elsewhere/amicode_tools.ts"]); + }); + it("registers skills.paths only when a stage dir is given (opencode-native skills)", () => { + const without = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(without.skills).toBeUndefined(); // no stage dir → no skills key at all const withStage = JSON.parse( - buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default', undefined, undefined, [], '/tmp/proj/skills'), - ) - expect(withStage.skills).toEqual({ paths: ['/tmp/proj/skills'] }) // absolute per-session dir (guarded set), never a library root - }) - it('declares the pulse-designer agent whose prompt defers to the AGENTS.md interview', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - const pd = cfg.agent['pulse-designer'] - expect(pd.description).toBe('Guided quantum pulse design interview') - expect(pd.prompt).toContain('one question at a time') // the interview protocol - expect(pd.prompt).toContain("'Pulse-designer interview'") // script lives in AGENTS.md, not here - expect(pd.prompt).toContain('amicode_') // record stages via the tool pack - expect(pd.prompt).toContain('solve workflow') // launches stay on the bash workflow - }) - it('grants external_directory on the problems root (default + $AMICODE_PROBLEMS_DIR override)', () => { - const defGrant = join(homedir(), '.amico', 'problems') + '/**' - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(cfg.permission.external_directory[defGrant]).toBe('allow') - const prev = process.env.AMICODE_PROBLEMS_DIR - process.env.AMICODE_PROBLEMS_DIR = '/custom/problems' + buildOpencodeConfigContent( + "/abs/AGENTS.md", + TPL, + "/home/u/.amico/runs/default", + undefined, + undefined, + [], + "/tmp/proj/skills", + ), + ); + expect(withStage.skills).toEqual({ paths: ["/tmp/proj/skills"] }); // absolute per-session dir (guarded set), never a library root + }); + it("declares the pulse-designer agent whose prompt defers to the AGENTS.md interview", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + const pd = cfg.agent["pulse-designer"]; + expect(pd.description).toBe("Guided quantum pulse design interview"); + expect(pd.prompt).toContain("one question at a time"); // the interview protocol + expect(pd.prompt).toContain("'Pulse-designer interview'"); // script lives in AGENTS.md, not here + expect(pd.prompt).toContain("amicode_"); // record stages via the tool pack + expect(pd.prompt).toContain("solve workflow"); // launches stay on the bash workflow + }); + it("grants external_directory on the problems root (default + $AMICODE_PROBLEMS_DIR override)", () => { + const defGrant = join(homedir(), ".amico", "problems") + "/**"; + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(cfg.permission.external_directory[defGrant]).toBe("allow"); + const prev = process.env.AMICODE_PROBLEMS_DIR; + process.env.AMICODE_PROBLEMS_DIR = "/custom/problems"; try { - const cfg2 = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(cfg2.permission.external_directory['/custom/problems/**']).toBe('allow') // grant follows the plugin + const cfg2 = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(cfg2.permission.external_directory["/custom/problems/**"]).toBe("allow"); // grant follows the plugin } finally { - if (prev === undefined) delete process.env.AMICODE_PROBLEMS_DIR - else process.env.AMICODE_PROBLEMS_DIR = prev + if (prev === undefined) delete process.env.AMICODE_PROBLEMS_DIR; + else process.env.AMICODE_PROBLEMS_DIR = prev; } - }) - it('never embeds a credential in the config content (D11 no-store/no-inject regression guard)', () => { + }); + it("never embeds a credential in the config content (D11 no-store/no-inject regression guard)", () => { // amico owns no secret: the config it writes into OPENCODE_CONFIG_CONTENT must // never carry a provider key, even when one is present in the environment. // Guards against a future edit that starts sourcing a key into the config. - const SENTINEL = 'sk-ant-LEAK5ENTINEL0000000000000000' - const prev = process.env.ANTHROPIC_API_KEY - process.env.ANTHROPIC_API_KEY = SENTINEL + const SENTINEL = "sk-ant-LEAK5ENTINEL0000000000000000"; + const prev = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = SENTINEL; try { - const content = buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default') - expect(content).not.toContain(SENTINEL) // no env-sourced key leaks in - expect(content).not.toMatch(/sk-[A-Za-z0-9-]{16,}/) // no key-shaped string at all - expect(content.toLowerCase()).not.toMatch(/"(apikey|api_key|authorization|bearer|token)"\s*:/) + const content = buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default"); + expect(content).not.toContain(SENTINEL); // no env-sourced key leaks in + expect(content).not.toMatch(/sk-[A-Za-z0-9-]{16,}/); // no key-shaped string at all + expect(content.toLowerCase()).not.toMatch(/"(apikey|api_key|authorization|bearer|token)"\s*:/); } finally { - if (prev === undefined) delete process.env.ANTHROPIC_API_KEY - else process.env.ANTHROPIC_API_KEY = prev + if (prev === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = prev; } - }) -}) + }); +}); // Integration (#25): boots the REAL opencode binary (`opencode debug config` // resolves + dumps the merged config, equivalent to GET /config) with the REAL @@ -129,60 +139,79 @@ describe('buildOpencodeConfigContent', () => { // Uses the real builder (no transcribed copy → no drift; boot_smoke.mjs can't // import the TS builder, which is why this lives here). Skipped when the vendored // binary isn't present (e.g. minimal CI before `fetch:opencode`). -const OC_BIN = join(__dirname, '..', 'vendor', 'opencode', `${process.platform}-${process.arch}`, 'opencode') -describe.skipIf(!existsSync(OC_BIN))('opencode config injection + merge (1.17.3)', () => { - it('injects instructions/permission AND preserves the user global model + permission', () => { - const home = mkdtempSync(join(tmpdir(), 'ochome-')) - mkdirSync(join(home, '.config', 'opencode'), { recursive: true }) +const OC_BIN = join(__dirname, "..", "vendor", "opencode", `${process.platform}-${process.arch}`, "opencode"); +describe.skipIf(!existsSync(OC_BIN))("opencode config injection + merge (1.17.3)", () => { + it("injects instructions/permission AND preserves the user global model + permission", () => { + const home = mkdtempSync(join(tmpdir(), "ochome-")); + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); // A user global config with a distinctive model + permission key — both must // survive the deep-merge under OPENCODE_CONFIG_CONTENT. - writeFileSync(join(home, '.config', 'opencode', 'opencode.json'), - JSON.stringify({ model: 'anthropic/claude-sonnet-4-6', permission: { doom_loop: 'deny' } })) - const agentsPath = join(home, 'AGENTS.md') // the exact file our `instructions` must point at - writeFileSync(agentsPath, '# amico\n') - const out = execFileSync(OC_BIN, ['debug', 'config'], { - encoding: 'utf8', - env: { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(agentsPath, '/ext/templates/solve_template.jl', join(home, '.amico', 'runs', 'default')) }, - }) - const cfg = JSON.parse(out) + writeFileSync( + join(home, ".config", "opencode", "opencode.json"), + JSON.stringify({ model: "anthropic/claude-sonnet-4-6", permission: { doom_loop: "deny" } }), + ); + const agentsPath = join(home, "AGENTS.md"); // the exact file our `instructions` must point at + writeFileSync(agentsPath, "# amico\n"); + const out = execFileSync(OC_BIN, ["debug", "config"], { + encoding: "utf8", + env: { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + agentsPath, + "/ext/templates/solve_template.jl", + join(home, ".amico", "runs", "default"), + ), + }, + }); + const cfg = JSON.parse(out); // our injection landed (the false-green boot_smoke couldn't catch): - expect(cfg.instructions).toContain(agentsPath) // the AGENTS.md instruction injection - expect(typeof cfg.permission.external_directory).toBe('object') // our injected permission key + expect(cfg.instructions).toContain(agentsPath); // the AGENTS.md instruction injection + expect(typeof cfg.permission.external_directory).toBe("object"); // our injected permission key // the runs-root grant survives the real deep-merge — the agent's post-solve // FINISHED/result.toml/run.log read-backs must not "ask" on every run: - expect(cfg.permission.external_directory[join(home, '.amico', 'runs', 'default') + '/**']).toBe('allow') + expect(cfg.permission.external_directory[join(home, ".amico", "runs", "default") + "/**"]).toBe("allow"); // the user's global config SURVIVED the deep-merge: - expect(cfg.model).toBe('anthropic/claude-sonnet-4-6') // provider/model preserved (Q129 needs this) - expect(cfg.permission.doom_loop).toBe('deny') // user permission key preserved (#22) + expect(cfg.model).toBe("anthropic/claude-sonnet-4-6"); // provider/model preserved (Q129 needs this) + expect(cfg.permission.doom_loop).toBe("deny"); // user permission key preserved (#22) // L0 pulse-designer registration survived resolution against the REAL binary. // NOTE: `debug config` IMPORTS listed plugins before printing JSON to stdout // (verified on 1.17.3) — so JSON.parse(out) succeeding above doubles as a // regression guard that amicode_tools.ts loads cleanly AND never writes to // stdout at module scope (its load line must stay on stderr). - expect(cfg.plugin).toHaveLength(1) - expect(cfg.plugin[0].endsWith(join('opencode-plugin', 'amicode_tools.ts'))).toBe(true) - expect(cfg.agent['pulse-designer'].description).toBe('Guided quantum pulse design interview') - expect(cfg.agent['pulse-designer'].prompt).toContain('one question at a time') - }) -}) + expect(cfg.plugin).toHaveLength(1); + expect(cfg.plugin[0].endsWith(join("opencode-plugin", "amicode_tools.ts"))).toBe(true); + expect(cfg.agent["pulse-designer"].description).toBe("Guided quantum pulse design interview"); + expect(cfg.agent["pulse-designer"].prompt).toContain("one question at a time"); + }); +}); -describe('prepareOpencodeProject', () => { - it('substitutes the julia project AND the absolute template path, leaving no placeholders', () => { - const ext = fakeExtRoot() - const templateSrc = join(ext, 'templates', 'solve_template.jl') - const p = prepareOpencodeProject({ agentsSrc: join(ext, 'AGENTS.md'), templateSrc, juliaProject: '/opt/piccolo', vaultDir: '' }) - const agents = readFileSync(p.agentsPath, 'utf8') - expect(agents).toContain('/opt/piccolo') - expect(agents).toContain(templateSrc) // {{TEMPLATE_PATH}} → the absolute bundled template - expect(agents).not.toMatch(/\{\{.*?\}\}/) // no residual placeholders - expect(p.templatePath).toBe(templateSrc) // points at the bundled source, not a copy - }) - it('does NOT copy the template or write a vestigial .opencode/opencode.json into the session dir', () => { - const ext = fakeExtRoot() - const p = prepareOpencodeProject({ agentsSrc: join(ext, 'AGENTS.md'), - templateSrc: join(ext, 'templates', 'solve_template.jl'), juliaProject: '/opt/piccolo', vaultDir: '' }) - expect(existsSync(join(p.projectDir, 'solve_template.jl'))).toBe(false) - expect(existsSync(join(p.projectDir, '.opencode', 'opencode.json'))).toBe(false) - }) -}) +describe("prepareOpencodeProject", () => { + it("substitutes the julia project AND the absolute template path, leaving no placeholders", () => { + const ext = fakeExtRoot(); + const templateSrc = join(ext, "templates", "solve_template.jl"); + const p = prepareOpencodeProject({ + agentsSrc: join(ext, "AGENTS.md"), + templateSrc, + juliaProject: "/opt/piccolo", + vaultDir: "", + }); + const agents = readFileSync(p.agentsPath, "utf8"); + expect(agents).toContain("/opt/piccolo"); + expect(agents).toContain(templateSrc); // {{TEMPLATE_PATH}} → the absolute bundled template + expect(agents).not.toMatch(/\{\{.*?\}\}/); // no residual placeholders + expect(p.templatePath).toBe(templateSrc); // points at the bundled source, not a copy + }); + it("does NOT copy the template or write a vestigial .opencode/opencode.json into the session dir", () => { + const ext = fakeExtRoot(); + const p = prepareOpencodeProject({ + agentsSrc: join(ext, "AGENTS.md"), + templateSrc: join(ext, "templates", "solve_template.jl"), + juliaProject: "/opt/piccolo", + vaultDir: "", + }); + expect(existsSync(join(p.projectDir, "solve_template.jl"))).toBe(false); + expect(existsSync(join(p.projectDir, ".opencode", "opencode.json"))).toBe(false); + }); +}); diff --git a/packages/extension/test/opencode_paths.test.ts b/packages/extension/test/opencode_paths.test.ts index f365766f..5a140f27 100644 --- a/packages/extension/test/opencode_paths.test.ts +++ b/packages/extension/test/opencode_paths.test.ts @@ -1,43 +1,45 @@ -import { describe, it, expect } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' -import { resolveAmicoRunBinDir, resolveRunsRoot, inspectorResourceRootDirs } from '../src/opencode_paths' +import { describe, it, expect } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveAmicoRunBinDir, resolveRunsRoot, inspectorResourceRootDirs } from "../src/opencode_paths"; -describe('resolveAmicoRunBinDir', () => { - it('prefers the staged bin/launcher when present (packaged VSIX)', () => { - const ext = mkdtempSync(join(tmpdir(), 'ext-')) - mkdirSync(join(ext, 'bin', 'launcher'), { recursive: true }) - writeFileSync(join(ext, 'bin', 'launcher', 'amico-run'), '#!/usr/bin/env bash\n') - expect(resolveAmicoRunBinDir(ext)).toBe(join(ext, 'bin', 'launcher')) - }) - it('falls back to the workspace sibling launcher (dev Extension Host)', () => { - const pkgs = mkdtempSync(join(tmpdir(), 'pkgs-')) - const ext = join(pkgs, 'extension'); mkdirSync(ext, { recursive: true }) - const sib = join(pkgs, 'amico-run', 'launcher'); mkdirSync(sib, { recursive: true }) - writeFileSync(join(sib, 'amico-run'), '#!/usr/bin/env bash\n') - expect(resolveAmicoRunBinDir(ext)).toBe(sib) - }) - it('returns undefined when neither exists', () => { - expect(resolveAmicoRunBinDir(mkdtempSync(join(tmpdir(), 'none-')))).toBeUndefined() - }) -}) +describe("resolveAmicoRunBinDir", () => { + it("prefers the staged bin/launcher when present (packaged VSIX)", () => { + const ext = mkdtempSync(join(tmpdir(), "ext-")); + mkdirSync(join(ext, "bin", "launcher"), { recursive: true }); + writeFileSync(join(ext, "bin", "launcher", "amico-run"), "#!/usr/bin/env bash\n"); + expect(resolveAmicoRunBinDir(ext)).toBe(join(ext, "bin", "launcher")); + }); + it("falls back to the workspace sibling launcher (dev Extension Host)", () => { + const pkgs = mkdtempSync(join(tmpdir(), "pkgs-")); + const ext = join(pkgs, "extension"); + mkdirSync(ext, { recursive: true }); + const sib = join(pkgs, "amico-run", "launcher"); + mkdirSync(sib, { recursive: true }); + writeFileSync(join(sib, "amico-run"), "#!/usr/bin/env bash\n"); + expect(resolveAmicoRunBinDir(ext)).toBe(sib); + }); + it("returns undefined when neither exists", () => { + expect(resolveAmicoRunBinDir(mkdtempSync(join(tmpdir(), "none-")))).toBeUndefined(); + }); +}); -describe('resolveRunsRoot', () => { - it('defaults to ~/.amico/runs/default computed via homedir', () => { - expect(resolveRunsRoot('')).toBe(join(homedir(), '.amico', 'runs', 'default')) - }) - it('expands a leading ~ in a configured value', () => { - expect(resolveRunsRoot('~/custom/runs')).toBe(join(homedir(), 'custom', 'runs')) - }) - it('passes an absolute path through', () => { - expect(resolveRunsRoot('/var/runs')).toBe('/var/runs') - }) -}) +describe("resolveRunsRoot", () => { + it("defaults to ~/.amico/runs/default computed via homedir", () => { + expect(resolveRunsRoot("")).toBe(join(homedir(), ".amico", "runs", "default")); + }); + it("expands a leading ~ in a configured value", () => { + expect(resolveRunsRoot("~/custom/runs")).toBe(join(homedir(), "custom", "runs")); + }); + it("passes an absolute path through", () => { + expect(resolveRunsRoot("/var/runs")).toBe("/var/runs"); + }); +}); -describe('inspectorResourceRootDirs', () => { - it('grants extension assets only — no run-dir roots (the view renders from message data)', () => { - const roots = inspectorResourceRootDirs('/ext') - expect(roots).toEqual(['/ext/dist', '/ext/media']) - }) -}) +describe("inspectorResourceRootDirs", () => { + it("grants extension assets only — no run-dir roots (the view renders from message data)", () => { + const roots = inspectorResourceRootDirs("/ext"); + expect(roots).toEqual(["/ext/dist", "/ext/media"]); + }); +}); diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 098530ef..7e008020 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -1,41 +1,41 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; -const VSIX = join(__dirname, '..', 'amicode.vsix') +const VSIX = join(__dirname, "..", "amicode.vsix"); const REQUIRED = [ - 'extension/bin/dist/amico-run.js', - 'extension/bin/launcher/amico-run', - 'extension/templates/solve_template.jl', + "extension/bin/dist/amico-run.js", + "extension/bin/launcher/amico-run", + "extension/templates/solve_template.jl", // spec C authoring assets — the tiered resolver + verification chain break // silently if any of these is dropped from the vsix. - 'extension/templates/registry.toml', // tier-1 template registry + support set + sandbox uuid map - 'extension/templates/skeleton_free.jl', // tier-3 free-authoring skeleton (contract + verify snapshot) - 'extension/exemplars/EXEMPLARS.toml', // tier-2 seed (build input) - 'extension/exemplars/index.json', // tier-2 index (the artifact amico-run reads) - 'extension/exemplars/rydberg-cz/script.jl', // the seeded exemplar script the index points at - 'extension/julia/verify_rollout.jl', // fixed re-rollout harness — the tier-3 trust anchor - 'extension/julia/Project.toml', - 'extension/julia/Manifest.toml', - 'extension/AGENTS.md', - 'extension/demo/run/run.toml', - 'extension/demo/run/FINISHED', - 'extension/demo/run/run.log', // inspector reads run.log for the demo's stats row; *.log-gitignored so easy to drop - 'extension/media/brand.css', // style variables (design-owned) — must ship, else an unstyled inspector - 'extension/media/layout.css', // layout selectors (design-owned) — must ship, else an unstyled inspector - 'extension/scores/pulse-designer/SCORE.md', // score #0 — the interview is data; a dropped repertoire = silent prose fallback - 'extension/scores/pulse-designer/templates/solve.jl', // score-local vetted template (lint requires it resolves) - 'extension/scores/memory/free-phase-objective-only.md', - 'extension/scores/entitlements.toml', // entitlement registry — gating breaks silently without it + "extension/templates/registry.toml", // tier-1 template registry + support set + sandbox uuid map + "extension/templates/skeleton_free.jl", // tier-3 free-authoring skeleton (contract + verify snapshot) + "extension/exemplars/EXEMPLARS.toml", // tier-2 seed (build input) + "extension/exemplars/index.json", // tier-2 index (the artifact amico-run reads) + "extension/exemplars/rydberg-cz/script.jl", // the seeded exemplar script the index points at + "extension/julia/verify_rollout.jl", // fixed re-rollout harness — the tier-3 trust anchor + "extension/julia/Project.toml", + "extension/julia/Manifest.toml", + "extension/AGENTS.md", + "extension/demo/run/run.toml", + "extension/demo/run/FINISHED", + "extension/demo/run/run.log", // inspector reads run.log for the demo's stats row; *.log-gitignored so easy to drop + "extension/media/brand.css", // style variables (design-owned) — must ship, else an unstyled inspector + "extension/media/layout.css", // layout selectors (design-owned) — must ship, else an unstyled inspector + "extension/scores/pulse-designer/SCORE.md", // score #0 — the interview is data; a dropped repertoire = silent prose fallback + "extension/scores/pulse-designer/templates/solve.jl", // score-local vetted template (lint requires it resolves) + "extension/scores/memory/free-phase-objective-only.md", + "extension/scores/entitlements.toml", // entitlement registry — gating breaks silently without it // amicode_* plugin (Bun-transpiled .ts, loaded by absolute path) — every sibling // is load-bearing: a dropped file silently reverts the session to vanilla opencode. - 'extension/opencode-plugin/amicode_tools.ts', - 'extension/opencode-plugin/entities.ts', - 'extension/opencode-plugin/problems.ts', - 'extension/opencode-plugin/hashes.ts', - 'extension/opencode-plugin/score_guard.ts', -] + "extension/opencode-plugin/amicode_tools.ts", + "extension/opencode-plugin/entities.ts", + "extension/opencode-plugin/problems.ts", + "extension/opencode-plugin/hashes.ts", + "extension/opencode-plugin/score_guard.ts", +]; // Guards against a silently-dropped runtime asset (the β.2 .gitignore-fallback // trap, generalized). Locally: inert without a built .vsix (run after @@ -43,14 +43,14 @@ const REQUIRED = [ // vsix-gate job (#45) sets AMICODE_REQUIRE_VSIX=1, under which the suite can // NEVER self-skip — a missing .vsix is a hard failure there, closing the // perennial "2 skip" false-green. -const REQUIRE_VSIX = process.env.AMICODE_REQUIRE_VSIX === '1' -describe.skipIf(!existsSync(VSIX) && !REQUIRE_VSIX)('packaged VSIX contains runtime assets', () => { - it('the .vsix exists (hard requirement under AMICODE_REQUIRE_VSIX=1)', () => { - expect(existsSync(VSIX), `no ${VSIX} — run: pnpm --filter amicode-v2 package`).toBe(true) - }) - it('includes amico-run, template, julia project, AGENTS.md + a vendored opencode', () => { - const listing = execFileSync('unzip', ['-Z1', VSIX], { encoding: 'utf8' }) - for (const p of REQUIRED) expect(listing, `missing ${p}`).toContain(p) - expect(/extension\/vendor\/opencode\/.+\/opencode/.test(listing), 'missing vendored opencode').toBe(true) - }) -}) +const REQUIRE_VSIX = process.env.AMICODE_REQUIRE_VSIX === "1"; +describe.skipIf(!existsSync(VSIX) && !REQUIRE_VSIX)("packaged VSIX contains runtime assets", () => { + it("the .vsix exists (hard requirement under AMICODE_REQUIRE_VSIX=1)", () => { + expect(existsSync(VSIX), `no ${VSIX} — run: pnpm --filter amicode-v2 package`).toBe(true); + }); + it("includes amico-run, template, julia project, AGENTS.md + a vendored opencode", () => { + const listing = execFileSync("unzip", ["-Z1", VSIX], { encoding: "utf8" }); + for (const p of REQUIRED) expect(listing, `missing ${p}`).toContain(p); + expect(/extension\/vendor\/opencode\/.+\/opencode/.test(listing), "missing vendored opencode").toBe(true); + }); +}); diff --git a/packages/extension/test/problems.test.ts b/packages/extension/test/problems.test.ts index 326e4170..43e005ca 100644 --- a/packages/extension/test/problems.test.ts +++ b/packages/extension/test/problems.test.ts @@ -3,11 +3,11 @@ // problems.ts uses node: builtins (fs/path/os) — sibling-module rules, not the // dependency-free entities.ts. Every test points AMICODE_PROBLEMS_DIR at a fresh // temp dir so nothing touches the real ~/.amico. Reads go through .json sidecars. -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { parse } from 'smol-toml' +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse } from "smol-toml"; import { problemsDir, problemDir, @@ -24,218 +24,230 @@ import { writeEntityFiles, lastEventSeq, migrateLegacyEntities, -} from '../opencode-plugin/problems' +} from "../opencode-plugin/problems"; -let tmp: string -let prevEnv: string | undefined +let tmp: string; +let prevEnv: string | undefined; beforeEach(() => { - prevEnv = process.env.AMICODE_PROBLEMS_DIR - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'amicode-problems-')) - process.env.AMICODE_PROBLEMS_DIR = tmp -}) + prevEnv = process.env.AMICODE_PROBLEMS_DIR; + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-problems-")); + process.env.AMICODE_PROBLEMS_DIR = tmp; +}); afterEach(() => { - if (prevEnv === undefined) delete process.env.AMICODE_PROBLEMS_DIR - else process.env.AMICODE_PROBLEMS_DIR = prevEnv - fs.rmSync(tmp, { recursive: true, force: true }) -}) - -describe('problemsDir / problemDir', () => { - it('honors AMICODE_PROBLEMS_DIR', () => { - expect(problemsDir()).toBe(tmp) - expect(problemDir('x-gate')).toBe(path.join(tmp, 'x-gate')) - }) -}) - -describe('createProblem', () => { - it('writes problem.toml + .json + entities/ and sets active', () => { - const meta = createProblem('X gate on Q1') - expect(meta.slug).toBe('x-gate-on-q1') - expect(meta.status).toBe('designing') - const dir = problemDir('x-gate-on-q1') - expect(fs.existsSync(path.join(dir, 'problem.toml'))).toBe(true) - expect(fs.existsSync(path.join(dir, 'problem.json'))).toBe(true) - expect(fs.existsSync(path.join(dir, 'entities'))).toBe(true) - expect(readActiveSlug()).toBe('x-gate-on-q1') - const doc = parse(fs.readFileSync(path.join(dir, 'problem.toml'), 'utf8')) as any - expect(doc.problem.name).toBe('X gate on Q1') - }) - it('auto-suffixes a colliding slug', () => { - createProblem('X gate') - const second = createProblem('X gate') - expect(second.slug).toBe('x-gate-2') - }) - it('records a problem/created lifecycle event', () => { - const meta = createProblem('X gate') - const lines = fs.readFileSync(path.join(problemDir(meta.slug), 'events.jsonl'), 'utf8').trim().split('\n') - const evt = JSON.parse(lines[0]) - expect(evt).toMatchObject({ seq: 1, entity: 'problem', action: 'created' }) - expect(Number.isNaN(Date.parse(evt.ts))).toBe(false) - }) -}) - -describe('openProblem', () => { - it('opens by exact slug and by fuzzy name, sets active', () => { - createProblem('X gate on Q1') - createProblem('Y gate on Q2') - expect(openProblem('x-gate-on-q1')?.slug).toBe('x-gate-on-q1') - expect(readActiveSlug()).toBe('x-gate-on-q1') - expect(openProblem('gate on q2')?.slug).toBe('y-gate-on-q2') - expect(openProblem('nonexistent')).toBeUndefined() - }) - it('excludes archived from fuzzy match but still opens by exact slug', () => { - createProblem('X gate on Q1') - archiveProblem('x-gate-on-q1') - expect(openProblem('gate on q1')).toBeUndefined() - expect(openProblem('x-gate-on-q1')?.slug).toBe('x-gate-on-q1') - }) -}) - -describe('renameProblem', () => { - it('renames name only for an established (non-untitled) slug', () => { - createProblem('X gate') - const meta = renameProblem('x-gate', 'X gate on transmon Q1') - expect(meta.slug).toBe('x-gate') // slug immutable - expect(meta.name).toBe('X gate on transmon Q1') - expect(fs.existsSync(problemDir('x-gate'))).toBe(true) - }) - it('re-slugs and renames the dir for an untitled slug, updating active', () => { - const u = ensureActiveProblem() // untitled-* - expect(u.slug.startsWith('untitled')).toBe(true) - const meta = renameProblem(u.slug, 'X gate on Q1') - expect(meta.slug).toBe('x-gate-on-q1') - expect(fs.existsSync(problemDir('x-gate-on-q1'))).toBe(true) - expect(fs.existsSync(problemDir(u.slug))).toBe(false) - expect(readActiveSlug()).toBe('x-gate-on-q1') - }) -}) - -describe('ensureActiveProblem', () => { - it('auto-creates an untitled problem when no active pointer exists', () => { - expect(readActiveSlug()).toBeUndefined() - const meta = ensureActiveProblem() - expect(meta.slug.startsWith('untitled')).toBe(true) - expect(readActiveSlug()).toBe(meta.slug) - }) - it('auto-creates when the active pointer is dangling', () => { - setActiveSlug('deleted-slug') // points at a dir that never existed - const meta = ensureActiveProblem() - expect(meta.slug).not.toBe('deleted-slug') - expect(fs.existsSync(problemDir(meta.slug))).toBe(true) - }) - it('returns the existing active problem when present', () => { - const created = createProblem('X gate') - const active = ensureActiveProblem() - expect(active.slug).toBe(created.slug) - }) -}) - -describe('appendEvent', () => { - it('returns a monotonic seq and writes valid JSONL', () => { - const meta = createProblem('X gate') // seq 1 = created - const s2 = appendEvent(meta.slug, { entity: 'system', action: 'created', diff: { platform: { from: null, to: 'transmon' } }, hash: 'sha256:abc', source: { tool: 'amicode_pick_system', stage: 'platform' } }) - const s3 = appendEvent(meta.slug, { entity: 'system', action: 'updated', diff: { levels: { from: 3, to: 4 } } }) - expect(s2).toBe(2) - expect(s3).toBe(3) - const lines = fs.readFileSync(path.join(problemDir(meta.slug), 'events.jsonl'), 'utf8').trim().split('\n') - expect(lines).toHaveLength(3) - const e2 = JSON.parse(lines[1]) - expect(e2).toMatchObject({ seq: 2, entity: 'system', action: 'created', hash: 'sha256:abc', provenance: null }) - expect(e2.source.tool).toBe('amicode_pick_system') - }) -}) - -describe('lastEventSeq', () => { - it('returns 0 before any event and the highest seq after', () => { - const meta = createProblem('X gate') // created event = seq 1 - expect(lastEventSeq(meta.slug)).toBe(1) - appendEvent(meta.slug, { entity: 'system', action: 'created' }) - expect(lastEventSeq(meta.slug)).toBe(2) - }) -}) - -describe('appendRunRef', () => { - it('appends to both runs.toml and runs.json', () => { - const meta = createProblem('X gate') - appendRunRef(meta.slug, { run_id: '20260703-190412-abcd', lab: 'default', tier: 'vetted', recorded: 't1' }) - appendRunRef(meta.slug, { run_id: '20260703-191500-efgh', lab: 'default', tier: 'free', recorded: 't2' }) - const toml = parse(fs.readFileSync(path.join(problemDir(meta.slug), 'runs.toml'), 'utf8')) as any - expect(toml.runs).toHaveLength(2) - expect(toml.runs[1].tier).toBe('free') - const json = JSON.parse(fs.readFileSync(path.join(problemDir(meta.slug), 'runs.json'), 'utf8')) - expect(json.runs).toHaveLength(2) - expect(json.runs[0].run_id).toBe('20260703-190412-abcd') - }) -}) - -describe('writeEntityFiles', () => { - it('writes entities/.toml + .json', () => { - const meta = createProblem('X gate') - writeEntityFiles(meta.slug, 'system', '[system]\nplatform = "transmon"\n', '{"platform":"transmon"}\n') - const dir = path.join(problemDir(meta.slug), 'entities') - expect(fs.readFileSync(path.join(dir, 'system.toml'), 'utf8')).toContain('transmon') - expect(JSON.parse(fs.readFileSync(path.join(dir, 'system.json'), 'utf8')).platform).toBe('transmon') - }) -}) - -describe('listProblems', () => { - it('lists all problems with status', () => { - createProblem('X gate') - createProblem('Y gate') - archiveProblem('y-gate') - const all = listProblems() - expect(all.map((p) => p.slug).sort()).toEqual(['x-gate', 'y-gate']) - expect(all.find((p) => p.slug === 'y-gate')?.status).toBe('archived') - }) -}) - -describe('migrateLegacyEntities (injectable roots — env-skip lives at the call site)', () => { + if (prevEnv === undefined) delete process.env.AMICODE_PROBLEMS_DIR; + else process.env.AMICODE_PROBLEMS_DIR = prevEnv; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe("problemsDir / problemDir", () => { + it("honors AMICODE_PROBLEMS_DIR", () => { + expect(problemsDir()).toBe(tmp); + expect(problemDir("x-gate")).toBe(path.join(tmp, "x-gate")); + }); +}); + +describe("createProblem", () => { + it("writes problem.toml + .json + entities/ and sets active", () => { + const meta = createProblem("X gate on Q1"); + expect(meta.slug).toBe("x-gate-on-q1"); + expect(meta.status).toBe("designing"); + const dir = problemDir("x-gate-on-q1"); + expect(fs.existsSync(path.join(dir, "problem.toml"))).toBe(true); + expect(fs.existsSync(path.join(dir, "problem.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "entities"))).toBe(true); + expect(readActiveSlug()).toBe("x-gate-on-q1"); + const doc = parse(fs.readFileSync(path.join(dir, "problem.toml"), "utf8")) as any; + expect(doc.problem.name).toBe("X gate on Q1"); + }); + it("auto-suffixes a colliding slug", () => { + createProblem("X gate"); + const second = createProblem("X gate"); + expect(second.slug).toBe("x-gate-2"); + }); + it("records a problem/created lifecycle event", () => { + const meta = createProblem("X gate"); + const lines = fs + .readFileSync(path.join(problemDir(meta.slug), "events.jsonl"), "utf8") + .trim() + .split("\n"); + const evt = JSON.parse(lines[0]); + expect(evt).toMatchObject({ seq: 1, entity: "problem", action: "created" }); + expect(Number.isNaN(Date.parse(evt.ts))).toBe(false); + }); +}); + +describe("openProblem", () => { + it("opens by exact slug and by fuzzy name, sets active", () => { + createProblem("X gate on Q1"); + createProblem("Y gate on Q2"); + expect(openProblem("x-gate-on-q1")?.slug).toBe("x-gate-on-q1"); + expect(readActiveSlug()).toBe("x-gate-on-q1"); + expect(openProblem("gate on q2")?.slug).toBe("y-gate-on-q2"); + expect(openProblem("nonexistent")).toBeUndefined(); + }); + it("excludes archived from fuzzy match but still opens by exact slug", () => { + createProblem("X gate on Q1"); + archiveProblem("x-gate-on-q1"); + expect(openProblem("gate on q1")).toBeUndefined(); + expect(openProblem("x-gate-on-q1")?.slug).toBe("x-gate-on-q1"); + }); +}); + +describe("renameProblem", () => { + it("renames name only for an established (non-untitled) slug", () => { + createProblem("X gate"); + const meta = renameProblem("x-gate", "X gate on transmon Q1"); + expect(meta.slug).toBe("x-gate"); // slug immutable + expect(meta.name).toBe("X gate on transmon Q1"); + expect(fs.existsSync(problemDir("x-gate"))).toBe(true); + }); + it("re-slugs and renames the dir for an untitled slug, updating active", () => { + const u = ensureActiveProblem(); // untitled-* + expect(u.slug.startsWith("untitled")).toBe(true); + const meta = renameProblem(u.slug, "X gate on Q1"); + expect(meta.slug).toBe("x-gate-on-q1"); + expect(fs.existsSync(problemDir("x-gate-on-q1"))).toBe(true); + expect(fs.existsSync(problemDir(u.slug))).toBe(false); + expect(readActiveSlug()).toBe("x-gate-on-q1"); + }); +}); + +describe("ensureActiveProblem", () => { + it("auto-creates an untitled problem when no active pointer exists", () => { + expect(readActiveSlug()).toBeUndefined(); + const meta = ensureActiveProblem(); + expect(meta.slug.startsWith("untitled")).toBe(true); + expect(readActiveSlug()).toBe(meta.slug); + }); + it("auto-creates when the active pointer is dangling", () => { + setActiveSlug("deleted-slug"); // points at a dir that never existed + const meta = ensureActiveProblem(); + expect(meta.slug).not.toBe("deleted-slug"); + expect(fs.existsSync(problemDir(meta.slug))).toBe(true); + }); + it("returns the existing active problem when present", () => { + const created = createProblem("X gate"); + const active = ensureActiveProblem(); + expect(active.slug).toBe(created.slug); + }); +}); + +describe("appendEvent", () => { + it("returns a monotonic seq and writes valid JSONL", () => { + const meta = createProblem("X gate"); // seq 1 = created + const s2 = appendEvent(meta.slug, { + entity: "system", + action: "created", + diff: { platform: { from: null, to: "transmon" } }, + hash: "sha256:abc", + source: { tool: "amicode_pick_system", stage: "platform" }, + }); + const s3 = appendEvent(meta.slug, { entity: "system", action: "updated", diff: { levels: { from: 3, to: 4 } } }); + expect(s2).toBe(2); + expect(s3).toBe(3); + const lines = fs + .readFileSync(path.join(problemDir(meta.slug), "events.jsonl"), "utf8") + .trim() + .split("\n"); + expect(lines).toHaveLength(3); + const e2 = JSON.parse(lines[1]); + expect(e2).toMatchObject({ seq: 2, entity: "system", action: "created", hash: "sha256:abc", provenance: null }); + expect(e2.source.tool).toBe("amicode_pick_system"); + }); +}); + +describe("lastEventSeq", () => { + it("returns 0 before any event and the highest seq after", () => { + const meta = createProblem("X gate"); // created event = seq 1 + expect(lastEventSeq(meta.slug)).toBe(1); + appendEvent(meta.slug, { entity: "system", action: "created" }); + expect(lastEventSeq(meta.slug)).toBe(2); + }); +}); + +describe("appendRunRef", () => { + it("appends to both runs.toml and runs.json", () => { + const meta = createProblem("X gate"); + appendRunRef(meta.slug, { run_id: "20260703-190412-abcd", lab: "default", tier: "vetted", recorded: "t1" }); + appendRunRef(meta.slug, { run_id: "20260703-191500-efgh", lab: "default", tier: "free", recorded: "t2" }); + const toml = parse(fs.readFileSync(path.join(problemDir(meta.slug), "runs.toml"), "utf8")) as any; + expect(toml.runs).toHaveLength(2); + expect(toml.runs[1].tier).toBe("free"); + const json = JSON.parse(fs.readFileSync(path.join(problemDir(meta.slug), "runs.json"), "utf8")); + expect(json.runs).toHaveLength(2); + expect(json.runs[0].run_id).toBe("20260703-190412-abcd"); + }); +}); + +describe("writeEntityFiles", () => { + it("writes entities/.toml + .json", () => { + const meta = createProblem("X gate"); + writeEntityFiles(meta.slug, "system", '[system]\nplatform = "transmon"\n', '{"platform":"transmon"}\n'); + const dir = path.join(problemDir(meta.slug), "entities"); + expect(fs.readFileSync(path.join(dir, "system.toml"), "utf8")).toContain("transmon"); + expect(JSON.parse(fs.readFileSync(path.join(dir, "system.json"), "utf8")).platform).toBe("transmon"); + }); +}); + +describe("listProblems", () => { + it("lists all problems with status", () => { + createProblem("X gate"); + createProblem("Y gate"); + archiveProblem("y-gate"); + const all = listProblems(); + expect(all.map((p) => p.slug).sort()).toEqual(["x-gate", "y-gate"]); + expect(all.find((p) => p.slug === "y-gate")?.status).toBe("archived"); + }); +}); + +describe("migrateLegacyEntities (injectable roots — env-skip lives at the call site)", () => { function legacyFixture(): string { - const legacy = fs.mkdtempSync(path.join(os.tmpdir(), 'amicode-legacy-')) - fs.writeFileSync(path.join(legacy, 'system.toml'), '[system]\nplatform = "transmon"\n') - fs.writeFileSync(path.join(legacy, 'system.json'), '{"platform":"transmon"}') - fs.writeFileSync(path.join(legacy, 'formulation.toml'), '[formulation]\nproblem = "gate_synthesis"\n') - fs.writeFileSync(path.join(legacy, 'score_manifest.json'), '{"manifest":{}}') - fs.writeFileSync(path.join(legacy, 'interview_state.json'), '{}') - fs.writeFileSync(path.join(legacy, 'usage.jsonl'), '{}\n') - return legacy + const legacy = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-legacy-")); + fs.writeFileSync(path.join(legacy, "system.toml"), '[system]\nplatform = "transmon"\n'); + fs.writeFileSync(path.join(legacy, "system.json"), '{"platform":"transmon"}'); + fs.writeFileSync(path.join(legacy, "formulation.toml"), '[formulation]\nproblem = "gate_synthesis"\n'); + fs.writeFileSync(path.join(legacy, "score_manifest.json"), '{"manifest":{}}'); + fs.writeFileSync(path.join(legacy, "interview_state.json"), "{}"); + fs.writeFileSync(path.join(legacy, "usage.jsonl"), "{}\n"); + return legacy; } - it('reshapes a flat legacy dir into an archived problem workspace + sets active', () => { - const legacy = legacyFixture() - const root = path.join(tmp, 'fresh-problems') // does not exist yet - migrateLegacyEntities(legacy, root) - const dirs = fs.readdirSync(root).filter((d) => d.startsWith('legacy-')) - expect(dirs).toHaveLength(1) - const ws = path.join(root, dirs[0]) + it("reshapes a flat legacy dir into an archived problem workspace + sets active", () => { + const legacy = legacyFixture(); + const root = path.join(tmp, "fresh-problems"); // does not exist yet + migrateLegacyEntities(legacy, root); + const dirs = fs.readdirSync(root).filter((d) => d.startsWith("legacy-")); + expect(dirs).toHaveLength(1); + const ws = path.join(root, dirs[0]); // entity files reshaped under entities/ - expect(fs.existsSync(path.join(ws, 'entities', 'system.toml'))).toBe(true) - expect(fs.existsSync(path.join(ws, 'entities', 'system.json'))).toBe(true) - expect(fs.existsSync(path.join(ws, 'entities', 'formulation.toml'))).toBe(true) + expect(fs.existsSync(path.join(ws, "entities", "system.toml"))).toBe(true); + expect(fs.existsSync(path.join(ws, "entities", "system.json"))).toBe(true); + expect(fs.existsSync(path.join(ws, "entities", "formulation.toml"))).toBe(true); // score-state files at the workspace root - expect(fs.existsSync(path.join(ws, 'score_manifest.json'))).toBe(true) - expect(fs.existsSync(path.join(ws, 'interview_state.json'))).toBe(true) - expect(fs.existsSync(path.join(ws, 'usage.jsonl'))).toBe(true) + expect(fs.existsSync(path.join(ws, "score_manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(ws, "interview_state.json"))).toBe(true); + expect(fs.existsSync(path.join(ws, "usage.jsonl"))).toBe(true); // synthesized archived meta + active set (no other problem) - const meta = JSON.parse(fs.readFileSync(path.join(ws, 'problem.json'), 'utf8')) - expect(meta.status).toBe('archived') - expect(fs.readFileSync(path.join(root, 'active'), 'utf8').trim()).toBe(dirs[0]) - fs.rmSync(legacy, { recursive: true, force: true }) - }) - - it('no-ops when problemsRoot already exists', () => { - const legacy = legacyFixture() - const root = path.join(tmp, 'existing-problems') - fs.mkdirSync(root, { recursive: true }) - migrateLegacyEntities(legacy, root) - expect(fs.readdirSync(root).filter((d) => d.startsWith('legacy-'))).toHaveLength(0) - fs.rmSync(legacy, { recursive: true, force: true }) - }) - - it('no-ops when legacySrc is absent', () => { - const root = path.join(tmp, 'root-no-legacy') - migrateLegacyEntities(path.join(tmp, 'does-not-exist'), root) - expect(fs.existsSync(root)).toBe(false) - }) -}) + const meta = JSON.parse(fs.readFileSync(path.join(ws, "problem.json"), "utf8")); + expect(meta.status).toBe("archived"); + expect(fs.readFileSync(path.join(root, "active"), "utf8").trim()).toBe(dirs[0]); + fs.rmSync(legacy, { recursive: true, force: true }); + }); + + it("no-ops when problemsRoot already exists", () => { + const legacy = legacyFixture(); + const root = path.join(tmp, "existing-problems"); + fs.mkdirSync(root, { recursive: true }); + migrateLegacyEntities(legacy, root); + expect(fs.readdirSync(root).filter((d) => d.startsWith("legacy-"))).toHaveLength(0); + fs.rmSync(legacy, { recursive: true, force: true }); + }); + + it("no-ops when legacySrc is absent", () => { + const root = path.join(tmp, "root-no-legacy"); + migrateLegacyEntities(path.join(tmp, "does-not-exist"), root); + expect(fs.existsSync(root)).toBe(false); + }); +}); diff --git a/packages/extension/test/run_dir_reader_stopped.test.ts b/packages/extension/test/run_dir_reader_stopped.test.ts index 7ddb7f6c..21b9248e 100644 --- a/packages/extension/test/run_dir_reader_stopped.test.ts +++ b/packages/extension/test/run_dir_reader_stopped.test.ts @@ -34,8 +34,14 @@ describe("ingestRunDir — stopped relabel", () => { const runs: Array<{ status: string; fidelity?: number }> = []; const promotes: unknown[] = []; return { - runs, promotes, - sink: { iter() {}, pulse() {}, run: (r: never) => runs.push(r as never), promote: (p: never) => promotes.push(p) }, + runs, + promotes, + sink: { + iter() {}, + pulse() {}, + run: (r: never) => runs.push(r as never), + promote: (p: never) => promotes.push(p), + }, }; } diff --git a/packages/extension/test/scores/allowlist_production.test.ts b/packages/extension/test/scores/allowlist_production.test.ts index 42998a7e..e737096b 100644 --- a/packages/extension/test/scores/allowlist_production.test.ts +++ b/packages/extension/test/scores/allowlist_production.test.ts @@ -18,9 +18,9 @@ describe("production-path entitlement allowlist (bundled assets)", () => { }); it("bundled scores/entitlements.toml carries the [packages] table", () => { - const parsed = parseToml( - fs.readFileSync(path.join(DEFAULT_SCORES_ROOT, "entitlements.toml"), "utf8"), - ) as { packages?: { default?: string[]; issimo?: string[] } }; + const parsed = parseToml(fs.readFileSync(path.join(DEFAULT_SCORES_ROOT, "entitlements.toml"), "utf8")) as { + packages?: { default?: string[]; issimo?: string[] }; + }; expect(parsed.packages?.default).toContain("Piccolo"); expect(parsed.packages?.issimo).toContain("Piccolissimo"); }); diff --git a/packages/extension/test/scores/entitlements_router.test.ts b/packages/extension/test/scores/entitlements_router.test.ts index 03515223..21c99f87 100644 --- a/packages/extension/test/scores/entitlements_router.test.ts +++ b/packages/extension/test/scores/entitlements_router.test.ts @@ -9,9 +9,17 @@ import { Score } from "../../src/scores/loader"; function score(id: string, ents: string[], extra: Partial = {}): Score { return { manifest: { - type: "score", schema_version: 1, id, version: 1, derived_from: null, - name: `Name of ${id}`, outcome: `Outcome of ${id}`, audience: ["t"], - entitlements: ents, stages: [{ id: "one" }], ...extra, + type: "score", + schema_version: 1, + id, + version: 1, + derived_from: null, + name: `Name of ${id}`, + outcome: `Outcome of ${id}`, + audience: ["t"], + entitlements: ents, + stages: [{ id: "one" }], + ...extra, }, body: "", dir: `/scores/${id}`, @@ -115,7 +123,11 @@ describe("packageAllowlist (spec C entitlement → package tiers)", () => { it("no entitlements → the five public packages", () => { expect(packageAllowlist(registry, [])).toEqual([ - "Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt", + "Piccolo", + "Legato", + "Intonato", + "NamedTrajectories", + "DirectTrajOpt", ]); }); it("issimo entitlement → adds the three gated packages", () => { @@ -125,7 +137,11 @@ describe("packageAllowlist (spec C entitlement → package tiers)", () => { }); it("missing file / malformed [packages] → public defaults, never throws", () => { expect(packageAllowlist(path.join(dir, "nope.toml"), ["issimo"])).toEqual([ - "Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt", + "Piccolo", + "Legato", + "Intonato", + "NamedTrajectories", + "DirectTrajOpt", ]); }); }); diff --git a/packages/extension/test/scores/guard.test.ts b/packages/extension/test/scores/guard.test.ts index 757d884c..a23308ec 100644 --- a/packages/extension/test/scores/guard.test.ts +++ b/packages/extension/test/scores/guard.test.ts @@ -46,7 +46,12 @@ describe("checkStagePrereqs — entity dependencies, not conversation order", () }); it("solve requires the formulation", () => { const r = checkStagePrereqs(STAGES, state(["model"]), "solve"); - expect(r).toEqual({ ok: false, code: "stage_order", required_stage: "formulate", missing_entities: ["formulation"] }); + expect(r).toEqual({ + ok: false, + code: "stage_order", + required_stage: "formulate", + missing_entities: ["formulation"], + }); }); it("optional emitting stages do not block later stages", () => { // hardware is optional; nothing after it here, but ensure optional is excluded from blockers @@ -60,11 +65,19 @@ describe("checkStagePrereqs — entity dependencies, not conversation order", () expect(r).toEqual({ ok: false, code: "gate_required", gate: "light" }); }); it("gate stage with a pass record is allowed", () => { - const r = checkStagePrereqs(STAGES, state(["model", "formulate", "solve"], { light: { result: "pass" } }), "device-sim"); + const r = checkStagePrereqs( + STAGES, + state(["model", "formulate", "solve"], { light: { result: "pass" } }), + "device-sim", + ); expect(r).toEqual({ ok: true }); }); it("gate stage with an override record is allowed", () => { - const r = checkStagePrereqs(STAGES, state(["model", "formulate", "solve"], { light: { result: "override" } }), "device-sim"); + const r = checkStagePrereqs( + STAGES, + state(["model", "formulate", "solve"], { light: { result: "override" } }), + "device-sim", + ); expect(r).toEqual({ ok: true }); }); it("unknown stage id → ok (fail-open for forward compatibility)", () => { @@ -76,7 +89,10 @@ describe("manifest + state IO (entitiesDir contract)", () => { it("loadManifest reads score_manifest.json, undefined when absent/corrupt", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "guard-")); expect(loadManifest(dir)).toBeUndefined(); - fs.writeFileSync(path.join(dir, "score_manifest.json"), JSON.stringify({ manifest: { id: "x", version: 1, stages: STAGES } })); + fs.writeFileSync( + path.join(dir, "score_manifest.json"), + JSON.stringify({ manifest: { id: "x", version: 1, stages: STAGES } }), + ); expect(loadManifest(dir)?.id).toBe("x"); fs.writeFileSync(path.join(dir, "score_manifest.json"), "{torn"); expect(loadManifest(dir)).toBeUndefined(); diff --git a/packages/extension/test/scores/overture_routing.test.ts b/packages/extension/test/scores/overture_routing.test.ts index 63113a97..da14ca59 100644 --- a/packages/extension/test/scores/overture_routing.test.ts +++ b/packages/extension/test/scores/overture_routing.test.ts @@ -72,12 +72,32 @@ describe("overture routing predicate (spec §3)", () => { describe("compileChainedScore / chainManifest (unit)", () => { const head: Score = { - manifest: { type: "score", schema_version: 1, id: "overture", version: 1, derived_from: null, name: "O", outcome: "", audience: [], stages: [{ id: "identity" }, { id: "handoff" }] } as never, + manifest: { + type: "score", + schema_version: 1, + id: "overture", + version: 1, + derived_from: null, + name: "O", + outcome: "", + audience: [], + stages: [{ id: "identity" }, { id: "handoff" }], + } as never, body: "OVERTURE BODY", dir: "/scores/overture", }; const tail: Score = { - manifest: { type: "score", schema_version: 1, id: "pulse-designer", version: 3, derived_from: null, name: "P", outcome: "", audience: [], stages: [{ id: "platform" }, { id: "solve", template: "templates/solve.jl" }] } as never, + manifest: { + type: "score", + schema_version: 1, + id: "pulse-designer", + version: 3, + derived_from: null, + name: "P", + outcome: "", + audience: [], + stages: [{ id: "platform" }, { id: "solve", template: "templates/solve.jl" }], + } as never, body: "PULSE BODY", dir: "/scores/pulse-designer", }; diff --git a/packages/extension/test/scores/package_skills.test.ts b/packages/extension/test/scores/package_skills.test.ts index 9d54485f..4e680bd8 100644 --- a/packages/extension/test/scores/package_skills.test.ts +++ b/packages/extension/test/scores/package_skills.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { resolvePackageSkills, resolveLibrarySkills, buildSkillIndexSection, stageOpencodeSkills } from "../../src/scores/package_skills"; +import { + resolvePackageSkills, + resolveLibrarySkills, + buildSkillIndexSection, + stageOpencodeSkills, +} from "../../src/scores/package_skills"; function mkRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "amicode-skillroot-")); @@ -45,7 +50,8 @@ describe("resolvePackageSkills (spec-20260704-113005 §3)", () => { expect(idx.map((e) => e.name)).toEqual(["authoring"]); }); it("first root containing

.jl/skills wins", () => { - const r1 = mkRoot(), r2 = mkRoot(); + const r1 = mkRoot(), + r2 = mkRoot(); writeSkill(r1, "Piccolissimo", "authoring", "from r1"); writeSkill(r2, "Piccolissimo", "authoring", "from r2"); const idx = resolvePackageSkills(["Piccolissimo"], [r1, r2]); @@ -86,7 +92,13 @@ describe("buildSkillIndexSection", () => { it("renders both entry kinds, the heading, and the invoke-before-authoring instruction", () => { const s = buildSkillIndexSection([ { source: "library", name: "atoms", description: "Rydberg physics", path: "/lib/atoms/SKILL.md" }, - { source: "package", package: "Piccolissimo", name: "authoring", description: "Author solves", path: "/abs/SKILL.md" }, + { + source: "package", + package: "Piccolissimo", + name: "authoring", + description: "Author solves", + path: "/abs/SKILL.md", + }, ]); expect(s).toContain("## Skill index"); // registered opencode skills (platform + package) expect(s).toContain("atoms"); diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index 6cf82322..31554351 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -52,16 +52,20 @@ function mkPkgSkillRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "pkgskill-")); const d = path.join(root, "Piccolissimo.jl", "skills", "authoring"); fs.mkdirSync(d, { recursive: true }); - fs.writeFileSync(path.join(d, "SKILL.md"), - "---\nname: piccolissimo-authoring\ndescription: author piccolissimo solves\nagents: [experimenter]\n---\n# body\n"); + fs.writeFileSync( + path.join(d, "SKILL.md"), + "---\nname: piccolissimo-authoring\ndescription: author piccolissimo solves\nagents: [experimenter]\n---\n# body\n", + ); return root; } function mkLibRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "libskill-")); const d = path.join(root, "atoms"); fs.mkdirSync(d, { recursive: true }); - fs.writeFileSync(path.join(d, "SKILL.md"), - "---\nname: atoms\ndescription: rydberg physics\nagents: [experimenter]\n---\n# body\n"); + fs.writeFileSync( + path.join(d, "SKILL.md"), + "---\nname: atoms\ndescription: rydberg physics\nagents: [experimenter]\n---\n# body\n", + ); return root; } function entitledDir(): string { @@ -120,7 +124,7 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { expect(authoring.schema_version).toBe(1); expect(authoring.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]); expect(authoring.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])); - expect(authoring.verify_tolerance).toBe(0.001); // spec-20260704-113005 §6 (resolves spec-C open q1) + expect(authoring.verify_tolerance).toBe(0.001); // spec-20260704-113005 §6 (resolves spec-C open q1) // the paths point at REAL bundled assets (Task 9 shipped them) expect(path.isAbsolute(authoring.registry) && fs.existsSync(authoring.registry)).toBe(true); expect(path.isAbsolute(authoring.exemplars) && fs.existsSync(authoring.exemplars)).toBe(true); @@ -150,8 +154,11 @@ describe("buildOpencodeConfigContent × scores", () => { it("grants each indexed skill's OWN dir only — NOT a library root (spec §3, least-privilege)", () => { const cfg = JSON.parse( buildOpencodeConfigContent( - "/abs/AGENTS.md", "/abs/templates/solve_template.jl", "/home/u/.amico/runs/default", - undefined, undefined, + "/abs/AGENTS.md", + "/abs/templates/solve_template.jl", + "/home/u/.amico/runs/default", + undefined, + undefined, ["/lib/atoms/SKILL.md", "/pkgs/Piccolissimo.jl/skills/authoring/SKILL.md"], ), ); @@ -204,7 +211,7 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source }); const agents = fs.readFileSync(proj.agentsPath, "utf8"); expect(agents).toContain("Stages, in order:"); // score compile failed → fallback interview - expect(agents).toContain("## Skill index"); // yet the skill index is STILL present + expect(agents).toContain("## Skill index"); // yet the skill index is STILL present const skills = readSkills(); expect(libNames(skills)).toContain("atoms"); expect(pkgNames(skills)).toContain("Piccolissimo"); diff --git a/packages/extension/test/scores/repertoire_lint.test.ts b/packages/extension/test/scores/repertoire_lint.test.ts index 81ab81ba..933d7037 100644 --- a/packages/extension/test/scores/repertoire_lint.test.ts +++ b/packages/extension/test/scores/repertoire_lint.test.ts @@ -9,10 +9,16 @@ import { lintRepertoire } from "../../src/scores/lint"; const EXT_ROOT = path.resolve(__dirname, "..", ".."); const REAL_SCORES = path.join(EXT_ROOT, "scores"); -function mkScore(root: string, id: string, opts: { template?: string; hooks?: string[]; derived?: string; ents?: string[] } = {}) { +function mkScore( + root: string, + id: string, + opts: { template?: string; hooks?: string[]; derived?: string; ents?: string[] } = {}, +) { const dir = path.join(root, id); fs.mkdirSync(dir, { recursive: true }); - const q = opts.hooks ? `\n questions:\n - {id: q1, prompt: "P?", memory_hooks: [${opts.hooks.join(", ")}]}` : ""; + const q = opts.hooks + ? `\n questions:\n - {id: q1, prompt: "P?", memory_hooks: [${opts.hooks.join(", ")}]}` + : ""; const tpl = opts.template ? `\n template: ${opts.template}` : ""; fs.writeFileSync( path.join(dir, "SCORE.md"), @@ -87,7 +93,9 @@ describe("lintRepertoire", () => { }); it("the REAL shipped repertoire lints clean", () => { - const registry = parseToml(fs.readFileSync(path.join(REAL_SCORES, "entitlements.toml"), "utf8")) as { known: string[] }; + const registry = parseToml(fs.readFileSync(path.join(REAL_SCORES, "entitlements.toml"), "utf8")) as { + known: string[]; + }; const load = loadRepertoire(REAL_SCORES); expect(lintRepertoire(load, path.join(REAL_SCORES, "memory"), registry.known)).toEqual([]); }); diff --git a/packages/extension/test/scores/schema.test.ts b/packages/extension/test/scores/schema.test.ts index f8cd87f3..7bf7e9c8 100644 --- a/packages/extension/test/scores/schema.test.ts +++ b/packages/extension/test/scores/schema.test.ts @@ -2,13 +2,23 @@ import { describe, it, expect } from "vitest"; import { validateScoreManifest, KNOWN_ENTITIES } from "../../src/scores/schema"; const VALID = { - type: "score", schema_version: 1, id: "pasqal-mis", version: 1, derived_from: null, - name: "Solve a graph problem", outcome: "An optimized waveform", audience: ["algorithms"], + type: "score", + schema_version: 1, + id: "pasqal-mis", + version: 1, + derived_from: null, + name: "Solve a graph problem", + outcome: "An optimized waveform", + audience: ["algorithms"], duration_estimate: "60–90 min", device: { backend: "pasqal", qpu_runnable: true, emulators: ["emu-mps"] }, entitlements: ["pasqal-hackathon-2026"], stages: [ - { id: "application", emits: ["circuit"], questions: [{ id: "graph", prompt: "Which graph?", choices: ["sample", "upload"], default: "sample" }] }, + { + id: "application", + emits: ["circuit"], + questions: [{ id: "graph", prompt: "Which graph?", choices: ["sample", "upload"], default: "sample" }], + }, { id: "solve", emits: ["run", "pulse"], executor: "cloud-altissimo", template: "templates/solve.jl" }, { id: "device-sim", emits: ["device_session"], backend: "emu-mps", gate: "light" }, { id: "device-qpu", emits: ["device_session"], backend: "fresnel", gate: "heavy" }, @@ -18,27 +28,33 @@ const VALID = { describe("validateScoreManifest", () => { it("accepts a valid manifest", () => expect(validateScoreManifest(VALID)).toEqual([])); it("rejects an unknown entity in emits", () => { - const m = structuredClone(VALID); (m.stages[0] as any).emits = ["blob"]; + const m = structuredClone(VALID); + (m.stages[0] as any).emits = ["blob"]; expect(validateScoreManifest(m).join()).toMatch(/unknown entity.*blob/i); }); it("rejects an unknown gate class", () => { - const m = structuredClone(VALID); (m.stages[2] as any).gate = "medium"; + const m = structuredClone(VALID); + (m.stages[2] as any).gate = "medium"; expect(validateScoreManifest(m).join()).toMatch(/unknown gate/i); }); it("rejects non-positive version", () => { - const m = structuredClone(VALID); m.version = 0; + const m = structuredClone(VALID); + m.version = 0; expect(validateScoreManifest(m).join()).toMatch(/version/); }); it("rejects unsupported schema_version", () => { - const m = structuredClone(VALID); m.schema_version = 99; + const m = structuredClone(VALID); + m.schema_version = 99; expect(validateScoreManifest(m).join()).toMatch(/schema_version/); }); it("rejects duplicate stage ids", () => { - const m = structuredClone(VALID); m.stages.push({ id: "solve" } as any); + const m = structuredClone(VALID); + m.stages.push({ id: "solve" } as any); expect(validateScoreManifest(m).join()).toMatch(/duplicate stage/i); }); it("rejects a question missing id or prompt", () => { - const m = structuredClone(VALID); (m.stages[0] as any).questions = [{ prompt: "no id" }]; + const m = structuredClone(VALID); + (m.stages[0] as any).questions = [{ prompt: "no id" }]; expect(validateScoreManifest(m).join()).toMatch(/question.*id/i); }); it("rejects a default not among choices", () => { @@ -47,14 +63,24 @@ describe("validateScoreManifest", () => { expect(validateScoreManifest(m).join()).toMatch(/default not among choices/i); }); it("IGNORES unknown fields (additive schema policy, spec §8)", () => { - const m = structuredClone(VALID); (m as any).future_field = { x: 1 }; + const m = structuredClone(VALID); + (m as any).future_field = { x: 1 }; (m.stages[0] as any).future_stage_field = true; expect(validateScoreManifest(m)).toEqual([]); }); it("rejects empty stages", () => { - const m = structuredClone(VALID); m.stages = []; + const m = structuredClone(VALID); + m.stages = []; expect(validateScoreManifest(m).join()).toMatch(/stages/); }); it("exports the workflow-frames entity vocabulary", () => - expect(KNOWN_ENTITIES).toEqual(["circuit", "system", "formulation", "pulse", "run", "device_session", "knowledge"])); + expect(KNOWN_ENTITIES).toEqual([ + "circuit", + "system", + "formulation", + "pulse", + "run", + "device_session", + "knowledge", + ])); }); diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index 62298ea0..c9f60cdc 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, afterAll } from 'vitest' -import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' -import { tmpdir, homedir } from 'node:os' -import { join } from 'node:path' -import { spawn, type ChildProcess } from 'node:child_process' -import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from '../../src/opencode_config' +import { describe, it, expect, afterAll } from "vitest"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from "../../src/opencode_config"; // ============================================================================ // T13 e2e — pulse-designer interview against the REAL vendored binary. @@ -23,19 +23,19 @@ import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject // readiness is polled on `GET /` + the listening log line instead. // ============================================================================ -const EXT = join(__dirname, '..', '..') -const OC_BIN = join(EXT, 'vendor', 'opencode', `${process.platform}-${process.arch}`, 'opencode') -const PLUGIN = join(EXT, 'opencode-plugin', 'amicode_tools.ts') -const AGENTS_SRC = join(EXT, 'AGENTS.md') +const EXT = join(__dirname, "..", ".."); +const OC_BIN = join(EXT, "vendor", "opencode", `${process.platform}-${process.arch}`, "opencode"); +const PLUGIN = join(EXT, "opencode-plugin", "amicode_tools.ts"); +const AGENTS_SRC = join(EXT, "AGENTS.md"); -const AUTH_JSON = join(homedir(), '.local', 'share', 'opencode', 'auth.json') +const AUTH_JSON = join(homedir(), ".local", "share", "opencode", "auth.json"); function hasCreds(): boolean { - if (process.env.AMICODE_E2E_LIVE === '1') return true // force: e.g. opencode's free anonymous tier resolves without auth.json - if (process.env.ANTHROPIC_API_KEY) return true + if (process.env.AMICODE_E2E_LIVE === "1") return true; // force: e.g. opencode's free anonymous tier resolves without auth.json + if (process.env.ANTHROPIC_API_KEY) return true; try { - return Object.keys(JSON.parse(readFileSync(AUTH_JSON, 'utf8'))).length > 0 + return Object.keys(JSON.parse(readFileSync(AUTH_JSON, "utf8"))).length > 0; } catch { - return false + return false; } } @@ -43,184 +43,212 @@ function hasCreds(): boolean { * buildOpencodeConfigContent itself (agent block + plugin path), the builder * output is used verbatim: zero test-local drift. */ function layer0Config(agentsPath: string): string { - return buildOpencodeConfigContent(agentsPath, join(EXT, 'templates', 'solve_template.jl'), join(homedir(), '.amico', 'runs', 'default')) + return buildOpencodeConfigContent( + agentsPath, + join(EXT, "templates", "solve_template.jl"), + join(homedir(), ".amico", "runs", "default"), + ); } -interface Server { child: ChildProcess; url: string; log: () => string } -const servers: ChildProcess[] = [] +interface Server { + child: ChildProcess; + url: string; + log: () => string; +} +const servers: ChildProcess[] = []; async function serve(opts: { hermetic: boolean; port: number }): Promise { - let env: NodeJS.ProcessEnv - let agentsPath: string + let env: NodeJS.ProcessEnv; + let agentsPath: string; if (opts.hermetic) { - const home = mkdtempSync(join(tmpdir(), 'e2ehome-')) - mkdirSync(join(home, '.config', 'opencode'), { recursive: true }) - writeFileSync(join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({})) - agentsPath = join(home, 'AGENTS.md') - writeFileSync(agentsPath, readFileSync(AGENTS_SRC, 'utf8')) // unsubstituted is fine for A/B - env = { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_DATA_HOME: join(home, '.local', 'share') } + const home = mkdtempSync(join(tmpdir(), "e2ehome-")); + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + writeFileSync(join(home, ".config", "opencode", "opencode.json"), JSON.stringify({})); + agentsPath = join(home, "AGENTS.md"); + writeFileSync(agentsPath, readFileSync(AGENTS_SRC, "utf8")); // unsubstituted is fine for A/B + env = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + XDG_DATA_HOME: join(home, ".local", "share"), + }; } else { // Real home: user creds + global config load (deliberate, tiers C/D). AGENTS.md // goes through the extension's REAL session prep so {{TEMPLATE_PATH}} / // {{JULIA_PROJECT}} are substituted — stage 6 depends on the real paths. const project = prepareOpencodeProject({ agentsSrc: AGENTS_SRC, - templateSrc: join(EXT, 'templates', 'solve_template.jl'), - juliaProject: resolveJuliaProject(''), - }) - agentsPath = project.agentsPath - env = { ...process.env } + templateSrc: join(EXT, "templates", "solve_template.jl"), + juliaProject: resolveJuliaProject(""), + }); + agentsPath = project.agentsPath; + env = { ...process.env }; } - env.OPENCODE_CONFIG_CONTENT = layer0Config(agentsPath) - let buf = '' - const child = spawn(OC_BIN, ['serve', '--port', String(opts.port)], { env, stdio: ['ignore', 'pipe', 'pipe'] }) - servers.push(child) - child.stdout!.on('data', (c) => (buf += c)) - child.stderr!.on('data', (c) => (buf += c)) - const url = `http://127.0.0.1:${opts.port}` - const deadline = Date.now() + 30_000 + env.OPENCODE_CONFIG_CONTENT = layer0Config(agentsPath); + let buf = ""; + const child = spawn(OC_BIN, ["serve", "--port", String(opts.port)], { env, stdio: ["ignore", "pipe", "pipe"] }); + servers.push(child); + child.stdout!.on("data", (c) => (buf += c)); + child.stderr!.on("data", (c) => (buf += c)); + const url = `http://127.0.0.1:${opts.port}`; + const deadline = Date.now() + 30_000; for (;;) { try { - const r = await fetch(url + '/', { signal: AbortSignal.timeout(1000) }) - if (r.ok) break - } catch { /* not up yet */ } - if (Date.now() > deadline) throw new Error(`serve not ready in 30s; log:\n${buf.slice(0, 2000)}`) - await new Promise((r) => setTimeout(r, 300)) + const r = await fetch(url + "/", { signal: AbortSignal.timeout(1000) }); + if (r.ok) break; + } catch { + /* not up yet */ + } + if (Date.now() > deadline) throw new Error(`serve not ready in 30s; log:\n${buf.slice(0, 2000)}`); + await new Promise((r) => setTimeout(r, 300)); } - return { child, url, log: () => buf } + return { child, url, log: () => buf }; } afterAll(() => { for (const c of servers) { - c.kill('SIGTERM') + c.kill("SIGTERM"); } -}) - -describe.skipIf(!existsSync(OC_BIN))('L0 registration against the real binary (creds-free)', () => { - it('A: pulse-designer appears in GET /agent', { timeout: 60_000 }, async () => { - const s = await serve({ hermetic: true, port: 14310 }) - const agents = (await (await fetch(s.url + '/agent')).json()) as Array<{ name: string }> - expect(agents.map((a) => a.name)).toContain('pulse-designer') - }) - - it.skipIf(!existsSync(PLUGIN))('B: amicode_tools plugin loads on session creation', { timeout: 60_000 }, async () => { - const s = await serve({ hermetic: true, port: 14311 }) - const r = await fetch(s.url + '/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) - expect(r.ok).toBe(true) - const deadline = Date.now() + 15_000 - while (!s.log().includes('[amicode-tools]') && Date.now() < deadline) await new Promise((r) => setTimeout(r, 300)) - expect(s.log(), 'plugin load line in serve log').toContain('[amicode-tools]') - }) -}) - -describe.skipIf(!existsSync(OC_BIN) || !hasCreds())('live interview turns (creds required)', () => { +}); + +describe.skipIf(!existsSync(OC_BIN))("L0 registration against the real binary (creds-free)", () => { + it("A: pulse-designer appears in GET /agent", { timeout: 60_000 }, async () => { + const s = await serve({ hermetic: true, port: 14310 }); + const agents = (await (await fetch(s.url + "/agent")).json()) as Array<{ name: string }>; + expect(agents.map((a) => a.name)).toContain("pulse-designer"); + }); + + it.skipIf(!existsSync(PLUGIN))("B: amicode_tools plugin loads on session creation", { timeout: 60_000 }, async () => { + const s = await serve({ hermetic: true, port: 14311 }); + const r = await fetch(s.url + "/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(r.ok).toBe(true); + const deadline = Date.now() + 15_000; + while (!s.log().includes("[amicode-tools]") && Date.now() < deadline) await new Promise((r) => setTimeout(r, 300)); + expect(s.log(), "plugin load line in serve log").toContain("[amicode-tools]"); + }); +}); + +describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("live interview turns (creds required)", () => { it('C: opens with ONE platform question, then LaTeX on "transmon"', { timeout: 300_000 }, async () => { - const s = await serve({ hermetic: false, port: 14312 }) + const s = await serve({ hermetic: false, port: 14312 }); const ses = (await ( - await fetch(s.url + '/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) - ).json()) as { id: string } + await fetch(s.url + "/session", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }) + ).json()) as { id: string }; const turn = async (text: string): Promise => { const r = await fetch(`${s.url}/session/${ses.id}/message`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ agent: 'pulse-designer', parts: [{ type: 'text', text }] }), - }) - expect(r.ok, `message POST ${r.status}`).toBe(true) - const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> } - return (msg.parts ?? []).filter((p) => p.type === 'text').map((p) => p.text).join('\n') - } - - const q1 = await turn('help me design a pulse') - expect(q1.toLowerCase()).toMatch(/system|platform/) + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), + }); + expect(r.ok, `message POST ${r.status}`).toBe(true); + const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> }; + return (msg.parts ?? []) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("\n"); + }; + + const q1 = await turn("help me design a pulse"); + expect(q1.toLowerCase()).toMatch(/system|platform/); // One question AT A TIME = stage 1 only. Multiple "?" inside the platform // question (listing options) is fine; asking stage-2+ topics in the same // breath is the real protocol violation. - expect(q1.toLowerCase(), 'no stage-batching in turn 1').not.toMatch(/max_iter|timestep|objective|constraint|drive_max|how many levels/) + expect(q1.toLowerCase(), "no stage-batching in turn 1").not.toMatch( + /max_iter|timestep|objective|constraint|drive_max|how many levels/, + ); - const q2 = await turn('transmon') - expect(q2).toMatch(/\\hat|H\s*\/\s*\\hbar|hamiltonian/i) + const q2 = await turn("transmon"); + expect(q2).toMatch(/\\hat|H\s*\/\s*\\hbar|hamiltonian/i); writeFileSync( join(tmpdir(), `amicode-e2e-transcript-${Date.now()}.md`), `# tier C transcript\n\n## turn 1 (help me design a pulse)\n\n${q1}\n\n## turn 2 (transmon)\n\n${q2}\n`, - ) - }) + ); + }); - it.skipIf(process.env.AMICODE_E2E_FULLCHAIN !== '1')( - 'D: full chain — interview through a REAL launched solve (MVP DoD)', + it.skipIf(process.env.AMICODE_E2E_FULLCHAIN !== "1")( + "D: full chain — interview through a REAL launched solve (MVP DoD)", { timeout: 900_000 }, async () => { - const RUNS = join(homedir(), '.amico', 'runs', 'default') - const before = new Set(existsSync(RUNS) ? require('node:fs').readdirSync(RUNS) : []) + const RUNS = join(homedir(), ".amico", "runs", "default"); + const before = new Set(existsSync(RUNS) ? require("node:fs").readdirSync(RUNS) : []); - const s = await serve({ hermetic: false, port: 14314 }) + const s = await serve({ hermetic: false, port: 14314 }); const ses = (await ( - await fetch(s.url + '/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) - ).json()) as { id: string } + await fetch(s.url + "/session", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }) + ).json()) as { id: string }; const turn = async (text: string): Promise => { const r = await fetch(`${s.url}/session/${ses.id}/message`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ agent: 'pulse-designer', parts: [{ type: 'text', text }] }), - }) - expect(r.ok, `message POST ${r.status}`).toBe(true) - const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> } - return (msg.parts ?? []).filter((p) => p.type === 'text').map((p) => p.text).join('\n') - } + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), + }); + expect(r.ok, `message POST ${r.status}`).toBe(true); + const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> }; + return (msg.parts ?? []) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("\n"); + }; // Keyword-routed answers — the model controls stage order, we answer whatever // it asks. Bounded turns; exit as soon as it reports the launch. const route = (q: string): string => { - const l = q.toLowerCase() - if (/launched|run inspector/.test(l)) return '' - if (/system|platform/.test(l) && !/frequency|levels/.test(l)) return 'transmon' - if (/omega|frequency|\\omega|delta|anharmonicity/.test(l)) return 'omega = 4.8 GHz, delta = -0.2 GHz' - if (/levels|parameteriz|drive_max|drive bound|amplitude/.test(l)) return '3 levels, default drives' - if (/simulate|warm start|straight to solve|mode/.test(l)) return 'straight to solve, no warm start' - if (/gate|target|state prep|problem/.test(l)) return 'an X gate' - if (/objective|constraint/.test(l)) return 'defaults are fine' - if (/max_iter|iterations|gate time|timesteps|solve param|\bT\b|\bN\b/.test(l)) return 'T = 10 ns, N = 50, max_iter = 60 — launch it' - return 'defaults are fine — continue' - } - - const transcript: string[] = [] - let reply = await turn('help me design a pulse for my transmon — walk me through it') - transcript.push(`## turn 1\n\n${reply}`) - let launched = /solve launched|run inspector/i.test(reply) + const l = q.toLowerCase(); + if (/launched|run inspector/.test(l)) return ""; + if (/system|platform/.test(l) && !/frequency|levels/.test(l)) return "transmon"; + if (/omega|frequency|\\omega|delta|anharmonicity/.test(l)) return "omega = 4.8 GHz, delta = -0.2 GHz"; + if (/levels|parameteriz|drive_max|drive bound|amplitude/.test(l)) return "3 levels, default drives"; + if (/simulate|warm start|straight to solve|mode/.test(l)) return "straight to solve, no warm start"; + if (/gate|target|state prep|problem/.test(l)) return "an X gate"; + if (/objective|constraint/.test(l)) return "defaults are fine"; + if (/max_iter|iterations|gate time|timesteps|solve param|\bT\b|\bN\b/.test(l)) + return "T = 10 ns, N = 50, max_iter = 60 — launch it"; + return "defaults are fine — continue"; + }; + + const transcript: string[] = []; + let reply = await turn("help me design a pulse for my transmon — walk me through it"); + transcript.push(`## turn 1\n\n${reply}`); + let launched = /solve launched|run inspector/i.test(reply); for (let t = 2; t <= 14 && !launched; t++) { - const answer = route(reply) - reply = await turn(answer) - transcript.push(`## turn ${t} (sent: ${answer})\n\n${reply}`) - launched = /solve launched|run inspector/i.test(reply) + const answer = route(reply); + reply = await turn(answer); + transcript.push(`## turn ${t} (sent: ${answer})\n\n${reply}`); + launched = /solve launched|run inspector/i.test(reply); } - writeFileSync(join(tmpdir(), `amicode-e2e-fullchain-${Date.now()}.md`), transcript.join('\n\n')) - expect(launched, 'agent reported the launch').toBe(true) + writeFileSync(join(tmpdir(), `amicode-e2e-fullchain-${Date.now()}.md`), transcript.join("\n\n")); + expect(launched, "agent reported the launch").toBe(true); // A NEW run-dir appears and completes. - const deadline = Date.now() + 420_000 - let newRun: string | undefined + const deadline = Date.now() + 420_000; + let newRun: string | undefined; for (;;) { - const now = existsSync(RUNS) ? (require('node:fs').readdirSync(RUNS) as string[]) : [] - newRun = now.find((d) => !before.has(d) && d.startsWith('r')) - if (newRun && existsSync(join(RUNS, newRun, 'FINISHED'))) break - if (Date.now() > deadline) throw new Error(`no FINISHED run-dir (newRun=${newRun})`) - await new Promise((r) => setTimeout(r, 5000)) + const now = existsSync(RUNS) ? (require("node:fs").readdirSync(RUNS) as string[]) : []; + newRun = now.find((d) => !before.has(d) && d.startsWith("r")); + if (newRun && existsSync(join(RUNS, newRun, "FINISHED"))) break; + if (Date.now() > deadline) throw new Error(`no FINISHED run-dir (newRun=${newRun})`); + await new Promise((r) => setTimeout(r, 5000)); } - const result = readFileSync(join(RUNS, newRun!, 'result.toml'), 'utf8') - const fidelity = Number(/fidelity\s*=\s*([0-9.eE+-]+)/.exec(result)?.[1]) - expect(fidelity, `fidelity from ${newRun}`).toBeGreaterThan(0.99) + const result = readFileSync(join(RUNS, newRun!, "result.toml"), "utf8"); + const fidelity = Number(/fidelity\s*=\s*([0-9.eE+-]+)/.exec(result)?.[1]); + expect(fidelity, `fidelity from ${newRun}`).toBeGreaterThan(0.99); // Entity bookkeeping (soft — free-tier models may skip tool calls; a miss is // a prompt-strength finding, not a chain failure). Entities live in the // active problem workspace now (spec A), not the old global _entities dir. - const problemsRoot = join(homedir(), '.amico', 'problems') - const activeFile = join(problemsRoot, 'active') - const activeSlug = existsSync(activeFile) ? readFileSync(activeFile, 'utf8').trim() : '' - const sysToml = activeSlug ? join(problemsRoot, activeSlug, 'entities', 'system.toml') : '' + const problemsRoot = join(homedir(), ".amico", "problems"); + const activeFile = join(problemsRoot, "active"); + const activeSlug = existsSync(activeFile) ? readFileSync(activeFile, "utf8").trim() : ""; + const sysToml = activeSlug ? join(problemsRoot, activeSlug, "entities", "system.toml") : ""; if (!sysToml || !existsSync(sysToml)) { - console.warn('[tier D] amicode_pick_system was not called — record as prompt-strength finding') + console.warn("[tier D] amicode_pick_system was not called — record as prompt-strength finding"); } }, - ) -}) + ); +}); diff --git a/packages/extension/test/slow/template.test.ts b/packages/extension/test/slow/template.test.ts index 484421e9..3f1e5d39 100644 --- a/packages/extension/test/slow/template.test.ts +++ b/packages/extension/test/slow/template.test.ts @@ -1,26 +1,29 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, readdirSync, existsSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const RUN = join(__dirname, '..', '..', '..', 'amico-run', 'dist', 'amico-run.js') // β.1 bundle -const TEMPLATE = join(__dirname, '..', '..', 'templates', 'solve_template.jl') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const RUN = join(__dirname, "..", "..", "..", "amico-run", "dist", "amico-run.js"); // β.1 bundle +const TEMPLATE = join(__dirname, "..", "..", "templates", "solve_template.jl"); -describe.skipIf(!PROJECT)('slow: solve_template.jl through amico-run (β.3 AC)', () => { - it('unmodified template → FINISHED{completed} + pulse + iter PNG + AMICODE_ITER', () => { - const root = mkdtempSync(join(tmpdir(), 'tmpl-vet-')) - const stdout = execFileSync('node', [RUN, TEMPLATE, '--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=/) // mirror β.1 - const runDir = stdout.match(/AMICODE_FINISHED .*runDir=(.+)/)![1].trim() // anchored capture - expect(parse(readFileSync(join(runDir, 'FINISHED'), 'utf8')).status).toBe('completed') - expect(existsSync(join(runDir, 'pulse.jld2'))).toBe(true) - expect(readdirSync(runDir).some(f => /^iter_\d+\.png$/.test(f))).toBe(true) - const r = parse(readFileSync(join(runDir, 'result.toml'), 'utf8')) as Record - expect(r.fidelity as number).toBeGreaterThan(0.99) - }, 600_000) -}) +describe.skipIf(!PROJECT)("slow: solve_template.jl through amico-run (β.3 AC)", () => { + it("unmodified template → FINISHED{completed} + pulse + iter PNG + AMICODE_ITER", () => { + const root = mkdtempSync(join(tmpdir(), "tmpl-vet-")); + const stdout = execFileSync( + "node", + [RUN, TEMPLATE, "--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=/); // mirror β.1 + const runDir = stdout.match(/AMICODE_FINISHED .*runDir=(.+)/)![1].trim(); // anchored capture + expect(parse(readFileSync(join(runDir, "FINISHED"), "utf8")).status).toBe("completed"); + expect(existsSync(join(runDir, "pulse.jld2"))).toBe(true); + expect(readdirSync(runDir).some((f) => /^iter_\d+\.png$/.test(f))).toBe(true); + const r = parse(readFileSync(join(runDir, "result.toml"), "utf8")) as Record; + expect(r.fidelity as number).toBeGreaterThan(0.99); + }, 600_000); +}); diff --git a/packages/extension/test/slow/verify_harness.test.ts b/packages/extension/test/slow/verify_harness.test.ts index 9c8d0a73..e7848567 100644 --- a/packages/extension/test/slow/verify_harness.test.ts +++ b/packages/extension/test/slow/verify_harness.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, writeFileSync, existsSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; // Live golden test for the tier-3 re-rollout harness (spec C). Gated on a real // Julia+Piccolo project (same gate the template slow test uses). Builds a @@ -12,8 +12,8 @@ import { parse } from 'smol-toml' // pulse so the harness's re-rollout disagrees → agree=false. This exercises the // full harness plumbing (jld2 read → QuantumSystem reconstruction → // unitary_rollout → unitary_fidelity → verification.toml). -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const HARNESS = join(__dirname, '..', '..', 'julia', 'verify_rollout.jl') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const HARNESS = join(__dirname, "..", "..", "julia", "verify_rollout.jl"); // Fixture builder (Julia): construct the SAME system the vetted template uses, // take a pulse, record its native-rollout fidelity, and serialize the tier-3 @@ -54,28 +54,29 @@ if scale != 1.0 end JLD2.save(joinpath(run_dir, "pulse.jld2"), "traj", traj) println("FIXTURE fid=$(fid)") -` +`; function stageAndVerify(scale: number): Record { - const runDir = mkdtempSync(join(tmpdir(), 'verify-golden-')) - writeFileSync(join(runDir, 'fixture.jl'), FIXTURE) - execFileSync('julia', [`--project=${PROJECT}`, join(runDir, 'fixture.jl'), runDir, String(scale)], - { encoding: 'utf8', timeout: 600_000 }) - execFileSync('julia', [`--project=${PROJECT}`, HARNESS, runDir, '0.01'], - { encoding: 'utf8', timeout: 600_000 }) - expect(existsSync(join(runDir, 'verification.toml'))).toBe(true) - return parse(readFileSync(join(runDir, 'verification.toml'), 'utf8')) as Record + const runDir = mkdtempSync(join(tmpdir(), "verify-golden-")); + writeFileSync(join(runDir, "fixture.jl"), FIXTURE); + execFileSync("julia", [`--project=${PROJECT}`, join(runDir, "fixture.jl"), runDir, String(scale)], { + encoding: "utf8", + timeout: 600_000, + }); + execFileSync("julia", [`--project=${PROJECT}`, HARNESS, runDir, "0.01"], { encoding: "utf8", timeout: 600_000 }); + expect(existsSync(join(runDir, "verification.toml"))).toBe(true); + return parse(readFileSync(join(runDir, "verification.toml"), "utf8")) as Record; } -describe.skipIf(!PROJECT)('slow: verify_rollout.jl golden (spec C tier-3 harness)', () => { - it('unmodified pulse round-trips with agree=true', () => { - const v = stageAndVerify(1.0) - expect(v.integrator).toBe('piccolo_unitary_rollout') - expect(v.agree).toBe(true) - expect(Math.abs((v.fidelity_rerolled as number) - (v.fidelity_reported as number))).toBeLessThanOrEqual(0.01) - }, 600_000) - it('corrupted pulse (×0.5) → re-rollout disagrees, agree=false', () => { - const v = stageAndVerify(0.5) - expect(v.agree).toBe(false) - }, 600_000) -}) +describe.skipIf(!PROJECT)("slow: verify_rollout.jl golden (spec C tier-3 harness)", () => { + it("unmodified pulse round-trips with agree=true", () => { + const v = stageAndVerify(1.0); + expect(v.integrator).toBe("piccolo_unitary_rollout"); + expect(v.agree).toBe(true); + expect(Math.abs((v.fidelity_rerolled as number) - (v.fidelity_reported as number))).toBeLessThanOrEqual(0.01); + }, 600_000); + it("corrupted pulse (×0.5) → re-rollout disagrees, agree=false", () => { + const v = stageAndVerify(0.5); + expect(v.agree).toBe(false); + }, 600_000); +}); diff --git a/packages/extension/test/slow/verify_spline_free_phase.test.ts b/packages/extension/test/slow/verify_spline_free_phase.test.ts index 49c0d13c..f34ced06 100644 --- a/packages/extension/test/slow/verify_spline_free_phase.test.ts +++ b/packages/extension/test/slow/verify_spline_free_phase.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; // Live golden for the spline/free-phase harness (spec-20260704-113005 §6/§9). // make_verify_golden.jl builds a REAL qubit⊗qutrit [2,3] EmbeddedOperator (unequal @@ -12,36 +12,36 @@ import { parse } from 'smol-toml' // binary-decomposition builder — agreement validates the convention on unequal // levels — and must FAIL CLOSED when a spline solve omits the dense pulse. // (The PWC/fixed-phase path is covered by verify_harness.test.ts, unchanged.) -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const GEN = join(__dirname, '..', '..', 'julia', 'make_verify_golden.jl') -const HARNESS = join(__dirname, '..', '..', 'julia', 'verify_rollout.jl') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const GEN = join(__dirname, "..", "..", "julia", "make_verify_golden.jl"); +const HARNESS = join(__dirname, "..", "..", "julia", "verify_rollout.jl"); function genGolden(): string { - const dir = mkdtempSync(join(tmpdir(), 'verify-fp-')) - execFileSync('julia', [`--project=${PROJECT}`, GEN, dir], { encoding: 'utf8', timeout: 600_000 }) - return dir + const dir = mkdtempSync(join(tmpdir(), "verify-fp-")); + execFileSync("julia", [`--project=${PROJECT}`, GEN, dir], { encoding: "utf8", timeout: 600_000 }); + return dir; } function runHarness(dir: string): Record { - execFileSync('julia', [`--project=${PROJECT}`, HARNESS, dir, '0.001'], { encoding: 'utf8', timeout: 600_000 }) - expect(existsSync(join(dir, 'verification.toml'))).toBe(true) - return parse(readFileSync(join(dir, 'verification.toml'), 'utf8')) as Record + execFileSync("julia", [`--project=${PROJECT}`, HARNESS, dir, "0.001"], { encoding: "utf8", timeout: 600_000 }); + expect(existsSync(join(dir, "verification.toml"))).toBe(true); + return parse(readFileSync(join(dir, "verification.toml"), "utf8")) as Record; } -describe.skipIf(!PROJECT)('slow: verify_rollout.jl spline + free-phase (spec-20260704-113005 §6/§9)', () => { - it('dense pulse + free-phase [2,3] → agree=true via the binary-decomposition builder', () => { - const v = runHarness(genGolden()) - expect(v.integrator).toBe('piccolo_unitary_rollout_dense') - expect(v.agree).toBe(true) - expect(Math.abs((v.fidelity_rerolled as number) - (v.fidelity_reported as number))).toBeLessThanOrEqual(0.001) - }, 600_000) +describe.skipIf(!PROJECT)("slow: verify_rollout.jl spline + free-phase (spec-20260704-113005 §6/§9)", () => { + it("dense pulse + free-phase [2,3] → agree=true via the binary-decomposition builder", () => { + const v = runHarness(genGolden()); + expect(v.integrator).toBe("piccolo_unitary_rollout_dense"); + expect(v.agree).toBe(true); + expect(Math.abs((v.fidelity_rerolled as number) - (v.fidelity_reported as number))).toBeLessThanOrEqual(0.001); + }, 600_000); - it('spline solve with pulse_dense.jld2 missing → fails closed (missing_dense_pulse)', () => { - const dir = genGolden() - rmSync(join(dir, 'pulse_dense.jld2')) - const v = runHarness(dir) - expect(v.agree).toBe(false) - expect(v.error).toBe('missing_dense_pulse') - expect(v.integrator).toBe('none') - expect(v.fidelity_rerolled).toBe('nan') // string fallback convention (verify.ts writeFallback) - }, 600_000) -}) + it("spline solve with pulse_dense.jld2 missing → fails closed (missing_dense_pulse)", () => { + const dir = genGolden(); + rmSync(join(dir, "pulse_dense.jld2")); + const v = runHarness(dir); + expect(v.agree).toBe(false); + expect(v.error).toBe("missing_dense_pulse"); + expect(v.integrator).toBe("none"); + expect(v.fidelity_rerolled).toBe("nan"); // string fallback convention (verify.ts writeFallback) + }, 600_000); +}); diff --git a/packages/extension/test/sparkline.test.ts b/packages/extension/test/sparkline.test.ts index a4473a0b..ca108d88 100644 --- a/packages/extension/test/sparkline.test.ts +++ b/packages/extension/test/sparkline.test.ts @@ -9,7 +9,9 @@ describe("makeSparkBuffer", () => { }); it("reset clears", () => { const b = makeSparkBuffer(3); - b.push(1); b.push(2); b.reset(); + b.push(1); + b.push(2); + b.reset(); expect(b.values()).toEqual([]); }); it("returns a copy (caller can't mutate internal state)", () => { diff --git a/packages/extension/test/substrate/user_splice.test.ts b/packages/extension/test/substrate/user_splice.test.ts index ddd2ab5f..9d237f94 100644 --- a/packages/extension/test/substrate/user_splice.test.ts +++ b/packages/extension/test/substrate/user_splice.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { buildAboutUserSection, buildRecentProblemsSection, buildReferenceDemosSection } from "../../src/substrate/user_splice"; +import { + buildAboutUserSection, + buildRecentProblemsSection, + buildReferenceDemosSection, +} from "../../src/substrate/user_splice"; import { buildOpencodeConfigContent, prepareOpencodeProject } from "../../src/opencode_config"; describe("buildAboutUserSection (spec §6)", () => { @@ -85,7 +89,9 @@ describe("buildReferenceDemosSection (L1 §3)", () => { expect(buildReferenceDemosSection([])).toBe(""); }); it("renders demo lines + the precedent/medium-confidence instruction", () => { - const s = buildReferenceDemosSection(["- [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity cat, N_fock=20"]); + const s = buildReferenceDemosSection([ + "- [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity cat, N_fock=20", + ]); expect(s).toContain("## Reference demos"); expect(s).toContain("N_fock=20"); expect(s).toMatch(/precedent/i); diff --git a/packages/extension/test/substrate/vault_store.test.ts b/packages/extension/test/substrate/vault_store.test.ts index 602401d8..91a8f1a8 100644 --- a/packages/extension/test/substrate/vault_store.test.ts +++ b/packages/extension/test/substrate/vault_store.test.ts @@ -99,7 +99,10 @@ describe("hasOnboardingCompleted (spec §3 routing predicate, second disjunct)", it("malformed lines are skipped, not fatal", () => { const dir = path.join(mkTmp("ops-"), "onboarding"); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "events.jsonl"), "not json\n" + JSON.stringify({ entity: "onboarding_completed" }) + "\n"); + fs.writeFileSync( + path.join(dir, "events.jsonl"), + "not json\n" + JSON.stringify({ entity: "onboarding_completed" }) + "\n", + ); expect(hasOnboardingCompleted(dir)).toBe(true); }); }); diff --git a/packages/schema/.DS_Store b/packages/schema/.DS_Store index 48a56093c13b986ccad23e14bc15e225daf3775d..56f51e04ca7c10deb9427899ca5907dbb094aab6 100644 GIT binary patch delta 47 zcmZp1XmQw}CctFYyIDhEJ|ko5A}l-r DbGi=w delta 47 zcmV+~0MP%0K!iZBCJ+KEn6oGlp8)}1lbjJ8ljISQ0w?{mb`ll=0wUy-juUVKMD?;1 F1Ppy-56A!j diff --git a/packages/schema/esbuild.config.mjs b/packages/schema/esbuild.config.mjs index 9e0bdbc8..b2a964a6 100644 --- a/packages/schema/esbuild.config.mjs +++ b/packages/schema/esbuild.config.mjs @@ -1,19 +1,19 @@ -import { build } from 'esbuild' -import { chmodSync } from 'node:fs' +import { build } from "esbuild"; +import { chmodSync } from "node:fs"; // The library is consumed as TS source (main = src/index.ts; consumers bundle it // via their own esbuild). We bundle two artifacts here: // - dist/index.js: a smoke check that the dep graph (ajv + ajv-formats + the // JSON schemas) bundles cleanly into a single ESM module. // - dist/amico-validate.js: the standalone validator CLI (0.1c). -const common = { bundle: true, platform: 'node', target: 'node20', format: 'esm', sourcemap: true, logLevel: 'info' } +const common = { bundle: true, platform: "node", target: "node20", format: "esm", sourcemap: true, logLevel: "info" }; -await build({ ...common, entryPoints: ['src/index.ts'], outfile: 'dist/index.js' }) +await build({ ...common, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }); await build({ ...common, - entryPoints: ['src/cli.ts'], - outfile: 'dist/amico-validate.js', - banner: { js: '#!/usr/bin/env node' }, -}) -chmodSync('dist/amico-validate.js', 0o755) + entryPoints: ["src/cli.ts"], + outfile: "dist/amico-validate.js", + banner: { js: "#!/usr/bin/env node" }, +}); +chmodSync("dist/amico-validate.js", 0o755); diff --git a/packages/schema/package.json b/packages/schema/package.json index 0d1604a6..fa1e0c60 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -5,8 +5,12 @@ "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", - "bin": { "amico-validate": "./launcher/amico-validate" }, - "engines": { "node": ">=20" }, + "bin": { + "amico-validate": "./launcher/amico-validate" + }, + "engines": { + "node": ">=20" + }, "scripts": { "build": "node esbuild.config.mjs", "typecheck": "tsc --noEmit", diff --git a/packages/schema/schemas/catalog-entry.schema.json b/packages/schema/schemas/catalog-entry.schema.json index 219b7ba8..fcb44faa 100644 --- a/packages/schema/schemas/catalog-entry.schema.json +++ b/packages/schema/schemas/catalog-entry.schema.json @@ -12,8 +12,16 @@ "lab_id": { "type": "string", "minLength": 1 }, "gate": { "type": "string", "description": "target gate label, if recorded" }, "fidelity": { "type": "number", "minimum": 0, "maximum": 1.0001 }, - "pulse_path": { "type": "string", "minLength": 1, "description": "path/ref to the promoted pulse artifact (e.g. pulse.jld2)" }, + "pulse_path": { + "type": "string", + "minLength": 1, + "description": "path/ref to the promoted pulse artifact (e.g. pulse.jld2)" + }, "created_at": { "type": "string", "minLength": 1, "format": "date-time" }, - "params": { "type": "object", "additionalProperties": true, "description": "the regime solved (self-describing), copied from result.toml" } + "params": { + "type": "object", + "additionalProperties": true, + "description": "the regime solved (self-describing), copied from result.toml" + } } } diff --git a/packages/schema/schemas/lab.schema.json b/packages/schema/schemas/lab.schema.json index d38536d0..fd5f8f99 100644 --- a/packages/schema/schemas/lab.schema.json +++ b/packages/schema/schemas/lab.schema.json @@ -21,10 +21,30 @@ "additionalProperties": false, "required": ["omega_GHz", "delta_GHz", "levels", "drive_max_GHz"], "properties": { - "omega_GHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 100, "description": "qubit transition frequency (GHz)" }, - "delta_GHz": { "type": "number", "minimum": -2, "maximum": 2, "description": "anharmonicity (GHz; positive convention in the beta template). Bounded to catch garbage/sign-flipped values — physical |δ| is ~0.1–0.5 GHz; the sign convention itself isn't enforced." }, - "levels": { "type": "integer", "minimum": 2, "maximum": 10, "description": "transmon levels modeled (qubit + leakage)" }, - "drive_max_GHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 10, "description": "per-quadrature drive bound (GHz)" } + "omega_GHz": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100, + "description": "qubit transition frequency (GHz)" + }, + "delta_GHz": { + "type": "number", + "minimum": -2, + "maximum": 2, + "description": "anharmonicity (GHz; positive convention in the beta template). Bounded to catch garbage/sign-flipped values — physical |δ| is ~0.1–0.5 GHz; the sign convention itself isn't enforced." + }, + "levels": { + "type": "integer", + "minimum": 2, + "maximum": 10, + "description": "transmon levels modeled (qubit + leakage)" + }, + "drive_max_GHz": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 10, + "description": "per-quadrature drive bound (GHz)" + } } } } diff --git a/packages/schema/schemas/result.schema.json b/packages/schema/schemas/result.schema.json index d42cbe38..4799da2c 100644 --- a/packages/schema/schemas/result.schema.json +++ b/packages/schema/schemas/result.schema.json @@ -8,7 +8,12 @@ "required": ["schema_version", "fidelity", "iterations"], "properties": { "schema_version": { "enum": ["1"] }, - "fidelity": { "type": "number", "minimum": 0, "maximum": 1.0001, "description": "subspace gate fidelity (rollout-based; allow a few ulp over 1 for numerical noise, reject gross out-of-range)" }, + "fidelity": { + "type": "number", + "minimum": 0, + "maximum": 1.0001, + "description": "subspace gate fidelity (rollout-based; allow a few ulp over 1 for numerical noise, reject gross out-of-range)" + }, "iterations": { "type": "integer", "minimum": 0 }, "wall_seconds": { "type": "number", "minimum": 0 }, "pulse_kind": { @@ -20,11 +25,13 @@ "description": "Which convention `fidelity` reports. Absent = fixed. free_phase requires free_phases + subsystem_levels." }, "free_phases": { - "type": "array", "items": { "type": "number" }, + "type": "array", + "items": { "type": "number" }, "description": "Optimized virtual-Z phases (rad), one per subsystem in order; applied post-hoc by the harness — never in dynamics." }, "subsystem_levels": { - "type": "array", "items": { "type": "integer", "minimum": 2 }, + "type": "array", + "items": { "type": "integer", "minimum": 2 }, "description": "Subsystem dimensions for the phase-operator construction." }, "params": { diff --git a/packages/schema/schemas/run.schema.json b/packages/schema/schemas/run.schema.json index a0cc0763..70278afa 100644 --- a/packages/schema/schemas/run.schema.json +++ b/packages/schema/schemas/run.schema.json @@ -5,10 +5,25 @@ "description": "Per-run identity + provenance, written FIRST by amico-run (formerly manifest.toml — renamed to avoid colliding with Julia's Manifest.toml on case-insensitive filesystems). The per-run schema_version carrier for the run-dir contract.", "type": "object", "additionalProperties": false, - "required": ["schema_version", "run_id", "script_path", "lab", "lab_id", "created_at", "orchestrator_version", "julia"], + "required": [ + "schema_version", + "run_id", + "script_path", + "lab", + "lab_id", + "created_at", + "orchestrator_version", + "julia" + ], "properties": { - "schema_version": { "enum": ["1", "2"], "description": "run-dir contract version (enum = the supported-version SET; grows by one entry per bump). v2 (spec C) adds tier + [hashes] for --spec launches" }, - "tier": { "enum": ["vetted", "composed", "free"], "description": "trust tier stamped by amico-run when launched via --spec (v2)" }, + "schema_version": { + "enum": ["1", "2"], + "description": "run-dir contract version (enum = the supported-version SET; grows by one entry per bump). v2 (spec C) adds tier + [hashes] for --spec launches" + }, + "tier": { + "enum": ["vetted", "composed", "free"], + "description": "trust tier stamped by amico-run when launched via --spec (v2)" + }, "hashes": { "type": "object", "additionalProperties": false, diff --git a/packages/schema/schemas/solvespec.schema.json b/packages/schema/schemas/solvespec.schema.json index 0f70eee5..9dedc2b5 100644 --- a/packages/schema/schemas/solvespec.schema.json +++ b/packages/schema/schemas/solvespec.schema.json @@ -9,17 +9,34 @@ "properties": { "schema_version": { "enum": ["1", "2"] }, "script_path": { "type": "string", "minLength": 1, "description": "the Julia script to run" }, - "lab_id": { "type": "string", "minLength": 1, "description": "lab pointer (id or path) — physics params live in the lab.toml/script, not here" }, + "lab_id": { + "type": "string", + "minLength": 1, + "description": "lab pointer (id or path) — physics params live in the lab.toml/script, not here" + }, "gate": { "type": "string", "description": "target gate label (e.g. X, H), if known at assembly" }, - "params": { "type": "object", "additionalProperties": true, "description": "lenient solve-knob block (T, N, max_iter, …)" }, - "executor": { "enum": ["local"], "description": "whose machine runs it — per-solve and explicit (Δ10); only local exists today" }, - "tier": { "enum": ["vetted", "composed", "free"], "description": "trust tier of the authored script (spec C resolver)" }, + "params": { + "type": "object", + "additionalProperties": true, + "description": "lenient solve-knob block (T, N, max_iter, …)" + }, + "executor": { + "enum": ["local"], + "description": "whose machine runs it — per-solve and explicit (Δ10); only local exists today" + }, + "tier": { + "enum": ["vetted", "composed", "free"], + "description": "trust tier of the authored script (spec C resolver)" + }, "env": { "type": "object", "additionalProperties": false, "required": ["kind"], "properties": { - "kind": { "enum": ["provisioned", "project", "sandbox"], "description": "which Julia environment (NOT which machine — that is executor)" }, + "kind": { + "enum": ["provisioned", "project", "sandbox"], + "description": "which Julia environment (NOT which machine — that is executor)" + }, "project": { "type": "string", "description": "Julia project path for kind=project|sandbox" } } }, @@ -28,7 +45,10 @@ "additionalProperties": false, "properties": { "template_id": { "type": "string", "description": "tier-1 registry entry id" }, - "exemplar_id": { "type": "string", "description": "tier-2 exemplars-index entry id (required by the gate when tier=composed)" } + "exemplar_id": { + "type": "string", + "description": "tier-2 exemplars-index entry id (required by the gate when tier=composed)" + } } }, "hashes": { diff --git a/packages/schema/src/cli.ts b/packages/schema/src/cli.ts index 27cab38d..03a705d4 100644 --- a/packages/schema/src/cli.ts +++ b/packages/schema/src/cli.ts @@ -15,19 +15,30 @@ export function main(argv: string[]): number { let schema: string | undefined; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "--help" || a === "-h") { console.log(USAGE); return 0; } + if (a === "--help" || a === "-h") { + console.log(USAGE); + return 0; + } if (a === "--schema") { schema = argv[++i]; - if (schema === undefined) { console.error(`amico-validate: --schema requires a value\n${USAGE}`); return 64; } + if (schema === undefined) { + console.error(`amico-validate: --schema requires a value\n${USAGE}`); + return 64; + } } else if (a.startsWith("-")) { - console.error(`amico-validate: unknown flag ${a}\n${USAGE}`); return 64; + console.error(`amico-validate: unknown flag ${a}\n${USAGE}`); + return 64; } else if (file !== undefined) { - console.error(`amico-validate: multiple files given\n${USAGE}`); return 64; + console.error(`amico-validate: multiple files given\n${USAGE}`); + return 64; } else { file = a; } } - if (file === undefined) { console.error(`amico-validate: no file given\n${USAGE}`); return 64; } + if (file === undefined) { + console.error(`amico-validate: no file given\n${USAGE}`); + return 64; + } const inferred = schema ?? kindForFilename(file); if (inferred === undefined) { @@ -41,7 +52,10 @@ export function main(argv: string[]): number { const kind = inferred as SchemaKind; const r = validateFile(file, kind); - if (r.ok) { console.log(`OK ${file} (${kind})`); return 0; } + if (r.ok) { + console.log(`OK ${file} (${kind})`); + return 0; + } console.error(`INVALID ${file} (${kind}):`); for (const e of r.errors) console.error(` ${e}`); return 64; diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 414fa44a..9b6cb926 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -48,7 +48,10 @@ export const SUPPORTED_VERSIONS_BY_KIND: Record, ]), ) as Record, string[]>; -export interface Validation { ok: boolean; errors: string[] } +export interface Validation { + ok: boolean; + errors: string[]; +} /** Resolve a schema kind from a file's basename, for the fixed-filename artifacts * (run.toml, result.toml, lab.toml, FINISHED). Returns undefined for files @@ -84,11 +87,17 @@ export function validate(artifact: unknown, kind: SchemaKind): Validation { * Parse/read failures are themselves field-precise-ish errors, never a throw. */ export function validateFile(filePath: string, kind: SchemaKind): Validation { let raw: string; - try { raw = readFileSync(filePath, "utf8"); } - catch (e) { return { ok: false, errors: [`cannot read ${filePath}: ${(e as Error).message}`] }; } + try { + raw = readFileSync(filePath, "utf8"); + } catch (e) { + return { ok: false, errors: [`cannot read ${filePath}: ${(e as Error).message}`] }; + } let parsed: unknown; - try { parsed = extname(filePath).toLowerCase() === ".json" ? JSON.parse(raw) : parseToml(raw); } - catch (e) { return { ok: false, errors: [`${filePath}: parse error — ${(e as Error).message}`] }; } + try { + parsed = extname(filePath).toLowerCase() === ".json" ? JSON.parse(raw) : parseToml(raw); + } catch (e) { + return { ok: false, errors: [`${filePath}: parse error — ${(e as Error).message}`] }; + } return validate(normalizeDates(parsed), kind); } diff --git a/packages/schema/test/cli.test.ts b/packages/schema/test/cli.test.ts index 15915286..cd43ce04 100644 --- a/packages/schema/test/cli.test.ts +++ b/packages/schema/test/cli.test.ts @@ -12,7 +12,9 @@ const validDir = join(here, "fixtures", "valid"); const invalidDir = join(here, "fixtures", "invalid"); const KINDS = ["run", "result", "lab", "solvespec", "catalog-entry", "finished"]; -beforeAll(() => { execFileSync("node", [join(pkg, "esbuild.config.mjs")], { cwd: pkg }); }); +beforeAll(() => { + execFileSync("node", [join(pkg, "esbuild.config.mjs")], { cwd: pkg }); +}); function run(args: string[]): { code: number; stdout: string; stderr: string } { try { @@ -46,7 +48,7 @@ describe("amico-validate CLI", () => { it("file-role resolution by basename for the fixed-filename schemas (no --schema)", () => { expect(run([join(validDir, "run.toml")]).code).toBe(0); expect(run([join(validDir, "result.toml")]).code).toBe(0); - expect(run([join(invalidDir, "result.toml")]).code).toBe(64); // missing schema_version + expect(run([join(invalidDir, "result.toml")]).code).toBe(64); // missing schema_version }); it("FINISHED resolves by exact basename (no extension)", () => { const f = join(mkdtempSync(join(tmpdir(), "fin-")), "FINISHED"); @@ -54,7 +56,7 @@ describe("amico-validate CLI", () => { expect(run([f]).code).toBe(0); }); it("a non-filename schema without --schema cannot infer → 64", () => { - const r = run([join(validDir, "solvespec.toml")]); // solvespec.toml is not a canonical name + const r = run([join(validDir, "solvespec.toml")]); // solvespec.toml is not a canonical name expect(r.code).toBe(64); expect(r.stderr).toContain("cannot infer"); }); @@ -63,10 +65,10 @@ describe("amico-validate CLI", () => { expect(r.stderr).toContain("/transmon/levels"); }); it("usage / bad-arg errors exit 64", () => { - expect(run([]).code).toBe(64); // no file - expect(run(["a.toml", "b.toml"]).code).toBe(64); // multiple files + expect(run([]).code).toBe(64); // no file + expect(run(["a.toml", "b.toml"]).code).toBe(64); // multiple files expect(run(["f.toml", "--schema", "bogus"]).code).toBe(64); // unknown schema - expect(run(["f.toml", "--nope"]).code).toBe(64); // unknown flag + expect(run(["f.toml", "--nope"]).code).toBe(64); // unknown flag }); it("--help exits 0", () => expect(run(["--help"]).code).toBe(0)); }); diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index fac392f1..6fec4b22 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -4,9 +4,7 @@ import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { parse as parseToml } from "smol-toml"; -import { - validate, validateFile, SCHEMA_KINDS, SUPPORTED_VERSIONS_BY_KIND, type SchemaKind, -} from "../src/index.js"; +import { validate, validateFile, SCHEMA_KINDS, SUPPORTED_VERSIONS_BY_KIND, type SchemaKind } from "../src/index.js"; const here = dirname(fileURLToPath(import.meta.url)); const validDir = join(here, "fixtures", "valid"); @@ -27,9 +25,7 @@ describe("valid golden fixtures validate clean", () => { describe("schema set + exports", () => { it("exposes all five versioned schemas + the FINISHED sub-shape", () => { - expect(new Set(SCHEMA_KINDS)).toEqual( - new Set(["run", "result", "lab", "solvespec", "catalog-entry", "finished"]), - ); + expect(new Set(SCHEMA_KINDS)).toEqual(new Set(["run", "result", "lab", "solvespec", "catalog-entry", "finished"])); }); it("supported versions are PER-KIND: run + solvespec bumped to v2 (spec C), the rest v1", () => { expect(SUPPORTED_VERSIONS_BY_KIND).toEqual({ @@ -51,15 +47,17 @@ describe("schema set + exports", () => { describe("schema_version policy", () => { it("ABSENT version → field-precise missing-required (the five versioned schemas)", () => { for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as SchemaKind[]) { - const obj = load(kind); delete obj.schema_version; + const obj = load(kind); + delete obj.schema_version; const r = validate(obj, kind); expect(r.ok).toBe(false); - expect(hasErr(r.errors, "missing required key \"schema_version\"")).toBe(true); + expect(hasErr(r.errors, 'missing required key "schema_version"')).toBe(true); } }); it("UNRECOGNIZED version → distinct version-specific error (all five versioned schemas)", () => { for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as SchemaKind[]) { - const obj = load(kind); obj.schema_version = "99"; + const obj = load(kind); + obj.schema_version = "99"; const r = validate(obj, kind); expect(r.ok).toBe(false); expect(hasErr(r.errors, "/schema_version: unrecognized version")).toBe(true); @@ -69,9 +67,7 @@ describe("schema_version policy", () => { const schemasDir = join(here, "..", "schemas"); for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as const) { const schema = JSON.parse(readFileSync(join(schemasDir, `${kind}.schema.json`), "utf8")); - expect(schema.properties.schema_version.enum, `${kind} enum drift`).toEqual( - SUPPORTED_VERSIONS_BY_KIND[kind], - ); + expect(schema.properties.schema_version.enum, `${kind} enum drift`).toEqual(SUPPORTED_VERSIONS_BY_KIND[kind]); } }); it("FINISHED is a sub-shape — it carries NO schema_version and adding one is rejected", () => { @@ -85,46 +81,61 @@ describe("schema_version policy", () => { // ── field-precise negative matrix (#15 AC2, #16/#17 AC, #18 AC2/3) ── describe("field-precise negative matrix", () => { it("missing required key → names the absent key + path (top-level + nested)", () => { - const m = load("run"); delete m.run_id; + const m = load("run"); + delete m.run_id; expect(hasErr(validate(m, "run").errors, 'missing required key "run_id"')).toBe(true); - const j = load("run"); delete (j.julia as Record).binary; + const j = load("run"); + delete (j.julia as Record).binary; expect(hasErr(validate(j, "run").errors, '/julia: missing required key "binary"')).toBe(true); }); it("wrong-type and out-of-range are reported DISTINCTLY + field-precise (#18 AC3)", () => { - const wrong = load("result"); wrong.fidelity = "high"; - expect(hasErr(validate(wrong, "result").errors, "/fidelity: must be number")).toBe(true); // wrong type - const over = load("result"); over.fidelity = 1.5; - expect(hasErr(validate(over, "result").errors, "/fidelity: must be <= 1.0001")).toBe(true); // out of range — distinct - const lab = load("lab"); (lab.transmon as Record).levels = 99; + const wrong = load("result"); + wrong.fidelity = "high"; + expect(hasErr(validate(wrong, "result").errors, "/fidelity: must be number")).toBe(true); // wrong type + const over = load("result"); + over.fidelity = 1.5; + expect(hasErr(validate(over, "result").errors, "/fidelity: must be <= 1.0001")).toBe(true); // out of range — distinct + const lab = load("lab"); + (lab.transmon as Record).levels = 99; expect(hasErr(validate(lab, "lab").errors, "/transmon/levels: must be <= 10")).toBe(true); }); it("unknown key (top level) → names the offending key", () => { - const r = load("result"); r.bogus = 1; + const r = load("result"); + r.bogus = 1; expect(hasErr(validate(r, "result").errors, 'unknown key "bogus"')).toBe(true); }); it("a legitimately-converged fidelity slightly over 1.0 still validates (S1: no false-reject)", () => { - const r = load("result"); r.fidelity = 1.0000000002; + const r = load("result"); + r.fidelity = 1.0000000002; expect(validate(r, "result").ok).toBe(true); }); it("catalog-entry + solvespec negatives are field-precise (#15 AC8 / #17 AC5) [S5/S6]", () => { - const c = load("catalog-entry"); delete c.pulse_path; + const c = load("catalog-entry"); + delete c.pulse_path; expect(hasErr(validate(c, "catalog-entry").errors, 'missing required key "pulse_path"')).toBe(true); - const c2 = load("catalog-entry"); c2.fidelity = "x"; + const c2 = load("catalog-entry"); + c2.fidelity = "x"; expect(hasErr(validate(c2, "catalog-entry").errors, "/fidelity: must be number")).toBe(true); - const s = load("solvespec"); delete s.lab_id; + const s = load("solvespec"); + delete s.lab_id; expect(hasErr(validate(s, "solvespec").errors, 'missing required key "lab_id"')).toBe(true); - const s2 = load("solvespec"); s2.unexpected = 1; + const s2 = load("solvespec"); + s2.unexpected = 1; expect(hasErr(validate(s2, "solvespec").errors, 'unknown key "unexpected"')).toBe(true); }); it("lab hardware range bounds + name minLength are field-precise (#29)", () => { - const hi = load("lab"); (hi.transmon as Record).omega_GHz = 999; + const hi = load("lab"); + (hi.transmon as Record).omega_GHz = 999; expect(hasErr(validate(hi, "lab").errors, "/transmon/omega_GHz: must be <= 100")).toBe(true); - const dm = load("lab"); (dm.transmon as Record).drive_max_GHz = 50; + const dm = load("lab"); + (dm.transmon as Record).drive_max_GHz = 50; expect(hasErr(validate(dm, "lab").errors, "/transmon/drive_max_GHz: must be <= 10")).toBe(true); - const d = load("lab"); (d.transmon as Record).delta_GHz = 25; // garbage anharmonicity + const d = load("lab"); + (d.transmon as Record).delta_GHz = 25; // garbage anharmonicity expect(hasErr(validate(d, "lab").errors, "/transmon/delta_GHz: must be <= 2")).toBe(true); - const nm = load("lab"); (nm.lab as Record).name = ""; - expect(hasErr(validate(nm, "lab").errors, "/lab/name")).toBe(true); // minLength + const nm = load("lab"); + (nm.lab as Record).name = ""; + expect(hasErr(validate(nm, "lab").errors, "/lab/name")).toBe(true); // minLength }); it("FINISHED bad status → field-precise enum error", () => { const r = validate({ status: "halfway", exit_code: 0 }, "finished"); @@ -133,8 +144,8 @@ describe("field-precise negative matrix", () => { }); it("params sub-table is lenient (mixed int/float + extra keys allowed) [M2]", () => { const r = load("result"); - (r.params as Record).future_knob = 7; // unknown param OK - (r.params as Record).levels = 4.0; // float where int-ish OK + (r.params as Record).future_knob = 7; // unknown param OK + (r.params as Record).levels = 4.0; // float where int-ish OK expect(validate(r, "result").ok).toBe(true); }); }); @@ -144,8 +155,16 @@ describe("result.toml spline/free-phase fields (spec-20260704-113005 §6, additi it("accepts pulse_kind spline with free-phase declaration", () => { expect( - validate({ ...base, pulse_kind: "spline", fidelity_convention: "free_phase", - free_phases: [0.12, -1.7], subsystem_levels: [2, 3] }, "result").ok, + validate( + { + ...base, + pulse_kind: "spline", + fidelity_convention: "free_phase", + free_phases: [0.12, -1.7], + subsystem_levels: [2, 3], + }, + "result", + ).ok, ).toBe(true); }); it("accepts plain PWC results unchanged (fields all optional)", () => { @@ -167,9 +186,14 @@ describe("formalize-don't-fork: real beta.1 artifacts validate under the closed it("a beta.1 manifest (writeManifest shape) + schema_version validates clean", () => { // EXACT shape amico-run/src/run_dir.ts writeManifest emits. const m = { - schema_version: "1", run_id: "r20260101-000000Z-aaaa", script_path: "/s.jl", - lab: "default", lab_id: "default", created_at: "2026-01-01T00:00:00.000Z", - orchestrator_version: "0.1.0", julia: { binary: "julia", project: "/p", sysimage: "/img.so" }, + schema_version: "1", + run_id: "r20260101-000000Z-aaaa", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-01-01T00:00:00.000Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia", project: "/p", sysimage: "/img.so" }, }; expect(validate(m, "run")).toEqual({ ok: true, errors: [] }); }); @@ -187,10 +211,12 @@ describe("validateFile tolerates unquoted TOML datetimes", () => { it("an unquoted created_at validates identically to a quoted one", () => { const dir = mkdtempSync(join(tmpdir(), "labfx-")); const f = join(dir, "run.toml"); - writeFileSync(f, + writeFileSync( + f, 'schema_version = "1"\nrun_id = "r1"\nscript_path = "/s.jl"\nlab = "default"\n' + - 'lab_id = "default"\ncreated_at = 2026-06-15T00:00:00Z\norchestrator_version = "0.1.0"\n' + - '[julia]\nbinary = "julia"\n'); // NOTE: unquoted datetime + 'lab_id = "default"\ncreated_at = 2026-06-15T00:00:00Z\norchestrator_version = "0.1.0"\n' + + '[julia]\nbinary = "julia"\n', + ); // NOTE: unquoted datetime expect(validateFile(f, "run").errors).toEqual([]); }); }); @@ -209,10 +235,14 @@ describe("bundled demo run dir conforms", () => { // ── v2 (spec C): SolveSpec executor/tier/env/source/hashes + run.toml tier/hashes ── describe("v2 (spec C)", () => { const specV2 = { - schema_version: "2", script_path: "/w/solve.jl", lab_id: "default", - executor: "local", tier: "free", + schema_version: "2", + script_path: "/w/solve.jl", + lab_id: "default", + executor: "local", + tier: "free", env: { kind: "sandbox", project: "/w/env" }, - source: {}, hashes: { system_hash: "sha256:ab", formulation_hash: "sha256:cd" }, + source: {}, + hashes: { system_hash: "sha256:ab", formulation_hash: "sha256:cd" }, }; it("accepts a full v2 solvespec and still accepts v1", () => { expect(validate(specV2, "solvespec").errors).toEqual([]); @@ -225,14 +255,32 @@ describe("v2 (spec C)", () => { }); it("run v2: tier + [hashes] (all four keys) accepted; v1 manifests still valid", () => { const run1 = { - schema_version: "1", run_id: "r", script_path: "/s.jl", lab: "default", lab_id: "default", - created_at: "2026-07-03T00:00:00Z", orchestrator_version: "0.1.0", julia: { binary: "julia" }, + schema_version: "1", + run_id: "r", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-07-03T00:00:00Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia" }, }; expect(validate(run1, "run").ok).toBe(true); - expect(validate({ - ...run1, schema_version: "2", tier: "free", - hashes: { system_hash: "sha256:ab", formulation_hash: "sha256:cd", warm_start_hash: "sha256:ef", spec_hash: "sha256:01" }, - }, "run").errors).toEqual([]); + expect( + validate( + { + ...run1, + schema_version: "2", + tier: "free", + hashes: { + system_hash: "sha256:ab", + formulation_hash: "sha256:cd", + warm_start_hash: "sha256:ef", + spec_hash: "sha256:01", + }, + }, + "run", + ).errors, + ).toEqual([]); expect(validate({ ...run1, schema_version: "2", tier: "nope" }, "run").errors.join()).toMatch(/tier/); }); }); From ed7923c5fb41d5cacb45b59ce862a055199f9022 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 06:16:54 -0400 Subject: [PATCH 43/50] =?UTF-8?q?fix(config):=20fallback-only=20model=20pi?= =?UTF-8?q?n=20=E2=80=94=20without=20one,=20opencode's=20default=20resolut?= =?UTF-8?q?ion=20gambles=20on=20provider=20ordering=20and=20(with=20Google?= =?UTF-8?q?=20creds)=20picked=20a=20hanging=20preview=20model=20for=20ever?= =?UTF-8?q?y=20headless/agent=20turn;=20anthropic=20>=20GA=20gemini=20flas?= =?UTF-8?q?h,=20and=20a=20user's=20global=20model=20always=20wins=20(1.17.?= =?UTF-8?q?3=20preserve=20contract)=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/extension.ts | 6 ++- packages/extension/src/opencode_config.ts | 43 ++++++++++++++++++ .../extension/test/opencode_config.test.ts | 28 +++++++++++- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index b1828c242a460f3c314e9c06329c0bb46e65d60f..c11726d7a809c8b769502d47eac302a303839463 100644 GIT binary patch delta 133 zcmZn(XbIS`T$nLpa*eRe<|D!dOtKD%3=9m+48;sZ49U6qE-pzq`AI+#4&8&wJ6a|O mib=9EJ( delta 113 zcmZn(XbIS`T$s^*a*eRe<|D!dO#J5e85kIt8HyQ-7?N}IT_z`rNl%Uw<6)hfEk7f3 da=w@}W5MKZvE58=*EV~Izu_Z6{bUdEJpjd~Ba;9C diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 92682a59..f95e2a2a 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -9,7 +9,7 @@ import { registerRunInspector } from "./run_inspector"; import { registerCatalogCard } from "./catalog_card_shell"; import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; -import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "./opencode_config"; +import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent, resolveModelPin } from "./opencode_config"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; @@ -221,6 +221,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeProject.skillPaths, opencodeProject.skillsStageDir, opencodeProject.vaultDir, + // Model pin (fallback-only, resolveModelPin): without it, default + // resolution gambles on provider ordering — with Google creds it + // picked a preview model that hung every headless/agent turn. + resolveModelPin(), ), }, channel: opencodeChannel, diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 9d329091..bf9d8b44 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -223,6 +223,47 @@ export function writeAuthoringConfig( } } + +/** Default model pin for the generated config. Without one, opencode's + * default-resolution gambles on provider ordering and (with Google creds) + * lands on preview variants — gemini-3.1-pro-preview-customtools rejected or + * HUNG every turn. Preference: Anthropic if the user has creds for it, else + * the GA Gemini flash (verified: completes tool-bearing turns). The app's + * model picker still overrides per session; undefined leaves opencode's own + * default (no creds yet — nothing sane to pin). */ +export function preferredModel( + authPath: string = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"), +): string | undefined { + try { + const providers = Object.keys(JSON.parse(fs.readFileSync(authPath, "utf8")) as Record); + if (providers.includes("anthropic")) return "anthropic/claude-sonnet-5"; + if (providers.includes("google")) return "google/gemini-3.5-flash"; + } catch { + /* no auth.json yet */ + } + return undefined; +} + +/** The model pin to inject, or undefined. FALLBACK-only: a model in the user's + * global opencode config wins (our injected config would override it in the + * merge — the 1.17.3 preserve-user-model contract), so we pin nothing then. */ +export function resolveModelPin( + globalConfigPath: string = path.join( + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), + "opencode", + "opencode.json", + ), + authPath?: string, +): string | undefined { + try { + const cfg = JSON.parse(fs.readFileSync(globalConfigPath, "utf8")) as { model?: unknown }; + if (typeof cfg.model === "string" && cfg.model) return undefined; // user chose — never override + } catch { + /* no global config — fall through to the creds-based pin */ + } + return authPath === undefined ? preferredModel() : preferredModel(authPath); +} + export function buildOpencodeConfigContent( agentsPath: string, templatePath: string, @@ -232,6 +273,7 @@ export function buildOpencodeConfigContent( skillPaths: string[] = [], skillsStageDir: string = "", vaultDir: string = "", + modelPin?: string, ): string { const templatesDir = path.dirname(templatePath); // Least-privilege read grants for the skill index (spec §3): each indexed @@ -247,6 +289,7 @@ export function buildOpencodeConfigContent( const skills = skillsStageDir ? { paths: [skillsStageDir] } : undefined; return JSON.stringify({ $schema: "https://opencode.ai/config.json", + ...(modelPin ? { model: modelPin } : {}), instructions: [agentsPath], plugin: [pluginPath], ...(skills ? { skills } : {}), diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 6c2ed9f8..7b68e6cb 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from import { tmpdir, homedir } from "node:os"; import { join, isAbsolute } from "node:path"; import { execFileSync } from "node:child_process"; -import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "../src/opencode_config"; +import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent, preferredModel, resolveModelPin } from "../src/opencode_config"; function fakeExtRoot(): string { const root = mkdtempSync(join(tmpdir(), "extroot-")); @@ -215,3 +215,29 @@ describe("prepareOpencodeProject", () => { expect(existsSync(join(p.projectDir, ".opencode", "opencode.json"))).toBe(false); }); }); + +describe("preferredModel", () => { + it("anthropic wins, google falls back to GA flash, absent auth pins nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "auth-")); + const authPath = join(dir, "auth.json"); + writeFileSync(authPath, JSON.stringify({ google: { type: "api" } })); + expect(preferredModel(authPath)).toBe("google/gemini-3.5-flash"); + writeFileSync(authPath, JSON.stringify({ google: { type: "api" }, anthropic: { type: "api" } })); + expect(preferredModel(authPath)).toBe("anthropic/claude-sonnet-5"); + expect(preferredModel(join(dir, "missing.json"))).toBeUndefined(); + }); +}); + +describe("resolveModelPin (fallback-only)", () => { + it("a user global model suppresses the pin; no global model → creds-based pin", () => { + const dir = mkdtempSync(join(tmpdir(), "pin-")); + const cfgPath = join(dir, "opencode.json"); + const authPath = join(dir, "auth.json"); + writeFileSync(authPath, JSON.stringify({ google: { type: "api" } })); + writeFileSync(cfgPath, JSON.stringify({ model: "anthropic/claude-sonnet-4-6" })); + expect(resolveModelPin(cfgPath, authPath)).toBeUndefined(); + writeFileSync(cfgPath, JSON.stringify({})); + expect(resolveModelPin(cfgPath, authPath)).toBe("google/gemini-3.5-flash"); + expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("google/gemini-3.5-flash"); + }); +}); From 51da46cca987846a9cef6db5feb99299588ba59a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:18:47 +0000 Subject: [PATCH 44/50] =?UTF-8?q?ci(vsix-gate):=20fix=20by=20splitting=20p?= =?UTF-8?q?ackage=20step=20=E2=80=94=20explicit=20build=20+=20fetch:openco?= =?UTF-8?q?de=20+=20vsce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe3cb610..8e655401 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,9 @@ 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) + - 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: From cd95560698503a90641cdb7e97d7dc3d76339bee Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 06:26:54 -0400 Subject: [PATCH 45/50] =?UTF-8?q?test(slow):=20live-turn=20extractor=20is?= =?UTF-8?q?=20model-agnostic=20=E2=80=94=20Gemini=20opens=20with=20the=20a?= =?UTF-8?q?micode=5Fask=20TOOL=20CALL=20and=20no=20prose,=20so=20the=20ask?= =?UTF-8?q?=20input=20(question+options)=20counts=20as=20the=20turn=20text?= =?UTF-8?q?;=20production=20model=20pin=20wired=20into=20the=20e2e=20serve?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../extension/test/slow/interview_e2e.test.ts | 44 ++++++++++++++++--- .../extension/test/slow/scores_e2e.test.ts | 27 ++++++++++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index c9f60cdc..01ada6a2 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; -import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from "../../src/opencode_config"; +import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject, resolveModelPin } from "../../src/opencode_config"; // ============================================================================ // T13 e2e — pulse-designer interview against the REAL vendored binary. @@ -47,6 +47,14 @@ function layer0Config(agentsPath: string): string { agentsPath, join(EXT, "templates", "solve_template.jl"), join(homedir(), ".amico", "runs", "default"), + undefined, + undefined, + [], + "", + "", + // production model pin (fallback-only) — without it the live turns ride + // opencode's default resolution, which picks a hanging preview model here + resolveModelPin(), ); } @@ -146,11 +154,24 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("live interview turns (creds body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), }); expect(r.ok, `message POST ${r.status}`).toBe(true); - const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> }; - return (msg.parts ?? []) + const msg = (await r.json()) as { + parts?: Array<{ type: string; text?: string; tool?: string; state?: { input?: Record } }>; + }; + const textOut = (msg.parts ?? []) .filter((p) => p.type === "text") .map((p) => p.text) .join("\n"); + // Model-agnostic: some models (Gemini) open with the amicode_ask TOOL CALL + // and no prose — the ask input IS the question the assertions probe for. + const askOut = (msg.parts ?? []) + .filter((p) => p.type === "tool" && p.tool === "amicode_ask") + .map((p) => { + const input = (p.state?.input ?? (p as Record).input ?? {}) as Record; + const opts = Array.isArray(input.options) ? input.options.join(" | ") : ""; + return [input.question, opts].filter(Boolean).join("\n"); + }) + .join("\n"); + return [textOut, askOut].filter(Boolean).join("\n"); }; const q1 = await turn("help me design a pulse"); @@ -189,11 +210,24 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("live interview turns (creds body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), }); expect(r.ok, `message POST ${r.status}`).toBe(true); - const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> }; - return (msg.parts ?? []) + const msg = (await r.json()) as { + parts?: Array<{ type: string; text?: string; tool?: string; state?: { input?: Record } }>; + }; + const textOut = (msg.parts ?? []) .filter((p) => p.type === "text") .map((p) => p.text) .join("\n"); + // Model-agnostic: some models (Gemini) open with the amicode_ask TOOL CALL + // and no prose — the ask input IS the question the assertions probe for. + const askOut = (msg.parts ?? []) + .filter((p) => p.type === "tool" && p.tool === "amicode_ask") + .map((p) => { + const input = (p.state?.input ?? (p as Record).input ?? {}) as Record; + const opts = Array.isArray(input.options) ? input.options.join(" | ") : ""; + return [input.question, opts].filter(Boolean).join("\n"); + }) + .join("\n"); + return [textOut, askOut].filter(Boolean).join("\n"); }; // Keyword-routed answers — the model controls stage order, we answer whatever diff --git a/packages/extension/test/slow/scores_e2e.test.ts b/packages/extension/test/slow/scores_e2e.test.ts index b8d41301..c94686fe 100644 --- a/packages/extension/test/slow/scores_e2e.test.ts +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; -import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from "../../src/opencode_config"; +import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject, resolveModelPin } from "../../src/opencode_config"; import { loadState } from "../../src/scores/interview_state"; import { readUsage, reconstructTraversal } from "../../src/scores/usage"; @@ -66,6 +66,14 @@ async function serveWithScores(port: number) { project.agentsPath, join(EXT, "templates", "solve_template.jl"), join(homedir(), ".amico", "runs", "default"), + undefined, + undefined, + [], + "", + "", + // production model pin (fallback-only) — without it the live turns ride + // opencode's default resolution, which picks a hanging preview model here + resolveModelPin(), ); let buf = ""; const child = spawn(OC_BIN, ["serve", "--port", String(port)], { env, stdio: ["ignore", "pipe", "pipe"] }); @@ -111,11 +119,24 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("scores runtime live e2e (cr body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), }); expect(r.ok, `message POST ${r.status}`).toBe(true); - const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> }; - return (msg.parts ?? []) + const msg = (await r.json()) as { + parts?: Array<{ type: string; text?: string; tool?: string; state?: { input?: Record } }>; + }; + const textOut = (msg.parts ?? []) .filter((p) => p.type === "text") .map((p) => p.text) .join("\n"); + // Model-agnostic: some models (Gemini) open with the amicode_ask TOOL CALL + // and no prose — the ask input IS the question the assertions probe for. + const askOut = (msg.parts ?? []) + .filter((p) => p.type === "tool" && p.tool === "amicode_ask") + .map((p) => { + const input = (p.state?.input ?? (p as Record).input ?? {}) as Record; + const opts = Array.isArray(input.options) ? input.options.join(" | ") : ""; + return [input.question, opts].filter(Boolean).join("\n"); + }) + .join("\n"); + return [textOut, askOut].filter(Boolean).join("\n"); }; const transcript: string[] = []; From ec4484813f92233ce459e6ca3bf6ede879f9a550 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 10:06:39 -0400 Subject: [PATCH 46/50] =?UTF-8?q?feat(bridge):=20save-file=20lane=20?= =?UTF-8?q?=E2=80=94=20the=20run-card=20gallery's=20PNG=20export=20routes?= =?UTF-8?q?=20through=20a=20save=20dialog=20(downloads=20are=20dead=20in?= =?UTF-8?q?=20the=20framed=20app);=20PNG-only,=20basename-sanitized,=20siz?= =?UTF-8?q?e-bounded,=20relay-allowlisted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/chat_panel.ts | 32 ++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index c11726d7a809c8b769502d47eac302a303839463..903981570aefad7a14b5e78115afacef42715143 100644 GIT binary patch delta 75 zcmZn(XbIR*C&=V~XL5sJ6yt);w*}8IGA2&05ti9}M7V&7$(?PpnV0}0Q@G}25Ai6* RfX$1=C-Py4PF^gY2mrki7V-c9 delta 75 zcmZn(XbIR*C&=VrH@QJDim_qyZNW2)j1iM-gk?4#5iVe2a!A~4CMLkh 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" && @@ -173,7 +203,7 @@ export class ChatPanel { // Lane 1 — iframe → extension (commands): MUST come from the opencode // origin; the extension side additionally allowlists commands. if (e.origin === ${origin}) { - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "open-external")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "open-external" || d.kind === "save-file")) { vscode.postMessage(d); } return; From fbb3d31c5430b021288e861f51754527ac820a0c Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 10:16:49 -0400 Subject: [PATCH 47/50] =?UTF-8?q?fix(config):=20google=20pin=20moves=20to?= =?UTF-8?q?=20gemini-2.5-flash=20=E2=80=94=20the=20newest=20flash=20is=20c?= =?UTF-8?q?apacity-throttled=20at=20peak=20('model=20overloaded'=20?= =?UTF-8?q?=E2=86=92=20failed=20turns=20render=20as=20'model=20undefined'?= =?UTF-8?q?=20stubs=20in=20chat);=20the=20GA=20flash=20answered=20in=201.4?= =?UTF-8?q?s=20during=20the=20same=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/opencode_config.ts | 4 ++-- .../extension/test/opencode_config.test.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index 903981570aefad7a14b5e78115afacef42715143..0184aac88e298a0f8e64bd3f438c157f82b09abd 100644 GIT binary patch delta 112 zcmZn(XbIR*C&(1Hb8~~>Ru-9X1_lOZhGK>yhUDCQ7nh`*{3M_VNA_~R+w(UEiWM?4 eojEf3hyhUDCQ7nh`*{3M_Vhwee;9W9#!#R?gj edR!(S5l>@Wu-Q=J8vDcsw$1DcdThk%a{vH7XCQe1 diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index bf9d8b44..b62f2dbb 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -228,7 +228,7 @@ export function writeAuthoringConfig( * default-resolution gambles on provider ordering and (with Google creds) * lands on preview variants — gemini-3.1-pro-preview-customtools rejected or * HUNG every turn. Preference: Anthropic if the user has creds for it, else - * the GA Gemini flash (verified: completes tool-bearing turns). The app's + * the boring-but-available GA Gemini flash (the newest flash is capacity-throttled at peak; 2.5 answered in 1.4s while 3.5 returned overloaded). The app's * model picker still overrides per session; undefined leaves opencode's own * default (no creds yet — nothing sane to pin). */ export function preferredModel( @@ -237,7 +237,7 @@ export function preferredModel( try { const providers = Object.keys(JSON.parse(fs.readFileSync(authPath, "utf8")) as Record); if (providers.includes("anthropic")) return "anthropic/claude-sonnet-5"; - if (providers.includes("google")) return "google/gemini-3.5-flash"; + if (providers.includes("google")) return "google/gemini-2.5-flash"; } catch { /* no auth.json yet */ } diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 7b68e6cb..849b9b47 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -221,7 +221,7 @@ describe("preferredModel", () => { const dir = mkdtempSync(join(tmpdir(), "auth-")); const authPath = join(dir, "auth.json"); writeFileSync(authPath, JSON.stringify({ google: { type: "api" } })); - expect(preferredModel(authPath)).toBe("google/gemini-3.5-flash"); + expect(preferredModel(authPath)).toBe("google/gemini-2.5-flash"); writeFileSync(authPath, JSON.stringify({ google: { type: "api" }, anthropic: { type: "api" } })); expect(preferredModel(authPath)).toBe("anthropic/claude-sonnet-5"); expect(preferredModel(join(dir, "missing.json"))).toBeUndefined(); @@ -237,7 +237,7 @@ describe("resolveModelPin (fallback-only)", () => { writeFileSync(cfgPath, JSON.stringify({ model: "anthropic/claude-sonnet-4-6" })); expect(resolveModelPin(cfgPath, authPath)).toBeUndefined(); writeFileSync(cfgPath, JSON.stringify({})); - expect(resolveModelPin(cfgPath, authPath)).toBe("google/gemini-3.5-flash"); - expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("google/gemini-3.5-flash"); + expect(resolveModelPin(cfgPath, authPath)).toBe("google/gemini-2.5-flash"); + expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("google/gemini-2.5-flash"); }); }); From d6d099b2dcccf0511033aff35541c4d8a6156ccf Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 10:31:57 -0400 Subject: [PATCH 48/50] =?UTF-8?q?fix(config):=20creds-free=20default=20mod?= =?UTF-8?q?el=20=E2=80=94=20opencode/deepseek-v4-flash-free=20(zen=20free?= =?UTF-8?q?=20tier,=20no=20user=20quota;=20answered=20a=20tool-bearing=20t?= =?UTF-8?q?urn=20in=20~3s=20while=20Gemini=20kept=20capacity-throttling);?= =?UTF-8?q?=20anthropic=20still=20wins=20when=20creds=20exist,=20user=20gl?= =?UTF-8?q?obal=20model=20always=20wins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/opencode_config.ts | 8 +++++--- .../extension/test/opencode_config.test.ts | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index 0184aac88e298a0f8e64bd3f438c157f82b09abd..ea98653f20ef3694df3968f1b1248258a18199e1 100644 GIT binary patch delta 40 wcmZn(XbIR*C&(1fwYfo1jfqKV@#Gp|naxLpo0%CSH+zWx=Vy$Z>><7f01yxjRsaA1 delta 40 wcmZn(XbIR*C&(1Hb8~~B8WWR2;^Z1(naxLpo0%CkH+zWx=V#QM>><7f03A~eZ~y=R diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index b62f2dbb..c3bf0a93 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -237,11 +237,13 @@ export function preferredModel( try { const providers = Object.keys(JSON.parse(fs.readFileSync(authPath, "utf8")) as Record); if (providers.includes("anthropic")) return "anthropic/claude-sonnet-5"; - if (providers.includes("google")) return "google/gemini-2.5-flash"; } catch { - /* no auth.json yet */ + /* no auth.json — the free default below still works */ } - return undefined; + // Creds-free default: the zen free tier rides no user quota — Gemini keys + // kept hitting capacity throttles ("model overloaded" → failed turns render + // as "model undefined" stubs), while this answered a tool-bearing turn in ~3s. + return "opencode/deepseek-v4-flash-free"; } /** The model pin to inject, or undefined. FALLBACK-only: a model in the user's diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 849b9b47..ec13056c 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -221,10 +221,10 @@ describe("preferredModel", () => { const dir = mkdtempSync(join(tmpdir(), "auth-")); const authPath = join(dir, "auth.json"); writeFileSync(authPath, JSON.stringify({ google: { type: "api" } })); - expect(preferredModel(authPath)).toBe("google/gemini-2.5-flash"); + expect(preferredModel(authPath)).toBe("opencode/deepseek-v4-flash-free"); writeFileSync(authPath, JSON.stringify({ google: { type: "api" }, anthropic: { type: "api" } })); expect(preferredModel(authPath)).toBe("anthropic/claude-sonnet-5"); - expect(preferredModel(join(dir, "missing.json"))).toBeUndefined(); + expect(preferredModel(join(dir, "missing.json"))).toBe("opencode/deepseek-v4-flash-free"); }); }); @@ -237,7 +237,7 @@ describe("resolveModelPin (fallback-only)", () => { writeFileSync(cfgPath, JSON.stringify({ model: "anthropic/claude-sonnet-4-6" })); expect(resolveModelPin(cfgPath, authPath)).toBeUndefined(); writeFileSync(cfgPath, JSON.stringify({})); - expect(resolveModelPin(cfgPath, authPath)).toBe("google/gemini-2.5-flash"); - expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("google/gemini-2.5-flash"); + expect(resolveModelPin(cfgPath, authPath)).toBe("opencode/deepseek-v4-flash-free"); + expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("opencode/deepseek-v4-flash-free"); }); }); From 512cb8252d4983458e8c84f85c21eb14d397ad66 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Tue, 7 Jul 2026 12:24:44 -0400 Subject: [PATCH 49/50] ci: authenticate the private-fork opencode fetch (GH_TOKEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fast/vsix-gate/boot-smoke fetch the vendored opencode binary from the PRIVATE harmoniqs/opencode release via `gh release download`. The default Actions GITHUB_TOKEN is scoped to this repo only, so gh is unauthenticated for harmoniqs/opencode and the fetch exits 1 — reding every job that vendors opencode (schema-roundtrip is untouched; it needs no binary). Pass a cross-repo token as GH_TOKEN to the three fetch:opencode step defs (boot-smoke's single step covers both matrix legs). Mirrors #80, adapted to this branch's split vsix-gate step. Requires repo secret OPENCODE_FETCH_TOKEN: a fine-grained PAT scoped to harmoniqs/opencode with Contents:Read (validated end-to-end — downloads both assets and the bytes match opencode.lock.json's SHA256 gate). Stays red until the secret exists; green once it's set, no further push needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e655401..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: | @@ -72,6 +77,8 @@ jobs: - run: pnpm install --frozen-lockfile - 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: @@ -86,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 From ded0ae2a872e0aa2c8ad9732e18712952f9b48c2 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Tue, 7 Jul 2026 13:10:38 -0400 Subject: [PATCH 50/50] chore(extension): bump opencode.lock to v1.17.3-amicode.2 Pin the vendored opencode binary to the new fork release cut from rchari/amicode-fixes (opencode PR #1): Gemini tool-schema fix, /amicode run-cards + profile endpoints, one-spine run truth, multi-drive pulse fix, save-file bridge. darwin-arm64 + linux-x64 sha256 updated. vsix build verified locally (linux-x64 fetch + sha match + vsce package -> amicode.vsix). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/opencode.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 8e581aad..9d7e0994 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,15 +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" + "sha256": "3b41ea7344718e2c985b38b8e166603dd6b06e5472af8ac95431c897a557df33" }, "linux-x64": { "asset": "opencode-linux-x64.tar.gz", - "sha256": "268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10" + "sha256": "2aff796ab4685ecf9e506255a1a45dd86a0096d76f2a1b8473f4367b204d1ae8" } } }