diff --git a/.gitignore b/.gitignore index 4992a94e4..5b28ba899 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .vscode-test/ *.log packages/extension/vendor/ +packages/extension/bin/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 1a1bc40a4..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,71 +0,0 @@ -# Amicode project context - -This is the Amicode VS Code extension's per-session opencode project. You're -running here so a developer can iterate on quantum-optimal-control pulses -without leaving their editor. - -## Your tools (use bash, not MCP) - -There is **no MCP server** in this project. The single domain-specific tool -is a CLI binary you invoke via the `bash` tool: - -### `amico-run` - -Solves for an optimal control pulse using Piccolo / Piccolissimo (Julia). -Writes per-iteration PNGs + a final result.toml to disk; the VS Code Run -Inspector panel auto-refreshes as the solve runs. - -**Invoke via:** - -```bash -amico-run --system \ - --gate \ - --pulse \ - [--T-ns ] [--omega-cap ] [--max-iter ] -``` - -`amico-run --help` prints full usage. - -**Arg guidance** (pick sensible defaults if the user doesn't specify): - -- `--system`: `qubit` for textbook Pauli (X/Y/Z drift); `transmon` for the - physical 4-level Duffing model. Default `transmon` if the user mentions - "qubit hardware," "transmon," "leakage," or any GHz frequency. Default - `qubit` if they say "toy example" or "Pauli." -- `--gate`: literally what they asked for. `X`/`Y`/`Z`/`H`/`S`/`T` are 1q; - `CNOT`/`CZ`/`SWAP`/`iSWAP` are 2q. (`CNOT` and `CX` are aliases.) -- `--pulse`: `zero-order` (piecewise-constant) is the safe default and runs - faster. `linear-spline` for smoother controls when the user asks about - bandwidth, smoothness, or DRAG-style shaping. -- `--T-ns`: default 10 ns for `qubit`, 24 ns for `transmon` 1q gates, - ~150-300 ns for 2q gates. Don't pass if the user didn't specify. -- `--omega-cap`: transmon-only; default 0.05 (50 MHz). Above 0.15 GHz the - RWA / weak-anharmonic approximation degrades. -- `--max-iter`: leave off unless the user explicitly limits or extends. - -**Hazard checks** (call these out to the user *before* invoking the tool): - -- transmon + 1q + T < 20 ns → likely F ≲ 0.95; suggest T ~30-40 ns or higher ω cap. -- transmon + 2q + T < 150 ns → likely won't converge; suggest 150-300 ns. -- transmon + omega_cap > 0.15 GHz → expect leakage to |2⟩; rollout vs solver F will diverge. - -**Output**: - -Each run lands in `/tmp/amicode-runs//` and the symlink -`/tmp/amicode-runs/latest` points at it. The user sees the live iter PNGs -in the VS Code Run Inspector panel — you don't have to display them. - -When `amico-run` finishes, it prints a one-line `DONE` summary with the -fidelity. Quote that back to the user. If F ≥ 0.99, the extension will -automatically prompt them to promote the pulse to the catalog — you don't -need to ask. - -## Style - -- Be terse. The user is a quantum-control researcher; don't explain quantum - mechanics unless asked. -- Run `amico-run --help` first if you're unsure of an arg. -- If a run fails, read `/tmp/amicode-runs/latest/run.log` for the actual - julia traceback before guessing. -- Don't suggest installing new Julia packages. The dev environment is - pinned at `/tmp/amicode-spike-julia` and the user maintains it. diff --git a/packages/extension/.vscodeignore b/packages/extension/.vscodeignore index 5d5d6c657..0e0bcaffb 100644 --- a/packages/extension/.vscodeignore +++ b/packages/extension/.vscodeignore @@ -9,3 +9,4 @@ node_modules/** esbuild.config.mjs tsconfig.json **/*.map +!bin/** diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md new file mode 100644 index 000000000..e2584f13c --- /dev/null +++ b/packages/extension/AGENTS.md @@ -0,0 +1,56 @@ +# Amicode project context + +You help a quantum-control researcher synthesize optimal-control pulses with +Piccolo (Julia) without leaving VS Code. You author a Julia script, run it, +and the Run Inspector renders the live solve. + +## Workflow (this is the whole job) + +1. Read the bundled template `solve_template.jl` in this project dir. +2. Copy it to a working file (e.g. `solve.jl`) and fill in the `# FILL IN` + parameter block from the user's request: transmon frequency `ω` (GHz), + anharmonicity `δ` (GHz), `levels`, the target gate, gate time `T` (ns), + timesteps `N`, `max_iter`. **Parameters live in the script — never in this + file.** If the user gives a `lab.toml` path, read it in the script. +3. Run it via the `bash` tool: `amico-run --project solve.jl` + (use the project path provided below). `amico-run` takes only a script path + and runner flags — it parses **no** physics options; all the physics lives + in the script you wrote. +4. When it finishes, quote the final `DONE fidelity=…` line. If F ≥ 0.99 the + extension prompts promotion automatically — don't ask. + +There is **no MCP server**. The only tool is `amico-run` via bash. +`amico-run --help` prints usage. + +## The run-dir contract your script MUST emit + +`amico-run` writes `manifest.toml` (first) and `FINISHED` (last) itself. Your +script, running with cwd = the run dir, must emit: + +- `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` to stdout, flushed, + once per Ipopt iteration (drives the live stats row). +- `iter_.png` every few iterations (the live plot the Inspector shows). +- `result.toml`, written **atomically** (write `result.toml.tmp`, then `mv`), + with at least `fidelity` (float) and `iterations` (int). +- `pulse.jld2` (the solved pulse) via `JLD2.save`. +- a final `DONE fidelity=<…>` line. + +The template already does all of this — you only fill in numbers. + +## Warm-start idiom + +To seed from a previous solve: `traj = load_traj("path/to/pulse.jld2")` and +pass it as the initial guess to the problem constructor. `load_traj` is the +correct loader in this Piccolo. + +## Julia project + + The Julia project to pass as `--project` is: +**{{JULIA_PROJECT}}**. Always pass it. If it reads `UNSET`, omit `--project` +and tell the user `amicode.juliaProject` is not configured. + +## Style + +Terse — the user is a quantum-control researcher. On failure, read the run's +`run.log` for the Julia traceback before guessing. Don't suggest installing +Julia packages; the environment is provisioned. diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 949bec366..6b04467ef 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -1,7 +1,23 @@ import { build, context } from "esbuild"; +import { cpSync, mkdirSync, existsSync, chmodSync } from "node:fs"; const watch = process.argv.includes("--watch"); +// Stage amico-run so `bin/launcher/amico-run` finds `bin/dist/amico-run.js` +// (the launcher execs node "$DIR/../dist/amico-run.js"). Package-time artifact; +// the dev Extension Host uses the sibling-launcher fallback (resolveAmicoRunBinDir). +// Runs in both build and --watch (CWD = the package dir under `pnpm run`). +const arRoot = "../amico-run"; +if (existsSync(`${arRoot}/dist/amico-run.js`)) { + mkdirSync("bin/launcher", { recursive: true }); + mkdirSync("bin/dist", { recursive: true }); + cpSync(`${arRoot}/launcher/amico-run`, "bin/launcher/amico-run", { dereference: true }); + cpSync(`${arRoot}/dist/amico-run.js`, "bin/dist/amico-run.js", { dereference: true }); + chmodSync("bin/launcher/amico-run", 0o755); // guarantee +x survives pack/unpack +} else { + console.warn("[esbuild] amico-run/dist not built — run `pnpm --filter @amicode/amico-run build` before packaging"); +} + const targets = [ // extension host entry point { diff --git a/packages/extension/package.json b/packages/extension/package.json index 790645810..b94036320 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -9,7 +9,9 @@ "engines": { "vscode": "^1.95.0" }, - "categories": ["Other"], + "categories": [ + "Other" + ], "activationEvents": [ "onCommand:amicode.openChat", "onCommand:amicode.openInspector", @@ -37,18 +39,43 @@ }, "views": { "amicode": [ - { "id": "amicode.vault", "name": "Vault", "type": "tree" }, - { "id": "amicode.catalog", "name": "Catalog", "type": "tree" }, - { "id": "amicode.armonia", "name": "Armonia", "type": "tree" } + { + "id": "amicode.vault", + "name": "Vault", + "type": "tree" + }, + { + "id": "amicode.catalog", + "name": "Catalog", + "type": "tree" + }, + { + "id": "amicode.armonia", + "name": "Armonia", + "type": "tree" + } ], "amicode-panel": [ - { "id": "amicode.runInspector", "name": "Run Inspector", "type": "webview" } + { + "id": "amicode.runInspector", + "name": "Run Inspector", + "type": "webview" + } ] }, "commands": [ - { "command": "amicode.openChat", "title": "Amicode: Open Chat" }, - { "command": "amicode.openInspector", "title": "Amicode: Open Run Inspector" }, - { "command": "amicode.restartServer", "title": "Amicode: Restart opencode server" } + { + "command": "amicode.openChat", + "title": "Amicode: Open Chat" + }, + { + "command": "amicode.openInspector", + "title": "Amicode: Open Run Inspector" + }, + { + "command": "amicode.restartServer", + "title": "Amicode: Restart opencode server" + } ], "configuration": { "title": "Amicode", @@ -60,31 +87,33 @@ }, "amicode.juliaProject": { "type": "string", - "default": "/tmp/amicode-spike-julia", - "description": "Julia project root (--project=...) the run_julia tool will use." + "default": "", + "description": "Julia project (--project) the agent passes to amico-run. Empty = agent omits --project." }, - "amicode.juliaScript": { + "amicode.runsRoot": { "type": "string", "default": "", - "description": "Absolute path to spike_solve.jl. If empty, looks up via the extensionPath/../amicode/julia/spike_solve.jl convention." + "description": "Runs root the inspector watches. Empty = ~/.amico/runs/default (must match where amico-run writes)." } } } }, "scripts": { - "build": "node esbuild.config.mjs", - "watch": "node esbuild.config.mjs --watch", + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run --passWithNoTests --exclude '**/slow/**'", + "test:slow": "vitest run test/slow", "test:smoke": "node test/boot_smoke.mjs", "fetch:opencode": "node scripts/fetch_opencode.mjs" }, "devDependencies": { "@amicode/amico-run": "workspace:*", - "@types/node": "^22.0.0", + "@types/node": "^22.0.0", "@types/vscode": "^1.95.0", - "esbuild": "^0.24.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "esbuild": "^0.24.0", + "smol-toml": "^1.3.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" } } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 25e7ce46c..2333ef6f3 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -8,6 +8,7 @@ import { registerRunInspector } from "./run_inspector"; import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; import { prepareOpencodeProject } from "./opencode_config"; +import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; import { OpencodeEventClient } from "./sse_client"; import { RunsRootWatcher } from "./file_watcher"; @@ -26,33 +27,38 @@ let sseClient: OpencodeEventClient | undefined; let watcher: RunsRootWatcher | undefined; let opencodeReadyUrl: URL | undefined; -const DEFAULT_RUNS_ROOT = "/tmp/amicode-runs"; export async function activate(ctx: vscode.ExtensionContext): Promise { const opencodeChannel = vscode.window.createOutputChannel("Amicode — opencode"); const runsChannel = vscode.window.createOutputChannel("Amicode — runs"); ctx.subscriptions.push(opencodeChannel, runsChannel); + // Runs root (resolved early — the inspector needs it for its CSP resource roots). + const runsRoot = resolveRunsRoot(vscode.workspace.getConfiguration("amicode").get("runsRoot", "")); + // 1. UI surfaces registerTrees(ctx); - registerRunInspector(ctx); + registerRunInspector(ctx, runsRoot); 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. - const runsRoot = vscode.workspace.getConfiguration("amicode").get("runsRoot", DEFAULT_RUNS_ROOT); fs.mkdirSync(runsRoot, { recursive: true }); watcher = new RunsRootWatcher({ runsRoot, channel: runsChannel, statusBar }); watcher.start(); ctx.subscriptions.push(watcher); // 3. opencode project bootstrap - const binDir = path.resolve(ctx.extensionPath, "bin"); - const agentsSrc = path.resolve(ctx.extensionPath, "AGENTS.md"); - const opencodeProject = prepareOpencodeProject({ binDir, agentsSrc }); + const amicoRunBinDir = resolveAmicoRunBinDir(ctx.extensionPath); + const opencodeProject = prepareOpencodeProject({ + agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), + templateSrc: path.resolve(ctx.extensionPath, "templates", "solve_template.jl"), + juliaProject: vscode.workspace.getConfiguration("amicode").get("juliaProject", ""), + }); opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); + opencodeChannel.appendLine(`[boot] template: ${opencodeProject.templatePath}`); // 4. Spawn opencode — the VENDORED binary by default (spec §4; S35, kills // Assumption 4). Config override is a dev-only escape hatch. On a missing @@ -78,16 +84,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } if (binary !== undefined) { - const juliaScript = resolveJuliaScript(ctx); - const juliaProject = resolveJuliaProject(); + // amico-run is argv-only (β.1) — no AMICO_* env propagation (S37). The agent + // 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)`); + } serverManager = new ServerManager({ binary, cwd: opencodeProject.projectDir, env: { - PATH: `${binDir}:${process.env.PATH ?? ""}`, - AMICO_JULIA_SCRIPT: juliaScript, - AMICO_JULIA_PROJECT: juliaProject, - AMICO_RUNS_ROOT: runsRoot, + PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, }, channel: opencodeChannel, }); @@ -134,7 +141,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), ); - opencodeChannel.appendLine(`[boot] activated; runsRoot=${runsRoot}; binDir=${binDir}`); + opencodeChannel.appendLine(`[boot] activated; runsRoot=${runsRoot}; amicoRunBinDir=${amicoRunBinDir ?? "(none)"}`); } export function deactivate(): void { @@ -144,16 +151,3 @@ export function deactivate(): void { statusBar?.dispose(); } -// --------------------------------------------------------------------------- - -function resolveJuliaScript(ctx: vscode.ExtensionContext): string { - const fromCfg = vscode.workspace.getConfiguration("amicode").get("juliaScript", ""); - if (fromCfg && fs.existsSync(fromCfg)) return fromCfg; - const sibling = path.resolve(ctx.extensionPath, "..", "amicode", "julia", "spike_solve.jl"); - if (fs.existsSync(sibling)) return sibling; - return path.join(ctx.extensionPath, "dist", "julia", "spike_solve.jl"); -} - -function resolveJuliaProject(): string { - return vscode.workspace.getConfiguration("amicode").get("juliaProject", "/tmp/amicode-spike-julia"); -} diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index 8c07850c4..6d9a70762 100644 --- a/packages/extension/src/file_watcher.ts +++ b/packages/extension/src/file_watcher.ts @@ -1,55 +1,109 @@ 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, ITER_PNG_RE, ingestRunDir, readTomlSafe, parseAmicoNum, + type IterRecord, type RunCompletion, type PromoteInfo, type RunSink, +} from "./run_dir_reader"; // ============================================================================ -// RunsRootWatcher — the heart of the v2 CLI-direct architecture. +// RunsRootWatcher — watches the β.1 run-dir contract and drives the Inspector +// + status bar. Follows the `latest` symlink; for the active run it reads: +// manifest.toml → run identity (run_id, lab_id), written FIRST +// iter_.png → live plot frames (unbounded digits) +// run.log → AMICODE_ITER lines → live stats row +// result.toml → fidelity (display + promote gate), atomic +// FINISHED → authoritative terminal signal {status, exit_code} // -// Sits on /tmp/amicode-runs/ and watches for: -// - new run subdirs (created by amico-run via mkdir + symlink swap) -// - the `latest` symlink target changing -// - per-run files inside the active run dir: -// .start → run lifecycle begin -// iter_NNNN.png → push to Run Inspector -// formulation.md → auto-open markdown side preview -// result.toml → final fidelity → promote-to-catalog QuickPick -// FINISHED → release the run dir watcher -// -// We follow the `latest` symlink — when amico-run swings it, we re-target. -// This keeps the watcher logic stateless w.r.t. who told us about the run; -// no callback HTTP, no MCP, no event propagation across process boundaries. +// 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; - /** Fidelity threshold (≥) at which to prompt promotion. Default 0.99. */ promoteThreshold?: number; } -const AMICODE_ITER_RE = /^AMICODE_ITER\s+iter=(\d+)\s+f=(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s+inf_pr=(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s+inf_du=(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*$/; +/** 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 { + private latestIter = -1; + 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, + ) {} + + image(fsPath: string, iter: number): void { + if (iter <= this.latestIter) return; + this.latestIter = iter; + getInspector()?.setImageSource(fsPath, iter); + } + iter(rec: IterRecord): void { + if (rec.iter > this.latestIter) this.latestIter = 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 { + this.opts.statusBar?.setRun({ + runId: c.runId, outputDir: c.runDir, startedAt: 0, + status: c.status, latestIter: this.latestIter >= 0 ? this.latestIter : 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")}`); + } + } + 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") { + vscode.window.showInformationMessage(`Amicode: promotion stub — would catalog ${info.runId}.`); + await vscode.commands.executeCommand("amicode.catalog.refresh").then(undefined, () => undefined); + } + })(); + } +} export class RunsRootWatcher implements vscode.Disposable { private rootWatcher?: fs.FSWatcher; private activeRunDir?: string; private activeRunWatcher?: fs.FSWatcher; private logTailer?: LogTailer; - private latestIter = -1; - private formulationOpened = false; - private promotedForRun = false; + 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(); constructor(private readonly opts: RunsRootWatcherOptions) {} start(): void { fs.mkdirSync(this.opts.runsRoot, { recursive: true }); - // Pick up an in-progress or just-completed run if `latest` already exists. const latest = path.join(this.opts.runsRoot, "latest"); if (fs.existsSync(latest)) this.followLatest(); - - this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_event, filename) => { + this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => { if (filename === "latest") this.followLatest(); }); this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot}`); @@ -64,17 +118,10 @@ export class RunsRootWatcher implements vscode.Disposable { this.logTailer = undefined; } - // --------------------------------------------------------------------- - private followLatest(): void { - const latest = path.join(this.opts.runsRoot, "latest"); let target: string | undefined; - try { - target = fs.realpathSync(latest); - } catch (err) { - this.opts.channel.appendLine(`[runs] latest unresolved: ${(err as Error).message}`); - return; - } + 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); @@ -84,138 +131,71 @@ export class RunsRootWatcher implements vscode.Disposable { try { this.activeRunWatcher?.close(); } catch { /* noop */ } this.logTailer?.dispose(); this.activeRunDir = runDir; - this.latestIter = -1; - this.formulationOpened = false; - this.promotedForRun = false; - // Reveal the inspector so the user sees the new run start. + const runId = String(readTomlSafe(path.join(runDir, "manifest.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(); - this.opts.statusBar?.setRun({ - runId: path.basename(runDir), - outputDir: runDir, - startedAt: Date.now(), - status: "running", - }); - // Replay any files that already exist (we may be late to the run). - try { - for (const f of fs.readdirSync(runDir)) { - this.handle(f, path.join(runDir, f)); - } - } catch (err) { - this.opts.channel.appendLine(`[runs] readdir failed: ${(err as Error).message}`); - } + // 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; - this.activeRunWatcher = fs.watch(runDir, { persistent: false }, (_event, filename) => { + // Incremental: new iter PNGs + FINISHED. + this.activeRunWatcher = fs.watch(runDir, { persistent: false }, (_e, filename) => { if (!filename) return; const fp = path.join(runDir, filename); if (!fs.existsSync(fp)) return; - this.handle(filename, fp); + const m = ITER_PNG_RE.exec(filename); + if (m) { this.sink?.image(fp, parseInt(m[1], 10)); return; } + if (filename === "FINISHED" && !this.finishedSeen) { this.finishedSeen = true; this.onFinished(runDir); } }); - // Tail run.log for AMICODE_ITER records → drives the Inspector stats row. + // 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) => this.onLogLine(line), + 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]) }); + }, }); this.logTailer.start(); } - private onLogLine(line: string): void { - const m = AMICODE_ITER_RE.exec(line); - if (!m) return; - getInspector()?.postIterationRecord({ - iter: parseInt(m[1], 10), - f_val: parseFloat(m[2]), - inf_pr: parseFloat(m[3]), - inf_du: parseFloat(m[4]), - }); - } - - private handle(filename: string, fp: string): void { - const iterMatch = /^iter_(\d{4})\.png$/.exec(filename); - if (iterMatch) { - const iter = parseInt(iterMatch[1], 10); - if (iter <= this.latestIter) return; - this.latestIter = iter; - getInspector()?.setImageSource(fp, iter); - this.opts.statusBar?.setRun({ - runId: path.basename(this.activeRunDir!), - outputDir: this.activeRunDir!, - startedAt: 0, - status: "running", - latestIter: iter, - }); - return; + 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, "manifest.toml"))?.run_id ?? path.basename(runDir)); + let fidelity: number | undefined; + if (status === "completed") { + const result = readTomlSafe(path.join(runDir, "result.toml")); + if (result && validateResult(result).ok) fidelity = result.fidelity as number; } - if (filename === "final.png") { - getInspector()?.setImageSource(fp, this.latestIter + 1, /*final*/ true); - return; - } - if (filename === "formulation.md" && !this.formulationOpened) { - this.formulationOpened = true; - vscode.commands - .executeCommand("markdown.showPreviewToSide", vscode.Uri.file(fp)) - .then(undefined, (err) => this.opts.channel.appendLine(`[runs] preview failed: ${err}`)); - return; - } - if (filename === "result.toml") { - this.onResult(fp); - return; + this.sink?.run({ runId, runDir, status, fidelity }); + if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) { + this.sink?.promote({ runId, runDir, fidelity }); } } - - private onResult(resultPath: string): void { - if (this.promotedForRun) return; - let txt = ""; - try { txt = fs.readFileSync(resultPath, "utf8"); } - catch { return; } - const fid = parseFloat(((txt.match(/fidelity\s*=\s*([\d.eE+-]+)/) ?? [])[1] ?? "")); - if (!Number.isFinite(fid)) return; - - this.opts.statusBar?.setRun({ - runId: path.basename(this.activeRunDir!), - outputDir: this.activeRunDir!, - startedAt: 0, - status: "completed", - latestIter: this.latestIter, - }); - this.opts.channel.appendLine(`[runs] result.toml: F=${fid.toFixed(6)}`); - - const threshold = this.opts.promoteThreshold ?? 0.99; - if (fid < threshold) return; - this.promotedForRun = true; - - void (async () => { - const choice = await vscode.window.showInformationMessage( - `Amicode: solve converged (F=${fid.toFixed(4)}). Promote pulse to catalog?`, - { modal: false }, - "Yes — promote", - "No — keep local only", - ); - if (choice === "Yes — promote") { - // Catalog write isn't wired yet — toast for now. - vscode.window.showInformationMessage( - `Amicode: promotion stub — would copy ${path.basename(this.activeRunDir!)} to catalog.`, - ); - await vscode.commands.executeCommand("amicode.catalog.refresh").then(undefined, () => undefined); - } - })(); - } } // =========================================================================== -// LogTailer — follows run.log as julia appends to it, emits each new line via -// onLine. Uses fs.watch + fs.read at the persisted offset; survives the file -// not existing yet (amico-run touches it lazily via tee). +// LogTailer — follows run.log as julia appends, emitting each new line. // =========================================================================== -interface LogTailerOptions { - path: string; - channel: vscode.OutputChannel; - onLine: (line: string) => void; -} +interface LogTailerOptions { path: string; channel: vscode.OutputChannel; onLine: (line: string) => void; startOffset?: number } class LogTailer implements vscode.Disposable { private watcher?: fs.FSWatcher; @@ -227,15 +207,10 @@ class LogTailer implements vscode.Disposable { constructor(private readonly opts: LogTailerOptions) {} start(): void { - // The log file may not exist yet (amico-run creates it via `tee`). Poll - // for it briefly; once it appears, switch to fs.watch. const tryAttach = () => { if (this.disposed) return; - if (fs.existsSync(this.opts.path)) { - this.attach(); - } else { - this.pollTimer = setTimeout(tryAttach, 250); - } + if (fs.existsSync(this.opts.path)) this.attach(); + else this.pollTimer = setTimeout(tryAttach, 250); }; tryAttach(); } @@ -249,32 +224,27 @@ class LogTailer implements vscode.Disposable { private attach(): void { if (this.disposed) 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; 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}`); - return; } - // Initial read in case content already exists. + // 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 { - fd = fs.openSync(this.opts.path, "r"); - } catch { return; } - try { - const stat = fs.fstatSync(fd); - const size = stat.size; - if (size < this.offset) { - // File was truncated (or replaced by a newer run somehow). - this.offset = 0; - this.buf = ""; - } + 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); @@ -284,8 +254,7 @@ 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 */ } diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 715ce1241..40b08f90c 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -5,56 +5,50 @@ import * as os from "node:os"; // ============================================================================ // Prepare a per-session opencode project directory. // -// Architecture: opencode invokes amico-run via its built-in `bash` tool — no -// MCP, no callback HTTP. We just need to make sure opencode (a) has context -// about how to use amico-run, and (b) finds it on PATH. -// -// Layout written: -// /AGENTS.md ← LLM context (auto-loaded) -// /.opencode/opencode.json ← optional config tweaks -// -// PATH augmentation happens at spawn time (ServerManager env), not here. +// opencode invokes amico-run via its built-in `bash` tool — no MCP, no +// callback HTTP. We deliver into the session: (a) AGENTS.md (auto-loaded LLM +// context, with the Julia project path substituted in), and (b) the vetted +// solve_template.jl the agent copies + fills in. PATH augmentation (so +// `amico-run` resolves) happens at spawn time in extension.ts. // ============================================================================ export interface OpencodeConfigOptions { - /** Absolute path to amicode-v2/bin/ — added to PATH so `amico-run` resolves. */ - binDir: string; - /** Absolute path to amicode-v2/AGENTS.md to copy into the project dir. */ + /** Absolute path to packages/extension/AGENTS.md to copy into the project dir. */ agentsSrc: string; + /** Absolute path to the vetted solve_template.jl to copy into the project dir. */ + templateSrc: string; + /** Julia project (--project) the agent should use; substituted into AGENTS.md. + * undefined → "UNSET" (AGENTS.md tells the agent to omit --project). */ + juliaProject: string | undefined; } export interface OpencodeProject { projectDir: string; agentsPath: string; + templatePath: string; } export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodeProject { const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-v2-")); - const opencodeDir = path.join(projectDir, ".opencode"); - fs.mkdirSync(opencodeDir, { recursive: true }); + fs.mkdirSync(path.join(projectDir, ".opencode"), { recursive: true }); - // Copy AGENTS.md into the project dir. opencode auto-loads it as system - // context for any session it spawns from this dir. + // AGENTS.md: read → substitute {{JULIA_PROJECT}} → write (auto-loaded by opencode). const agentsPath = path.join(projectDir, "AGENTS.md"); - if (fs.existsSync(opts.agentsSrc)) { - fs.copyFileSync(opts.agentsSrc, agentsPath); - } else { - // Fallback minimal stub so opencode has *something* to anchor on. - fs.writeFileSync( - agentsPath, - "# Amicode\nInvoke `amico-run --help` via bash to see the solver CLI.\n", - "utf8", - ); - } - - // Empty/minimal opencode config — we no longer need MCP or plugin entries. - // Leaving the file behind so opencode treats this dir as its project root. - const configPath = path.join(opencodeDir, "opencode.json"); + const raw = fs.existsSync(opts.agentsSrc) + ? fs.readFileSync(opts.agentsSrc, "utf8") + : "# Amicode\nRead solve_template.jl, fill params, run `amico-run