From 302600479ecdde6e8e07ea864616b49fd984bf03 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:22:23 -0400 Subject: [PATCH 01/17] chore: convert repo to pnpm workspace; move extension to packages/extension Co-Authored-By: Claude Fable 5 --- .vscode/launch.json | 4 +- package.json | 85 ++---------------- .../extension/esbuild.config.mjs | 0 {media => packages/extension/media}/amico.svg | 0 packages/extension/package.json | 86 +++++++++++++++++++ {src => packages/extension/src}/chat_panel.ts | 0 {src => packages/extension/src}/extension.ts | 0 .../extension/src}/file_watcher.ts | 0 .../extension/src}/inspector_webview.ts | 0 .../extension/src}/opencode_config.ts | 0 .../extension/src}/run_inspector.ts | 0 .../extension/src}/server_manager.ts | 0 {src => packages/extension/src}/sse_client.ts | 0 {src => packages/extension/src}/status_bar.ts | 0 {src => packages/extension/src}/trees.ts | 0 {src => packages/extension/src}/types.ts | 0 .../extension/test}/opencode_boot_harness.mjs | 0 .../extension/test}/smoke_cli.mjs | 0 .../extension/test}/smoke_opencode_boot.mjs | 0 .../extension/test}/vscode_shim.mjs | 0 .../extension/tsconfig.json | 0 pnpm-lock.yaml | 4 +- pnpm-workspace.yaml | 2 + 23 files changed, 98 insertions(+), 83 deletions(-) rename esbuild.config.mjs => packages/extension/esbuild.config.mjs (100%) rename {media => packages/extension/media}/amico.svg (100%) create mode 100644 packages/extension/package.json rename {src => packages/extension/src}/chat_panel.ts (100%) rename {src => packages/extension/src}/extension.ts (100%) rename {src => packages/extension/src}/file_watcher.ts (100%) rename {src => packages/extension/src}/inspector_webview.ts (100%) rename {src => packages/extension/src}/opencode_config.ts (100%) rename {src => packages/extension/src}/run_inspector.ts (100%) rename {src => packages/extension/src}/server_manager.ts (100%) rename {src => packages/extension/src}/sse_client.ts (100%) rename {src => packages/extension/src}/status_bar.ts (100%) rename {src => packages/extension/src}/trees.ts (100%) rename {src => packages/extension/src}/types.ts (100%) rename {test => packages/extension/test}/opencode_boot_harness.mjs (100%) rename {test => packages/extension/test}/smoke_cli.mjs (100%) rename {test => packages/extension/test}/smoke_opencode_boot.mjs (100%) rename {test => packages/extension/test}/vscode_shim.mjs (100%) rename tsconfig.json => packages/extension/tsconfig.json (100%) create mode 100644 pnpm-workspace.yaml diff --git a/.vscode/launch.json b/.vscode/launch.json index 49ecf6a0..8fa91fd5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,8 +5,8 @@ "name": "Run Extension (amicode-v2)", "type": "extensionHost", "request": "launch", - "args": ["--extensionDevelopmentPath=${workspaceFolder}"], - "outFiles": ["${workspaceFolder}/dist/**/*.js"] + "args": ["--extensionDevelopmentPath=${workspaceFolder}/packages/extension"], + "outFiles": ["${workspaceFolder}/packages/extension/dist/**/*.js"] } ] } diff --git a/package.json b/package.json index f2cca778..e57da6a8 100644 --- a/package.json +++ b/package.json @@ -1,85 +1,10 @@ { - "name": "amicode-v2", - "displayName": "Amicode v2", - "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", + "name": "amicode-workspace", "private": true, - "engines": { - "vscode": "^1.95.0" - }, - "categories": ["Other"], - "activationEvents": [ - "onCommand:amicode.openChat", - "onCommand:amicode.openInspector", - "onView:amicode.runInspector", - "onView:amicode.vault", - "onStartupFinished" - ], - "main": "./dist/extension.js", - "contributes": { - "viewsContainers": { - "activitybar": [ - { - "id": "amicode", - "title": "Amicode", - "icon": "media/amico.svg" - } - ], - "panel": [ - { - "id": "amicode-panel", - "title": "Amicode", - "icon": "media/amico.svg" - } - ] - }, - "views": { - "amicode": [ - { "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" } - ] - }, - "commands": [ - { "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", - "properties": { - "amicode.opencodeBinary": { - "type": "string", - "default": "opencode", - "description": "Path or command name for the opencode CLI." - }, - "amicode.juliaProject": { - "type": "string", - "default": "/tmp/amicode-spike-julia", - "description": "Julia project root (--project=...) the run_julia tool will use." - }, - "amicode.juliaScript": { - "type": "string", - "default": "", - "description": "Absolute path to spike_solve.jl. If empty, looks up via the extensionPath/../amicode/julia/spike_solve.jl convention." - } - } - } - }, "scripts": { - "build": "node esbuild.config.mjs", - "watch": "node esbuild.config.mjs --watch", - "typecheck": "tsc --noEmit" + "build": "pnpm -r run build", + "typecheck": "pnpm -r run typecheck", + "test": "pnpm -r run test" }, - "devDependencies": { - "@types/node": "^22.0.0", - "@types/vscode": "^1.95.0", - "esbuild": "^0.24.0", - "typescript": "^5.6.0" - } + "packageManager": "pnpm@9.15.9" } diff --git a/esbuild.config.mjs b/packages/extension/esbuild.config.mjs similarity index 100% rename from esbuild.config.mjs rename to packages/extension/esbuild.config.mjs diff --git a/media/amico.svg b/packages/extension/media/amico.svg similarity index 100% rename from media/amico.svg rename to packages/extension/media/amico.svg diff --git a/packages/extension/package.json b/packages/extension/package.json new file mode 100644 index 00000000..638b2a0a --- /dev/null +++ b/packages/extension/package.json @@ -0,0 +1,86 @@ +{ + "name": "amicode-v2", + "displayName": "Amicode v2", + "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", + "private": true, + "engines": { + "vscode": "^1.95.0" + }, + "categories": ["Other"], + "activationEvents": [ + "onCommand:amicode.openChat", + "onCommand:amicode.openInspector", + "onView:amicode.runInspector", + "onView:amicode.vault", + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "amicode", + "title": "Amicode", + "icon": "media/amico.svg" + } + ], + "panel": [ + { + "id": "amicode-panel", + "title": "Amicode", + "icon": "media/amico.svg" + } + ] + }, + "views": { + "amicode": [ + { "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" } + ] + }, + "commands": [ + { "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", + "properties": { + "amicode.opencodeBinary": { + "type": "string", + "default": "opencode", + "description": "Path or command name for the opencode CLI." + }, + "amicode.juliaProject": { + "type": "string", + "default": "/tmp/amicode-spike-julia", + "description": "Julia project root (--project=...) the run_julia tool will use." + }, + "amicode.juliaScript": { + "type": "string", + "default": "", + "description": "Absolute path to spike_solve.jl. If empty, looks up via the extensionPath/../amicode/julia/spike_solve.jl convention." + } + } + } + }, + "scripts": { + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "echo 'extension: spike smoke tests in test/ need opencode+julia (not headless); skipped'" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "^1.95.0", + "esbuild": "^0.24.0", + "typescript": "^5.6.0" + } +} diff --git a/src/chat_panel.ts b/packages/extension/src/chat_panel.ts similarity index 100% rename from src/chat_panel.ts rename to packages/extension/src/chat_panel.ts diff --git a/src/extension.ts b/packages/extension/src/extension.ts similarity index 100% rename from src/extension.ts rename to packages/extension/src/extension.ts diff --git a/src/file_watcher.ts b/packages/extension/src/file_watcher.ts similarity index 100% rename from src/file_watcher.ts rename to packages/extension/src/file_watcher.ts diff --git a/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts similarity index 100% rename from src/inspector_webview.ts rename to packages/extension/src/inspector_webview.ts diff --git a/src/opencode_config.ts b/packages/extension/src/opencode_config.ts similarity index 100% rename from src/opencode_config.ts rename to packages/extension/src/opencode_config.ts diff --git a/src/run_inspector.ts b/packages/extension/src/run_inspector.ts similarity index 100% rename from src/run_inspector.ts rename to packages/extension/src/run_inspector.ts diff --git a/src/server_manager.ts b/packages/extension/src/server_manager.ts similarity index 100% rename from src/server_manager.ts rename to packages/extension/src/server_manager.ts diff --git a/src/sse_client.ts b/packages/extension/src/sse_client.ts similarity index 100% rename from src/sse_client.ts rename to packages/extension/src/sse_client.ts diff --git a/src/status_bar.ts b/packages/extension/src/status_bar.ts similarity index 100% rename from src/status_bar.ts rename to packages/extension/src/status_bar.ts diff --git a/src/trees.ts b/packages/extension/src/trees.ts similarity index 100% rename from src/trees.ts rename to packages/extension/src/trees.ts diff --git a/src/types.ts b/packages/extension/src/types.ts similarity index 100% rename from src/types.ts rename to packages/extension/src/types.ts diff --git a/test/opencode_boot_harness.mjs b/packages/extension/test/opencode_boot_harness.mjs similarity index 100% rename from test/opencode_boot_harness.mjs rename to packages/extension/test/opencode_boot_harness.mjs diff --git a/test/smoke_cli.mjs b/packages/extension/test/smoke_cli.mjs similarity index 100% rename from test/smoke_cli.mjs rename to packages/extension/test/smoke_cli.mjs diff --git a/test/smoke_opencode_boot.mjs b/packages/extension/test/smoke_opencode_boot.mjs similarity index 100% rename from test/smoke_opencode_boot.mjs rename to packages/extension/test/smoke_opencode_boot.mjs diff --git a/test/vscode_shim.mjs b/packages/extension/test/vscode_shim.mjs similarity index 100% rename from test/vscode_shim.mjs rename to packages/extension/test/vscode_shim.mjs diff --git a/tsconfig.json b/packages/extension/tsconfig.json similarity index 100% rename from tsconfig.json rename to packages/extension/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e3468c8..206aab06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,9 @@ settings: importers: - .: + .: {} + + packages/extension: devDependencies: '@types/node': specifier: ^22.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* From 562111f92a223760fb4d1d9765c29c7e5a067c15 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:23:40 -0400 Subject: [PATCH 02/17] feat(amico-run): package skeleton + public API types Co-Authored-By: Claude Fable 5 --- packages/amico-run/esbuild.config.mjs | 17 + packages/amico-run/package.json | 23 + packages/amico-run/src/cli.ts | 3 + packages/amico-run/src/index.ts | 1 + packages/amico-run/src/types.ts | 37 ++ packages/amico-run/tsconfig.json | 11 + pnpm-lock.yaml | 884 ++++++++++++++++++++++++++ 7 files changed, 976 insertions(+) create mode 100644 packages/amico-run/esbuild.config.mjs create mode 100644 packages/amico-run/package.json create mode 100644 packages/amico-run/src/cli.ts create mode 100644 packages/amico-run/src/index.ts create mode 100644 packages/amico-run/src/types.ts create mode 100644 packages/amico-run/tsconfig.json diff --git a/packages/amico-run/esbuild.config.mjs b/packages/amico-run/esbuild.config.mjs new file mode 100644 index 00000000..eb12aaa7 --- /dev/null +++ b/packages/amico-run/esbuild.config.mjs @@ -0,0 +1,17 @@ +import { build } from 'esbuild' +import { chmodSync } from 'node:fs' + +await build({ + entryPoints: ['src/cli.ts'], + bundle: true, + 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' }, + sourcemap: true, + logLevel: 'info', +}) +chmodSync('dist/amico-run.js', 0o755) diff --git a/packages/amico-run/package.json b/packages/amico-run/package.json new file mode 100644 index 00000000..b831f2d6 --- /dev/null +++ b/packages/amico-run/package.json @@ -0,0 +1,23 @@ +{ + "name": "@amicode/amico-run", + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "bin": { "amico-run": "./launcher/amico-run" }, + "engines": { "node": ">=20" }, + "scripts": { + "build": "node esbuild.config.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests --exclude '**/slow/**'", + "test:slow": "vitest run test/slow" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "esbuild": "^0.24.0", + "smol-toml": "^1.3.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts new file mode 100644 index 00000000..89acc5d4 --- /dev/null +++ b/packages/amico-run/src/cli.ts @@ -0,0 +1,3 @@ +// stub — the real CLI lands with the executor (plan Task 8) +console.error('amico-run: not implemented yet') +process.exitCode = 64 diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts new file mode 100644 index 00000000..fdc63323 --- /dev/null +++ b/packages/amico-run/src/index.ts @@ -0,0 +1 @@ +export * from './types.js' diff --git a/packages/amico-run/src/types.ts b/packages/amico-run/src/types.ts new file mode 100644 index 00000000..069d7561 --- /dev/null +++ b/packages/amico-run/src/types.ts @@ -0,0 +1,37 @@ +export type RunStatus = 'completed' | 'failed' | 'aborted' + +export interface JuliaOpts { + 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. +} + +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 } + +export interface RunHandle { + 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 +} + +/** Exit-64-class fault: bad config, nothing solver-related ran. */ +export class ConfigError extends Error {} diff --git a/packages/amico-run/tsconfig.json b/packages/amico-run/tsconfig.json new file mode 100644 index 00000000..66385e57 --- /dev/null +++ b/packages/amico-run/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 206aab06..fde055eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,24 @@ importers: .: {} + packages/amico-run: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 + smol-toml: + specifier: ^1.3.0 + version: 1.6.1 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19) + packages/extension: devDependencies: '@types/node': @@ -25,102 +43,204 @@ importers: packages: + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.24.2': resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.24.2': resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.24.2': resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.24.2': resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.24.2': resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.24.2': resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.24.2': resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.24.2': resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.24.2': resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.24.2': resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.24.2': resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.24.2': resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.24.2': resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.24.2': resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} engines: {node: '>=18'} @@ -133,6 +253,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} engines: {node: '>=18'} @@ -145,47 +271,354 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.24.2': resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.24.2': resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.24.2': resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.24.2': resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} '@types/vscode@1.120.0': resolution: {integrity: sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -194,89 +627,391 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + snapshots: + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.24.2': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.24.2': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.24.2': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.24.2': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.24.2': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.24.2': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.24.2': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.24.2': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.24.2': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.24.2': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.24.2': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.24.2': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.24.2': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.24.2': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.24.2': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.24.2': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.24.2': optional: true '@esbuild/netbsd-arm64@0.24.2': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.24.2': optional: true '@esbuild/openbsd-arm64@0.24.2': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.24.2': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.24.2': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.24.2': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.24.2': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.24.2': optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.61.1': + optional: true + + '@rollup/rollup-android-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-x64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.1': + optional: true + + '@types/estree@1.0.9': {} + '@types/node@22.19.19': dependencies: undici-types: 6.21.0 '@types/vscode@1.120.0': {} + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -305,6 +1040,155 @@ snapshots: '@esbuild/win32-ia32': 0.24.2 '@esbuild/win32-x64': 0.24.2 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fsevents@2.3.3: + optional: true + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.61.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + typescript@5.9.3: {} undici-types@6.21.0: {} + + vite-node@2.1.9(@types/node@22.19.19): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.19): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.61.1 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.19.19): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.19) + vite-node: 2.1.9(@types/node@22.19.19) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 From 3e4b95a4611b4bca678dedaa812c0df9481232f1 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:25:01 -0400 Subject: [PATCH 03/17] feat(amico-run): telemetry line classifier Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/index.ts | 1 + packages/amico-run/src/telemetry.ts | 14 ++++++++++++ packages/amico-run/test/telemetry.test.ts | 28 +++++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 packages/amico-run/src/telemetry.ts create mode 100644 packages/amico-run/test/telemetry.test.ts diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index fdc63323..596ce346 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -1 +1,2 @@ export * from './types.js' +export * from './telemetry.js' diff --git a/packages/amico-run/src/telemetry.ts b/packages/amico-run/src/telemetry.ts new file mode 100644 index 00000000..b88159f9 --- /dev/null +++ b/packages/amico-run/src/telemetry.ts @@ -0,0 +1,14 @@ +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) + } + return { kind: 'iter', raw: line, fields } + } + if (stream === 'stdout' && /^DONE(\s|$)/.test(line)) return { kind: 'done', raw: line } + return { kind: 'log', stream, line } +} diff --git a/packages/amico-run/test/telemetry.test.ts b/packages/amico-run/test/telemetry.test.ts new file mode 100644 index 00000000..126489ff --- /dev/null +++ b/packages/amico-run/test/telemetry.test.ts @@ -0,0 +1,28 @@ +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') + 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', + }) + }) +}) From 3c0da8a9f0b8bff30691ebbc868da7ff64e09c19 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:26:26 -0400 Subject: [PATCH 04/17] feat(amico-run): run-dir primitives (lab-id, runId, atomic writers) Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/index.ts | 1 + packages/amico-run/src/run_dir.ts | 86 +++++++++++++++++++++++++ packages/amico-run/test/helpers.ts | 21 ++++++ packages/amico-run/test/run_dir.test.ts | 64 ++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 packages/amico-run/src/run_dir.ts create mode 100644 packages/amico-run/test/helpers.ts create mode 100644 packages/amico-run/test/run_dir.test.ts diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index 596ce346..3434fed7 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -1,2 +1,3 @@ export * from './types.js' export * from './telemetry.js' +export * from './run_dir.js' diff --git a/packages/amico-run/src/run_dir.ts b/packages/amico-run/src/run_dir.ts new file mode 100644 index 00000000..b428ea09 --- /dev/null +++ b/packages/amico-run/src/run_dir.ts @@ -0,0 +1,86 @@ +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_-]*$/ + +/** 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`) + } + 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) +} + +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` + for (;;) { + 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 ts = (s: string) => JSON.stringify(s) // JSON escaping is valid TOML basic-string + +export interface Manifest { + schema_version: '1' + run_id: string + script_path: string + lab: string + lab_id: string + created_at: string + orchestrator_version: string + julia: { binary: string; project?: string; sysimage?: string } +} + +export function writeManifest(runDir: string, m: Manifest): void { + const lines = [ + `schema_version = ${ts(m.schema_version)}`, + `run_id = ${ts(m.run_id)}`, + `script_path = ${ts(m.script_path)}`, + `lab = ${ts(m.lab)}`, + `lab_id = ${ts(m.lab_id)}`, + `created_at = ${ts(m.created_at)}`, + `orchestrator_version = ${ts(m.orchestrator_version)}`, + '', + '[julia]', + `binary = ${ts(m.julia.binary)}`, + ...(m.julia.project ? [`project = ${ts(m.julia.project)}`] : []), + ...(m.julia.sysimage ? [`sysimage = ${ts(m.julia.sysimage)}`] : []), + ] + atomicWriteFile(runDir, 'manifest.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`) +} + +export function appendIndex(runsRoot: string, runId: string, createdAt: string, scriptPath: string): void { + appendFileSync(join(runsRoot, 'index'), `${runId}\t${createdAt}\t${scriptPath}\n`) +} + +export function updateLatest(runsRoot: string, runId: string): void { + const tmp = join(runsRoot, '.latest.tmp') + rmSync(tmp, { force: true }) + symlinkSync(runId, tmp) + renameSync(tmp, join(runsRoot, 'latest')) +} diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts new file mode 100644 index 00000000..ff491074 --- /dev/null +++ b/packages/amico-run/test/helpers.ts @@ -0,0 +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' + +export function tmpRoot(): string { + return mkdtempSync(join(tmpdir(), 'amico-run-test-')) +} + +export function readToml(path: string): 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 +} diff --git a/packages/amico-run/test/run_dir.test.ts b/packages/amico-run/test/run_dir.test.ts new file mode 100644 index 00000000..474bfd92 --- /dev/null +++ b/packages/amico-run/test/run_dir.test.ts @@ -0,0 +1,64 @@ +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' + +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('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, 'manifest.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('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') + }) +}) From b5d5ce4a092072da689a29005d6d85d88c8e8036 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:27:00 -0400 Subject: [PATCH 05/17] feat(amico-run): provisional schema validators Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/index.ts | 1 + packages/amico-run/src/schemas.ts | 42 +++++++++++++++++++++++++ packages/amico-run/test/schemas.test.ts | 37 ++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 packages/amico-run/src/schemas.ts create mode 100644 packages/amico-run/test/schemas.test.ts diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index 3434fed7..80532c82 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -1,3 +1,4 @@ export * from './types.js' export * from './telemetry.js' export * from './run_dir.js' +export * from './schemas.js' diff --git a/packages/amico-run/src/schemas.ts b/packages/amico-run/src/schemas.ts new file mode 100644 index 00000000..53cc0ab3 --- /dev/null +++ b/packages/amico-run/src/schemas.ts @@ -0,0 +1,42 @@ +// PROVISIONAL shapes — superseded by Phase 0' SchemaPackage (plan task 0.1). +// Field names here ARE the contract and must survive that migration (spec §7). +export interface Validation { ok: boolean; errors: string[] } + +type Obj = Record +const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v) + +function check(errors: string[], cond: boolean, path: string, want: string): void { + if (!cond) errors.push(`${path}: expected ${want}`) +} + +export function validateManifest(v: unknown): Validation { + const errors: string[] = [] + if (!isObj(v)) return { ok: false, errors: ['manifest: expected table'] } + check(errors, v.schema_version === '1', 'schema_version', '"1"') + for (const k of ['run_id', 'script_path', 'lab', 'lab_id', 'created_at', 'orchestrator_version']) + check(errors, typeof v[k] === 'string' && (v[k] as string).length > 0, k, 'non-empty string') + check(errors, isObj(v.julia), 'julia', 'table') + if (isObj(v.julia)) { + check(errors, typeof v.julia.binary === 'string', 'julia.binary', 'string') + for (const k of ['project', 'sysimage'] as const) + check(errors, v.julia[k] === undefined || typeof v.julia[k] === 'string', `julia.${k}`, 'string if present') + } + return { ok: errors.length === 0, errors } +} + +export function validateFinished(v: unknown): Validation { + const errors: string[] = [] + if (!isObj(v)) return { ok: false, errors: ['FINISHED: expected table'] } + check(errors, v.status === 'completed' || v.status === 'failed' || v.status === 'aborted', + 'status', 'completed|failed|aborted') + check(errors, Number.isInteger(v.exit_code), 'exit_code', 'integer') + return { ok: errors.length === 0, errors } +} + +export function validateResult(v: unknown): Validation { + const errors: string[] = [] + if (!isObj(v)) return { ok: false, errors: ['result: expected table'] } + check(errors, typeof v.fidelity === 'number', 'fidelity', 'number') + check(errors, Number.isInteger(v.iterations), 'iterations', 'integer') + return { ok: errors.length === 0, errors } +} diff --git a/packages/amico-run/test/schemas.test.ts b/packages/amico-run/test/schemas.test.ts new file mode 100644 index 00000000..14ac2434 --- /dev/null +++ b/packages/amico-run/test/schemas.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { validateManifest, validateFinished, validateResult } from '../src/schemas.js' + +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' }, +} + +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') + expect(r.errors.join(' ')).toContain('julia.binary') + }) + it('rejects unknown schema_version', () => + expect(validateManifest({ ...goodManifest, schema_version: '2' }).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 fidelity number, iterations integer', () => { + expect(validateResult({ fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true) + expect(validateResult({ iterations: 200 }).ok).toBe(false) + }) +}) From f936ce1f9fe79ca9e50d79f2ebaa38178249d007 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:28:27 -0400 Subject: [PATCH 06/17] =?UTF-8?q?feat(amico-run):=20LocalExecutor=20?= =?UTF-8?q?=E2=80=94=20submit=20lifecycle,=20event=20stream,=20contract=20?= =?UTF-8?q?enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/event_queue.ts | 26 ++++ packages/amico-run/src/index.ts | 2 + packages/amico-run/src/local_executor.ts | 135 ++++++++++++++++++ .../amico-run/test/local_executor.test.ts | 75 ++++++++++ 4 files changed, 238 insertions(+) create mode 100644 packages/amico-run/src/event_queue.ts create mode 100644 packages/amico-run/src/local_executor.ts create mode 100644 packages/amico-run/test/local_executor.test.ts diff --git a/packages/amico-run/src/event_queue.ts b/packages/amico-run/src/event_queue.ts new file mode 100644 index 00000000..361b42f3 --- /dev/null +++ b/packages/amico-run/src/event_queue.ts @@ -0,0 +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 + + 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) + } + close(): void { + 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)) + }, + } + } +} diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index 80532c82..f8af7d5f 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -2,3 +2,5 @@ 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' diff --git a/packages/amico-run/src/local_executor.ts b/packages/amico-run/src/local_executor.ts new file mode 100644 index 00000000..f0e523cb --- /dev/null +++ b/packages/amico-run/src/local_executor.ts @@ -0,0 +1,135 @@ +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, 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' + +const ORCHESTRATOR_VERSION = '0.1.0' + +function resolveExecutable(bin: string): void { + const candidates = bin.includes('/') + ? [resolve(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 */ } + } + 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) +} + +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})`) + } + + // ---- 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() + writeManifest(runDir, { + schema_version: '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 }, + }) + 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 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 + const settle = (status: RunStatus, exitCode: number): void => { + 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`) + } + 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'], + }) + // spawn failure AFTER manifest exists → FINISHED{failed, 127} (spec §6) + 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) + }) + + 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 abort = async (): Promise => { + 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) + } + + 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+)')) + } +} diff --git a/packages/amico-run/test/local_executor.test.ts b/packages/amico-run/test/local_executor.test.ts new file mode 100644 index 00000000..f850cec2 --- /dev/null +++ b/packages/amico-run/test/local_executor.test.ts @@ -0,0 +1,75 @@ +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 +} + +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 }, + }) + + // manifest observable before events finish — submit() resolved, so it must exist NOW + const manifest = readToml(join(h.runDir, 'manifest.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 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) + // no temp files left anywhere in the run dir + 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('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) + }) +}) From 9991d0452913088cd75e1856bfa0de1279c85125 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:30:55 -0400 Subject: [PATCH 07/17] =?UTF-8?q?test(amico-run):=20=C2=A76=20failure=20ma?= =?UTF-8?q?trix=20+=20abort=20lanes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/amico-run/test/abort.test.ts | 44 ++++++ packages/amico-run/test/failure_lanes.test.ts | 128 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 packages/amico-run/test/abort.test.ts create mode 100644 packages/amico-run/test/failure_lanes.test.ts diff --git a/packages/amico-run/test/abort.test.ts b/packages/amico-run/test/abort.test.ts new file mode 100644 index 00000000..8946c0b1 --- /dev/null +++ b/packages/amico-run/test/abort.test.ts @@ -0,0 +1,44 @@ +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 +// 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)` + +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 + }) + for await (const e of h.events) { + if (e.kind === 'log' && e.line === 'READY') void h.abort() // handler installed — now abort + } + 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') + }) +}) diff --git a/packages/amico-run/test/failure_lanes.test.ts b/packages/amico-run/test/failure_lanes.test.ts new file mode 100644 index 00000000..03112dc6 --- /dev/null +++ b/packages/amico-run/test/failure_lanes.test.ts @@ -0,0 +1,128 @@ +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 } }) + +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, 'manifest.toml')).run_id).toBe(h.runId) + }) + + 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, 'manifest.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('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) + }) + + 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 }) + }) + + 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('manifest.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('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', ` + 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 }) + }) + + 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', ` + 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 }) + while (!done) { + 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') + }) +}) From bc0f170c897928f1505affc1bbc3d2c3685b7f30 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:32:21 -0400 Subject: [PATCH 08/17] feat(amico-run): CLI + single-file bundle + launcher; delete pre-D9 bash spike (S31 grep green) Co-Authored-By: Claude Fable 5 --- bin/amico-run | 133 -------------------------- packages/amico-run/launcher/amico-run | 15 +++ packages/amico-run/src/cli.ts | 78 ++++++++++++++- packages/amico-run/test/cli.test.ts | 66 +++++++++++++ packages/amico-run/test/s31.test.ts | 19 ++++ 5 files changed, 175 insertions(+), 136 deletions(-) delete mode 100755 bin/amico-run create mode 100755 packages/amico-run/launcher/amico-run create mode 100644 packages/amico-run/test/cli.test.ts create mode 100644 packages/amico-run/test/s31.test.ts diff --git a/bin/amico-run b/bin/amico-run deleted file mode 100755 index e7d4e6c3..00000000 --- a/bin/amico-run +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================ -# amico-run — CLI wrapper around spike_solve.jl for opencode's `bash` tool. -# -# Why: the v2 architecture went CLI-direct (no MCP, no callback HTTP). The -# LLM picks bash + amico-run, julia writes to a deterministic dir, and the -# VS Code extension watches that dir via fs.watch. One process boundary -# instead of three, no env propagation across them. -# -# Usage: -# amico-run --system -# --gate -# --pulse -# [--T-ns ] -# [--omega-cap ] -# [--max-iter ] -# [--seed ] -# [--solver ipopt] -# -# Side effects: -# 1. Creates /tmp/amicode-runs// (timestamp + random hex) -# 2. Updates /tmp/amicode-runs/latest -> -# 3. Spawns stdbuf + julia, streaming AMICODE_ITER lines to stdout. -# 4. On exit, prints a one-line summary the LLM can quote back. -# ============================================================================ -set -euo pipefail - -# --help short-circuit: don't fire julia just because someone probed. -for arg in "$@"; do - case "$arg" in - -h|--help|help) - cat <<'EOF' -amico-run — Amicode quantum optimal-control solve runner. - -USAGE - amico-run --system --gate - --pulse - [--T-ns ] [--omega-cap ] [--max-iter ] - [--seed ] [--solver ipopt] - -EXAMPLES - amico-run --system transmon --gate X --pulse zero-order --T-ns 24 - amico-run --system qubit --gate H --pulse zero-order - amico-run --system transmon --gate CNOT --pulse linear-spline --T-ns 200 - -OUTPUT - /tmp/amicode-runs// per-run dir (PNGs, formulation.md, result.toml) - /tmp/amicode-runs/latest symlink to most recent run dir - -The VS Code extension's Run Inspector watches /tmp/amicode-runs/latest/. -EOF - exit 0 - ;; - esac -done - -# Locate spike_solve.jl relative to this script. -SCRIPT="$(realpath "$0")" -SCRIPT_DIR="$(dirname "$SCRIPT")" -# amicode-v2/bin/amico-run → amicode/julia/spike_solve.jl (sibling tree) -JULIA_SCRIPT_DEFAULT="$(realpath "$SCRIPT_DIR/../../amicode/julia/spike_solve.jl")" -JULIA_PROJECT_DEFAULT="/tmp/amicode-spike-julia" - -JULIA_SCRIPT="${AMICO_JULIA_SCRIPT:-$JULIA_SCRIPT_DEFAULT}" -JULIA_PROJECT="${AMICO_JULIA_PROJECT:-$JULIA_PROJECT_DEFAULT}" - -if [[ ! -f "$JULIA_SCRIPT" ]]; then - echo "amico-run: julia script not found at $JULIA_SCRIPT" >&2 - echo "amico-run: set AMICO_JULIA_SCRIPT or place spike_solve.jl at that location." >&2 - exit 64 -fi -if [[ ! -d "$JULIA_PROJECT" ]]; then - echo "amico-run: julia project not found at $JULIA_PROJECT" >&2 - echo "amico-run: set AMICO_JULIA_PROJECT to your dev'd Piccolissimo env." >&2 - exit 64 -fi - -# Generate a runId, prep the output dir, swing the 'latest' symlink. -RUNS_ROOT="${AMICO_RUNS_ROOT:-/tmp/amicode-runs}" -mkdir -p "$RUNS_ROOT" -RUN_ID="r$(date +%Y%m%d-%H%M%S)-$(openssl rand -hex 2 2>/dev/null || printf '%04x' $RANDOM)" -OUT_DIR="$RUNS_ROOT/$RUN_ID" -mkdir -p "$OUT_DIR" - -# Atomic-ish symlink swap. -ln -sfn "$OUT_DIR" "$RUNS_ROOT/.latest.tmp" && mv -Tf "$RUNS_ROOT/.latest.tmp" "$RUNS_ROOT/latest" - -# Emit a one-line start record (the extension's file watcher picks this up -# as soon as the .start file lands in latest/). -{ - printf 'runId=%s\n' "$RUN_ID" - printf 'outputDir=%s\n' "$OUT_DIR" - printf 'started=%s\n' "$(date -Iseconds)" -} > "$OUT_DIR/.start" - -# Build the julia argv. We pass --output-dir explicitly; all other args -# go through as-is. spike_solve.jl silently swallows unknown flags. -JULIA_ARGS=( "--project=$JULIA_PROJECT" "$JULIA_SCRIPT" "--output-dir" "$OUT_DIR" "$@" ) - -# stdbuf -oL -eL bypasses Ipopt's C-side stdout buffering on Linux. Without -# it, AMICODE_ITER lines spool until program exit. -EXE="julia"; PRE=() -if command -v stdbuf >/dev/null 2>&1; then - PRE=( "stdbuf" "-oL" "-eL" ) -fi - -echo "amico-run: spawning $EXE ${JULIA_ARGS[*]}" >&2 -echo "amico-run: output dir = $OUT_DIR" >&2 -echo "amico-run: latest symlink -> $RUNS_ROOT/latest" >&2 - -# Tee julia stdout to both our stdout (for the LLM) and a log file (for -# the extension, in case the LLM truncates). -set +e -"${PRE[@]}" "$EXE" "${JULIA_ARGS[@]}" 2>&1 | tee "$OUT_DIR/run.log" -JULIA_RC=${PIPESTATUS[0]} -set -e - -# Final summary. The extension also reads result.toml; this is for the LLM. -if [[ -f "$OUT_DIR/result.toml" ]]; then - FID=$(grep -E '^fidelity\s*=' "$OUT_DIR/result.toml" | head -1 | awk -F= '{gsub(/ /,"",$2); print $2}') - ITERS=$(grep -E '^iterations\s*=' "$OUT_DIR/result.toml" | head -1 | awk -F= '{gsub(/ /,"",$2); print $2}') - WALL=$(grep -E '^wall_time_s\s*=' "$OUT_DIR/result.toml" | head -1 | awk -F= '{gsub(/ /,"",$2); print $2}') - echo - echo "amico-run: DONE runId=$RUN_ID F=$FID iter=$ITERS wall=${WALL}s outputDir=$OUT_DIR" -else - echo - echo "amico-run: FAILED runId=$RUN_ID rc=$JULIA_RC outputDir=$OUT_DIR (no result.toml)" -fi - -# Mark completion so the extension's watcher knows to read result.toml. -touch "$OUT_DIR/FINISHED" - -exit "$JULIA_RC" diff --git a/packages/amico-run/launcher/amico-run b/packages/amico-run/launcher/amico-run new file mode 100755 index 00000000..a9b35f71 --- /dev/null +++ b/packages/amico-run/launcher/amico-run @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Thin launcher: resolve node, exec the bundled orchestrator. No logic lives here. +set -euo pipefail +SOURCE="${BASH_SOURCE[0]}" +while [ -h "$SOURCE" ]; do # resolve symlink chains (node_modules/.bin) + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" +done +DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" +if ! command -v node >/dev/null 2>&1; then + echo "amico-run: node >= 20 not found on PATH (install node or fix PATH; see provisioning runbook)" >&2 + exit 64 +fi +exec node "$DIR/../dist/amico-run.js" "$@" diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index 89acc5d4..9c7a6cb4 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -1,3 +1,75 @@ -// stub — the real CLI lands with the executor (plan Task 8) -console.error('amico-run: not implemented yet') -process.exitCode = 64 +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { LocalExecutor } from './local_executor.js' +import { ConfigError, type Finished, type SubmitOpts } from './types.js' + +const USAGE = `usage: amico-run [--executor local] [--lab ] + [--runs-root ] [--julia ] [--project ] [--sysimage ]` + +export async function main(argv: string[]): Promise { + let script: string | undefined + let executor = 'local' + const opts: SubmitOpts = { julia: {} } + + for (let i = 0; i < argv.length; 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 + } + 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(); break + case '--sysimage': opts.julia!.sysimage = 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 + } + } catch (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 } + + let handle + try { + handle = await new LocalExecutor().submit(script, opts) + } catch (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) + + 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 } + } + 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 + } + // 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 +} + +main(process.argv.slice(2)).then(c => { process.exitCode = c }) diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts new file mode 100644 index 00000000..892cc966 --- /dev/null +++ b/packages/amico-run/test/cli.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { execFileSync, execFile } from 'node:child_process' +import { join } from 'node:path' +import { tmpRoot, fakeJulia } from './helpers.js' + +const BUNDLE = join(__dirname, '..', 'dist', 'amico-run.js') +beforeAll(() => { + execFileSync('node', [join(__dirname, '..', 'esbuild.config.mjs')], { cwd: join(__dirname, '..') }) +}) + +function run(args: string[]): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync('node', [BUNDLE, ...args], { encoding: 'utf8' }) + 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 ?? '' } + } +} + +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('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/s31.test.ts b/packages/amico-run/test/s31.test.ts new file mode 100644 index 00000000..0567ff55 --- /dev/null +++ b/packages/amico-run/test/s31.test.ts @@ -0,0 +1,19 @@ +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 SolveSpec, no MCP, no HTTP in the orchestrator. +const FORBIDDEN = [/SolveSpec/, /--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') + for (const f of readdirSync(srcDir)) { + const text = readFileSync(join(srcDir, f), 'utf8') + for (const re of FORBIDDEN) { + expect(text, `${f} matches forbidden ${re}`).not.toMatch(re) + } + } + }) +}) From 7369449141d9d273fd95ff939d0e0376bc549400 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:33:09 -0400 Subject: [PATCH 09/17] ci: fast-tier workflow + extension type-level contract check Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 17 +++++++++++++++++ packages/extension/package.json | 1 + packages/extension/src/executor_check.ts | 9 +++++++++ pnpm-lock.yaml | 3 +++ 4 files changed, 30 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 packages/extension/src/executor_check.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e28706b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: ci +on: + push: { branches: [main] } + pull_request: +jobs: + fast: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 9 } + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm -r run build + - run: pnpm -r run typecheck + - run: pnpm --filter @amicode/amico-run test # includes the S31 grep-rule test diff --git a/packages/extension/package.json b/packages/extension/package.json index 638b2a0a..516206b0 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -78,6 +78,7 @@ "test": "echo 'extension: spike smoke tests in test/ need opencode+julia (not headless); skipped'" }, "devDependencies": { + "@amicode/amico-run": "workspace:*", "@types/node": "^22.0.0", "@types/vscode": "^1.95.0", "esbuild": "^0.24.0", diff --git a/packages/extension/src/executor_check.ts b/packages/extension/src/executor_check.ts new file mode 100644 index 00000000..ebf5abea --- /dev/null +++ b/packages/extension/src/executor_check.ts @@ -0,0 +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' + +export type _ExecutorContract = { + submit: Executor['submit'] + handle: Pick + event: RunEvent['kind'] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fde055eb..aa0a6a85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: packages/extension: devDependencies: + '@amicode/amico-run': + specifier: workspace:* + version: link:../amico-run '@types/node': specifier: ^22.0.0 version: 22.19.19 From bae0df85e5395cf24ca45dbc48ae54b4801b289d Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:46:32 -0400 Subject: [PATCH 10/17] test(amico-run): slow-tier real Piccolo solves validate the run-dir contract end-to-end Co-Authored-By: Claude Fable 5 --- .../amico-run/test/slow/integration.test.ts | 38 +++++++++++ packages/amico-run/test/slow/solve_common.jl | 64 +++++++++++++++++++ packages/amico-run/test/slow/solve_h_gate.jl | 3 + packages/amico-run/test/slow/solve_x_gate.jl | 3 + 4 files changed, 108 insertions(+) create mode 100644 packages/amico-run/test/slow/integration.test.ts create mode 100644 packages/amico-run/test/slow/solve_common.jl create mode 100644 packages/amico-run/test/slow/solve_h_gate.jl create mode 100644 packages/amico-run/test/slow/solve_x_gate.jl diff --git a/packages/amico-run/test/slow/integration.test.ts b/packages/amico-run/test/slow/integration.test.ts new file mode 100644 index 00000000..60051da5 --- /dev/null +++ b/packages/amico-run/test/slow/integration.test.ts @@ -0,0 +1,38 @@ +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') + +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 }) + + 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, 'manifest.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) + + it('h-gate solve produces a fully conforming run dir', () => { + solveAndValidate('solve_h_gate.jl') + }, 600_000) +}) diff --git a/packages/amico-run/test/slow/solve_common.jl b/packages/amico-run/test/slow/solve_common.jl new file mode 100644 index 00000000..63e5a4e3 --- /dev/null +++ b/packages/amico-run/test/slow/solve_common.jl @@ -0,0 +1,64 @@ +# Single-qubit gate solve (gate chosen by the including script via GATES[GATE_TARGET_NAME]) exercising the run-dir contract end-to-end. +# Runs with cwd = run dir (amico-run sets that); writes result.toml + pulse.jld2 here. +# Follows the conventions the β.3 AGENTS.md template teaches the LLM: +# - AMICODE_ITER key=value lines on stdout, flushed per iteration +# - result.toml written atomically (temp + rename) +# - final DONE line +# Written against Piccolo 0.11 (QuantumCollocation/DirectTrajOpt 0.8 stack). +using Piccolo +using JLD2 +using TOML + +# Per-iter callback machinery lives in DirectTrajOpt's internal IpoptSolverExt +# module (not re-exported by Piccolo); reach it through the solve! method table. +const IpoptSolverExt = first(filter( + m -> string(m.module) == "DirectTrajOpt.IpoptSolverExt", + collect(methods(solve!)))).module +const Callbacks = IpoptSolverExt.Callbacks + +# Toy single-qubit system (matches the Piccolo docs intro + the frozen spike). +sys = QuantumSystem(0.5 * PAULIS[:Z], [PAULIS[:X], PAULIS[:Y]], [1.0, 1.0]) + +N = 51 +T = 10.0 +times = collect(range(0.0, T, length = N)) +initial = 0.1 * randn(sys.n_drives, N) + +pulse = ZeroOrderPulse(initial, times) +qtraj = UnitaryTrajectory(sys, pulse, GATES[GATE_TARGET_NAME]) +qcp = SmoothPulseProblem(qtraj, N; + piccolo_options = PiccoloOptions(timesteps_all_equal = true), + ddu_bound = 1.0, Q = 100.0, R = 1e-2) + +dto_prob = hasproperty(qcp, :prob) ? qcp.prob : qcp +iters = Ref(0) +cb_update = Callbacks.callback_update_trajectory_factory(dto_prob) +function cb_log(optimizer, optimizer_state; kwargs...) + iters[] = Int(optimizer_state.iter_count) + println("AMICODE_ITER iter=$(optimizer_state.iter_count) f=$(optimizer_state.obj_value) " * + "inf_pr=$(optimizer_state.inf_pr) inf_du=$(optimizer_state.inf_du)") + flush(stdout) + return true +end + +t0 = time() +solve!(qcp; + max_iter = 150, print_level = 1, + callback = Callbacks.callback_factory(cb_update, cb_log)) +wall = time() - t0 + +fid = fidelity(qcp) + +JLD2.save("pulse.jld2", "trajectory", dto_prob.trajectory) + +open("result.toml.tmp", "w") do io + TOML.print(io, Dict( + "fidelity" => fid, + "iterations" => iters[], + "wall_seconds" => wall, + )) +end +mv("result.toml.tmp", "result.toml"; force = true) + +println("DONE fidelity=$(fid)") +flush(stdout) diff --git a/packages/amico-run/test/slow/solve_h_gate.jl b/packages/amico-run/test/slow/solve_h_gate.jl new file mode 100644 index 00000000..624f20d0 --- /dev/null +++ b/packages/amico-run/test/slow/solve_h_gate.jl @@ -0,0 +1,3 @@ +# Slow-tier solve #2: H gate on the toy qubit. See solve_common.jl. +const GATE_TARGET_NAME = :H +include(joinpath(@__DIR__, "solve_common.jl")) diff --git a/packages/amico-run/test/slow/solve_x_gate.jl b/packages/amico-run/test/slow/solve_x_gate.jl new file mode 100644 index 00000000..fc448cc5 --- /dev/null +++ b/packages/amico-run/test/slow/solve_x_gate.jl @@ -0,0 +1,3 @@ +# Slow-tier solve #1: X gate on the toy qubit. See solve_common.jl. +const GATE_TARGET_NAME = :X +include(joinpath(@__DIR__, "solve_common.jl")) From f8c8cde3247013358f695433a4cbfb87d0fd8f55 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 11:51:59 -0400 Subject: [PATCH 11/17] ci: let packageManager pin pnpm (action-setup rejects double pinning) Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e28706b3..5aee6947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: { version: 9 } + - uses: pnpm/action-setup@v4 # version pinned by packageManager in package.json - uses: actions/setup-node@v4 with: { node-version: 20, cache: pnpm } - run: pnpm install --frozen-lockfile From be3a4789cc511c1acc2acd3e9b76ff5356054ef7 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 17 Jun 2026 12:13:21 -0400 Subject: [PATCH 12/17] =?UTF-8?q?fix(amico-run):=20address=20=CE=B2.1=20re?= =?UTF-8?q?view=20(#7)=20=E2=80=94=20index=20TSV=20escaping,=20cli=20unexp?= =?UTF-8?q?ected-throw=E2=86=92exit=2064,=20updateLatest=20per-runId=20tem?= =?UTF-8?q?p=20(concurrent-submit=20race),=20version=20single-sourced=20fr?= =?UTF-8?q?om=20package.json;=20solve=5Fcommon.jl=20on=20Piccolo=201.19=20?= =?UTF-8?q?via=20public=20Piccolo.Callbacks=20(no=20reach-through)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/amico-run/src/cli.ts | 9 ++++++++- packages/amico-run/src/local_executor.ts | 3 ++- packages/amico-run/src/run_dir.ts | 10 ++++++++-- packages/amico-run/test/run_dir.test.ts | 7 +++++++ packages/amico-run/test/slow/solve_common.jl | 11 ++++------- packages/amico-run/tsconfig.json | 1 + 6 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index 9c7a6cb4..b00ddfbc 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -72,4 +72,11 @@ export async function main(argv: string[]): Promise { return f.exitCode === 0 ? 1 : f.exitCode } -main(process.argv.slice(2)).then(c => { process.exitCode = c }) +main(process.argv.slice(2)).then( + 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 + }, +) diff --git a/packages/amico-run/src/local_executor.ts b/packages/amico-run/src/local_executor.ts index f0e523cb..0ccba0fd 100644 --- a/packages/amico-run/src/local_executor.ts +++ b/packages/amico-run/src/local_executor.ts @@ -14,7 +14,8 @@ import { type RunStatus, type SubmitOpts, } from './types.js' -const ORCHESTRATOR_VERSION = '0.1.0' +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('/') diff --git a/packages/amico-run/src/run_dir.ts b/packages/amico-run/src/run_dir.ts index b428ea09..2c0f949c 100644 --- a/packages/amico-run/src/run_dir.ts +++ b/packages/amico-run/src/run_dir.ts @@ -75,11 +75,17 @@ export function writeFinished(runDir: string, status: RunStatus, exitCode: numbe } export function appendIndex(runsRoot: string, runId: string, createdAt: string, scriptPath: string): void { - appendFileSync(join(runsRoot, 'index'), `${runId}\t${createdAt}\t${scriptPath}\n`) + // 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 — manifest.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`) } export function updateLatest(runsRoot: string, runId: string): void { - const tmp = join(runsRoot, '.latest.tmp') + // 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')) diff --git a/packages/amico-run/test/run_dir.test.ts b/packages/amico-run/test/run_dir.test.ts index 474bfd92..2e6a4a6d 100644 --- a/packages/amico-run/test/run_dir.test.ts +++ b/packages/amico-run/test/run_dir.test.ts @@ -61,4 +61,11 @@ describe('writers', () => { 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/slow/solve_common.jl b/packages/amico-run/test/slow/solve_common.jl index 63e5a4e3..f4e865e3 100644 --- a/packages/amico-run/test/slow/solve_common.jl +++ b/packages/amico-run/test/slow/solve_common.jl @@ -4,17 +4,14 @@ # - AMICODE_ITER key=value lines on stdout, flushed per iteration # - result.toml written atomically (temp + rename) # - final DONE line -# Written against Piccolo 0.11 (QuantumCollocation/DirectTrajOpt 0.8 stack). +# Vetted against Piccolo 1.19 (the version `Pkg.add Piccolo` installs today). using Piccolo using JLD2 using TOML -# Per-iter callback machinery lives in DirectTrajOpt's internal IpoptSolverExt -# module (not re-exported by Piccolo); reach it through the solve! method table. -const IpoptSolverExt = first(filter( - m -> string(m.module) == "DirectTrajOpt.IpoptSolverExt", - collect(methods(solve!)))).module -const Callbacks = IpoptSolverExt.Callbacks +# Per-iter callbacks via Piccolo's PUBLIC `Callbacks` module (Piccolo 1.19) — +# no `methods(solve!)` reach-through into solver internals. +const Callbacks = Piccolo.Callbacks # Toy single-qubit system (matches the Piccolo docs intro + the frozen spike). sys = QuantumSystem(0.5 * PAULIS[:Z], [PAULIS[:X], PAULIS[:Y]], [1.0, 1.0]) diff --git a/packages/amico-run/tsconfig.json b/packages/amico-run/tsconfig.json index 66385e57..b9ee75a8 100644 --- a/packages/amico-run/tsconfig.json +++ b/packages/amico-run/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "NodeNext", "strict": true, "noEmit": true, + "resolveJsonModule": true, "types": ["node"] }, "include": ["src", "test"] From b2e604f7caa028ee4a6474a78f97a97cc2ea329a Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 17:30:41 -0400 Subject: [PATCH 13/17] feat(extension): opencode pin manifest + fetcher manifest/platform logic Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + packages/extension/opencode.lock.json | 7 +++ packages/extension/package.json | 7 ++- packages/extension/scripts/fetch_opencode.mjs | 39 +++++++++++++++ .../extension/test/fetch_opencode.test.ts | 47 +++++++++++++++++++ pnpm-lock.yaml | 3 ++ 6 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 packages/extension/opencode.lock.json create mode 100644 packages/extension/scripts/fetch_opencode.mjs create mode 100644 packages/extension/test/fetch_opencode.test.ts diff --git a/.gitignore b/.gitignore index 4fe02caf..4992a94e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.vsix .vscode-test/ *.log +packages/extension/vendor/ diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json new file mode 100644 index 00000000..78fa8a17 --- /dev/null +++ b/packages/extension/opencode.lock.json @@ -0,0 +1,7 @@ +{ + "version": "1.17.3", + "platforms": { + "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "0000000000000000000000000000000000000000000000000000000000000000" }, + "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "0000000000000000000000000000000000000000000000000000000000000000" } + } +} diff --git a/packages/extension/package.json b/packages/extension/package.json index 516206b0..b463579e 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -75,13 +75,16 @@ "build": "node esbuild.config.mjs", "watch": "node esbuild.config.mjs --watch", "typecheck": "tsc --noEmit", - "test": "echo 'extension: spike smoke tests in test/ need opencode+julia (not headless); skipped'" + "test": "vitest run --passWithNoTests", + "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/vscode": "^1.95.0", "esbuild": "^0.24.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } } diff --git a/packages/extension/scripts/fetch_opencode.mjs b/packages/extension/scripts/fetch_opencode.mjs new file mode 100644 index 00000000..b1142262 --- /dev/null +++ b/packages/extension/scripts/fetch_opencode.mjs @@ -0,0 +1,39 @@ +#!/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 { + 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)), '..') + +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') + 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`) + } + return m +} + +export function resolvePlatform(manifest, flag) { + 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(', ')})`) + } + return key +} + +export function assetUrl(manifest, platform) { + return `https://github.com/sst/opencode/releases/download/v${manifest.version}/${manifest.platforms[platform].asset}` +} + +export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex') diff --git a/packages/extension/test/fetch_opencode.test.ts b/packages/extension/test/fetch_opencode.test.ts new file mode 100644 index 00000000..93110094 --- /dev/null +++ b/packages/extension/test/fetch_opencode.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadManifest, resolvePlatform } 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 GOOD = { + version: '1.17.3', + platforms: { + '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('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/) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa0a6a85..806c9a64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: typescript: specifier: ^5.6.0 version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19) packages: From 300b1f19b2bf2b9b11ba03c07f95c65534bbe7fc Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 17:32:12 -0400 Subject: [PATCH 14/17] feat(extension): fetcher download/verify/unpack + recorded v1.17.3 pin hashes Co-Authored-By: Claude Fable 5 --- packages/extension/opencode.lock.json | 4 +- packages/extension/scripts/fetch_opencode.mjs | 65 +++++++++++++++++++ .../extension/test/fetch_opencode.test.ts | 38 ++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 78fa8a17..aeb4282b 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,7 +1,7 @@ { "version": "1.17.3", "platforms": { - "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "0000000000000000000000000000000000000000000000000000000000000000" }, - "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "0000000000000000000000000000000000000000000000000000000000000000" } + "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "b49948f96d8e92c577d54854e2f038389d03c3dfbbeacc44643b73123210fd13" }, + "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "d4bd238a2c1ff56aca1cd3397d21a0a317f5992234517a7f8e2afbbd72010a7d" } } } diff --git a/packages/extension/scripts/fetch_opencode.mjs b/packages/extension/scripts/fetch_opencode.mjs index b1142262..c0ae9aab 100644 --- a/packages/extension/scripts/fetch_opencode.mjs +++ b/packages/extension/scripts/fetch_opencode.mjs @@ -37,3 +37,68 @@ export function assetUrl(manifest, platform) { } 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 + } + if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`) + return Buffer.from(await r.arrayBuffer()) +} + +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') + + if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, 'utf8').trim() === want) { + return { skipped: true, path: bin } // offline repeat builds + } + + const bytes = 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}`) + } + + 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) + } finally { + rmSync(work, { recursive: true, force: true }) + } + 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() + for (const key of Object.keys(manifest.platforms)) { + const bytes = await defaultDownload(assetUrl(manifest, key)) + console.log(`${key} ${sha256(bytes)}`) + } + 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 }) +} diff --git a/packages/extension/test/fetch_opencode.test.ts b/packages/extension/test/fetch_opencode.test.ts index 93110094..2ce32ba3 100644 --- a/packages/extension/test/fetch_opencode.test.ts +++ b/packages/extension/test/fetch_opencode.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from 'vitest' -import { mkdtempSync, writeFileSync } from 'node:fs' +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 { loadManifest, resolvePlatform } from '../scripts/fetch_opencode.mjs' +import { fetchOpencode, loadManifest, resolvePlatform, sha256 } from '../scripts/fetch_opencode.mjs' function rootWith(manifest: unknown): string { const root = mkdtempSync(join(tmpdir(), 'oc-test-')) @@ -45,3 +46,36 @@ describe('resolvePlatform', () => { 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) } +} + +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) + }) +}) From 5de7bca5d56981ee841f5c093559c3ea2f006528 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 17:33:45 -0400 Subject: [PATCH 15/17] feat(extension): resolve vendored opencode binary; kill $PATH assumption (S35) Co-Authored-By: Claude Fable 5 --- packages/extension/package.json | 4 +- packages/extension/src/extension.ts | 88 ++++++++++++------- packages/extension/src/opencode_binary.ts | 28 ++++++ .../extension/test/opencode_binary.test.ts | 34 +++++++ 4 files changed, 119 insertions(+), 35 deletions(-) create mode 100644 packages/extension/src/opencode_binary.ts create mode 100644 packages/extension/test/opencode_binary.test.ts diff --git a/packages/extension/package.json b/packages/extension/package.json index b463579e..79064581 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -55,8 +55,8 @@ "properties": { "amicode.opencodeBinary": { "type": "string", - "default": "opencode", - "description": "Path or command name for the opencode CLI." + "default": "", + "description": "Override path to the opencode CLI (dev only). Empty = use the vendored binary." }, "amicode.juliaProject": { "type": "string", diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 84746f12..25e7ce46 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import * as path from "node:path"; import * as fs from "node:fs"; import { ServerManager } from "./server_manager"; +import { resolveOpencodeBinary, OpencodeMissingError } from "./opencode_binary"; import { ChatPanel } from "./chat_panel"; import { registerRunInspector } from "./run_inspector"; import { registerTrees } from "./trees"; @@ -53,39 +54,60 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); - // 4. Spawn opencode. PATH=binDir:$PATH so `amico-run` resolves; env vars - // also tell amico-run where to find julia+script in case the user has - // multiple checkouts. - const binary = vscode.workspace.getConfiguration("amicode").get("opencodeBinary", "opencode"); - const juliaScript = resolveJuliaScript(ctx); - const juliaProject = resolveJuliaProject(); - 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, - }, - channel: opencodeChannel, - }); - ctx.subscriptions.push({ dispose: () => serverManager?.stop() }); - - // SSE event channel — opens once opencode is healthy. - sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); - ctx.subscriptions.push(sseClient); - - serverManager.onReady((url) => { - opencodeReadyUrl = url; - statusBar?.setServerReady(true); - sseClient?.connect(url); - }); - - serverManager.start().catch((err) => { - vscode.window.showErrorMessage(`Amicode: opencode failed to start — ${err.message}`); - opencodeChannel.appendLine(`[boot] start failed: ${err.stack ?? err.message}`); - }); + // 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 + // binary, chat is disabled but the rest of the extension (inspector, + // watcher, commands) still activates. + let binary: string | undefined; + try { + const resolved = resolveOpencodeBinary( + ctx.extensionPath, + vscode.workspace.getConfiguration("amicode").get("opencodeBinary", ""), + ); + binary = resolved.path; + opencodeChannel.appendLine( + resolved.source === "config-override" + ? `[boot] OVERRIDE: amicode.opencodeBinary = ${binary}` + : `[boot] vendored opencode: ${binary}`, + ); + } catch (e) { + if (e instanceof OpencodeMissingError) { + opencodeChannel.appendLine(`[boot] ${e.message} — chat disabled`); + void vscode.window.showErrorMessage(`Amicode: ${e.message}`); + } else throw e; + } + + if (binary !== undefined) { + const juliaScript = resolveJuliaScript(ctx); + const juliaProject = resolveJuliaProject(); + 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, + }, + channel: opencodeChannel, + }); + ctx.subscriptions.push({ dispose: () => serverManager?.stop() }); + + // SSE event channel — opens once opencode is healthy. + sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); + ctx.subscriptions.push(sseClient); + + serverManager.onReady((url) => { + opencodeReadyUrl = url; + statusBar?.setServerReady(true); + sseClient?.connect(url); + }); + + serverManager.start().catch((err) => { + vscode.window.showErrorMessage(`Amicode: opencode failed to start — ${err.message}`); + opencodeChannel.appendLine(`[boot] start failed: ${err.stack ?? err.message}`); + }); + } // 5. Commands ctx.subscriptions.push( diff --git a/packages/extension/src/opencode_binary.ts b/packages/extension/src/opencode_binary.ts new file mode 100644 index 00000000..126949a0 --- /dev/null +++ b/packages/extension/src/opencode_binary.ts @@ -0,0 +1,28 @@ +import { accessSync, constants } from "node:fs"; +import { join } from "node:path"; + +export class OpencodeMissingError extends Error {} + +export interface ResolvedBinary { path: string; source: "config-override" | "vendored" } + +const SUPPORTED = ["darwin-arm64", "linux-x64"] as const; + +/** Spec §4: config override → vendored → hard error. NO $PATH fallback (Assumption 4 stays dead). */ +export function resolveOpencodeBinary(extensionRoot: string, configValue: string): ResolvedBinary { + const override = (configValue ?? "").trim(); + if (override !== "") return { path: override, source: "config-override" }; + + const key = `${process.platform}-${process.arch}`; + if (!(SUPPORTED as readonly string[]).includes(key)) { + throw new OpencodeMissingError(`platform ${key} not supported (supported: ${SUPPORTED.join(", ")})`); + } + const vendored = join(extensionRoot, "vendor", "opencode", key, "opencode"); + try { + accessSync(vendored, constants.X_OK); + } catch { + throw new OpencodeMissingError( + `vendored opencode missing at ${vendored} — run \`pnpm --filter amicode-v2 fetch:opencode\` (dev) or reinstall the extension`, + ); + } + return { path: vendored, source: "vendored" }; +} diff --git a/packages/extension/test/opencode_binary.test.ts b/packages/extension/test/opencode_binary.test.ts new file mode 100644 index 00000000..fe3a3af9 --- /dev/null +++ b/packages/extension/test/opencode_binary.test.ts @@ -0,0 +1,34 @@ +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}` + +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 +} + +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/) + }) +}) From 968fe0347a850dd3a73df783e0e4c625b5373616 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 17:34:41 -0400 Subject: [PATCH 16/17] test(extension): boot-smoke gate for the vendored opencode pin; delete spike smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.17.3 passes: GET /event → 200 text/event-stream against a synthesized project, clean SIGTERM exit. No walk-back needed despite the 1.3.x-era spike. Co-Authored-By: Claude Fable 5 --- packages/extension/test/boot_smoke.mjs | 74 ++++++++ .../extension/test/opencode_boot_harness.mjs | 17 -- packages/extension/test/smoke_cli.mjs | 93 ---------- .../extension/test/smoke_opencode_boot.mjs | 162 ------------------ packages/extension/test/vscode_shim.mjs | 38 ---- 5 files changed, 74 insertions(+), 310 deletions(-) create mode 100644 packages/extension/test/boot_smoke.mjs delete mode 100644 packages/extension/test/opencode_boot_harness.mjs delete mode 100644 packages/extension/test/smoke_cli.mjs delete mode 100644 packages/extension/test/smoke_opencode_boot.mjs delete mode 100644 packages/extension/test/vscode_shim.mjs diff --git a/packages/extension/test/boot_smoke.mjs b/packages/extension/test/boot_smoke.mjs new file mode 100644 index 00000000..4fd7447d --- /dev/null +++ b/packages/extension/test/boot_smoke.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// β.2 boot-smoke gate (spec §5): the VENDORED opencode binary must serve +// GET /event as an SSE stream (HTTP 200, text/event-stream) against a +// synthesized project, with no LLM creds. Exit 0 = pass. +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const key = `${process.platform}-${process.arch}` +const bin = join(PKG_ROOT, 'vendor', 'opencode', key, 'opencode') + +const fail = (msg, code = 1) => { console.error(`[smoke] FAIL: ${msg}`); process.exit(code) } + +if (!existsSync(bin)) fail(`vendored binary missing at ${bin} — run \`pnpm --filter amicode-v2 fetch:opencode\``, 10) + +// Synthesize a minimal opencode project (inline port of the spike's prepareOpencodeProject). +const proj = mkdtempSync(join(tmpdir(), 'amicode-smoke-')) +mkdirSync(join(proj, '.opencode'), { recursive: true }) +writeFileSync(join(proj, 'AGENTS.md'), '# Amicode boot smoke\n') +writeFileSync(join(proj, '.opencode', 'opencode.json'), + JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)) + +const port = await new Promise((res, rej) => { + const srv = createServer() + srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => res(p)) }) + srv.on('error', rej) +}) + +console.log(`[smoke] spawning ${bin} serve --port ${port}`) +let done = false +let serverLog = '' +const child = spawn(bin, ['serve', '--port', String(port)], { cwd: proj, stdio: ['ignore', 'pipe', 'pipe'] }) +child.on('error', e => fail(`spawn failed: ${e.message}`)) +child.stdout.on('data', d => { serverLog += d }) +child.stderr.on('data', d => { serverLog += d }) +child.on('exit', (c, s) => { if (!done) fail(`opencode exited early (code=${c} signal=${s})\n--- server output ---\n${serverLog}`) }) + +// Poll until the server answers, 30s cap. +const deadline = Date.now() + 30_000 +let up = false +while (Date.now() < deadline && !up) { + 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 */ } + if (!up) await new Promise(r => setTimeout(r, 200)) +} +if (!up) { done = true; child.kill('SIGKILL'); fail(`server not up within 30s\n--- server output ---\n${serverLog}`) } + +// THE gate: /event answers 200 with an SSE content-type. fetch resolves on +// headers (the SSE body streaming doesn't block us); 10s cap so a header-less +// hang fails fast instead of eating the CI job timeout. +const ctrl = new AbortController() +const gateTimer = setTimeout(() => ctrl.abort(), 10_000) +const ev = await fetch(`http://127.0.0.1:${port}/event`, { signal: ctrl.signal }) +clearTimeout(gateTimer) +const ctype = ev.headers.get('content-type') ?? '' +console.log(`[smoke] GET /event → ${ev.status} (${ctype})`) +if (ev.status !== 200) { done = true; child.kill('SIGKILL'); fail(`/event status ${ev.status}, want 200`) } +if (!ctype.includes('text/event-stream')) { done = true; child.kill('SIGKILL'); fail(`/event content-type "${ctype}", want text/event-stream`) } +ctrl.abort() + +// Clean shutdown: SIGTERM, exit within 5s. +done = true +const exited = new Promise(res => child.on('exit', res)) +child.kill('SIGTERM') +const t = setTimeout(() => child.kill('SIGKILL'), 5_000) +await exited +clearTimeout(t) +console.log('[smoke] PASS') diff --git a/packages/extension/test/opencode_boot_harness.mjs b/packages/extension/test/opencode_boot_harness.mjs deleted file mode 100644 index 8869b5e6..00000000 --- a/packages/extension/test/opencode_boot_harness.mjs +++ /dev/null @@ -1,17 +0,0 @@ -// Small standalone harness that invokes prepareOpencodeProject and prints -// the resulting project dir on stdout. - -import * as path from "node:path"; -import { prepareOpencodeProject } from "../src/opencode_config.ts"; - -// bundle rebases import.meta.url to /tmp; harness gets repo via env. -const repo = process.env.AMICODE_V2_REPO; -if (!repo) { process.stderr.write("AMICODE_V2_REPO unset\n"); process.exit(2); } - -const { projectDir, agentsPath } = prepareOpencodeProject({ - binDir: path.resolve(repo, "bin"), - agentsSrc: path.resolve(repo, "AGENTS.md"), -}); - -process.stdout.write(`PROJECT=${projectDir}\n`); -process.stderr.write(`agents=${agentsPath}\n`); diff --git a/packages/extension/test/smoke_cli.mjs b/packages/extension/test/smoke_cli.mjs deleted file mode 100644 index 251b3aa5..00000000 --- a/packages/extension/test/smoke_cli.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -// CLI-direct smoke test: invokes bin/amico-run as opencode/the LLM would -// (via bash), asserts the run dir lifecycle matches what the extension's -// RunsRootWatcher will observe. -// -// Asserts: -// - exit code 0 -// - /tmp/amicode-runs/latest symlink points at the new run dir -// - run dir contains: .start, iter_NNNN.png (≥2), result.toml, FINISHED -// - result.toml fidelity is > 0.99 -// -// Usage: node test/smoke_cli.mjs - -import * as cp from "node:child_process"; -import * as path from "node:path"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const repo = path.resolve(here, ".."); -const amicoRun = path.join(repo, "bin", "amico-run"); - -if (!fs.existsSync(amicoRun)) { - console.error("[cli] FAIL: amico-run missing at", amicoRun); - process.exit(10); -} - -// Use an isolated runs root so we don't clobber whatever the user has. -const runsRoot = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-smoke-cli-")); -console.log(`[cli] runs root: ${runsRoot}`); - -const before = Date.now(); -const child = cp.spawnSync( - amicoRun, - ["--system", "qubit", "--gate", "X", "--pulse", "zero-order", "--max-iter", "50"], - { - cwd: repo, - encoding: "utf8", - env: { ...process.env, AMICO_RUNS_ROOT: runsRoot }, - stdio: ["ignore", "pipe", "pipe"], - timeout: 180_000, - }, -); -const elapsed = ((Date.now() - before) / 1000).toFixed(1); - -if (child.status !== 0) { - console.error(`[cli] FAIL: amico-run exited ${child.status} in ${elapsed}s`); - console.error("[cli] stderr tail:", (child.stderr ?? "").split("\n").slice(-15).join("\n")); - process.exit(11); -} -console.log(`[cli] OK: amico-run exited 0 in ${elapsed}s`); - -const latest = path.join(runsRoot, "latest"); -if (!fs.existsSync(latest)) { console.error("[cli] FAIL: latest symlink missing"); process.exit(12); } -const target = fs.realpathSync(latest); -console.log(`[cli] OK: latest → ${target}`); - -const want = [".start", "result.toml", "FINISHED"]; -for (const f of want) { - if (!fs.existsSync(path.join(target, f))) { - console.error(`[cli] FAIL: missing ${f} in ${target}`); - process.exit(13); - } - console.log(`[cli] OK: ${f} present`); -} - -const iterPngs = fs.readdirSync(target).filter((f) => /^iter_\d{4}\.png$/.test(f)).sort(); -if (iterPngs.length < 2) { - console.error(`[cli] FAIL: expected >=2 iter PNGs, got ${iterPngs.length}`); - process.exit(14); -} -console.log(`[cli] OK: ${iterPngs.length} iter PNGs (first=${iterPngs[0]}, last=${iterPngs[iterPngs.length-1]})`); - -const result = fs.readFileSync(path.join(target, "result.toml"), "utf8"); -const fid = parseFloat((result.match(/fidelity\s*=\s*([\d.eE+-]+)/) ?? [])[1] ?? ""); -if (!Number.isFinite(fid) || fid < 0.99) { - console.error(`[cli] FAIL: fidelity ${fid} below 0.99`); - process.exit(15); -} -console.log(`[cli] OK: F=${fid.toFixed(6)}`); - -// Sanity: last line of stdout should contain DONE summary. -const stdout = child.stdout ?? ""; -const doneLine = stdout.split("\n").reverse().find((l) => l.includes("amico-run: DONE")); -if (!doneLine) { - console.error("[cli] FAIL: no 'amico-run: DONE' in stdout"); - console.error("[cli] stdout tail:", stdout.split("\n").slice(-5).join("\n")); - process.exit(16); -} -console.log(`[cli] OK: ${doneLine.trim()}`); - -console.log("[cli] ALL GREEN"); diff --git a/packages/extension/test/smoke_opencode_boot.mjs b/packages/extension/test/smoke_opencode_boot.mjs deleted file mode 100644 index c46312f5..00000000 --- a/packages/extension/test/smoke_opencode_boot.mjs +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env node -// Smoke test: synthesize an opencode project dir via prepareOpencodeProject, -// boot opencode serve against it, confirm /event SSE returns 200 OK and the -// amico MCP shows up in the registered tools listing (via /config or /mcp -// endpoints — opencode 1.3.3 exposes these). -// -// Usage: node test/smoke_opencode_boot.mjs - -import * as cp from "node:child_process"; -import * as path from "node:path"; -import * as fs from "node:fs"; -import * as net from "node:net"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const repo = path.resolve(here, ".."); - -// Bundle prepareOpencodeProject standalone. -const harnessOut = path.join(os.tmpdir(), "amicode-smoke-bootharness.mjs"); -const buildResult = cp.spawnSync( - "npx", - [ - "esbuild", - path.join(here, "opencode_boot_harness.mjs"), - "--bundle", - "--format=esm", - "--platform=node", - `--outfile=${harnessOut}`, - ], - { encoding: "utf8" }, -); -if (buildResult.status !== 0) { - console.error("[smoke] bundle failed", buildResult.stdout, buildResult.stderr); - process.exit(10); -} - -// Run the harness — it prints PROJECT= on stdout. -const harnessChild = cp.spawnSync("node", [harnessOut], { - encoding: "utf8", - env: { ...process.env, AMICODE_V2_REPO: repo }, -}); -if (harnessChild.status !== 0) { - console.error("[smoke] harness failed:", harnessChild.stderr); - process.exit(11); -} -const projectDir = harnessChild.stdout.match(/PROJECT=(.+)/)?.[1]?.trim(); -if (!projectDir) { console.error("[smoke] no PROJECT in harness output:", harnessChild.stdout); process.exit(12); } -console.log(`[smoke] project dir: ${projectDir}`); - -// Confirm config landed. -const configPath = path.join(projectDir, ".opencode", "opencode.json"); -if (!fs.existsSync(configPath)) { - console.error("[smoke] FAIL: config.json missing at " + configPath); - process.exit(13); -} -console.log("[smoke] OK: config.json written"); - -// v2 CLI-direct: no MCP, no plugin. Just AGENTS.md. -const agentsPath = path.join(projectDir, "AGENTS.md"); -if (!fs.existsSync(agentsPath)) { - console.error("[smoke] FAIL: AGENTS.md missing at " + agentsPath); - process.exit(15); -} -const agentsTxt = fs.readFileSync(agentsPath, "utf8"); -if (!/amico-run/.test(agentsTxt)) { - console.error("[smoke] FAIL: AGENTS.md does not mention amico-run"); - process.exit(16); -} -console.log("[smoke] OK: AGENTS.md present and references amico-run"); - -// Pick a free port for opencode. -const port = await new Promise((resolve, reject) => { - const s = net.createServer(); - s.listen(0, "127.0.0.1", () => { - const p = s.address().port; - s.close(() => resolve(p)); - }); - s.on("error", reject); -}); - -console.log(`[smoke] booting opencode serve --port=${port} in ${projectDir}`); -const oc = cp.spawn("opencode", ["serve", "--port", String(port)], { - cwd: projectDir, - stdio: ["ignore", "pipe", "pipe"], -}); - -let log = ""; -oc.stdout.on("data", (b) => { const s = b.toString(); log += s; process.stdout.write(`[opencode] ${s}`); }); -oc.stderr.on("data", (b) => { const s = b.toString(); log += s; process.stderr.write(`[opencode!] ${s}`); }); - -// Wait for opencode to become healthy. -async function waitHealthy(timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }); - if (r.ok || (r.status >= 200 && r.status < 400)) return true; - } catch {} - await new Promise((r) => setTimeout(r, 200)); - } - return false; -} - -let exitCode = 0; -const fails = []; - -const healthy = await waitHealthy(15000); -if (!healthy) { - fails.push("opencode failed to become healthy within 15s"); - exitCode = 1; -} else { - console.log("[smoke] OK: opencode is healthy"); -} - -if (healthy) { - // Try /event with a short read. - try { - const ctrl = new AbortController(); - const resp = await fetch(`http://127.0.0.1:${port}/event`, { - headers: { Accept: "text/event-stream" }, - signal: ctrl.signal, - }); - if (resp.status !== 200) { - fails.push(`/event returned ${resp.status}`); - exitCode = 1; - } else { - console.log("[smoke] OK: /event returns 200 (SSE handshake)"); - } - setTimeout(() => ctrl.abort(), 200); - try { for await (const _ of resp.body) { break; } } catch {} - } catch (e) { - fails.push(`/event probe failed: ${e.message}`); - exitCode = 1; - } - - // Probe a few likely "what tools are loaded" endpoints in opencode 1.3.3. - // Result is informational — opencode's HTTP surface isn't fully stable. - for (const ep of ["/config", "/mcp", "/tool", "/tools"]) { - try { - const r = await fetch(`http://127.0.0.1:${port}${ep}`, { signal: AbortSignal.timeout(500) }); - const text = r.status === 200 ? (await r.text()).slice(0, 200) : ""; - console.log(`[smoke] info: ${ep} → ${r.status} ${text ? "→ " + text : ""}`); - } catch { /* endpoint not present */ } - } -} - -// Inspect log for amico-mcp/plugin loaded markers. -if (/amico/i.test(log)) { - console.log("[smoke] OK: opencode log mentions 'amico' (mcp or plugin loaded)"); -} else { - console.log("[smoke] info: opencode log does not yet reference amico (may load lazily)"); -} - -if (fails.length > 0) { - console.error("[smoke] FAILURES:"); - fails.forEach((f) => console.error(" - " + f)); -} - -oc.kill("SIGTERM"); -setTimeout(() => oc.kill("SIGKILL"), 2000); -process.exit(exitCode); diff --git a/packages/extension/test/vscode_shim.mjs b/packages/extension/test/vscode_shim.mjs deleted file mode 100644 index 9e00e535..00000000 --- a/packages/extension/test/vscode_shim.mjs +++ /dev/null @@ -1,38 +0,0 @@ -// Minimal vscode API surface used by callback_server.ts / run_inspector.ts / -// file_watcher.ts. Used in isolation smoke tests only. - -const calls = []; -export const __calls = calls; - -const log = (kind, args) => calls.push({ kind, args }); - -export const window = { - showInformationMessage: (...a) => { log("info", a); return Promise.resolve(undefined); }, - showWarningMessage: (...a) => { log("warn", a); return Promise.resolve(undefined); }, - showErrorMessage: (...a) => { log("error", a); return Promise.resolve(undefined); }, - showQuickPick: (items, opts) => { log("quickPick", [items, opts]); return Promise.resolve(items[0]); }, - showTextDocument: (uri, opts) => { log("openText", [uri, opts]); return Promise.resolve({}); }, - createOutputChannel: (name) => ({ - appendLine: (s) => log("channel", [name, s]), - append: (s) => log("channel", [name, s]), - dispose: () => {}, - }), -}; - -export const commands = { - executeCommand: (...a) => { log("cmd", a); return Promise.resolve(undefined); }, -}; - -export const Uri = { - file: (p) => ({ fsPath: p, toString: () => "file://" + p }), -}; - -export const ViewColumn = { Active: 1, Beside: 2 }; - -export class EventEmitter { - constructor() { this.listeners = []; } - get event() { return (l) => { this.listeners.push(l); return { dispose: () => {} }; }; } - fire(v) { for (const l of this.listeners) try { l(v); } catch {} } -} - -export default { window, commands, Uri, ViewColumn, EventEmitter }; From 26d9018691f041b4aa13a4682e23adaa675b7888 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 10 Jun 2026 17:35:31 -0400 Subject: [PATCH 17/17] ci: extension unit suite in fast job; two-platform opencode boot-smoke matrix; .vscodeignore ships vendor/ Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 15 ++++++++++++++- packages/extension/.vscodeignore | 11 +++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/extension/.vscodeignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aee6947..72d1b6e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,4 +13,17 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm -r run build - run: pnpm -r run typecheck - - run: pnpm --filter @amicode/amico-run test # includes the S31 grep-rule test + - run: pnpm -r run test # amico-run suite (incl. S31 grep rule) + extension unit suite + boot-smoke: + strategy: + matrix: + os: [ubuntu-latest, macos-14] # linux-x64 + darwin-arm64 — both pins machine-gated (spec §5) + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm --filter amicode-v2 fetch:opencode + - run: pnpm --filter amicode-v2 test:smoke diff --git a/packages/extension/.vscodeignore b/packages/extension/.vscodeignore new file mode 100644 index 00000000..5d5d6c65 --- /dev/null +++ b/packages/extension/.vscodeignore @@ -0,0 +1,11 @@ +# This file's EXISTENCE stops vsce falling back to .gitignore (which excludes vendor/). +# The explicit negation keeps the vendored opencode shipping even if a future +# exclusion pattern would otherwise swallow it — that's the trap β.2 exists to kill. +!vendor/** +src/** +test/** +scripts/** +node_modules/** +esbuild.config.mjs +tsconfig.json +**/*.map