Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/amico-run/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"test": "vitest run --passWithNoTests --exclude '**/slow/**'",
"test:slow": "vitest run test/slow"
},
"dependencies": {
"@amicode/schema": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.24.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/amico-run/src/run_dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function writeManifest(runDir: string, m: Manifest): void {
...(m.julia.project ? [`project = ${ts(m.julia.project)}`] : []),
...(m.julia.sysimage ? [`sysimage = ${ts(m.julia.sysimage)}`] : []),
]
atomicWriteFile(runDir, 'manifest.toml', lines.join('\n') + '\n')
atomicWriteFile(runDir, 'run.toml', lines.join('\n') + '\n')
}

export function writeFinished(runDir: string, status: RunStatus, exitCode: number): void {
Expand All @@ -77,7 +77,7 @@ export function writeFinished(runDir: string, status: RunStatus, exitCode: numbe
export function appendIndex(runsRoot: string, runId: string, createdAt: string, scriptPath: string): void {
// The index is a tab-separated, one-line-per-run log; a tab/newline in the
// (last-field) script path would corrupt it. Sanitize control chars to a
// space — manifest.toml holds the canonical, TOML-escaped script_path.
// space — run.toml holds the canonical, TOML-escaped script_path.
const safePath = scriptPath.replace(/[\t\r\n]/g, ' ')
appendFileSync(join(runsRoot, 'index'), `${runId}\t${createdAt}\t${safePath}\n`)
}
Expand Down
44 changes: 12 additions & 32 deletions packages/amico-run/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,22 @@
// 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[] }
// Run-dir validators now DELEGATE to the shared @amicode/schema package — the
// single source of truth for the run-dir contract (Phase 0' SchemaPackage). The
// export names and the {ok, errors} shape are preserved so existing consumers
// (the extension's run_dir_reader) are unchanged.
//
// No schema is DEFINED here anymore. Re-introducing a hand-rolled validator /
// schema in this file is a regression (guarded by schemas.test.ts).
import { validate, type Validation } from "@amicode/schema";

type Obj = Record<string, unknown>
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 type { Validation };

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 }
return validate(v, "run");
}

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 }
return validate(v, "finished");
}

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 }
return validate(v, "result");
}
6 changes: 3 additions & 3 deletions packages/amico-run/test/failure_lanes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('§6 failure matrix', () => {
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)
expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId)
})

it('spawn failure after manifest (X_OK dir → spawn error) → FINISHED{failed, 127}', async () => {
Expand All @@ -30,7 +30,7 @@ describe('§6 failure matrix', () => {
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
expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) // manifest survived
})

it('shell exec-failure rc passes through verbatim (wrapper execs missing target)', async () => {
Expand Down Expand Up @@ -70,7 +70,7 @@ describe('§6 failure matrix', () => {
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)`)
`process.exit(require('node:fs').existsSync('run.toml') ? 0 : 7)`)
const h = await sub(root, julia, fakeJulia(root, 's.jl', ''))
expect(await h.finished).toEqual({ status: 'completed', exitCode: 0 })
})
Expand Down
2 changes: 1 addition & 1 deletion packages/amico-run/test/local_executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('LocalExecutor happy path', () => {
})

// manifest observable before events finish — submit() resolved, so it must exist NOW
const manifest = readToml(join(h.runDir, 'manifest.toml'))
const manifest = readToml(join(h.runDir, 'run.toml'))
expect(validateManifest(manifest).ok).toBe(true)
expect(manifest.lab_id).toBe('testlab')

Expand Down
2 changes: 1 addition & 1 deletion packages/amico-run/test/run_dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('writers', () => {
created_at: '2026-06-10T10:12:45Z', orchestrator_version: '0.1.0',
julia: { binary: 'julia', project: '/proj' },
})
const m = readToml(join(root, 'manifest.toml'))
const m = readToml(join(root, 'run.toml'))
expect(m.schema_version).toBe('1')
expect(m.lab_id).toBe('x')
expect((m.julia as Record<string, unknown>).project).toBe('/proj')
Expand Down
30 changes: 25 additions & 5 deletions packages/amico-run/test/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { validateManifest, validateFinished, validateResult } from '../src/schemas.js'

// These wrappers delegate to the shared @amicode/schema (single source of truth);
// this suite is the delegation smoke + the field-precise contract they expose.
const goodManifest = {
schema_version: '1', run_id: 'r20260610-101245Z-ab12', script_path: '/s.jl',
lab: 'default', lab_id: 'default', created_at: '2026-06-10T10:12:45Z',
Expand All @@ -13,8 +17,8 @@ describe('validateManifest', () => {
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')
expect(r.errors.join(' ')).toContain('run_id') // wrong-typed top-level field
expect(r.errors.join(' ')).toContain('binary') // /julia missing required "binary"
})
it('rejects unknown schema_version', () =>
expect(validateManifest({ ...goodManifest, schema_version: '2' }).ok).toBe(false))
Expand All @@ -30,8 +34,24 @@ describe('validateFinished', () => {
})

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)
it('requires schema_version, fidelity number, iterations integer', () => {
// The formalized contract carries schema_version on result.toml (0.1a adds the
// emit; the Julia round-trip enforces it). An artifact lacking it is rejected.
expect(validateResult({ schema_version: '1', fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true)
expect(validateResult({ fidelity: 0.999, iterations: 200 }).ok).toBe(false) // no schema_version
expect(validateResult({ schema_version: '1', iterations: 200 }).ok).toBe(false) // no fidelity
})
})

// Anti-regression (N4): schemas.ts must remain a thin DELEGATION, never re-define
// a schema/validator. Guards the "one validator path" invariant (#15 AC7).
describe('schemas.ts is delegation-only (no re-introduced schema)', () => {
const src = readFileSync(join(__dirname, '..', 'src', 'schemas.ts'), 'utf8')
it('imports the shared @amicode/schema', () =>
expect(src).toMatch(/from ["']@amicode\/schema["']/))
it('does not hand-roll validation (no local check helper / additionalProperties / required arrays)', () => {
expect(src).not.toMatch(/additionalProperties/)
expect(src).not.toMatch(/function check\b/)
expect(src).not.toMatch(/errors\.push/)
})
})
2 changes: 1 addition & 1 deletion packages/amico-run/test/slow/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function solveAndValidate(script: string): void {
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(validateManifest(readToml(join(runDir, 'run.toml'))).ok).toBe(true)
expect(validateFinished(readToml(join(runDir, 'FINISHED'))).ok).toBe(true)
const result = readToml(join(runDir, 'result.toml'))
expect(validateResult(result).ok).toBe(true)
Expand Down
7 changes: 4 additions & 3 deletions packages/amico-run/test/slow/solve_common.jl
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ 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,
"schema_version" => "1", # run-dir contract version (@amicode/schema result schema)
"fidelity" => fid,
"iterations" => iters[],
"wall_seconds" => wall,
))
end
mv("result.toml.tmp", "result.toml"; force = true)
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ gate, tell them plainly it isn't supported yet and stop.

## The run-dir contract your script MUST emit

`amico-run` writes `manifest.toml` (first) and `FINISHED` (last) itself. Your
`amico-run` writes `run.toml` (first) and `FINISHED` (last) itself. Your
script, running with cwd = the run dir, must emit:

- `AMICODE_ITER iter=<n> f=<obj> inf_pr=<…> inf_du=<…>` to stdout, flushed,
Expand Down
4 changes: 2 additions & 2 deletions packages/extension/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ Phase 0' (the SchemaPackage supersedes the provisional validators below).

A run lives at `~/.amico/runs/<lab-id>/<runId>/`, where `runId` is
`r<UTC-timestamp>Z-<hex>` (e.g. `r20260617-161814Z-e8cb`). `amico-run` writes
`manifest.toml` **first** and `FINISHED` **last**; the script (cwd = the run dir)
`run.toml` **first** and `FINISHED` **last**; the script (cwd = the run dir)
emits the rest.

| Artifact | Writer | Contents |
|---|---|---|
| `manifest.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). |
| `run.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). |
| `run.log` | amico-run (stdout tee) | One `AMICODE_ITER iter=<n> f=<obj> inf_pr=<…> inf_du=<…>` line per Ipopt iteration (drives the live stats row), plus a final `DONE fidelity=<…>` line and any Julia traceback. |
| `iter_<N>.png` | script | Per-iteration pulse/fidelity plot. `N` is the iteration with **unbounded digits** (`iter_0`, `iter_10`, … `iter_0060`). The inspector globs `iter_*.png`. |
| `result.toml` | script (atomic) | Written `result.toml.tmp` then renamed. At least `fidelity` (float) and `iterations` (int); `wall_seconds` optional. |
Expand Down
1 change: 1 addition & 0 deletions packages/extension/demo/run/result.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
schema_version = "1"
iterations = 60
fidelity = 0.9999788203047787
wall_seconds = 101.5023238658905
1 change: 1 addition & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
},
"devDependencies": {
"@amicode/amico-run": "workspace:*",
"@amicode/schema": "workspace:*",
"@types/node": "^22.0.0",
"@types/vscode": "^1.95.0",
"@vscode/vsce": "^3.2.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/extension/src/demo_replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { generateRunId, appendIndex, updateLatest } from "@amicode/amico-run";

/**
* Copy a bundled demo run-dir into the runs root under a fresh β.1 runId,
* rewrite manifest.toml's `run_id` to match the new directory, append the
* rewrite run.toml's `run_id` to match the new directory, append the
* index, and swing `latest` to it. Reuses the β.1 run-dir primitives so the
* staged run is byte-for-byte contract-identical and the existing
* RunsRootWatcher renders it exactly like a live solve.
Expand All @@ -18,7 +18,7 @@ export function stageDemoRun(demoDir: string, runsRoot: string): string {
const runDir = join(runsRoot, runId);
mkdirSync(runDir);
for (const f of readdirSync(demoDir)) {
if (f === "manifest.toml") {
if (f === "run.toml") {
const m = readFileSync(join(demoDir, f), "utf8").replace(
/run_id\s*=\s*"[^"]*"/,
`run_id = ${JSON.stringify(runId)}`,
Expand Down
15 changes: 11 additions & 4 deletions packages/extension/src/file_watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
// ============================================================================
// RunsRootWatcher — watches the β.1 run-dir contract and drives the Inspector
// + status bar. Follows the `latest` symlink; for the active run it reads:
// manifest.toml → run identity (run_id, lab_id), written FIRST
// run.toml → run identity (run_id, lab_id), written FIRST
// iter_<N>.png → live plot frames (unbounded digits)
// run.log → AMICODE_ITER lines → live stats row
// result.toml → fidelity (display + promote gate), atomic
Expand Down Expand Up @@ -182,7 +182,7 @@ export class RunsRootWatcher implements vscode.Disposable {
this.logTailer?.dispose();
this.activeRunDir = runDir;

const runId = String(readTomlSafe(path.join(runDir, "manifest.toml"))?.run_id ?? path.basename(runDir));
const runId = String(readTomlSafe(path.join(runDir, "run.toml"))?.run_id ?? path.basename(runDir));
// If the run was ALREADY finished when we switched to it (e.g. launch follows
// `latest` to a prior completed run, or the user switches back), don't pop the
// promote prompt — only a FRESH live completion promotes. Pre-marking the run
Expand Down Expand Up @@ -234,11 +234,18 @@ export class RunsRootWatcher implements vscode.Disposable {
const finished = readTomlSafe(path.join(runDir, "FINISHED"));
if (!finished || !validateFinished(finished).ok) return;
const status = finished.status as RunStatus;
const runId = String(readTomlSafe(path.join(runDir, "manifest.toml"))?.run_id ?? path.basename(runDir));
const runId = String(readTomlSafe(path.join(runDir, "run.toml"))?.run_id ?? path.basename(runDir));
let fidelity: number | undefined;
if (status === "completed") {
const result = readTomlSafe(path.join(runDir, "result.toml"));
if (result && validateResult(result).ok) fidelity = result.fidelity as number;
if (result) {
const v = validateResult(result);
if (v.ok) fidelity = result.fidelity as number;
// Don't silently drop fidelity + skip promote on a present-but-invalid
// result.toml — say why (S4). e.g. a pre-0.1a result.toml with no
// schema_version, or a fidelity gross-out-of-range.
else this.opts.channel.appendLine(`[runs] result.toml present but invalid: ${v.errors.join("; ")}`);
}
}
this.sink?.run({ runId, runDir, status, fidelity });
if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) {
Expand Down
11 changes: 9 additions & 2 deletions packages/extension/src/run_dir_reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function readTomlSafe(fp: string): Record<string, unknown> | undefined {
* consumed, so the live tailer can attach exactly there — no gap (lines
* appended after the read are tailed) and no overlap (already-replayed lines). */
export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0.99): number {
const manifest = readTomlSafe(path.join(runDir, "manifest.toml"));
const manifest = readTomlSafe(path.join(runDir, "run.toml"));
if (!manifest || !validateManifest(manifest).ok) return 0; // no valid manifest → not a run dir yet
const runId = String(manifest.run_id);

Expand Down Expand Up @@ -107,7 +107,14 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0
let fidelity: number | undefined;
if (status === "completed") {
const result = readTomlSafe(path.join(runDir, "result.toml"));
if (result && validateResult(result).ok) fidelity = result.fidelity as number;
if (result) {
const v = validateResult(result);
if (v.ok) fidelity = result.fidelity as number;
// Present-but-nonconforming result.toml: surface WHY rather than silently
// dropping fidelity + skipping promote (S4). console.warn keeps this reader
// vscode-free; the live watcher logs to its channel too.
else console.warn(`[amico] result.toml present but invalid (${runDir}): ${v.errors.join("; ")}`);
}
}
sink.run({ runId, runDir, status, fidelity });
if (status === "completed" && fidelity !== undefined && fidelity >= promoteThreshold) {
Expand Down
1 change: 1 addition & 0 deletions packages/extension/templates/solve_template.jl
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ open("result.toml.tmp", "w") do io
# Record the regime each run actually solved (scalar FILL-IN params), so the
# result is self-describing — not just fidelity/iterations.
TOML.print(io, Dict(
"schema_version" => "1", # run-dir contract version (@amicode/schema result schema)
"fidelity" => fid, "iterations" => iters[], "wall_seconds" => wall,
"params" => Dict("delta" => δ, "levels" => levels, "T" => T, "N" => N,
"drive_max" => drive_max, "max_iter" => max_iter),
Expand Down
6 changes: 3 additions & 3 deletions packages/extension/test/demo_replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { stageDemoRun } from '../src/demo_replay'

function fakeDemo(): string {
const d = mkdtempSync(join(tmpdir(), 'demo-'))
writeFileSync(join(d, 'manifest.toml'),
writeFileSync(join(d, 'run.toml'),
`schema_version = "1"\nrun_id = "rDEMO"\nscript_path = "/demo.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-17T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`)
writeFileSync(join(d, 'run.log'), 'AMICODE_ITER iter=10 f=0.1 inf_pr=1e-8 inf_du=1e-6\n')
writeFileSync(join(d, 'iter_0010.png'), 'PNG')
writeFileSync(join(d, 'result.toml'), 'fidelity = 0.9999\niterations = 10\n')
writeFileSync(join(d, 'result.toml'), 'schema_version = "1"\nfidelity = 0.9999\niterations = 10\n')
writeFileSync(join(d, 'FINISHED'), 'status = "completed"\nexit_code = 0\n')
return d
}
Expand All @@ -25,7 +25,7 @@ describe('stageDemoRun', () => {
const runId = runDir.split('/').pop()!
expect(runId).toMatch(/^r\d{8}-\d{6}Z-[0-9a-f]{4}$/) // β.1 runId format
expect(existsSync(join(runDir, 'iter_0010.png'))).toBe(true)
const m = parse(readFileSync(join(runDir, 'manifest.toml'), 'utf8')) as Record<string, unknown>
const m = parse(readFileSync(join(runDir, 'run.toml'), 'utf8')) as Record<string, unknown>
expect(validateManifest(m).ok).toBe(true)
expect(m.run_id).toBe(runId) // rewritten to match the dir
expect(validateFinished(parse(readFileSync(join(runDir, 'FINISHED'), 'utf8'))).ok).toBe(true)
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/test/packaging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const REQUIRED = [
'extension/julia/Project.toml',
'extension/julia/Manifest.toml',
'extension/AGENTS.md',
'extension/demo/run/manifest.toml',
'extension/demo/run/run.toml',
'extension/demo/run/FINISHED',
'extension/demo/run/run.log', // inspector reads run.log for the demo's stats row; *.log-gitignored so easy to drop
]
Expand Down
Loading
Loading