diff --git a/packages/amico-run/esbuild.config.mjs b/packages/amico-run/esbuild.config.mjs index 6da1a7d2..37ab531d 100644 --- a/packages/amico-run/esbuild.config.mjs +++ b/packages/amico-run/esbuild.config.mjs @@ -11,6 +11,12 @@ const common = { bundle: true, platform: "node", target: "node20", + // Data-as-import (the #820 sota seed): resources/*.toml are the CANONICAL + // shipped data files, imported as text so the self-contained bins carry + // them (the vsix stages only bin/dist/*.js — no resources dir rides + // along). Vitest loads the same files through the matching plugin in + // vitest.config.ts; the d.ts beside src/ types the specifier. + loader: { ".toml": "text" }, // ESM, not CJS: the package is "type": "module", so node executes the bundle as ESM — // a CJS bundle would die on `require is not defined in ES module scope`. format: "esm", diff --git a/packages/amico-run/resources/watched-repos.seed.toml b/packages/amico-run/resources/watched-repos.seed.toml new file mode 100644 index 00000000..4a8fae31 --- /dev/null +++ b/packages/amico-run/resources/watched-repos.seed.toml @@ -0,0 +1,81 @@ +# watched-repos.seed.toml — the SEED registry (#820, spec spec-20260905-103000 +# living-sota D3): the SOTA codebase lens's shipped data. A fresh sota root +# bootstraps its living copy from this seed; thereafter adding a repo is a +# DATA EDIT of the living registry (never code). The categories are the +# spec's D3 seed set: canonical opencode + the harness-agnostic SOTA field, +# Julia optimal-control adjacent to the Piccolo stack, QEC/qLDPC challenge +# repos, quantum SDK release tracks, the agent-framework field the +# fork-split session surveyed by hand. +schema_version = "1" + +[[repos]] +repo = "anomalyco/opencode" +why_watched = "the canonical harness our vendored fork tracks — upstream drift is product drift" +domains = ["agent-harness"] +fetch_surface = ["releases", "issues"] +match_keywords = ["plugin", "permission", "session", "agent", "mcp"] +last_success = "" +consecutive_failures = 0 + +[[repos]] +repo = "earendil-works/pi" +why_watched = "the minimal-core extension harness the fork-split SOTA pass surveyed — session trees, steering queues" +domains = ["agent-harness"] +fetch_surface = ["releases", "issues"] +match_keywords = ["session", "branch", "steering", "extension"] +last_success = "" +consecutive_failures = 0 + +[[repos]] +repo = "JuliaQuantumControl/QuantumControl.jl" +why_watched = "the Julia optimal-control framework adjacent to the Piccolo stack — API and method shifts propagate into our authoring map" +domains = ["julia-optimal-control"] +fetch_surface = ["releases", "issues"] +match_keywords = ["GRAPE", "Krotov", "propagator", "objective", "trajectory"] +last_success = "" +consecutive_failures = 0 + +[[repos]] +repo = "JuliaQuantumControl/QuantumPropagators.jl" +why_watched = "the propagator layer under QuantumControl — integrator changes move our convergence behavior" +domains = ["julia-optimal-control"] +fetch_surface = ["releases", "issues"] +match_keywords = ["Magnus", "integrator", "Chebyshev", "exponential"] +last_success = "" +consecutive_failures = 0 + +[[repos]] +repo = "errorcorrectionzoo/ecz" +why_watched = "the error-correction zoo — new qLDPC code families feed the qLDPC challenge campaign's hypothesis space" +domains = ["qec-qldpc"] +fetch_surface = ["releases", "issues"] +match_keywords = ["qLDPC", "quantum LDPC", "code", "decoder"] +last_success = "" +consecutive_failures = 0 + +[[repos]] +repo = "Qiskit/qiskit" +why_watched = "the canonical quantum SDK release track — pulse-level and transpiler moves define the field's direction" +domains = ["quantum-sdk"] +fetch_surface = ["releases"] +match_keywords = ["pulse", "transpiler", "qasm", "backend"] +last_success = "" +consecutive_failures = 0 + +[[repos]] +repo = "quantumlib/Cirq" +why_watched = "the second quantum SDK track — schedule/pulse surface changes signal field moves" +domains = ["quantum-sdk"] +fetch_surface = ["releases"] +match_keywords = ["pulse", "schedule", "gate", "simulator"] +last_success = "" +consecutive_failures = 0 + +[[repos]] +repo = "pasqal-io/Pulser" +why_watched = "the neutral-atom SDK — Pasqal device-path changes move our pasqal submission contract" +domains = ["quantum-sdk", "neutral-atoms"] +fetch_surface = ["releases", "issues"] +match_keywords = ["sequence", "register", "pulse", "emulator", "device"] +last_success = "" +consecutive_failures = 0 diff --git a/packages/amico-run/src/sota_codebase.ts b/packages/amico-run/src/sota_codebase.ts new file mode 100644 index 00000000..1892f857 --- /dev/null +++ b/packages/amico-run/src/sota_codebase.ts @@ -0,0 +1,439 @@ +// sota_codebase.ts — the CODEBASE lens (#820, spec spec-20260905-103000 +// living-sota D1/D3-data): the watched-repo registry is VALIDATOR-CHECKED +// TOML DATA (adding a repo is a data edit of the living registry, never +// code), and the fetch surface is the GitHub API against the CANONICAL repo +// (owner/name) — never a local fork checkout; the builder below can only emit +// https://api.github.com/repos/… URLs and the transport is the same one- +// fetcher seam as the papers lens (cache → fleet-wide queue → fetch → cache +// + history). +// +// Quiet failures are the ones that matter: a successful round stamps +// last_success and resets consecutive_failures; a failed round accrues the +// counter; at the registry's failure_threshold (default 7, data) the brief +// names the entry for HUMAN RETIRE-OR-CONFIRM — the flag is DERIVED from the +// persisted counter at read time, never stored, so it can never disagree with +// the counter it reads. The anomaly floor rides per-source exactly as in the +// papers lens: an empty 200 against a nonzero trailing mean renders +// "scan returned nothing — anomalous", never "nothing new". +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import seedToml from "../resources/watched-repos.seed.toml"; +import { + parseWatchedRepoRegistry, + validateWatchedRepoRegistry, + flaggedForRetireOrConfirm, + type FetchSurface, + type WatchedRepoRegistry, +} from "@amicode/schema"; +import { + fetchThroughQueue, + curlArgs, + parseCurlOut, + type FetchThroughQueueOpts, + type FetchThroughQueueResult, + type SotaFetch, +} from "./sota_fetch.js"; +import { ANOMALY_FLOOR_WINDOW_DAYS, type AnomalyFloorVerdict } from "./sota_history.js"; + +export const REGISTRY_FILENAME = "watched-repos.toml"; + +/** The stable per-source history key for one repo+surface. The slug is + * HASHED, not flattened: the key is a FILE NAME under fetch-history/ (a + * slash would invent a directory that does not exist), and every + * character-level flattening collides — `a/b__c` and `a__b/c` both + * flatten to `a__b__c`, as do `a--b/c` and `a/b--c`. A 16-hex sha256 + * prefix is collision-free for any realistic watch list. */ +export function githubSourceKey(repo: string, surface: FetchSurface): string { + const slug = createHash("sha256").update(repo, "utf8").digest("hex").slice(0, 16); + return `github:${slug}:${surface}`; +} + +/** The living registry's path under the sota root. */ +export function registryPath(root: string): string { + return join(root, REGISTRY_FILENAME); +} + +/** The packaged seed — the shipped bootstrap data (the spec's D3 seed set). */ +export function seedRegistryToml(): string { + return seedToml; +} + +// ── the GitHub API fetch surface (canonical repos, never a checkout) ──────── + +/** The canonical GitHub API URL for one repo + surface. https BY + * CONSTRUCTION — this function cannot emit a local path or another scheme. + * The `changelog` surface rides the releases endpoint (release notes ARE + * the changelog on GitHub-shaped data); only the brief's section differs. */ +export function githubApiUrl(repo: string, surface: FetchSurface): string { + switch (surface) { + case "releases": + case "changelog": + return `https://api.github.com/repos/${repo}/releases?per_page=10`; + case "issues": + return `https://api.github.com/repos/${repo}/issues?state=open&per_page=20`; + } +} + +export interface GithubRelease { + id: string; // repo@tag + tag: string; + title: string; + url: string; + when: string; + detail: string; // the release body +} + +export interface GithubIssue { + id: string; // repo#number + title: string; + url: string; + when: string; + detail: string; // the issue body +} + +function bodyOf(item: unknown): string { + const b = (item as { body?: unknown }).body; + return typeof b === "string" ? b : ""; +} + +/** Parse the GitHub releases payload (tolerant: malformed → [], the caller + * renders the named anomaly, never a crash). */ +export function parseGithubReleases(json: string, repo = "repo"): GithubRelease[] { + let items: unknown; + try { + items = JSON.parse(json); + } catch { + return []; + } + if (!Array.isArray(items)) return []; + const out: GithubRelease[] = []; + for (const it of items) { + const tag = (it as { tag_name?: unknown }).tag_name; + if (typeof tag !== "string") continue; + out.push({ + id: `${repo}@${tag}`, + tag, + title: typeof (it as { name?: unknown }).name === "string" ? (it as { name: string }).name : tag, + url: typeof (it as { html_url?: unknown }).html_url === "string" ? (it as { html_url: string }).html_url : `https://github.com/${repo}/releases/tag/${tag}`, + when: typeof (it as { published_at?: unknown }).published_at === "string" ? (it as { published_at: string }).published_at : "", + detail: bodyOf(it), + }); + } + return out; +} + +/** Parse the GitHub issues payload (tolerant, same discipline). */ +export function parseGithubIssues(json: string, repo = "repo"): GithubIssue[] { + let items: unknown; + try { + items = JSON.parse(json); + } catch { + return []; + } + if (!Array.isArray(items)) return []; + const out: GithubIssue[] = []; + for (const it of items) { + const n = (it as { number?: unknown }).number; + if (typeof n !== "number") continue; + out.push({ + id: `${repo}#${n}`, + title: typeof (it as { title?: unknown }).title === "string" ? (it as { title: string }).title : "", + url: typeof (it as { html_url?: unknown }).html_url === "string" ? (it as { html_url: string }).html_url : `https://github.com/${repo}/issues/${n}`, + when: typeof (it as { updated_at?: unknown }).updated_at === "string" ? (it as { updated_at: string }).updated_at : "", + detail: bodyOf(it), + }); + } + return out; +} + +// ── the production transport (the S31 curl doctrine) ──────────────────────── + +/** The production SotaFetch for GitHub surfaces: one query's worth of + * network via curl against api.github.com, with the shared anti-laundering + * flags (curlArgs: --fail + the %{http_code} write-out) — an HTTP 403/429 + * (rate-limited unauthenticated GitHub is 60 req/h, the most common real + * failure) carries the REAL status as a named failure, never a successful + * empty scan that would reset the failure stamps (B1). Count semantics + * feed the anomaly floor: the number of items the surface returned. */ +export function curlGithubFetch(url: string): Promise<{ ok: true; status: number; body: string; count: number } | { ok: false; status: number; error: string }> { + return (async () => { + let out: string; + try { + out = execFileSync("curl", curlArgs(url, ["accept: application/vnd.github+json"]), { encoding: "utf8", maxBuffer: 4 << 20 }); + } catch (e) { + const err = e as { stdout?: string | Buffer; stderr?: string | Buffer; message: string }; + const stdout = (err.stdout ?? "").toString(); + const stderr = (err.stderr ?? "").toString(); + const { status } = parseCurlOut(stdout); // --fail still emits the write-out + return { ok: false, status: Number.isFinite(status) ? status : 0, error: `curl: ${stderr.trim() || err.message}` }; + } + const { body, status } = parseCurlOut(out); + if (!(status >= 200 && status < 300)) { + return { ok: false, status: Number.isFinite(status) ? status : 0, error: `HTTP ${status} (non-2xx from the GitHub API)` }; + } + const count = url.includes("/releases") ? parseGithubReleases(body).length : parseGithubIssues(body).length; + return { ok: true, status, body, count }; + })(); +} + +// ── registry load + the stamp writer (revalidate before persist) ──────────── + +/** Load the LIVING registry under the sota root, bootstrapping it from the + * packaged seed on first read (the seed must validate — a drifted seed is a + * loud authoring failure, never a silent skip). A malformed living registry + * THROWS field-precise. */ +export function loadRegistry(root: string): WatchedRepoRegistry { + const p = registryPath(root); + if (!existsSync(p)) { + const v = validateWatchedRepoRegistry(seedToml); + if (!v.ok) throw new Error(`the packaged seed registry is invalid — ${v.errors.join("; ")}`); + mkdirSync(root, { recursive: true }); + writeFileSync(p, seedToml.endsWith("\n") ? seedToml : seedToml + "\n"); + } + return parseWatchedRepoRegistry(readFileSync(p, "utf8")); +} + +const tomlString = (s: string): string => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +const tomlArray = (xs: string[]): string => `[${xs.map(tomlString).join(", ")}]`; + +/** Render the registry back to TOML (the stamp writer's serialize step — + * the data shape is closed, so the round-trip is mechanical). */ +export function renderRegistryToml(reg: WatchedRepoRegistry): string { + const lines = [`schema_version = "${reg.schema_version}"`, "", `failure_threshold = ${reg.failure_threshold}`, ""]; + for (const r of reg.repos) { + lines.push("[[repos]]"); + lines.push(`repo = ${tomlString(r.repo)}`); + lines.push(`why_watched = ${tomlString(r.why_watched)}`); + lines.push(`domains = ${tomlArray(r.domains)}`); + lines.push(`fetch_surface = ${tomlArray(r.fetch_surface)}`); + lines.push(`match_keywords = ${tomlArray(r.match_keywords)}`); + lines.push(`last_success = ${tomlString(r.last_success)}`); + lines.push(`consecutive_failures = ${r.consecutive_failures}`); + lines.push(""); + } + return lines.join("\n"); +} + +export interface StampUpdate { + repo: string; + ok: boolean; // did this repo's round succeed (any surface ok)? +} + +/** Apply one fetch round's stamps to the registry and persist it — writing + * the NEW text to a tmp file, REVALIDATING it against the validator, and + * only then renaming it into place. A persist that would corrupt the + * registry throws instead. */ +export function persistStampedRegistry(root: string, reg: WatchedRepoRegistry, updates: StampUpdate[], nowIso: string): void { + const stamped: WatchedRepoRegistry = { + ...reg, + repos: reg.repos.map((r) => { + const u = updates.find((x) => x.repo === r.repo); + if (!u) return r; + return u.ok + ? { ...r, last_success: nowIso, consecutive_failures: 0 } + : { ...r, consecutive_failures: r.consecutive_failures + 1 }; + }), + }; + const text = renderRegistryToml(stamped); + const check = validateWatchedRepoRegistry(text); + if (!check.ok) throw new Error(`refusing to persist a registry the validator rejects — ${check.errors.join("; ")}`); + const p = registryPath(root); + const tmp = `${p}.tmp-${process.pid}`; + writeFileSync(tmp, text); + renameSync(tmp, p); +} + +// ── keyword matching (word-boundary, explainable) ────────────────────────── + +const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** Which of the entry's match keywords hit (an unweighted word-boundary + * scan over title + body). An event surfaces in the brief iff it matched + * at least one keyword — and the brief names WHICH ones. */ +export function matchKeywords(title: string, body: string, keywords: string[]): string[] { + const text = `${title}\n${body}`.toLowerCase(); + const hits: string[] = []; + for (const kw of keywords) { + const m = text.match(new RegExp(`(^|[^a-z0-9-])${escapeRe(kw.toLowerCase())}([^a-z0-9-]|$)`)); + if (m) hits.push(kw); + } + return hits; +} + +// ── the lens (the fetch round) ─────────────────────────────────────────────── + +export interface CodebaseEvent { + id: string; + title: string; + url: string; + when: string; + detail: string; + matched: string[]; +} + +export interface SurfaceResult { + surface: FetchSurface; + url: string; + via: string; // cache | fetched | fetch-failed | queue-timeout | refused + ok: boolean; + count: number; + events: CodebaseEvent[]; // keyword-filtered, cited + anomaly?: AnomalyFloorVerdict; + error?: string; +} + +export interface RepoResult { + repo: string; + why_watched: string; + ok: boolean; // any surface ok → the round succeeded for this repo + flagged: boolean; // retire-or-confirm, derived from the PERSISTED counter + surfaces: SurfaceResult[]; +} + +export interface CodebaseLensOpts extends Omit { + root: string; + fetchFn?: SotaFetch; // the transport — injectable for hermetic tests; default curlGithubFetch + repos?: string[]; // optional filter (canonical owner/name) +} + +export interface CodebaseLensResult { + ok: boolean; // every repo's round succeeded + brief: string; + repos: RepoResult[]; + stamp: { iso: string }; +} + +/** The codebase fetch round: for each watched repo, each declared surface, + * one GitHub-API fetch through the one-fetcher seam; events keyword-filter + * into the brief; stamps persist at the end. The survey never blocks: a + * queue-timeout surface is a NAMED failure, never a throw. */ +export async function runCodebaseLens(opts: CodebaseLensOpts): Promise { + const { root } = opts; + const nowMs = opts.nowMs ?? Date.now; + const reg = loadRegistry(root); + const wanted = opts.repos ? reg.repos.filter((r) => opts.repos!.includes(r.repo)) : reg.repos; + const fetchFn = opts.fetchFn ?? curlGithubFetch; + + const results: RepoResult[] = []; + for (const repo of wanted) { + const surfaces: SurfaceResult[] = []; + for (const surface of repo.fetch_surface) { + const url = githubApiUrl(repo.repo, surface); + const res: FetchThroughQueueResult = await fetchThroughQueue(url, { + ...opts, + root, + fetchFn, + nowMs, + sourceKey: githubSourceKey(repo.repo, surface), // stable per-source history key + }); + if (!res.ok) { + surfaces.push({ + surface, + url, + via: res.via, + ok: false, + count: 0, + events: [], + error: res.via === "queue-timeout" ? `${res.detail} (waited ${res.waitedMs}ms)` : res.via === "refused" ? res.reason : res.error, + }); + continue; + } + const items = + surface === "issues" + ? parseGithubIssues(res.body, repo.repo).map((i) => ({ id: i.id, title: i.title, url: i.url, when: i.when, detail: i.detail })) + : parseGithubReleases(res.body, repo.repo).map((r) => ({ id: r.id, title: r.title, url: r.url, when: r.when, detail: r.detail })); + const events: CodebaseEvent[] = []; + for (const it of items) { + const matched = matchKeywords(it.title, it.detail, repo.match_keywords); + if (matched.length > 0) events.push({ ...it, matched }); + } + surfaces.push({ surface, url, via: res.via, ok: true, count: res.count, events, anomaly: res.anomaly }); + } + results.push({ + repo: repo.repo, + why_watched: repo.why_watched, + ok: surfaces.some((s) => s.ok), + flagged: false, // derived AFTER stamps persist (below) + surfaces, + }); + } + + // stamps: one round verdict per repo; the flag derives from the persisted counter + const nowIso = new Date(nowMs()).toISOString(); + persistStampedRegistry(root, reg, results.map((r) => ({ repo: r.repo, ok: r.ok })), nowIso); + const persisted = parseWatchedRepoRegistry(readFileSync(registryPath(root), "utf8")); + for (const r of results) { + const entry = persisted.repos.find((x) => x.repo === r.repo); + if (entry) r.flagged = flaggedForRetireOrConfirm(entry, persisted.failure_threshold); + } + + return { + ok: results.every((r) => r.ok), + brief: renderCodebaseBrief({ repos: results, stampIso: nowIso, threshold: persisted.failure_threshold }), + repos: results, + stamp: { iso: nowIso }, + }; +} + +// ── the brief (PI register: cited, provenance-stamped, flags named) ───────── + +export interface CodebaseBriefInput { + repos: RepoResult[]; + stampIso: string; + threshold: number; +} + +/** Render the PI-register codebase brief: the outcome leads, every surfaced + * event is CITED by its canonical URL, each repo's why-watched renders (the + * retire-or-confirm decision reads this line), and the flagged entries get + * the human-decision section — never a silent unwatch. */ +export function renderCodebaseBrief(input: CodebaseBriefInput): string { + const { repos, stampIso, threshold } = input; + const matchedTotal = repos.reduce((s, r) => s + r.surfaces.reduce((t, su) => t + su.events.length, 0), 0); + const flagged = repos.filter((r) => r.flagged); + const lines: string[] = [ + `# SOTA codebase brief — ${repos.length} repos scanned (${matchedTotal} matched events)`, + `scanned at: ${stampIso} · source: GitHub API against canonical repos · never a local fork checkout`, + ]; + for (const r of repos) { + lines.push("", `## ${r.repo} — why-watched: ${r.why_watched}`); + for (const s of r.surfaces) { + if (!s.ok) { + lines.push(`- ${s.surface}: fetch failed — ${s.error ?? "unknown error"} (named failure; the entry accrues toward retire-or-confirm)`); + continue; + } + if (s.events.length === 0) { + if (s.anomaly?.anomaly && s.anomaly.render) { + lines.push(`- ${s.surface}: ${s.anomaly.render}`); // "scan returned nothing — anomalous (…)" + } else if (s.anomaly?.armed) { + lines.push(`- ${s.surface}: no matched events (trailing 7-fetch-day mean ${s.anomaly.mean?.toFixed(1) ?? "?"} — ordinary scan)`); + } else { + lines.push(`- ${s.surface}: no matched events (floor not yet armed — ${s.anomaly?.days ?? 0}/${ANOMALY_FLOOR_WINDOW_DAYS} fetch-days of history for this source)`); + } + continue; + } + for (const e of s.events) { + const when = e.when ? ` (${e.when.slice(0, 10)})` : ""; + lines.push(`- **${e.title}**${when} — ${e.url}`); + lines.push(` _matched: ${e.matched.join(", ")} · id ${e.id}_`); + } + } + } + if (flagged.length > 0) { + lines.push("", `## Retire-or-confirm (the human decision — never a silent unwatch)`); + for (const r of flagged) { + lines.push(`- ${r.repo} — the fetch failed ${threshold} consecutive times. why-watched: ${r.why_watched}`); + lines.push(` Confirm the watch (and fix the fetch) or retire the entry — the registry edit is yours, not the machinery's.`); + } + } + lines.push( + "", + "provenance:", + "- source: GitHub API against canonical repos (https://api.github.com/repos//)", + `- scanned_at: ${stampIso}`, + "- registry: validator-checked TOML data — adding a repo is a data edit, never code", + ); + return lines.join("\n"); +} diff --git a/packages/amico-run/src/sota_fetch.ts b/packages/amico-run/src/sota_fetch.ts new file mode 100644 index 00000000..419a86a4 --- /dev/null +++ b/packages/amico-run/src/sota_fetch.ts @@ -0,0 +1,214 @@ +// sota_fetch.ts — the ONE-FETCHER fetch flow (#820, spec spec-20260905-103000 +// living-sota D4, the one-fetcher invariant / S6): every live fetch through +// the SOTA lenses rides +// +// cache → queue lock → RE-CHECK cache → fetch → write cache → history +// +// so that (a) a second fetcher reads the first one's cache and never +// refetches what a cache holds, (b) a fetcher that cannot get the lock inside +// the bounded wait falls through to the NAMED queue-timeout outcome (read +// the cache, or record the waiver — the survey never blocks), and (c) every +// ok fetch records its source's history entry, feeding the anomaly floor by +// construction. +// +// The FETCH cache (this module) and the RENDER cache (the persisted briefs, +// later slices) are DIFFERENT referents — never conflated. The FETCH cache is +// content-keyed by the query URL under /fetch-cache/, written +// atomically (tmp+rename), fresh for FETCH_CACHE_TTL_MS: +// +// FETCH_CACHE_TTL_MS = 6h — hours, not days: the fleet dedupes a burst of +// on-demand casts inside one window, while the DAILY digest's cadence is +// never starved (yesterday's payload is stale by the next morning). +// +// The recipe gotcha is MECHANICAL: http:// is refused outright with the named +// reason — the arXiv export API's http endpoint silently hangs; always +// https (the web-search recipe, verbatim, pinned in code not just prose). +// +// The sota root: $AMICO_SOTA_ROOT wins (hermetic tests); otherwise the +// FLEET-SHARED personal vault mount (the mounts resolver's personal mount — +// vault-aaron, synced across the fleet) + amicode/sota. A per-host path +// would serialize nothing; fleet-wide means the shared vault path. +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { acquireQueueLock, releaseQueueLock, type QueueOpts } from "./sota_queue.js"; +import { evaluateAnomalyFloor, readFetchHistory, recordFetchOutcome, type AnomalyFloorVerdict } from "./sota_history.js"; +import { resolveMountStack, personalMount } from "./mounts.js"; + +/** The fetch cache's freshness window — see the header. */ +export const FETCH_CACHE_TTL_MS = 6 * 60 * 60_000; + +/** The shared production curl flags (B1, review fold on #828): `--fail` + * because WITHOUT it curl exits 0 on HTTP 403/404/429 and a server error + * launders as an empty success (status hardcoded 200, count 0 — stamps + * reset, last_success written, a fake zero in fetch history, the anomaly + * floor disarmed exactly when the fleet chronically fails); the bounded + * `--max-time 30` transport; a UA; and the `%{http_code}` write-out so the + * REAL status always rides the output — never a hardcoded 200. */ +export function curlArgs(url: string, extraHeaders: string[] = []): string[] { + const args = ["-sS", "--fail", "--max-time", "30", "-H", "user-agent: amicode-sota-review/0.1"]; + for (const h of extraHeaders) args.push("-H", h); + args.push("-w", "\n%{http_code}", url); + return args; +} + +/** Split curl's stdout into body + the `-w %{http_code}` write-out. The + * write-out ALWAYS emits (even under --fail, where the body is suppressed), + * so this is the one place the real status is read. */ +export function parseCurlOut(out: string): { body: string; status: number } { + const idx = out.lastIndexOf("\n"); + if (idx === -1) return { body: "", status: Number.NaN }; + return { body: out.slice(0, idx), status: Number(out.slice(idx + 1).trim()) }; +} + +/** The transport seam — one query's worth of network. Injected in tests; + * the production body is subprocess curl (the S31 zero-dep doctrine, + * papers_digest.ts's fetchFeed). */ +export type SotaFetch = (url: string) => Promise< + | { ok: true; status: number; body: string; count: number } + | { ok: false; status: number; error: string } +>; + +export interface FetchThroughQueueOpts extends QueueOpts { + root: string; + fetchFn: SotaFetch; + /** The history/anomaly source key (defaults to the URL's content key — + * recurring sources pass a stable explicit key). */ + sourceKey?: string; + /** Skip the cache both ways (a forced refresh; still queued). */ + skipCache?: boolean; +} + +export type FetchThroughQueueResult = + | { + via: "cache" | "fetched"; + ok: true; + status: number; + body: string; + count: number; + /** The anomaly-floor verdict for the returned payload — live fetches + * AND cache reads (A1: a cached empty scan renders the fill's verdict, + * never a false disarm). Undefined only when the floor could not be + * evaluated; `armed: false` is the honest unarmed state. */ + anomaly?: AnomalyFloorVerdict; + } + | { via: "queue-timeout"; ok: false; detail: string; waitedMs: number } + | { via: "fetch-failed"; ok: false; status: number; error: string } + | { via: "refused"; ok: false; reason: string }; + +/** The sota root — the FLEET-SHARED vault path. $AMICO_SOTA_ROOT wins (the + * hermetic escape); production is the personal vault mount + amicode/sota + * (the same mount the ledger-bridge contract writes under). */ +export function sotaRoot(): string { + const env = process.env.AMICO_SOTA_ROOT; + if (env && env.trim() !== "") return env; + const stack = resolveMountStack(); + const personal = personalMount(stack); + const vaultPath = personal?.writable ? personal.path : join(homedir(), ".amico", "vaults"); + return join(vaultPath, "amicode", "sota"); +} + +/** The content key of a query URL — the cache file name AND the default + * history source key (one key space, two files). */ +export function sourceKeyOf(url: string): string { + return createHash("sha256").update(url, "utf8").digest("hex"); +} + +export function cachePath(root: string, url: string): string { + return join(root, "fetch-cache", `${sourceKeyOf(url)}.json`); +} + +interface CacheEntry { + url: string; + fetched_at: string; // ISO-8601 + body: string; + /** The payload's entry count, STORED AT FILL TIME (A1, review fold on + * #828): a cache read reports the real count — never a sentinel — and the + * anomaly floor is evaluated on the cache read, so an armed source served + * a cached empty payload renders the armed anomaly, never a false disarm. */ + count: number; +} + +function readCache(root: string, url: string, opts: { nowMs: () => number; skipCache?: boolean }): CacheEntry | null { + if (opts.skipCache) return null; + const p = cachePath(root, url); + try { + const j = JSON.parse(readFileSync(p, "utf8")) as Partial; + // count is REQUIRED (A1's shape): a legacy entry without it reads as a + // miss and refetches — the 6h TTL makes the upgrade harmless. + if (typeof j.body !== "string" || typeof j.fetched_at !== "string" || typeof j.count !== "number") return null; + const age = opts.nowMs() - Date.parse(j.fetched_at); + if (Number.isNaN(age) || age < 0 || age > FETCH_CACHE_TTL_MS) return null; // stale is not fresh + return j as CacheEntry; + } catch { + return null; + } +} + +function writeCache(root: string, url: string, body: string, count: number, nowMs: () => number): void { + mkdirSync(join(root, "fetch-cache"), { recursive: true }); + const p = cachePath(root, url); + const tmp = `${p}.tmp-${process.pid}`; + const entry: CacheEntry = { url, fetched_at: new Date(nowMs()).toISOString(), body, count }; + writeFileSync(tmp, JSON.stringify(entry) + "\n"); + renameSync(tmp, p); +} + +/** The cache read's floor verdict (A1): evaluate against the fill's OWN prior + * history — entries from STRICTLY EARLIER fetch-days — reproducing the + * verdict the live fill computed. The fill already recorded its outcome; a + * cache read records nothing. */ +function cachedAnomaly(root: string, sourceKey: string, cached: CacheEntry): AnomalyFloorVerdict { + const fillDay = cached.fetched_at.slice(0, 10); + const priorBeforeFill = readFetchHistory(root, sourceKey).filter((e) => e.date < fillDay); + return evaluateAnomalyFloor(priorBeforeFill, { date: fillDay, count: cached.count }); +} + +/** The one-fetcher flow — cache → queue → re-check cache → fetch → cache + + * history. See the module header; the S6 property (a second fetcher reads + * the cache) is the under-lock re-check. */ +export async function fetchThroughQueue(url: string, opts: FetchThroughQueueOpts): Promise { + // the recipe gotcha, mechanical: http:// hangs on the arXiv export API — + // refuse it by name, before any lock or transport. + if (/^http:\/\//i.test(url)) { + return { + via: "refused", + ok: false, + reason: "refused: http:// — the arXiv export API's http endpoint silently hangs; the sota fetch seam is https-only", + }; + } + const nowMs = opts.nowMs ?? Date.now; + const sourceKey = opts.sourceKey ?? sourceKeyOf(url); + + const cached = readCache(opts.root, url, { nowMs, skipCache: opts.skipCache }); + if (cached !== null) { + return { via: "cache", ok: true, status: 200, body: cached.body, count: cached.count, anomaly: cachedAnomaly(opts.root, sourceKey, cached) }; + } + + const lock = await acquireQueueLock(opts.root, opts); + if (!lock.acquired) { + return { via: "queue-timeout", ok: false, detail: lock.detail, waitedMs: lock.waitedMs }; + } + try { + // under the lock: RE-CHECK the cache — a fetcher that waited may find the + // cache the lock-holder just wrote (the second-fetcher property). + const underLock = readCache(opts.root, url, { nowMs, skipCache: opts.skipCache }); + if (underLock !== null) { + return { via: "cache", ok: true, status: 200, body: underLock.body, count: underLock.count, anomaly: cachedAnomaly(opts.root, sourceKey, underLock) }; + } + const res = await opts.fetchFn(url); + if (!res.ok) { + return { via: "fetch-failed", ok: false, status: res.status, error: res.error }; + } + writeCache(opts.root, url, res.body, res.count, nowMs); + // the anomaly floor: armed only after 7 prior fetch-days of THIS source's + // history; the current fetch is not part of its own window. + const prior = readFetchHistory(opts.root, sourceKey); + const anomaly = evaluateAnomalyFloor(prior, { date: new Date(nowMs()).toISOString().slice(0, 10), count: res.count }); + recordFetchOutcome(opts.root, sourceKey, { date: new Date(nowMs()).toISOString().slice(0, 10), count: res.count }); + return { via: "fetched", ok: true, status: res.status, body: res.body, count: res.count, anomaly }; + } finally { + releaseQueueLock(lock.lock); + } +} diff --git a/packages/amico-run/src/sota_history.ts b/packages/amico-run/src/sota_history.ts new file mode 100644 index 00000000..2d5fbfc2 --- /dev/null +++ b/packages/amico-run/src/sota_history.ts @@ -0,0 +1,119 @@ +// sota_history.ts — the per-source fetch history + the anomaly floor (#820, +// spec-20260905-103000 living-sota / sota_fetch_anomaly_floor / S6): the +// "quiet failures are the ones that matter" machinery. A successful fetch +// returning ZERO entries where the trailing 7-fetch-day mean is NONZERO +// records a NAMED anomaly ("empty-200-vs-nonzero-mean") and renders +// "scan returned nothing — anomalous" — never "nothing new". +// +// ── O1 — history storage + granularity, pinned ────────────────────────── +// +// STORAGE: one per-source JSON file under +// /fetch-history/.json — {entries: [{date, count}]} +// (an object with a named field, extensible without a breaking read of the +// old shape). An append-log in spirit, a bounded window in fact: entries are +// never edited or reordered, but each append rewrites the file atomically +// (tmp+rename) capped at HISTORY_CAP entries (a year+ of daily fetches at +// 400) — the floor only reads the trailing window, and an unbounded file is +// an unbounded cost for nothing. A corrupt or missing file reads as EMPTY, +// degrading to unarmed rather than crashing the lens. +// +// GRANULARITY EDGE: a "fetch-day" is a DISTINCT UTC calendar date on which at +// least one fetch was recorded. The floor is ARMED iff ≥7 distinct fetch-days +// exist in the PRIOR history (the current fetch is not part of its own +// window). A day's VALUE is the LAST count recorded that day (a same-day +// refetch supersedes — the day's final state is what the mean sees). The +// trailing window is the LAST 7 distinct fetch-days, so a long-gone quiet +// era drops out of the mean exactly 7 days after it ends. +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface FetchHistoryEntry { + /** UTC calendar date, YYYY-MM-DD. */ + date: string; + /** The count the fetch returned (entries, releases, …). */ + count: number; +} + +/** O1: the trailing window — 7 fetch-days, armed only after 7 days of history. */ +export const ANOMALY_FLOOR_WINDOW_DAYS = 7; + +/** O1: the store cap — the floor only reads the trailing window. */ +export const HISTORY_CAP = 400; + +export interface AnomalyFloorVerdict { + /** Is the floor armed (≥7 prior fetch-days)? */ + armed: boolean; + /** Did the armed floor fire? */ + anomaly: boolean; + /** The named anomaly kind ("empty-200-vs-nonzero-mean") when it fired. */ + name?: string; + /** The human render — "scan returned nothing — anomalous (…)", never "nothing new". */ + render?: string; + /** The trailing-window mean (when armed). */ + mean?: number; + /** Distinct PRIOR fetch-days of history — the disarm line renders n/7 + * honestly instead of a bare "not armed". */ + days?: number; +} + +export function historyPath(root: string, sourceKey: string): string { + return join(root, "fetch-history", `${sourceKey}.json`); +} + +/** Read a source's history — append-only entries; corrupt/missing → [] (the + * fresh-install state, unarmed by construction). */ +export function readFetchHistory(root: string, sourceKey: string): FetchHistoryEntry[] { + try { + const j = JSON.parse(readFileSync(historyPath(root, sourceKey), "utf8")) as { entries?: FetchHistoryEntry[] }; + return Array.isArray(j.entries) ? j.entries.slice(-HISTORY_CAP) : []; + } catch { + return []; + } +} + +/** Append one fetch outcome to the source's history (entries never edited or + * reordered; the file rewritten atomically on append, capped at HISTORY_CAP). */ +export function recordFetchOutcome(root: string, sourceKey: string, entry: FetchHistoryEntry): void { + const entries = [...readFetchHistory(root, sourceKey), entry].slice(-HISTORY_CAP); + mkdirSync(join(root, "fetch-history"), { recursive: true }); + const p = historyPath(root, sourceKey); + const tmp = `${p}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify({ entries } satisfies { entries: FetchHistoryEntry[] }) + "\n"); + renameSync(tmp, p); +} + +/** The trailing-7-fetch-day window over PRIOR history: the last 7 distinct + * dates, each valued by its LAST recorded count. */ +export function trailingWindow(history: FetchHistoryEntry[]): { date: string; value: number }[] { + const byDay = new Map(); + for (const e of history) byDay.set(e.date, e.count); // later entries supersede same-day + return [...byDay.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).slice(-ANOMALY_FLOOR_WINDOW_DAYS).map(([date, value]) => ({ date, value })); +} + +/** Evaluate the anomaly floor for a fetch that just returned `count` entries + * with HTTP 200, against the PRIOR history (armed only after 7 distinct + * fetch-days — a fresh source cannot cry anomaly). */ +export function evaluateAnomalyFloor(priorHistory: FetchHistoryEntry[], current: { date: string; count: number }): AnomalyFloorVerdict { + const days = new Set(priorHistory.map((e) => e.date)).size; + const window = trailingWindow(priorHistory); + if (window.length < ANOMALY_FLOOR_WINDOW_DAYS) { + return { armed: false, anomaly: false, days }; + } + const mean = window.reduce((s, d) => s + d.value, 0) / window.length; + if (current.count === 0 && mean > 0) { + return { + armed: true, + anomaly: true, + name: "empty-200-vs-nonzero-mean", + mean, + days, + render: `scan returned nothing — anomalous (empty 200 against a trailing ${ANOMALY_FLOOR_WINDOW_DAYS}-fetch-day mean of ${mean.toFixed(1)})`, + }; + } + return { armed: true, anomaly: false, mean, days }; +} + +/** The UTC calendar date of a timestamp (the granularity unit, O1). */ +export function utcFetchDay(nowIso: string): string { + return new Date(nowIso).toISOString().slice(0, 10); +} diff --git a/packages/amico-run/src/sota_papers.ts b/packages/amico-run/src/sota_papers.ts new file mode 100644 index 00000000..cf832fea --- /dev/null +++ b/packages/amico-run/src/sota_papers.ts @@ -0,0 +1,251 @@ +// sota_papers.ts — the PAPERS lens (#820, spec spec-20260905-103000 +// living-sota D1): on-demand arXiv queries through the fleet-wide serialized +// queue (sota_fetch.ts — cache → lock → re-check cache → fetch → cache + +// history), rendered as cited, provenance-stamped, PI-register briefs. +// +// The web-search recipe is carried VERBATIM and mechanically: +// 1. vault/repo grep FIRST — the SKILL's instruction step; the lens is the +// recipe's step 2, never a replacement for step 1. +// 2. the arXiv API over HTTPS — the builder below CANNOT emit http:// +// (ARXIV_API_ENDPOINT is https; fetchThroughQueue additionally refuses +// any http:// URL with the named reason — the export API's http +// endpoint silently hangs, the recipe's recorded gotcha). +// 3. never scraping — the only transport is the documented export API; +// search-engine HTML endpoints appear nowhere in this module (pinned by +// the extension's sota_review_skill lint). +// +// The brief's anomaly line is the fetch floor's verdict, rendered honestly: +// an empty-200 against a nonzero trailing mean is "scan returned nothing — +// anomalous", NEVER "nothing new" (spec sota_fetch_anomaly_floor). +import { execFileSync } from "node:child_process"; +import { + fetchThroughQueue, + curlArgs, + parseCurlOut, + type FetchThroughQueueOpts, + type FetchThroughQueueResult, + type SotaFetch, +} from "./sota_fetch.js"; +import { ANOMALY_FLOOR_WINDOW_DAYS, type AnomalyFloorVerdict } from "./sota_history.js"; + +/** The real export API — https BY CONSTRUCTION (the recipe's gotcha: the + * http:// endpoint silently hangs; there is no http constant anywhere). */ +export const ARXIV_API_ENDPOINT = "https://export.arxiv.org/api/query"; + +/** Build the canonical export-API query URL for one on-demand survey query. */ +export function arxivApiUrl(terms: string[], maxResults: number): string { + if (terms.length === 0) throw new Error("arxivApiUrl: at least one search term is required — a wildcard firehose is never the survey"); + const q = terms.map((t) => `all:${t}`).join(" AND "); + return `${ARXIV_API_ENDPOINT}?search_query=${encodeURIComponent(q)}&max_results=${maxResults}`; +} + +// ── the Atom subset (the parseArxivRss idiom: zero-dep, tolerant) ───────────── + +export interface ArxivEntry { + /** The arXiv id (2606.05060) — the citation key. */ + arxiv: string; + title: string; + abstract: string; + published: string; // ISO-8601, sliced + authors: string[]; +} + +/** The citation URL — the abs landing page, https. */ +export function absUrlOf(e: Pick): string { + return `https://arxiv.org/abs/${e.arxiv}`; +} + +function unescapeXml(s: string): string { + return s + .replace(//g, "$1") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/&/g, "&"); +} + +function stripTags(s: string): string { + return unescapeXml(s).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); +} + +/** Parse the export API's Atom: http://arxiv.org/abs/v + * <summary/><published/><author><name/></author></entry>. Malformed + * input degrades to [] — a bad payload never crashes the lens. */ +export function parseArxivAtom(xml: string): ArxivEntry[] { + const entries: ArxivEntry[] = []; + const blocks = xml.match(/<entry>([\s\S]*?)<\/entry>/g) ?? []; + for (const b of blocks) { + const id = b.match(/<id>([\s\S]*?)<\/id>/)?.[1]; + if (!id) continue; + const arxiv = id.trim().match(/abs\/([0-9]{4}\.[0-9]{4,5}|[a-z-]+\/[0-9]{7})(v\d+)?/)?.[1]; + if (!arxiv) continue; + const title = b.match(/<title>([\s\S]*?)<\/title>/)?.[1]; + const summary = b.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]; + const published = b.match(/<published>([\s\S]*?)<\/published>/)?.[1]; + const authors = [...b.matchAll(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<\/author>/g)].map((m) => stripTags(m[1])); + entries.push({ + arxiv, + title: title ? stripTags(title) : "", + abstract: summary ? stripTags(summary).slice(0, 2000) : "", + published: published ? published.trim().slice(0, 10) : "", + authors, + }); + } + return entries; +} + +// ── the production transport (the S31 zero-dep doctrine: curl subprocess) ──── + +/** The production SotaFetch: one query's worth of network via curl with the + * shared anti-laundering flags (curlArgs: --fail + the %{http_code} + * write-out). https-only is enforced upstream (the endpoint constant + + * fetchThroughQueue's http:// refusal). An HTTP 404/429 carries the REAL + * status as a named failure — never a successful empty scan (B1). */ +export function curlSotaFetch(url: string): Promise<{ ok: true; status: number; body: string; count: number } | { ok: false; status: number; error: string }> { + return (async () => { + let out: string; + try { + out = execFileSync("curl", curlArgs(url), { encoding: "utf8", maxBuffer: 4 << 20 }); + } catch (e) { + const err = e as { stdout?: string | Buffer; stderr?: string | Buffer; message: string }; + const stdout = (err.stdout ?? "").toString(); + const stderr = (err.stderr ?? "").toString(); + const { status } = parseCurlOut(stdout); // --fail still emits the write-out + return { ok: false, status: Number.isFinite(status) ? status : 0, error: `curl: ${stderr.trim() || err.message}` }; + } + const { body, status } = parseCurlOut(out); + if (!(status >= 200 && status < 300)) { + return { ok: false, status: Number.isFinite(status) ? status : 0, error: `HTTP ${status} (non-2xx from the export API)` }; + } + return { ok: true, status, body, count: parseArxivAtom(body).length }; + })(); +} + +// ── the brief (PI register: concise, cited, provenance-stamped) ────────────── + +export interface PapersProvenanceStamp { + query: string; + url: string; + fetched_at: string; // ISO-8601 + via: string; // cache | fetched | queue-timeout +} + +export interface PapersBriefOpts { + entries: ArxivEntry[]; + stamp: PapersProvenanceStamp; + anomaly: AnomalyFloorVerdict; +} + +/** Render the PI-register brief: the outcome leads, every entry is CITED by + * its abs URL with authors + date, and a provenance block stamps the query, + * the source, the fetch time, and the path (cache vs live). */ +export function papersBrief(opts: PapersBriefOpts): string { + const { entries, stamp, anomaly } = opts; + const lines: string[] = [`# SOTA papers brief — ${stamp.query} (${entries.length} results)`]; + if (entries.length === 0) { + if (anomaly.anomaly && anomaly.render) { + lines.push(anomaly.render); // "scan returned nothing — anomalous (…)" — NEVER "nothing new" + } else { + lines.push( + `no results (the anomaly floor is not yet armed for this source — ${anomaly.days ?? 0}/${ANOMALY_FLOOR_WINDOW_DAYS} fetch-days of history; an empty scan cannot be judged anomalous yet)`, + ); + } + } + entries.forEach((e, i) => { + const author = e.authors.length > 0 ? ` — ${e.authors.slice(0, 3).join(", ")}${e.authors.length > 3 ? " et al." : ""}` : ""; + const date = e.published ? ` (${e.published})` : ""; + lines.push(`${i + 1}. **${e.title || "(untitled)"}**${author}${date}`); + lines.push(` [arXiv:${e.arxiv}](${absUrlOf(e)})`); + if (e.abstract) lines.push(` > ${e.abstract.slice(0, 220)}${e.abstract.length > 220 ? "…" : ""}`); + }); + if (anomaly.armed && !anomaly.anomaly && anomaly.mean !== undefined) { + lines.push(`_fetch health: trailing 7-fetch-day mean ${anomaly.mean.toFixed(1)} — ordinary._`); + } + lines.push( + "", + "provenance:", + `- source: arXiv export API over HTTPS (${stamp.url})`, + `- fetched_at: ${stamp.fetched_at}`, + `- via: ${stamp.via}${stamp.via === "queue-timeout" ? " — read the fetch cache, or record the waiver; never a silent block" : ""}`, + `- query: ${stamp.query}`, + ); + return lines.join("\n"); +} + +// ── the lens (one on-demand query through the queue) ───────────────────────── + +export interface PapersLensOpts extends Omit<FetchThroughQueueOpts, "fetchFn" | "root" | "sourceKey"> { + root: string; + terms: string[]; + maxResults?: number; + /** The transport — injectable for hermetic tests; default curlSotaFetch. */ + fetchFn?: SotaFetch; +} + +export type PapersLensResult = + | { + via: "cache" | "fetched"; + ok: true; + entries: ArxivEntry[]; + /** The payload's entry count (the cache carries the FILL's count — A1). */ + count: number; + brief: string; + anomaly?: AnomalyFloorVerdict; + stamp: PapersProvenanceStamp; + } + | { + via: "queue-timeout" | "fetch-failed" | "refused"; + ok: false; + /** The REAL HTTP status when one was carried (B1: a 404/429 is a named + * failure with its status, never a successful empty scan). */ + status?: number; + brief: string; + detail: string; + }; + +/** One on-demand papers query: build the export-API URL, ride the queue, + * parse, and render the cited brief. The queue-timeout fall-through renders + * the NAMED outcome — the survey never blocks the loop. */ +export async function runPapersLens(opts: PapersLensOpts): Promise<PapersLensResult> { + const { root, terms } = opts; + const maxResults = opts.maxResults ?? 5; + const url = arxivApiUrl(terms, maxResults); + const nowMs = opts.nowMs ?? Date.now; + const res: FetchThroughQueueResult = await fetchThroughQueue(url, { + ...opts, + root, + fetchFn: opts.fetchFn ?? curlSotaFetch, + nowMs, + // stable per-URL history key: the default (content key of the URL) IS the + // per-source key for on-demand queries — each distinct query is its own + // source for the anomaly floor. + }); + const fetchedAt = new Date(nowMs()).toISOString(); + if (!res.ok) { + const status = "status" in res ? res.status : undefined; + const detail = + res.via === "queue-timeout" + ? `${res.detail} (waited ${res.waitedMs}ms)` + : res.via === "refused" + ? res.reason + : res.error; + const brief = [ + `# SOTA papers brief — ${terms.join(" ")} (0 results)`, + `no fetch: ${res.via}${status !== undefined ? ` (HTTP ${status})` : ""} — ${detail}`, + "the survey never blocks: read the fetch cache, or record the explicit waiver.", + "", + "provenance:", + `- source: arXiv export API over HTTPS (${url})`, + `- fetched_at: ${fetchedAt}`, + `- via: ${res.via}`, + `- query: ${terms.join(" ")}`, + ].join("\n"); + return { via: res.via, ok: false, status, brief, detail }; + } + const entries = parseArxivAtom(res.body); + const stamp: PapersProvenanceStamp = { query: terms.join(" "), url, fetched_at: fetchedAt, via: res.via }; + const anomaly = res.anomaly ?? { armed: false, anomaly: false }; + const brief = papersBrief({ entries, stamp, anomaly }); + return { via: res.via, ok: true, entries, count: res.count, brief, anomaly, stamp }; +} diff --git a/packages/amico-run/src/sota_queue.ts b/packages/amico-run/src/sota_queue.ts new file mode 100644 index 00000000..501f2a57 --- /dev/null +++ b/packages/amico-run/src/sota_queue.ts @@ -0,0 +1,210 @@ +// sota_queue.ts — the FLEET-WIDE serialized query queue (#820, spec +// spec-20260905-103000 living-sota D4, the one-fetcher invariant): every live +// arXiv fetch rides ONE lock-file queue at the SHARED vault path — the sota +// root under the fleet-synced personal vault mount, NOT a per-host ~/.amico +// path (a per-host lock serializes nothing; the fleet is the concurrency). +// +// The lock is a lease file (POSIX O_EXCL create, the mode_staging.ts +// discipline): entries carry owner token + expiry; an expired or corrupt +// lease is RECLAIMED so a dead fetcher never blocks the fleet; a release is +// RENAME-TO-TOMBSTONE — the lock file is renamed away first and only then +// read, so a release racing a reclaim can never unlink the NEW owner's lock +// (the token check and the unlink are never two unguarded steps; a foreign +// lease found in the tombstone is RESTORED, not dropped). +// +// KNOWN LIMIT (named, not hidden — a spec-level residual, A2): O_EXCL is +// atomic per HOST; the lock file crosses machines by vault sync, whose +// propagation latency makes fleet-wide mutual exclusion BEST-EFFORT — two +// hosts can hold overlapping leases for the sync-gap window. The under-lock +// cache RE-CHECK bounds the damage to a duplicate transport — the second +// fetcher reads the first one's cache — never a correctness break. +// +// The wait is BOUNDED and falls through to the NAMED outcome — the survey +// never blocks the loop: a fetcher that cannot get the lock inside +// QUEUE_WAIT_TIMEOUT_MS returns {outcome: "queue-timeout"} and the caller +// reads the cache or records the waiver, always named, never silent. +// +// ── O4 — the constants, named and justified (build-time values; the spec +// pins the shape) ───────────────────────────────────────────────────────── +// +// QUEUE_LEASE_TTL_MS = 90_000 (90s): the serialized unit is ONE query whose +// transport bound is curl --max-time 30 (the existing S31 network seam, +// papers_digest.ts). 90s = 3× the transport bound: a LIVE fetcher whose +// transport is at its worst-case limit is never evicted mid-fetch, while a +// DEAD fetcher's stale lease blocks the fleet for at most 90s before +// reclamation. +// +// QUEUE_WAIT_TIMEOUT_MS = 120_000 (120s): one full lease plus a poll interval +// of margin — a waiter whose holder is alive-but-slow still gets the lock +// right after a legitimate release, while a holder that never releases +// costs the waiter at most 120s before the named fall-through. The survey +// never blocks: the receipt-or-waiver discipline (later slices' gate) +// proceeds on the named outcome. +// +// QUEUE_POLL_INTERVAL_MS = 250: release-to-acquire latency is one poll at +// most — imperceptible for a human survey — while a fleet of waiters +// cannot spin the lock file. +import { closeSync, openSync, readFileSync, renameSync, linkSync, rmSync, writeSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +export const QUEUE_LOCK_NAME = "queue.lock"; + +/** O4: one lease covers one full transport attempt (curl --max-time 30) with + * 3× margin — see the header. */ +export const QUEUE_LEASE_TTL_MS = 90_000; + +/** O4: the bounded wait — one lease + a poll of margin, then the NAMED + * fall-through. See the header. */ +export const QUEUE_WAIT_TIMEOUT_MS = 120_000; + +/** The poll cadence — a poll, not a spin. */ +export const QUEUE_POLL_INTERVAL_MS = 250; + +export interface QueueLock { + token: string; + lockPath: string; + expiresAt: number; +} + +export interface QueueLease { + token: string; + acquired_at: number; + expires_at: number; +} + +export type AcquireResult = + | { acquired: true; lock: QueueLock } + | { + acquired: false; + outcome: "queue-timeout"; + waitedMs: number; + /** The named fall-through — the disclosed alternative (cache-read or waiver). */ + detail: string; + }; + +export interface QueueOpts { + /** The clock — injectable for deterministic tests. */ + nowMs?: () => number; + /** The yield between polls — injectable for deterministic tests. */ + sleep?: (ms: number) => Promise<void>; + /** Override the bounded wait (tests). */ + waitTimeoutMs?: number; + /** Override the poll cadence (tests). */ + pollIntervalMs?: number; + /** Override the lease TTL (tests). */ + leaseTtlMs?: number; +} + +const defaultSleep = (ms: number) => new Promise<void>((res) => setTimeout(res, ms)); + +function readLease(lockPath: string): QueueLease | null { + try { + const j = JSON.parse(readFileSync(lockPath, "utf8")) as Partial<QueueLease>; + if (typeof j.token !== "string" || typeof j.expires_at !== "number") return null; + return { token: j.token, acquired_at: j.acquired_at ?? 0, expires_at: j.expires_at }; + } catch { + return null; + } +} + +function tryLockOnce(lockPath: string, now: number, leaseTtlMs: number): QueueLock | null { + const token = randomUUID(); + const lease: QueueLease = { token, acquired_at: now, expires_at: now + leaseTtlMs }; + let fd: number | null = null; + try { + fd = openSync(lockPath, "wx"); + writeSync(fd, JSON.stringify(lease) + "\n"); + closeSync(fd); + fd = null; + return { token, lockPath, expiresAt: lease.expires_at }; + } catch (e) { + if (fd !== null) { + try { + closeSync(fd); + } catch { + /* best effort */ + } + } + const code = (e as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw e; + // held: is the lease EXPIRED (or corrupt)? → reclaim; the next loop + // iteration re-attempts the atomic O_EXCL create, so two concurrent + // reclaimers still produce exactly one owner. + const held = readLease(lockPath); + if (held === null || held.expires_at <= now) { + try { + rmSync(lockPath); + } catch { + /* someone else reclaimed first — fine */ + } + } + return null; + } +} + +/** Acquire the fleet-wide queue lock, waiting BOUNDED. Falls through to the + * NAMED queue-timeout outcome — never throws, never blocks the loop. */ +export async function acquireQueueLock(root: string, opts: QueueOpts = {}): Promise<AcquireResult> { + const nowMs = opts.nowMs ?? Date.now; + const sleep = opts.sleep ?? defaultSleep; + const pollMs = opts.pollIntervalMs ?? QUEUE_POLL_INTERVAL_MS; + const timeoutMs = opts.waitTimeoutMs ?? QUEUE_WAIT_TIMEOUT_MS; + const leaseTtlMs = opts.leaseTtlMs ?? QUEUE_LEASE_TTL_MS; + mkdirSync(root, { recursive: true }); + const lockPath = join(root, QUEUE_LOCK_NAME); + const start = nowMs(); + for (;;) { + const lock = tryLockOnce(lockPath, nowMs(), leaseTtlMs); + if (lock !== null) return { acquired: true, lock }; + const waited = nowMs() - start; + if (waited >= timeoutMs) { + return { + acquired: false, + outcome: "queue-timeout", + waitedMs: waited, + detail: + "queue lock held past the bounded wait — falling through to the named outcome (read the fetch cache, or record the waiver); never a silent block", + }; + } + await sleep(Math.min(pollMs, timeoutMs - waited)); + } +} + +/** Release a lock — rename-to-tombstone (A3): the lock file is renamed to a + * token-named tombstone FIRST (removing it from the slot atomically), then + * the tombstone is read. If the lease is OURS: the tombstone is removed — + * a clean release of exactly our own lease. If the lease is FOREIGN (the + * reclaim raced the release): the tombstone is restored into the slot via + * link() — which fails with EEXIST if a new owner already holds, in which + * case the live owner stands and the tombstone is dropped. Either way, a + * release can never unlink a lease it does not own. */ +export function releaseQueueLock(lock: QueueLock): void { + const tomb = `${lock.lockPath}.dead-${lock.token}`; + try { + renameSync(lock.lockPath, tomb); + } catch { + return; // nothing to release — already gone; never resurrect anything + } + const held = readLease(tomb); + if (held !== null && held.token === lock.token) { + try { + rmSync(tomb); + } catch { + /* best effort — the tombstone is inert either way */ + } + return; + } + // a foreign lease (the reclaim raced the release): restore it if the slot + // is still empty; link() fails with EEXIST if a new owner already holds. + try { + linkSync(tomb, lock.lockPath); + } catch { + /* the slot is taken — the live owner's lock stands */ + } + try { + rmSync(tomb); + } catch { + /* best effort */ + } +} diff --git a/packages/amico-run/src/sota_seed.d.ts b/packages/amico-run/src/sota_seed.d.ts new file mode 100644 index 00000000..9a945b04 --- /dev/null +++ b/packages/amico-run/src/sota_seed.d.ts @@ -0,0 +1,8 @@ +// Type the data-as-import seam (#820): resources/*.toml import as their text +// content under both runtimes — esbuild (`.toml: "text"` loader, so the +// self-contained bins carry the canonical seed) and vitest (the matching +// toml-as-text plugin). One copy of the data, two runtimes. +declare module "*.toml" { + const text: string; + export default text; +} diff --git a/packages/amico-run/src/sota_verb.ts b/packages/amico-run/src/sota_verb.ts new file mode 100644 index 00000000..f51861a7 --- /dev/null +++ b/packages/amico-run/src/sota_verb.ts @@ -0,0 +1,86 @@ +// sota_verb.ts — `amico sota papers|codebase` (#820, spec-20260905-103000 +// living-sota D1): the SOTA survey surface agents drive — one on-demand +// arXiv query through the fleet-wide serialized queue (papers lens), or one +// watched-repo fetch round via the GitHub API (codebase lens). Both are the +// one-fetcher seam's clients: cache → queue → fetch, never a direct +// transport. The survey never blocks: queue-timeout and fetch failures are +// NAMED outcomes with the disclosed alternative (read the cache, or record +// the waiver) — exit 1 with the named reason, never a hang, never a silent +// empty. +import type { VerbResult } from "./verbs.js"; +import { runPapersLens } from "./sota_papers.js"; +import { runCodebaseLens } from "./sota_codebase.js"; +import { sotaRoot } from "./sota_fetch.js"; + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +function flagValues(argv: string[], name: string): string[] { + const out: string[] = []; + for (let i = 0; i < argv.length - 1; i++) if (argv[i] === name) out.push(argv[i + 1]); + return out; +} + +const USAGE = `amico sota — the SOTA survey surface (read-only toward the world) + amico sota papers --query "<terms>" [--top N] [--root <sota-root>] + one on-demand arXiv query through the fleet-wide serialized queue + amico sota codebase [--repo owner/name]... [--root <sota-root>] + one watched-repo fetch round via the GitHub API against canonical repos`; + +export async function sotaVerb(argv: string[]): Promise<VerbResult> { + const head = argv[0] ?? ""; + const root = flagValue(argv, "--root") ?? sotaRoot(); + + if (head === "papers") { + const query = flagValue(argv, "--query"); + if (!query || query.trim() === "") return { json: { ok: false, error: "papers lens: --query is required" }, code: 64 }; + const maxResults = Number(flagValue(argv, "--top") ?? 5); + if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 50) { + return { json: { ok: false, error: `papers lens: --top must be an integer in [1, 50] (got "${flagValue(argv, "--top")}")` }, code: 64 }; + } + const res = await runPapersLens({ root, terms: [query.trim()], maxResults }); + if (!res.ok) { + return { json: { ok: false, via: res.via, detail: res.detail, brief: res.brief }, code: 1 }; + } + return { + json: { + ok: true, + via: res.via, + results: res.entries.length, + brief: res.brief, + entries: res.entries.map((e) => ({ arxiv: e.arxiv, title: e.title, url: `https://arxiv.org/abs/${e.arxiv}`, published: e.published })), + anomaly: res.anomaly, + provenance: res.stamp, + }, + code: 0, + }; + } + + if (head === "codebase") { + const repos = flagValues(argv, "--repo"); + for (const r of repos) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(r)) { + return { json: { ok: false, error: `codebase lens: --repo must be a canonical owner/name (got "${r}") — never a local checkout path` }, code: 64 }; + } + } + const res = await runCodebaseLens({ root, repos: repos.length > 0 ? repos : undefined }); + return { + json: { + ok: res.ok, + brief: res.brief, + stamped_at: res.stamp.iso, + repos: res.repos.map((r) => ({ + repo: r.repo, + ok: r.ok, + flagged_for_retire_or_confirm: r.flagged, + events: r.surfaces.reduce((t, s) => t + s.events.length, 0), + })), + }, + code: res.ok ? 0 : 1, + }; + } + + return { json: { ok: false, error: `sota: unknown lens "${head}"`, usage: USAGE }, code: 64 }; +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index e92c77a5..c120e12e 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -26,6 +26,7 @@ import { planVerb } from "./plan_verb.js"; import { handoffVerb } from "./handoff_verb.js"; import { campaignVerb } from "./campaign_verb.js"; import { projectVerb } from "./project_verb.js"; +import { sotaVerb } from "./sota_verb.js"; export interface VerbResult { json: unknown; // structured result (stdout as JSON for the CLI; tool content for MCP) @@ -227,6 +228,20 @@ const project: Verb = { run: projectVerb, }; +// sota — the living-SOTA survey surface (#820, spec-20260905-103000 D1): +// the dual-lens read-only survey (papers via the arXiv export API through +// the fleet-wide serialized queue; codebases via the GitHub API against the +// watched-repo registry's canonical repos). This slice fetches and reports — +// staged routing/receipts are later slices; a survey that cannot run is a +// NAMED outcome, never a block. +const sota: Verb = { + name: "sota", + summary: "the SOTA survey: papers (arXiv export API via the serialized queue) / codebase (GitHub API over the watched-repo registry)", + generalizes: "the loops' external-currency survey step (the sota-review skill's driving surface)", + slice: "living-sota (spec-20260905-103000 D1)", + run: sotaVerb, +}; + export const SPINE_VERBS: Verb[] = [ catalog, vault, @@ -241,4 +256,5 @@ export const SPINE_VERBS: Verb[] = [ papers, campaign, project, + sota, ]; diff --git a/packages/amico-run/test/amico.test.ts b/packages/amico-run/test/amico.test.ts index 02885491..a169f617 100644 --- a/packages/amico-run/test/amico.test.ts +++ b/packages/amico-run/test/amico.test.ts @@ -71,7 +71,7 @@ describe("amico router — help + unknown verb", () => { it("--help lists the full verb surface, exit 0", () => { const r = run(["--help"]); expect(r.code).toBe(0); - for (const v of ["run", "resolve", "sandbox", "catalog", "vault", "device", "note", "cloud", "doctor", "upgrade", "mcp-serve"]) { + for (const v of ["run", "resolve", "sandbox", "catalog", "vault", "device", "note", "cloud", "doctor", "upgrade", "mcp-serve", "sota"]) { expect(r.stdout).toContain(`amico ${v}`); } }); diff --git a/packages/amico-run/test/fixtures/sota/arxiv-live-atom.xml b/packages/amico-run/test/fixtures/sota/arxiv-live-atom.xml new file mode 100644 index 00000000..fdd5edc7 --- /dev/null +++ b/packages/amico-run/test/fixtures/sota/arxiv-live-atom.xml @@ -0,0 +1,127 @@ +<?xml version='1.0' encoding='UTF-8'?> +<feed xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/" xmlns:arxiv="http://arxiv.org/schemas/atom" xmlns="http://www.w3.org/2005/Atom"> + <id>https://arxiv.org/api/s6IRmEe7KLyTq1G1mDx2NR9i53g</id> + <title>arXiv Query: search_query=all:optimal OR all:control&id_list=&start=0&max_results=5 + 2026-09-05T18:22:27Z + + 5 + 547557 + 0 + + http://arxiv.org/abs/2211.12700v2 + Tailored Presolve Techniques in Branch-and-Bound Method for Fast Mixed-Integer Optimal Control Applications + 2023-07-11T01:35:17Z + + + Mixed-integer model predictive control (MI-MPC) can be a powerful tool for modeling hybrid control systems. In case of a linear-quadratic objective in combination with linear or piecewise-linear system dynamics and inequality constraints, MI-MPC needs to solve a mixed-integer quadratic program (MIQP) at each sampling time step. This paper presents a collection of block-sparse presolve techniques to efficiently remove decision variables, and to remove or tighten inequality constraints, tailored to mixed-integer optimal control problems (MIOCP). In addition, we describe a novel heuristic approach based on an iterative presolve algorithm to compute a feasible but possibly suboptimal MIQP solution. We present benchmarking results for a C code implementation of the proposed BB-ASIPM solver, including a branch-and-bound (B&B) method with the proposed tailored presolve techniques and an active-set based interior point method (ASIPM), compared against multiple state-of-the-art MIQP solvers on a case study of motion planning with obstacle avoidance constraints. Finally, we demonstrate the computational performance of the BB-ASIPM solver on the dSPACE Scalexio real-time embedded hardware using a second case study of stabilization for an underactuated cart-pole with soft contacts. + + + 2022-11-23T04:41:04Z + 28 pages, 7 figures, 2 tables, published in journal of Optimal Control Applications and Methods + + Optim Control Appl Meth. 2023; 1- 29 + + Rien Quirynen + + + Stefano Di Cairano + + 10.1002/oca.3030 + + + + http://arxiv.org/abs/1810.09292v2 + Optimal distributed control of a stochastic Cahn-Hilliard equation + 2019-07-05T16:40:30Z + + + We study an optimal distributed control problem associated to a stochastic Cahn-Hilliard equation with a classical double-well potential and Wiener multiplicative noise, where the control is represented by a source-term in the definition of the chemical potential. By means of probabilistic and analytical compactness arguments, existence of an optimal control is proved. Then the linearized system and the corresponding backward adjoint system are analysed through monotonicity and compactness arguments, and first-order necessary conditions for optimality are proved. + + + 2018-10-22T13:53:58Z + Key words and phrases: stochastic Cahn-Hilliard equation, phase separation, optimal control, linearized state system, adjoint state system, first-order optimality conditions + + SIAM J. Control Optim. 57 (2019), no. 5, 3571-3602 + + Luca Scarpa + + 10.1137/18M1222223 + + + + http://arxiv.org/abs/1310.5377v2 + Numerical Approximations to Fractional Problems of the Calculus of Variations and Optimal Control + 2013-11-01T01:26:25Z + + + This chapter presents some numerical methods to solve problems in the fractional calculus of variations and fractional optimal control. Although there are plenty of methods available in the literature, we concentrate mainly on approximating the fractional problem either by discretizing the fractional term or expanding the fractional derivatives as a series involving integer order derivatives. The former method, as a subclass of direct methods in the theory of calculus of variations, uses finite differences, Grunwald-Letnikov definition in this case, to discretize the fractional term. Any quadrature rule for integration, regarding the desired accuracy, is then used to discretize the whole problem including constraints. The final task in this method is to solve a static optimization problem to reach approximated values of the unknown functions on some mesh points. + The latter method, however, approximates fractional problems by classical ones in which only derivatives of integer order are present. Precisely, two continuous approximations for fractional derivatives by series involving ordinary derivatives are introduced. Local upper bounds for truncation errors are provided and, through some test functions, the accuracy of the approximations are justified. Then we substitute the fractional term in the original problem with these series and transform the fractional problem to an ordinary one. Hereafter, we use indirect methods of classical theory, e.g. Euler-Lagrange equations, to solve the approximated problem. The methods are mainly developed through some concrete examples which either have obvious solutions or the solution is computed using the fractional Euler-Lagrange equation. + + + 2013-10-20T21:55:29Z + This is a preprint of a paper whose final and definite form appeared in: Chapter V, Fractional Calculus in Analysis, Dynamics and Optimal Control (Editor: Jacky Cresson), Series: Mathematics Research Developments, Nova Science Publishers, New York, 2014. (See http://www.novapublishers.com/catalog/product_info.php?products_id=46851). Consists of 39 pages + + Fractional Calculus in Analysis, Dynamics and Optimal Control, Nova Science Publishers, New York, 2014, 201--239 + + Shakoor Pooseh + + + Ricardo Almeida + + + Delfim F. M. Torres + + + + http://arxiv.org/abs/1112.4113v1 + Optimal Control of Vehicular Formations with Nearest Neighbor Interactions + 2011-12-18T03:41:13Z + + + We consider the design of optimal localized feedback gains for one-dimensional formations in which vehicles only use information from their immediate neighbors. The control objective is to enhance coherence of the formation by making it behave like a rigid lattice. For the single-integrator model with symmetric gains, we establish convexity, implying that the globally optimal controller can be computed efficiently. We also identify a class of convex problems for double-integrators by restricting the controller to symmetric position and uniform diagonal velocity gains. To obtain the optimal non-symmetric gains for both the single- and the double-integrator models, we solve a parameterized family of optimal control problems ranging from an easily solvable problem to the problem of interest as the underlying parameter increases. When this parameter is kept small, we employ perturbation analysis to decouple the matrix equations that result from the optimality conditions, thereby rendering the unique optimal feedback gain. This solution is used to initialize a homotopy-based Newton's method to find the optimal localized gain. To investigate the performance of localized controllers, we examine how the coherence of large-scale stochastically forced formations scales with the number of vehicles. We establish several explicit scaling relationships and show that the best performance is achieved by a localized controller that is both non-symmetric and spatially-varying. + + + + 2011-12-18T03:41:13Z + To appear in IEEE Trans. Automat. Control; 15 pages, 10 figures + + IEEE Trans. Automat. Control (2012), vol. 57, no. 9, pp. 2203-2218 + + Fu Lin + + + Makan Fardad + + + Mihailo R. Jovanović + + 10.1109/TAC.2011.2181790 + + + + http://arxiv.org/abs/2107.08360v4 + Duality-based Convex Optimization for Real-time Obstacle Avoidance between Polytopes with Control Barrier Functions + 2022-04-18T23:49:48Z + + + Developing controllers for obstacle avoidance between polytopes is a challenging and necessary problem for navigation in tight spaces. Traditional approaches can only formulate the obstacle avoidance problem as an offline optimization problem. To address these challenges, we propose a duality-based safety-critical optimal control using nonsmooth control barrier functions for obstacle avoidance between polytopes, which can be solved in real-time with a QP-based optimization problem. A dual optimization problem is introduced to represent the minimum distance between polytopes and the Lagrangian function for the dual form is applied to construct a control barrier function. We validate the obstacle avoidance with the proposed dual formulation for L-shaped (sofa-shaped) controlled robot in a corridor environment. We demonstrate real-time tight obstacle avoidance with non-conservative maneuvers on a moving sofa (piano) problem with nonlinear dynamics. + + + + 2021-07-18T04:27:31Z + Accepted to 2022 American Control Conference (ACC) with full version of proofs in the appendix + + American Control Conf. (2022) 2239-2246 + + Akshay Thirugnanam + + + Jun Zeng + + + Koushil Sreenath + + 10.23919/ACC53348.2022.9867246 + + + diff --git a/packages/amico-run/test/sota_codebase.test.ts b/packages/amico-run/test/sota_codebase.test.ts new file mode 100644 index 00000000..7cec264c --- /dev/null +++ b/packages/amico-run/test/sota_codebase.test.ts @@ -0,0 +1,406 @@ +// sota_codebase.test.ts — the CODEBASE lens (#820, spec spec-20260905-103000 +// living-sota D1/D3-data / S1/S2): the watched-repo registry is validator- +// checked TOML data (adding a repo is a data edit, never code); the fetch +// surface is the GitHub API against CANONICAL repos (never a local fork +// checkout); events surface as cited, provenance-stamped, keyword-filtered +// briefs; last-success stamps update on success and consecutive failures +// accrue toward the retire-or-confirm flag. GitHub-shaped fixtures only — +// the transport here is injected and REFUSES anything that is not an +// https://api.github.com/repos/ URL. +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + REGISTRY_FILENAME, + registryPath, + githubApiUrl, + githubSourceKey, + loadRegistry, + parseGithubReleases, + parseGithubIssues, + runCodebaseLens, + curlGithubFetch, + renderCodebaseBrief, +} from "../src/sota_codebase.js"; +import { parseWatchedRepoRegistry, flaggedForRetireOrConfirm } from "@amicode/schema"; +import type { SotaFetch } from "../src/sota_fetch.js"; + +function root(): string { + return mkdtempSync(join(tmpdir(), "sota-code-")); +} + +function vclock(start = 1_000_000) { + let t = start; + return { + nowMs: () => t, + sleep: async (ms: number) => { + t += ms; + }, + jump: (ms: number) => { + t += ms; + }, + }; +} + +// A registry fixture (the S2 shape) — deliberately NOT the seed so the +// bootstrap path and the read path are tested apart. +const REGISTRY_TOML = ` +schema_version = "1" +failure_threshold = 2 + +[[repos]] +repo = "example/piccolo-adjacent" +why_watched = "API shifts adjacent to our authoring map" +domains = ["julia-optimal-control"] +fetch_surface = ["releases", "issues"] +match_keywords = ["trajectory", "integrator"] + +[[repos]] +repo = "example/agent-harness" +why_watched = "the harness field's canonical repo" +domains = ["agent-harness"] +fetch_surface = ["issues"] +match_keywords = ["session"] +`; + +// GitHub-shaped fixtures (the real API's response shape). +const RELEASES_JSON = JSON.stringify([ + { + tag_name: "v0.9.0", + name: "v0.9.0 — trajectory rework", + published_at: "2026-08-20T00:00:00Z", + html_url: "https://github.com/example/piccolo-adjacent/releases/tag/v0.9.0", + body: "breaking: trajectory API now requires integrator selection at construction", + }, + { + tag_name: "v0.8.1", + name: "patch release", + published_at: "2026-07-01T00:00:00Z", + html_url: "https://github.com/example/piccolo-adjacent/releases/tag/v0.8.1", + body: "docs only", + }, +]); + +const ISSUES_JSON = JSON.stringify([ + { + number: 42, + title: "session tree persistence", + html_url: "https://github.com/example/agent-harness/issues/42", + updated_at: "2026-08-25T00:00:00Z", + body: "sessions should survive restarts", + }, + { + number: 43, + title: "unrelated typography", + html_url: "https://github.com/example/agent-harness/issues/43", + updated_at: "2026-08-26T00:00:00Z", + body: "nothing relevant", + }, +]); + +/** The canonical-only transport: serves GitHub-shaped fixtures for known + * canonical repo URLs, REFUSES anything else (file://, local paths, http://, + * or a URL for an unregistered repo). This is the never-a-local-checkout + * property made mechanical. */ +function fixtureGithub(): { fetchFn: SotaFetch; calls: string[] } { + const calls: string[] = []; + const fetch = (async (url: string) => { + calls.push(url); + if (!/^https:\/\/api\.github\.com\/repos\//.test(url)) { + return { ok: false as const, status: 0, error: `refused non-canonical fetch target: ${url}` }; + } + if (url.includes("/releases")) return { ok: true as const, status: 200, body: RELEASES_JSON, count: 2 }; + if (url.includes("/issues")) return { ok: true as const, status: 200, body: ISSUES_JSON, count: 2 }; + return { ok: false as const, status: 404, error: `no fixture for ${url}` }; + }) as SotaFetch; + return { fetchFn: fetch, calls }; +} + +describe("githubApiUrl — the fetch surface is the GitHub API against CANONICAL repos", () => { + it("builds canonical api.github.com URLs for each declared surface", () => { + expect(githubApiUrl("example/piccolo-adjacent", "releases")).toBe( + "https://api.github.com/repos/example/piccolo-adjacent/releases?per_page=10", + ); + expect(githubApiUrl("example/agent-harness", "issues")).toBe( + "https://api.github.com/repos/example/agent-harness/issues?state=open&per_page=20", + ); + expect(githubApiUrl("example/piccolo-adjacent", "changelog")).toBe( + "https://api.github.com/repos/example/piccolo-adjacent/releases?per_page=10", + ); + }); + + it("the builder CANNOT emit a local checkout path or a non-https scheme", () => { + for (const surface of ["releases", "issues", "changelog"] as const) { + const url = githubApiUrl("a/b", surface); + expect(url.startsWith("https://api.github.com/repos/")).toBe(true); + } + }); +}); + +describe("the GitHub-shaped parsers (tolerant, zero-dep)", () => { + it("releases: tag, name, published_at, html_url, body", () => { + const rs = parseGithubReleases(RELEASES_JSON); + expect(rs).toHaveLength(2); + expect(rs[0]).toMatchObject({ tag: "v0.9.0", title: "v0.9.0 — trajectory rework" }); + expect(rs[0].url).toBe("https://github.com/example/piccolo-adjacent/releases/tag/v0.9.0"); + }); + + it("issues: number, title, url, updated_at", () => { + const is = parseGithubIssues(ISSUES_JSON, "example/agent-harness"); + expect(is).toHaveLength(2); + expect(is[0]).toMatchObject({ id: "example/agent-harness#42", title: "session tree persistence" }); + }); + + it("malformed JSON degrades to a named failure — never a silent empty brief", () => { + expect(parseGithubReleases("{not json")).toEqual([]); + expect(parseGithubIssues("")).toEqual([]); + }); +}); + +describe("loadRegistry — validator-checked TOML, bootstrapped from the seed (S2)", () => { + it("an EMPTY sota root bootstraps from the packaged seed — the seed itself validates", () => { + const r = root(); + const reg = loadRegistry(r); + expect(reg.repos.length).toBeGreaterThanOrEqual(7); // the spec's D3 seed set + expect(existsSync(registryPath(r))).toBe(true); // the living copy now exists + // the seed is DATA that passes the validator (a drifted seed is a loud authoring failure) + const persisted = readFileSync(registryPath(r), "utf8"); + expect(() => parseWatchedRepoRegistry(persisted)).not.toThrow(); + }); + + it("a MALFORMED living registry fails LOUDLY, field-precise (never a silent skip)", () => { + const r = root(); + mkdirSync(r, { recursive: true }); + writeFileSync(registryPath(r), "this is [not toml"); + expect(() => loadRegistry(r)).toThrow(/watched-repos\.toml/); + }); + + it("a schema-violating living registry (a non-canonical repo slug) fails loudly too", () => { + const r = root(); + writeFileSync(registryPath(r), REGISTRY_TOML.replace('repo = "example/agent-harness"', 'repo = "../etc/passwd"')); + expect(() => loadRegistry(r)).toThrow(/schema violation/); + }); + + it("the registry filename is the pinned constant", () => { + expect(REGISTRY_FILENAME).toBe("watched-repos.toml"); + }); +}); + +describe("runCodebaseLens — the fetch round, GitHub-shaped fixtures only (S1)", () => { + it("fetches each repo's declared surfaces via the GitHub API, filters events by match_keywords, cites what matched", async () => { + const r = root(); + writeFileSync(registryPath(r), REGISTRY_TOML); + const { fetchFn: fetch, calls } = fixtureGithub(); + const res = await runCodebaseLens({ root: r, fetchFn: fetch, nowMs: vclock().nowMs, sleep: vclock().sleep }); + // 2 surfaces for repo 1 (releases + issues) + 1 for repo 2 (issues) + expect(calls).toHaveLength(3); + expect(calls.every((u) => u.startsWith("https://api.github.com/repos/"))).toBe(true); // never a local checkout + expect(calls).toContain("https://api.github.com/repos/example/piccolo-adjacent/releases?per_page=10"); + expect(calls).toContain("https://api.github.com/repos/example/agent-harness/issues?state=open&per_page=20"); + // keyword filter: the trajectory release matches; the docs-only patch does not + const repo1 = res.repos.find((x) => x.repo === "example/piccolo-adjacent")!; + const releaseMatches = repo1.surfaces.find((s) => s.surface === "releases")!.events; + expect(releaseMatches.map((e) => e.title)).toContain("v0.9.0 — trajectory rework"); + expect(releaseMatches).toHaveLength(1); // the patch release (no keyword match) is filtered + // the issue lens's keyword filter: "session" matches #42, not #43 + const repo2 = res.repos.find((x) => x.repo === "example/agent-harness")!; + const issueMatches = repo2.surfaces.find((s) => s.surface === "issues")!.events; + expect(issueMatches.map((e) => e.title)).toContain("session tree persistence"); + expect(issueMatches).toHaveLength(1); + // the brief CITES matched events by their canonical URLs and stamps provenance + expect(res.brief).toContain("https://github.com/example/piccolo-adjacent/releases/tag/v0.9.0"); + expect(res.brief).toContain("https://github.com/example/agent-harness/issues/42"); + expect(res.brief).toMatch(/provenance:/); + expect(res.brief).toContain("source: GitHub API"); + }); + + it("ADDING A REPO TO THE TOML changes behavior with ZERO code edits — the registry is data", async () => { + const r = root(); + const { fetchFn: fetch, calls } = fixtureGithub(); + // one repo first + writeFileSync(registryPath(r), REGISTRY_TOML.replace(/\n\n\[\[repos\]\]\nrepo = "example\/agent-harness"[\s\S]*$/, "\n")); + const before = await runCodebaseLens({ root: r, fetchFn: fetch, nowMs: vclock().nowMs, sleep: vclock().sleep }); + expect(before.repos).toHaveLength(1); + const callsBefore = calls.length; + // THE DATA EDIT: append a second repo to the SAME TOML — no code change + writeFileSync(registryPath(r), REGISTRY_TOML); + const after = await runCodebaseLens({ root: r, fetchFn: fetch, nowMs: vclock().nowMs, sleep: vclock().sleep }); + expect(after.repos).toHaveLength(2); + expect(calls.length).toBeGreaterThan(callsBefore); // the new repo's surfaces were fetched + expect(after.brief).toContain("example/agent-harness"); + }); + + it("the anomaly floor rides per-source: an empty-200 against a nonzero mean renders 'anomalous'", async () => { + const r = root(); + writeFileSync(registryPath(r), REGISTRY_TOML); + const emptyIss = JSON.stringify([]); + const { fetchFn: fetch, calls } = fixtureGithub(); + const c = vclock(); + // a first round seeds nonzero history for the issues surface of repo 2… + const first = await runCodebaseLens({ root: r, fetchFn: fetch, nowMs: c.nowMs, sleep: c.sleep }); + expect(first.ok).toBe(true); + // …then craft 6 more fetch-days of nonzero history for that surface key… + for (let i = 0; i < 6; i++) { + const { recordFetchOutcome } = await import("../src/sota_history.js"); + recordFetchOutcome(r, githubSourceKey("example/agent-harness", "issues"), { date: `2026-08-0${i + 1}`, count: 3 }); + } + // …and age past the cache TTL so the empty round actually fetches + c.jump(7 * 60 * 60 * 1000); + // …and make the surface return an EMPTY 200 (a silent-quiet failure — the floor's whole point) + const emptyFetch = (async (url: string) => { + calls.push(url); + if (url.includes("example/agent-harness/issues")) { + return { ok: true as const, status: 200, body: emptyIss, count: 0 }; + } + return fetch(url); + }) as SotaFetch; + const res = await runCodebaseLens({ root: r, fetchFn: emptyFetch, nowMs: c.nowMs, sleep: c.sleep }); + expect(res.brief).toContain("scan returned nothing — anomalous"); + expect(res.brief).not.toMatch(/nothing new/i); + }); +}); + +describe("stamps + the retire-or-confirm flag (S2 — quiet failures are the ones that matter)", () => { + it("a SUCCESSFUL round stamps last_success and resets consecutive_failures — persisted, re-readable", async () => { + const r = root(); + // the same shape, with a PRIOR failure accrued (the reset path is the point) + writeFileSync(registryPath(r), REGISTRY_TOML.replace('match_keywords = ["trajectory", "integrator"]', 'match_keywords = ["trajectory", "integrator"]\nlast_success = ""\nconsecutive_failures = 1')); + const { fetchFn: fetch } = fixtureGithub(); + const res = await runCodebaseLens({ root: r, fetchFn: fetch, nowMs: vclock().nowMs, sleep: vclock().sleep }); + expect(res.ok).toBe(true); + // the persisted registry carries the stamps + const persisted = parseWatchedRepoRegistry(readFileSync(registryPath(r), "utf8")); + for (const repo of persisted.repos) { + expect(repo.last_success).not.toBe(""); // STAMPED + expect(repo.consecutive_failures).toBe(0); // reset + } + expect(res.stamp.iso).toBe(persisted.repos[0].last_success); // the reported stamp IS the persisted one + }); + + it("a FAILED round accrues consecutive_failures; N failures FLAG the entry for retire-or-confirm in the brief", async () => { + const r = root(); + writeFileSync(registryPath(r), REGISTRY_TOML); // failure_threshold = 2 (data, per-registry) + const failing = (async (url: string) => + ({ ok: false as const, status: 503, error: "github down" })) as SotaFetch; + // round 1: failures accrue, not yet flagged (threshold 2) — no flag SECTION yet + const round1 = await runCodebaseLens({ root: r, fetchFn: failing, nowMs: vclock().nowMs, sleep: vclock().sleep }); + expect(round1.brief).not.toMatch(/^## Retire-or-confirm/m); + const after1 = parseWatchedRepoRegistry(readFileSync(registryPath(r), "utf8")); + expect(after1.repos.every((x) => x.consecutive_failures === 1)).toBe(true); + expect(after1.repos.every((x) => x.last_success === "")).toBe(true); // never a silent success stamp + // round 2: AT the threshold — flagged, and the brief names the human decision + const round2 = await runCodebaseLens({ root: r, fetchFn: failing, nowMs: vclock().nowMs, sleep: vclock().sleep }); + expect(round2.brief).toMatch(/^## Retire-or-confirm \(the human decision/m); + expect(round2.brief).toContain("example/piccolo-adjacent"); + const after2 = parseWatchedRepoRegistry(readFileSync(registryPath(r), "utf8")); + for (const repo of after2.repos) { + expect(repo.consecutive_failures).toBe(2); + expect(flaggedForRetireOrConfirm(repo, after2.failure_threshold)).toBe(true); // DERIVED at read + } + }); + + it("a MIXED round: success on one surface still stamps that repo (per-repo verdicts, never all-or-nothing)", async () => { + const r = root(); + writeFileSync(registryPath(r), REGISTRY_TOML); + const c = vclock(); + const mixed = (async (url: string) => + url.includes("example/agent-harness") + ? { ok: false as const, status: 503, error: "github down" } + : { ok: true as const, status: 200, body: RELEASES_JSON, count: 2 }) as SotaFetch; + await runCodebaseLens({ root: r, fetchFn: mixed, nowMs: c.nowMs, sleep: c.sleep }); + const persisted = parseWatchedRepoRegistry(readFileSync(registryPath(r), "utf8")); + const ok1 = persisted.repos.find((x) => x.repo === "example/piccolo-adjacent")!; + const bad = persisted.repos.find((x) => x.repo === "example/agent-harness")!; + expect(ok1.last_success).not.toBe(""); + expect(ok1.consecutive_failures).toBe(0); + expect(bad.last_success).toBe(""); + expect(bad.consecutive_failures).toBe(1); + }); +}); + +describe("renderCodebaseBrief — the PI-register shape", () => { + it("leads with the outcome; every cited event carries its canonical URL; provenance stamps the fetch round", async () => { + const r = root(); + writeFileSync(registryPath(r), REGISTRY_TOML); + const { fetchFn: fetch } = fixtureGithub(); + const res = await runCodebaseLens({ root: r, fetchFn: fetch, nowMs: vclock().nowMs, sleep: vclock().sleep }); + const lines = res.brief.split("\n"); + expect(lines[0]).toMatch(/^# SOTA codebase brief/); + expect(lines[0]).toMatch(/\d+ repos scanned/); // the outcome leads + expect(res.brief).toMatch(/why-watched/); // details-informed: the human reason renders + expect(res.brief).toMatch(/scanned at:/); + expect(res.brief).toMatch(/source: GitHub API/); + }); +}); + +// ── B1: HTTP errors must not launder as empty successes (the stamps end) ──── +// curl WITHOUT --fail exits 0 on a 403/429 (unauthenticated GitHub is 60 +// req/h — the most common real failure); the old transport hardcoded +// status 200 / ok true / count 0, so a rate-limited round RESET +// consecutive_failures, STAMPED last_success, and recorded a fake-zero +// success in fetch history — the retire-or-confirm flag and the anomaly +// floor both disarmed exactly when the fleet chronically fails. + +/** A fake `curl` that behaves like real curl WITH --fail on an HTTP error: + * the write-out on stdout, the error on stderr, exit 22. */ +function fakeCurl404Dir(): string { + const dir = mkdtempSync(join(tmpdir(), "sota-fakecurl-")); + writeFileSync( + join(dir, "curl"), + "#!/bin/sh\nprintf '\\n404'\nprintf 'curl: (22) The requested URL returned error: 404\\n' >&2\nexit 22\n", + ); + chmodSync(join(dir, "curl"), 0o755); + return dir; +} + +describe("B1 — a 404/429 round through the PRODUCTION transport is a named failure (stamps end)", () => { + it("accrues consecutive_failures, does NOT stamp last_success, records NO fetch history, and renders the real status", async () => { + const r = root(); + writeFileSync(registryPath(r), REGISTRY_TOML); + const dir = fakeCurl404Dir(); + const prevPath = process.env.PATH; + process.env.PATH = `${dir}:${prevPath}`; + try { + const res = await runCodebaseLens({ root: r, nowMs: vclock().nowMs, sleep: vclock().sleep }); // default fetch = curlGithubFetch + expect(res.ok).toBe(false); // the round FAILED — under the old laundering it "succeeded" + for (const repo of res.repos) { + expect(repo.ok).toBe(false); + for (const s of repo.surfaces) { + expect(s.ok).toBe(false); + expect(s.error).toMatch(/404/); // the NAMED failure + expect(s.error).not.toMatch(/anomalous|no matched/i); // never rendered as a scan verdict + } + } + // the stamps: failures accrue; last_success stays empty (no laundering reset) + const persisted = parseWatchedRepoRegistry(readFileSync(registryPath(r), "utf8")); + for (const repo of persisted.repos) { + expect(repo.last_success).toBe(""); + expect(repo.consecutive_failures).toBe(1); + } + // the history: NO fake zero recorded for any surface + const { readFetchHistory } = await import("../src/sota_history.js"); + for (const repo of persisted.repos) { + for (const surface of repo.fetch_surface) { + expect(readFetchHistory(r, githubSourceKey(repo.repo, surface))).toEqual([]); + } + } + } finally { + process.env.PATH = prevPath; + } + }); +}); + +describe("githubSourceKey — collision-free, filesystem-safe (nit)", () => { + it("the flattening pair that collides (a/b__c vs a__b/c) produces DISTINCT keys", () => { + expect(githubSourceKey("a/b__c", "issues")).not.toBe(githubSourceKey("a__b/c", "issues")); + expect(githubSourceKey("a--b/c", "issues")).not.toBe(githubSourceKey("a/b--c", "issues")); // the `--` variant collides too + }); + + it("the key is always a safe single file name (no `/`, no invented directories)", () => { + for (const repo of ["example/piccolo-adjacent", "a/b__c", "a__b/c", "x/y/z"]) { + const key = githubSourceKey(repo, "releases"); + expect(key).not.toMatch(/\//); + } + }); +}); diff --git a/packages/amico-run/test/sota_fetch.test.ts b/packages/amico-run/test/sota_fetch.test.ts new file mode 100644 index 00000000..ece7f73a --- /dev/null +++ b/packages/amico-run/test/sota_fetch.test.ts @@ -0,0 +1,212 @@ +// sota_fetch.test.ts — the ONE-FETCHER property (#820, spec D4 / S6): all +// live arXiv traffic rides the FETCH cache plus the fleet-wide serialized +// queue. The flow under the lock RE-CHECKS the cache before fetching — a +// second fetcher that waited on the lock reads the first one's cache and +// NEVER refetches; a fetcher that cannot get the lock falls through to the +// NAMED queue-timeout outcome. The recipe gotcha is mechanical: http:// is +// refused (the arXiv export API's http endpoint silently hangs — always +// https). +import { describe, it, expect } from "vitest"; +import { mkdtempSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fetchThroughQueue, FETCH_CACHE_TTL_MS, cachePath, sourceKeyOf, curlArgs, type SotaFetch } from "../src/sota_fetch.js"; +import { QUEUE_LOCK_NAME, QUEUE_POLL_INTERVAL_MS, acquireQueueLock } from "../src/sota_queue.js"; + +function root(): string { + return mkdtempSync(join(tmpdir(), "sota-fetch-")); +} + +function vclock(start = 1_000_000) { + let t = start; + return { + nowMs: () => t, + sleep: async (ms: number) => { + t += ms; + }, + jump: (ms: number) => { + t += ms; + }, + }; +} + +const okFetch = (body: string, count: number): SotaFetch & { calls: string[] } => { + const calls: string[] = []; + const fn = (async (url: string) => { + calls.push(url); + return { ok: true as const, status: 200, body, count }; + }) as SotaFetch; + return Object.assign(fn, { calls }) as SotaFetch & { calls: string[] }; +}; + +describe("fetchThroughQueue — the one-fetcher flow (S6)", () => { + it("cache miss → acquires the lock (the fetch runs UNDER the lock) → fetches once → writes the cache", async () => { + const r = root(); + const c = vclock(); + const fetch = okFetch("", 3); + let lockHeldDuringFetch = false; + const res = await fetchThroughQueue("https://export.arxiv.org/api/query?search_query=all:test", { + root: r, + fetchFn: (async (url: string) => { + lockHeldDuringFetch = existsSync(join(r, QUEUE_LOCK_NAME)); + return fetch(url); + }) as SotaFetch, + nowMs: c.nowMs, + sleep: c.sleep, + }); + expect(res.via).toBe("fetched"); + expect(fetch.calls).toHaveLength(1); + expect(lockHeldDuringFetch).toBe(true); // the queue is real, not decorative + expect(existsSync(cachePath(r, "https://export.arxiv.org/api/query?search_query=all:test"))).toBe(true); + }); + + it("a SECOND fetcher reads the cache — the transport is called exactly once (never refetch what a cache holds)", async () => { + const r = root(); + const c = vclock(); + const url = "https://export.arxiv.org/api/query?search_query=all:test"; + const a = await fetchThroughQueue(url, { root: r, fetchFn: okFetch("", 3), nowMs: c.nowMs, sleep: c.sleep }); + expect(a.via).toBe("fetched"); + const b = await fetchThroughQueue(url, { root: r, fetchFn: okFetch("", 3), nowMs: c.nowMs, sleep: c.sleep }); + expect(b.via).toBe("cache"); // the S6 property, by name + }); + + it("concurrent-simulation: a fetcher that WAITED on the lock RE-CHECKS the cache under the lock and reads it — no second fetch", async () => { + const r = root(); + const c = vclock(); + const url = "https://export.arxiv.org/api/query?search_query=all:race"; + const slow = (async (u: string) => { + await c.sleep(QUEUE_POLL_INTERVAL_MS); // A's fetch is slow — B waits on the lock + return { ok: true as const, status: 200, body: "", count: 2 }; + }) as SotaFetch; + const aPromise = fetchThroughQueue(url, { root: r, fetchFn: slow, nowMs: c.nowMs, sleep: c.sleep }); + const calls: string[] = []; + const bPromise = fetchThroughQueue(url, { + root: r, + fetchFn: ((async (u: string) => { + calls.push(u); + return { ok: true as const, status: 200, body: "", count: 2 }; + }) as SotaFetch), + nowMs: c.nowMs, + sleep: c.sleep, + }); + const [a, b] = await Promise.all([aPromise, bPromise]); + expect(a.via).toBe("fetched"); + expect(b.via).toBe("cache"); // B waited, then read A's cache — ONE transport call for the fleet + expect(calls).toHaveLength(0); // B's transport was never invoked + }); + + it("a lock held past the bounded wait → the NAMED queue-timeout outcome, transport untouched, the cache-read fallback disclosed", async () => { + const r = root(); + const c = vclock(); + // a foreign lease far in the future — nothing will release it + await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep, leaseTtlMs: 60_000_000 }); + const calls: string[] = []; + const res = await fetchThroughQueue("https://export.arxiv.org/api/query?search_query=all:busy", { + root: r, + fetchFn: ((async (u: string) => { + calls.push(u); + return { ok: true as const, status: 200, body: "", count: 0 }; + }) as SotaFetch), + nowMs: c.nowMs, + sleep: c.sleep, + waitTimeoutMs: 500, + }); + expect(res.via).toBe("queue-timeout"); + if (res.via === "queue-timeout") expect(res.detail).toMatch(/cache|waiver/i); + expect(calls).toHaveLength(0); + }); + + it("http:// is REFUSED — the arXiv export API's http endpoint silently hangs; always https (the recipe gotcha, mechanical)", async () => { + const r = root(); + const c = vclock(); + const calls: string[] = []; + const res = await fetchThroughQueue("http://export.arxiv.org/api/query?id_list=1", { + root: r, + fetchFn: ((async (u: string) => { + calls.push(u); + return { ok: true as const, status: 200, body: "", count: 0 }; + }) as SotaFetch), + nowMs: c.nowMs, + sleep: c.sleep, + }); + expect(res.via).toBe("refused"); + if (res.via === "refused") { + expect(res.reason).toMatch(/https/i); + expect(res.reason).toMatch(/hang/i); // the gotcha is named, not just "bad scheme" + } + expect(calls).toHaveLength(0); + }); + + it("a STALE cache (older than the TTL) refetches — the daily digest's cadence is never starved by the cache", async () => { + const r = root(); + const c = vclock(); + const url = "https://export.arxiv.org/api/query?search_query=all:stale"; + const first = await fetchThroughQueue(url, { root: r, fetchFn: okFetch("", 1), nowMs: c.nowMs, sleep: c.sleep }); + expect(first.via).toBe("fetched"); + c.jump(FETCH_CACHE_TTL_MS + 1); + const second = await fetchThroughQueue(url, { root: r, fetchFn: okFetch("", 2), nowMs: c.nowMs, sleep: c.sleep }); + expect(second.via).toBe("fetched"); // stale → not a hit; the cache never serves day-old data as fresh + }); + + it("a transport FAILURE is a named outcome (never a silent empty) and does NOT write the cache", async () => { + const r = root(); + const c = vclock(); + const fail = (async () => ({ ok: false as const, status: 0, error: "curl: (28) timed out" })) as SotaFetch; + const res = await fetchThroughQueue("https://export.arxiv.org/api/query?search_query=all:down", { + root: r, + fetchFn: fail, + nowMs: c.nowMs, + sleep: c.sleep, + }); + expect(res.via).toBe("fetch-failed"); + if (res.via === "fetch-failed") expect(res.error).toMatch(/timed out/); + expect(existsSync(cachePath(r, "https://export.arxiv.org/api/query?search_query=all:down"))).toBe(false); + }); + + it("every ok fetch records its source's history (the anomaly floor's input, recorded by construction)", async () => { + const r = root(); + const c = vclock(); + const url = "https://export.arxiv.org/api/query?search_query=all:hist"; + await fetchThroughQueue(url, { root: r, fetchFn: okFetch("", 4), nowMs: c.nowMs, sleep: c.sleep }); + const { readFetchHistory } = await import("../src/sota_history.js"); + expect(readFetchHistory(r, sourceKeyOf(url))).toEqual([{ date: new Date(c.nowMs()).toISOString().slice(0, 10), count: 4 }]); + }); +}); + +describe("the fetch cache (the digest's FETCH cache — a shared referent, never the briefs)", () => { + it("the cache TTL is named and bounded: hours, not days — the daily cadence refetches, the fleet does not", () => { + expect(FETCH_CACHE_TTL_MS).toBeGreaterThanOrEqual(60 * 60_000); // ≥ 1h: the fleet dedupes within a window + expect(FETCH_CACHE_TTL_MS).toBeLessThan(24 * 60 * 60_000); // < 24h: a daily digest always refetches + }); + + it("a cache read reports the payload's REAL count (stored at fill time), and the floor verdict rides the cache too (A1)", async () => { + const r = root(); + const c = vclock(); + const url = "https://export.arxiv.org/api/query?search_query=all:cnt"; + await fetchThroughQueue(url, { root: r, fetchFn: okFetch("", 7), nowMs: c.nowMs, sleep: c.sleep }); + const res = await fetchThroughQueue(url, { root: r, fetchFn: okFetch("", 0), nowMs: c.nowMs, sleep: c.sleep }); + expect(res.via).toBe("cache"); + if (res.via === "cache") expect(res.count).toBe(7); // NOT -1: the cache carries the fill's count + }); +}); + +describe("curlArgs — the shared production transport flags (B1: HTTP errors must not launder as empty successes)", () => { + it("pins --fail (without it curl exits 0 on HTTP 403/404/429 and the seam launders the error as a 200)", () => { + const args = curlArgs("https://export.arxiv.org/api/query?search_query=all:x"); + expect(args).toContain("--fail"); + // the transport stays bounded and identifies itself + expect(args).toContain("--max-time"); + expect(args[args.indexOf("--max-time") + 1]).toBe("30"); + expect(args.some((a) => a.includes("user-agent"))).toBe(true); + // the REAL status rides the write-out — never a hardcoded 200 + expect(args).toContain("-w"); + expect(args[args.indexOf("-w") + 1]).toBe("\n%{http_code}"); + expect(args).toContain("https://export.arxiv.org/api/query?search_query=all:x"); + }); + + it("extra headers append without disturbing the pinned flags (the GitHub accept header)", () => { + const args = curlArgs("https://api.github.com/repos/a/b", ["accept: application/vnd.github+json"]); + expect(args).toContain("--fail"); + expect(args).toContain("accept: application/vnd.github+json"); + }); +}); diff --git a/packages/amico-run/test/sota_history.test.ts b/packages/amico-run/test/sota_history.test.ts new file mode 100644 index 00000000..ac0ef995 --- /dev/null +++ b/packages/amico-run/test/sota_history.test.ts @@ -0,0 +1,124 @@ +// sota_history.test.ts — the fetch anomaly floor (#820, spec +// spec-20260905-103000 / sota_fetch_anomaly_floor / S6): a successful fetch +// returning ZERO entries where the trailing 7-fetch-day mean is NONZERO +// records a NAMED anomaly and renders "scan returned nothing — anomalous" — +// NEVER "nothing new". Per-source; armed only after 7 fetch-days of history +// (O1's granularity edge is pinned here: what counts as a fetch-day and what +// a day's value is). +import { describe, it, expect } from "vitest"; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + evaluateAnomalyFloor, + recordFetchOutcome, + readFetchHistory, + historyPath, + type FetchHistoryEntry, +} from "../src/sota_history.js"; + +function root(): string { + return mkdtempSync(join(tmpdir(), "sota-hist-")); +} + +/** n prior fetch-DAYS with the given per-day counts (one fetch per day). */ +function days(counts: number[], startDay = 1): FetchHistoryEntry[] { + return counts.map((count, i) => ({ + date: `2026-08-${String(startDay + i).padStart(2, "0")}`, + count, + })); +} + +describe("evaluateAnomalyFloor (O1 — armed only after 7 fetch-days)", () => { + it("NOT armed with fewer than 7 prior fetch-days — an empty scan renders ordinary, never 'anomalous'", () => { + const v = evaluateAnomalyFloor(days([5, 4, 6, 5, 4, 5]), { date: "2026-08-07", count: 0 }); + expect(v.armed).toBe(false); + expect(v.anomaly).toBe(false); + }); + + it("armed at exactly 7 prior fetch-days: an empty-200 against a NONZERO mean is the NAMED anomaly", () => { + const v = evaluateAnomalyFloor(days([5, 4, 6, 5, 4, 5, 6]), { date: "2026-08-08", count: 0 }); + expect(v.armed).toBe(true); + expect(v.anomaly).toBe(true); + expect(v.name).toBe("empty-200-vs-nonzero-mean"); + expect(v.render).toContain("scan returned nothing — anomalous"); + }); + + it("the anomaly render NEVER says 'nothing new' (the silent-empty failure mode, banned verbatim)", () => { + const v = evaluateAnomalyFloor(days([5, 4, 6, 5, 4, 5, 6]), { date: "2026-08-08", count: 0 }); + expect(v.render).not.toMatch(/nothing new/i); + }); + + it("an empty scan with a ZERO trailing mean is ordinary — a source that always returns nothing is not anomalous", () => { + const v = evaluateAnomalyFloor(days([0, 0, 0, 0, 0, 0, 0]), { date: "2026-08-08", count: 0 }); + expect(v.armed).toBe(true); + expect(v.anomaly).toBe(false); + }); + + it("a NONZERO scan is never an anomaly (the floor only fires on empty-200)", () => { + const v = evaluateAnomalyFloor(days([5, 4, 6, 5, 4, 5, 6]), { date: "2026-08-08", count: 3 }); + expect(v.anomaly).toBe(false); + }); + + it("O1 granularity edge: a day's value is the LAST count recorded that day (a same-day refetch supersedes)", () => { + // day 7 recorded 5 then 1 — the day's value is 1, so the mean drops by 4/7 + const history = days([5, 4, 6, 5, 4, 5]); + history.push({ date: "2026-08-07", count: 5 }); + history.push({ date: "2026-08-07", count: 1 }); + const v = evaluateAnomalyFloor(history, { date: "2026-08-08", count: 0 }); + expect(v.armed).toBe(true); + expect(v.anomaly).toBe(true); + expect(v.mean).toBeCloseTo((5 + 4 + 6 + 5 + 4 + 5 + 1) / 7, 5); + }); + + it("the trailing window is the LAST 7 distinct fetch-days — older days drop out of the mean", () => { + // 8 prior days: the first (count 999) must NOT be in the window + const history = days([999, 5, 4, 6, 5, 4, 5, 6]); + const v = evaluateAnomalyFloor(history, { date: "2026-08-09", count: 0 }); + expect(v.mean).toBeCloseTo((5 + 4 + 6 + 5 + 4 + 5 + 6) / 7, 5); + }); +}); + +describe("the history store (O1 — storage pinned)", () => { + it("recordFetchOutcome appends to the per-source history file and readFetchHistory round-trips", () => { + const r = root(); + const key = "test-source"; + recordFetchOutcome(r, key, { date: "2026-08-01", count: 5 }); + recordFetchOutcome(r, key, { date: "2026-08-02", count: 0 }); + const hist = readFetchHistory(r, key); + expect(hist).toEqual([ + { date: "2026-08-01", count: 5 }, + { date: "2026-08-02", count: 0 }, + ]); + }); + + it("the history is PER-SOURCE: a second source's file is disjoint", () => { + const r = root(); + recordFetchOutcome(r, "source-a", { date: "2026-08-01", count: 5 }); + recordFetchOutcome(r, "source-b", { date: "2026-08-01", count: 1 }); + expect(readFetchHistory(r, "source-a")).toHaveLength(1); + expect(readFetchHistory(r, "source-b")).toHaveLength(1); + expect(readFetchHistory(r, "source-a")[0].count).toBe(5); + }); + + it("a corrupt history file reads as EMPTY (degrades, never crashes the lens)", () => { + const r = root(); + mkdirSync(join(r, "fetch-history"), { recursive: true }); + writeFileSync(historyPath(r, "corrupt"), "\x00 not json"); + expect(readFetchHistory(r, "corrupt")).toEqual([]); + }); + + it("a missing history file reads as EMPTY (a fresh source is unarmed by construction)", () => { + const r = root(); + expect(readFetchHistory(r, "fresh")).toEqual([]); + }); + + it("the store is APPEND-ONLY (entries never mutated) and capped (the lens only needs the trailing window)", () => { + const r = root(); + for (let i = 0; i < 30; i++) recordFetchOutcome(r, "cap", { date: `2026-07-${String(i + 1).padStart(2, "0")}`, count: i }); + const hist = readFetchHistory(r, "cap"); + expect(hist).toHaveLength(30); + const raw = JSON.parse(readFileSync(historyPath(r, "cap"), "utf8")) as { entries?: unknown[] }; + expect(raw.entries).toHaveLength(30); + }); +}); diff --git a/packages/amico-run/test/sota_papers.test.ts b/packages/amico-run/test/sota_papers.test.ts new file mode 100644 index 00000000..b487f93c --- /dev/null +++ b/packages/amico-run/test/sota_papers.test.ts @@ -0,0 +1,424 @@ +// sota_papers.test.ts — the PAPERS lens (#820, spec spec-20260905-103000 +// living-sota D1 / S1): on-demand arXiv queries through the fleet-wide +// serialized queue, cited + provenance-stamped PI-register briefs, the recipe +// gotcha mechanical (https only — the export API's http endpoint silently +// hangs). The REAL-API hermetic fixture (one call, cached) is +// test/fixtures/sota/arxiv-live-atom.xml — the recorded payload of a single +// real export.arxiv.org call made through the production transport at +// authoring time; the live-gated describe below re-verifies the full +// queue+cache path on demand (AMICO_SOTA_LIVE=1). +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, existsSync, mkdirSync, readFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ARXIV_API_ENDPOINT, + arxivApiUrl, + parseArxivAtom, + papersBrief, + runPapersLens, + curlSotaFetch, + absUrlOf, + type ArxivEntry, +} from "../src/sota_papers.js"; +import { cachePath, sourceKeyOf, type SotaFetch } from "../src/sota_fetch.js"; +import { recordFetchOutcome } from "../src/sota_history.js"; + +function root(): string { + return mkdtempSync(join(tmpdir(), "sota-papers-")); +} + +function vclock(start = 1_000_000) { + let t = start; + return { + nowMs: () => t, + sleep: async (ms: number) => { + t += ms; + }, + }; +} + +// A real-shaped Atom fixture (the export API's shape: entry/id → abs/, +// title, summary, published, authors). Entities, CDATA, multi-entry. +const ATOM_FIXTURE = ` + + + + http://arxiv.org/abs/2606.05060v2 + 2026-06-10T00:00:00Z + 2026-06-05T00:00:00Z + A Hessian method for & optimal control + We study pulse synthesis with <drags> CDATA things]]> for superconducting qubits. + A. Researcher + B. Author + + + + http://arxiv.org/abs/2607.11111v1 + 2026-07-01T00:00:00Z + Another survey of neutral atoms + Blockade gates and arrays. + C. Writer + +`; + +describe("arxivApiUrl — the query builder targets the REAL export API over HTTPS", () => { + it("builds the canonical export-API query URL (https, search_query, max_results)", () => { + expect(ARXIV_API_ENDPOINT).toBe("https://export.arxiv.org/api/query"); + const url = arxivApiUrl(["optimal control", "qubit"], 5); + expect(url.startsWith(`${ARXIV_API_ENDPOINT}?search_query=`)).toBe(true); + expect(url).toContain("max_results=5"); + expect(url).toContain(encodeURIComponent("all:optimal control")); + expect(url).toContain(encodeURIComponent("AND")); + expect(url.startsWith("https://")).toBe(true); // the recipe gotcha is structural: the builder cannot emit http + }); + + it("empty terms is a usage error (never a wildcard firehose)", () => { + expect(() => arxivApiUrl([], 5)).toThrow(/at least one/); + }); +}); + +describe("parseArxivAtom — the zero-dep Atom subset (the parseArxivRss idiom)", () => { + it("extracts id/title/abstract/published/authors from the real API's shape", () => { + const entries = parseArxivAtom(ATOM_FIXTURE); + expect(entries).toHaveLength(2); + const first = entries[0]; + expect(first.arxiv).toBe("2606.05060"); + expect(first.title).toBe("A Hessian method for & optimal control"); + expect(first.abstract).toMatch(/pulse synthesis with and CDATA things/); // entities unescaped, then inner pseudo-tags stripped (the digest idiom) + expect(first.published.startsWith("2026-06-05")).toBe(true); + expect(first.authors).toEqual(["A. Researcher", "B. Author"]); + expect(absUrlOf(first)).toBe("https://arxiv.org/abs/2606.05060"); // the CITATION url — https, the landing page + }); + + it("malformed input degrades to [] — a bad payload never crashes the lens", () => { + expect(parseArxivAtom("")).toEqual([]); + expect(parseArxivAtom("not xml at all")).toEqual([]); + expect(parseArxivAtom("http://elsewhere/x")).toEqual([]); + }); +}); + +describe("papersBrief — the PI-register brief: cited, provenance-stamped", () => { + const entries = parseArxivAtom(ATOM_FIXTURE); + const stamp = { + query: "optimal control", + url: arxivApiUrl(["optimal control"], 5), + fetched_at: "2026-09-05T12:00:00Z", + via: "fetched" as const, + }; + + it("leads with the outcome, cites every entry with its abs URL, stamps provenance", () => { + const brief = papersBrief({ entries, stamp, anomaly: { armed: false, anomaly: false } }); + const lines = brief.split("\n"); + expect(lines[0]).toMatch(/^# SOTA papers brief/); + expect(lines[0]).toMatch(/2 results/); // the outcome leads + for (const e of entries) expect(brief).toContain(`[arXiv:${e.arxiv}](${absUrlOf(e)})`); // CITED + expect(brief).toContain("A. Researcher"); // details-informed: authors render + expect(brief).toMatch(/provenance:/); // STAMPED + expect(brief).toContain("source: arXiv export API over HTTPS"); + expect(brief).toContain("fetched_at: 2026-09-05T12:00:00Z"); + expect(brief).toContain("via: fetched"); + expect(brief).toContain("query: optimal control"); + }); + + it("an armed anomaly renders 'scan returned nothing — anomalous' — NEVER 'nothing new'", () => { + // the anomaly only fires on an EMPTY scan — entries is [] here (the floor's semantics) + const brief = papersBrief({ + entries: [], + stamp, + anomaly: { + armed: true, + anomaly: true, + name: "empty-200-vs-nonzero-mean", + mean: 4.2, + render: "scan returned nothing — anomalous (empty 200 against a trailing 7-fetch-day mean of 4.2)", + }, + }); + expect(brief).toContain("scan returned nothing — anomalous"); + expect(brief).not.toMatch(/nothing new/i); // the silent-empty failure mode, banned verbatim + }); + + it("zero entries, floor unarmed → the HONEST unarmed line (not 'nothing new' either)", () => { + const brief = papersBrief({ entries: [], stamp, anomaly: { armed: false, anomaly: false } }); + expect(brief).toContain("no results"); + expect(brief).not.toMatch(/nothing new/i); + }); +}); + +describe("runPapersLens — the on-demand query through the queue (D1/D4)", () => { + it("fetches through the queue, parses, and renders a cited brief", async () => { + const r = root(); + const c = vclock(); + const res = await runPapersLens({ + root: r, + terms: ["optimal control"], + maxResults: 5, + fetchFn: (async () => ({ ok: true as const, status: 200, body: ATOM_FIXTURE, count: 2 })) as SotaFetch, + nowMs: c.nowMs, + sleep: c.sleep, + }); + expect(res.via).toBe("fetched"); + if (!res.ok) throw new Error("setup: must be ok"); + expect(res.entries).toHaveLength(2); + expect(res.brief).toContain("[arXiv:2606.05060]"); + expect(res.brief).toMatch(/provenance:.*source: arXiv export API over HTTPS/s); + }); + + it("an armed floor over a seeded history renders anomalous in the brief", async () => { + const r = root(); + const c = vclock(); + const url = arxivApiUrl(["optimal control"], 5); + // 7 prior fetch-days of nonzero history → the floor is armed + for (let i = 0; i < 7; i++) { + recordFetchOutcome(r, sourceKeyOf(url), { date: `2026-08-0${i + 1}`, count: 4 }); + } + const res = await runPapersLens({ + root: r, + terms: ["optimal control"], + maxResults: 5, + fetchFn: (async () => ({ ok: true as const, status: 200, body: "", count: 0 })) as SotaFetch, // empty 200 + nowMs: c.nowMs, + sleep: c.sleep, + }); + if (!res.ok) throw new Error("setup: must be ok"); + expect(res.entries).toHaveLength(0); + expect(res.anomaly?.anomaly).toBe(true); + expect(res.brief).toContain("scan returned nothing — anomalous"); // never "nothing new" + expect(res.brief).not.toMatch(/nothing new/i); + }); + + it("the queue-timeout fall-through renders the NAMED outcome (the survey never blocks)", async () => { + const r = root(); + const c = vclock(); + // a foreign lease that will never expire inside the bounded wait + mkdirSync(join(r), { recursive: true }); + writeFileSync(join(r, "queue.lock"), JSON.stringify({ token: "foreign", acquired_at: 0, expires_at: c.nowMs() + 10_000_000 }) + "\n"); + const res = await runPapersLens({ + root: r, + terms: ["optimal control"], + maxResults: 5, + fetchFn: (async () => ({ ok: true as const, status: 200, body: ATOM_FIXTURE, count: 2 })) as SotaFetch, + nowMs: c.nowMs, + sleep: c.sleep, + waitTimeoutMs: 500, + }); + expect(res.via).toBe("queue-timeout"); + expect(res.brief).toContain("queue-timeout"); // the named outcome renders + expect(res.brief).toMatch(/cache|waiver/i); // the disclosed alternative + }); +}); + +describe("the REAL-API hermetic fixture (S1 — one call, cached)", () => { + // The committed fixture is the payload of ONE real + // https://export.arxiv.org/api/query call (made through the production + // transport; the live-gated describe re-verifies). The hermetic body below + // seeds the FETCH cache with that payload and proves the lens serves it + // THROUGH the queue machinery with ZERO transports — the one call happened + // once; the cache is what everyone after reads. + const FIXTURE = join(__dirname, "fixtures", "sota", "arxiv-live-atom.xml"); + // The anchor: the recorded call's own harvest time — the feed's + // stamp of the recorded response (2026-09-05T18:22:20Z). The cache reads + // fresh only inside the TTL window of fetched_at — a FIXED pair, so the + // test is deterministic. + const FETCHED_AT = "2026-09-05T18:22:20Z"; + + function parseAtomFixture(): string { + return readFileSync(FIXTURE, "utf8"); + } + + it("the committed fixture exists and is the real API's Atom shape", () => { + expect(existsSync(FIXTURE)).toBe(true); + const body = parseAtomFixture(); + expect(body).toContain("http://arxiv.org/abs/"); + expect(parseArxivAtom(body).length).toBeGreaterThan(0); + }); + + it("the lens serves the cached REAL payload through the queue — zero transports (a second fetcher reads the cache)", async () => { + const r = root(); + const body = parseAtomFixture(); + const url = arxivApiUrl(["optimal control"], 5); + // seed the FETCH cache with the fixture payload (the one real call's + // harvest, count stored at fill time — A1's cache shape) + mkdirSync(join(r, "fetch-cache"), { recursive: true }); + writeFileSync(cachePath(r, url), JSON.stringify({ url, fetched_at: FETCHED_AT, body, count: parseArxivAtom(body).length }) + "\n"); + let transports = 0; + const res = await runPapersLens({ + root: r, + terms: ["optimal control"], + maxResults: 5, + fetchFn: (async () => { + transports += 1; + return { ok: true as const, status: 200, body: "", count: 0 }; + }) as SotaFetch, + nowMs: () => Date.parse(FETCHED_AT) + 60_000, // 60s after the harvest — inside the TTL window + sleep: async () => {}, + }); + expect(res.via).toBe("cache"); // the S6 property, at the lens + if (!res.ok) throw new Error("setup: must be ok"); + expect(transports).toBe(0); // the real call happened ONCE; this fetcher reads the cache + expect(res.entries.length).toBeGreaterThan(0); + expect(res.brief).toMatch(/via: cache/); // the brief's provenance says cache — honest, never laundering a live fetch + // and a second lens run also reads the cache (fleet dedup) + const res2 = await runPapersLens({ + root: r, + terms: ["optimal control"], + maxResults: 5, + fetchFn: (async () => { + transports += 1; + return { ok: true as const, status: 200, body: "", count: 0 }; + }) as SotaFetch, + nowMs: () => Date.parse(FETCHED_AT) + 61_000, + sleep: async () => {}, + }); + expect(res2.via).toBe("cache"); + if (!res2.ok) throw new Error("setup: must be ok"); + expect(transports).toBe(0); + }); +}); + +describe("the live arXiv call through the queue (opt-in — AMICO_SOTA_LIVE=1)", () => { + // The ONE sanctioned live path: the production curl transport against the + // real export API, through the fleet-wide queue, writing the cache. Skipped + // unless explicitly requested — the committed fixture carries the evidence + // for the hermetic suite; this re-verifies the wire on demand. + it.skipIf(process.env.AMICO_SOTA_LIVE !== "1")("hits the real API over HTTPS through the queue and caches", async () => { + const r = root(); + const res = await runPapersLens({ root: r, terms: ["optimal control"], maxResults: 5 }); + expect(res.via).toBe("fetched"); + if (!res.ok) throw new Error("live: must be ok"); + expect(res.entries.length).toBeGreaterThan(0); + expect(res.brief).toMatch(/provenance:/); + }); +}); + +// ── B1: HTTP errors must not launder as empty successes ───────────────────── +// curl WITHOUT --fail exits 0 on HTTP 403/404/429; the old transport then +// hard-coded status:200 / ok:true / count:0 — a rate-limited or moved source +// read as a SUCCESSFUL EMPTY SCAN: stamps reset, last_success written, a +// zero recorded in fetch history, the anomaly floor disarmed exactly when +// the fleet chronically fails. These tests run the PRODUCTION transport +// against a fake curl on PATH (hermetic, deterministic, no network). + +/** Write a fake `curl` that behaves like real curl WITH --fail: the body + * (if any) then the "-w %{http_code}" write-out on stdout, the error line + * on stderr, exit 22 on HTTP errors / 0 on success. */ +function fakeCurlDir(httpCode: number, body: string): string { + const dir = mkdtempSync(join(tmpdir(), "sota-fakecurl-")); + if (body !== "") writeFileSync(join(dir, "body"), body); + const script = [ + "#!/bin/sh", + ...(body !== "" ? [`cat '${join(dir, "body")}'`] : []), + `printf '\\n${httpCode}'`, + ...(httpCode >= 400 && httpCode < 600 ? [`printf 'curl: (22) The requested URL returned error: ${httpCode}\\n' >&2`] : []), + `exit ${httpCode >= 400 && httpCode < 600 ? 22 : 0}`, + ].join("\n") + "\n"; + writeFileSync(join(dir, "curl"), script); + chmodSync(join(dir, "curl"), 0o755); + return dir; +} + +describe("curlSotaFetch — the production transport carries the REAL status (B1)", () => { + it("an HTTP 404 is a NAMED failure with the REAL status — never a successful empty scan", async () => { + const dir = fakeCurlDir(404, ""); + const prevPath = process.env.PATH; + process.env.PATH = `${dir}:${prevPath}`; + try { + const res = await curlSotaFetch("https://export.arxiv.org/api/query?search_query=all:gone"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.status).toBe(404); // the REAL status, carried from the write-out + expect(res.error).toMatch(/404/); // the named failure + } + } finally { + process.env.PATH = prevPath; + } + }); + + it("a 429 (rate-limited) is a named failure too — the most common real GitHub/arXiv failure mode", async () => { + const dir = fakeCurlDir(429, ""); + const prevPath = process.env.PATH; + process.env.PATH = `${dir}:${prevPath}`; + try { + const res = await curlSotaFetch("https://export.arxiv.org/api/query?search_query=all:busy"); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.status).toBe(429); + } finally { + process.env.PATH = prevPath; + } + }); + + it("a 200 parses body + status from the write-out without corrupting either (the count derives from the body)", async () => { + const dir = fakeCurlDir(200, ATOM_FIXTURE); + const prevPath = process.env.PATH; + process.env.PATH = `${dir}:${prevPath}`; + try { + const res = await curlSotaFetch("https://export.arxiv.org/api/query?search_query=all:ok"); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.status).toBe(200); + // the write-out's trailing status line must NOT leak into the body + expect(res.body.endsWith("200")).toBe(false); + expect(res.count).toBe(parseArxivAtom(ATOM_FIXTURE).length); // the count derives from the real body + } + } finally { + process.env.PATH = prevPath; + } + }); +}); + +describe("the lens through the production transport — B1 end-to-end", () => { + it("a 404 round is a NAMED failure: no history record, no cache write (the floor is not fed a fake zero)", async () => { + const r = root(); + const c = vclock(); + const dir = fakeCurlDir(404, ""); + const prevPath = process.env.PATH; + process.env.PATH = `${dir}:${prevPath}`; + try { + const res = await runPapersLens({ root: r, terms: ["optimal control"], maxResults: 5, nowMs: c.nowMs, sleep: c.sleep }); + expect(res.via).toBe("fetch-failed"); + if (res.via === "fetch-failed") expect(res.status).toBe(404); + const url = arxivApiUrl(["optimal control"], 5); + const { readFetchHistory } = await import("../src/sota_history.js"); + expect(readFetchHistory(r, sourceKeyOf(url))).toEqual([]); // NO fake zero recorded + expect(existsSync(cachePath(r, url))).toBe(false); // no failed body cached + } finally { + process.env.PATH = prevPath; + } + }); +}); + +// ── A1: the floor verdict rides the CACHE too (a cached empty scan must not ── +// render a false disarm line) + +describe("A1 — an armed source served a CACHED EMPTY payload renders the armed anomaly, never a false disarm", () => { + it("the cache carries the fill's count and the floor verdict is evaluated on the cache read", async () => { + const r = root(); + // the clock sits in 2026 so the fabricated prior history sorts BEFORE the + // cache's fill day (the floor reads strictly-earlier fetch-days) + const c = vclock(Date.parse("2026-09-01T00:00:00Z")); + const url = arxivApiUrl(["optimal control"], 5); + // arm the floor: 7 prior fetch-days of nonzero history + for (let i = 0; i < 7; i++) { + recordFetchOutcome(r, sourceKeyOf(url), { date: `2026-08-0${i + 1}`, count: 5 }); + } + // seed the FETCH cache with an EMPTY payload fetched a minute ago (count stored at fill) + const fetchedAt = new Date(c.nowMs() - 60_000).toISOString(); + mkdirSync(join(r, "fetch-cache"), { recursive: true }); + writeFileSync(cachePath(r, url), JSON.stringify({ url, fetched_at: fetchedAt, body: "", count: 0 }) + "\n"); + const res = await runPapersLens({ + root: r, + terms: ["optimal control"], + maxResults: 5, + fetchFn: (async () => ({ ok: true as const, status: 200, body: "", count: 99 })) as SotaFetch, // must NOT run + nowMs: c.nowMs, + sleep: c.sleep, + }); + expect(res.via).toBe("cache"); + if (!res.ok) throw new Error("setup: must be ok"); + expect(res.entries).toHaveLength(0); + expect(res.count).toBe(0); // the CACHED payload's count, not -1 + expect(res.anomaly?.anomaly).toBe(true); // the verdict reproduces the fill's floor + expect(res.brief).toContain("scan returned nothing — anomalous"); // armed renders armed + expect(res.brief).not.toMatch(/not yet armed/); // the FALSE disarm line is gone + }); +}); diff --git a/packages/amico-run/test/sota_queue.test.ts b/packages/amico-run/test/sota_queue.test.ts new file mode 100644 index 00000000..1fd63fd5 --- /dev/null +++ b/packages/amico-run/test/sota_queue.test.ts @@ -0,0 +1,193 @@ +// sota_queue.test.ts — the FLEET-WIDE serialized query queue (#820, spec +// spec-20260905-103000 D4 / one-fetcher invariant / S6): every live arXiv +// fetch rides ONE lock-file queue at the SHARED vault path — a per-host lock +// serializes nothing, so the lock lives in the fleet-synced sota root, not in +// ~/.amico. Entries carry a TTL lease reclaimed on expiry; waits are BOUNDED +// by a named timeout that falls through to the NAMED outcome (the survey +// never blocks the loop). O4's constants are pinned here as assertions. +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + acquireQueueLock, + releaseQueueLock, + QUEUE_LOCK_NAME, + QUEUE_LEASE_TTL_MS, + QUEUE_WAIT_TIMEOUT_MS, + QUEUE_POLL_INTERVAL_MS, + type QueueLock, +} from "../src/sota_queue.js"; + +function root(): string { + return mkdtempSync(join(tmpdir(), "sota-queue-")); +} + +/** A virtual clock: `nowMs()` reads it, `sleep` advances it — the whole wait + * loop runs in zero wall time, fully deterministic. */ +function vclock(start = 1_000_000) { + let t = start; + return { + nowMs: () => t, + sleep: async (ms: number) => { + t += ms; + }, + peek: () => t, + jump: (ms: number) => { + t += ms; + }, + }; +} + +function lockPath(r: string): string { + return join(r, QUEUE_LOCK_NAME); +} + +describe("the fleet-wide queue lock (D4 — one serialized query queue)", () => { + it("acquire creates the lock file with a TTL lease; release removes it", async () => { + const r = root(); + const c = vclock(); + const res = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + expect(res.acquired).toBe(true); + if (!res.acquired) return; + expect(existsSync(lockPath(r))).toBe(true); + const lease = JSON.parse(readFileSync(lockPath(r), "utf8")); + expect(lease.token).toBe(res.lock.token); + expect(lease.expires_at).toBe(c.peek() + QUEUE_LEASE_TTL_MS); // the TTL lease is real, not decorative + releaseQueueLock(res.lock); + expect(existsSync(lockPath(r))).toBe(false); + }); + + it("a second fetcher WAITS (bounded) and acquires after the holder releases", async () => { + const r = root(); + const c = vclock(); + const a = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + expect(a.acquired).toBe(true); + let bPromise = acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + // release A the first time B's poll yields — B must then win the O_EXCL race + const instrumentedSleep = async (ms: number) => { + c.sleep(ms); + releaseQueueLock((a as { lock: QueueLock }).lock); + }; + bPromise = acquireQueueLock(r, { nowMs: c.nowMs, sleep: instrumentedSleep }); + const b = await bPromise; + expect(b.acquired).toBe(true); + if (b.acquired) releaseQueueLock(b.lock); + }); + + it("an EXPIRED lease is reclaimed — a dead fetcher never blocks the fleet past its TTL", async () => { + const r = root(); + const c = vclock(0); + // hand-craft a stale lease whose TTL is long past + mkdirSync(r, { recursive: true }); + writeFileSync( + lockPath(r), + JSON.stringify({ token: "dead-fetcher", acquired_at: 0, expires_at: QUEUE_LEASE_TTL_MS - 1 }) + "\n", + ); + const res = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + expect(res.acquired).toBe(true); + }); + + it("a CORRUPT lock file (unreadable lease) is reclaimed, not a hang", async () => { + const r = root(); + mkdirSync(r, { recursive: true }); + writeFileSync(lockPath(r), "\x00 not json at all"); + const res = await acquireQueueLock(r, { nowMs: vclock().nowMs, sleep: vclock().sleep }); + expect(res.acquired).toBe(true); + }); + + it("the OLD owner's release never unlinks a RECLAIMED lock (token match discipline)", async () => { + const r = root(); + const c = vclock(0); + // A acquires, then its lease expires; B reclaims + const a = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + if (!a.acquired) throw new Error("setup: A must acquire"); + c.jump(QUEUE_LEASE_TTL_MS + 1); + const b = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + expect(b.acquired).toBe(true); + if (!b.acquired) return; + // A (long-dead but finally runs its release) must NOT remove B's lock + releaseQueueLock(a.lock); + expect(existsSync(lockPath(r))).toBe(true); + releaseQueueLock(b.lock); + expect(existsSync(lockPath(r))).toBe(false); + }); + + it("a held lock past the BOUNDED WAIT falls through to the NAMED outcome — never a silent block", async () => { + const r = root(); + const c = vclock(); + const a = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + expect(a.acquired).toBe(true); + const b = await acquireQueueLock(r, { + nowMs: c.nowMs, + sleep: c.sleep, + waitTimeoutMs: QUEUE_LEASE_TTL_MS, // shorter than A's lease — must time out + }); + expect(b.acquired).toBe(false); + if (!b.acquired) { + expect(b.outcome).toBe("queue-timeout"); // the NAMED outcome + expect(b.detail).toMatch(/cache|waiver/i); // the named FALLBACK is disclosed + expect(b.waitedMs).toBeGreaterThanOrEqual(QUEUE_LEASE_TTL_MS); + } + if (a.acquired) releaseQueueLock(a.lock); + }); +}); + +describe("O4 — the queue constants, pinned (named, with reasons)", () => { + it("the lease TTL covers a full transport attempt with margin: ≥ 3× the curl --max-time 30 transport bound", () => { + expect(QUEUE_LEASE_TTL_MS).toBeGreaterThanOrEqual(3 * 30_000); // a live fetcher is never evicted mid-fetch + expect(QUEUE_LEASE_TTL_MS).toBeLessThan(10 * 60_000); // a dead fetcher blocks the fleet only briefly + }); + + it("the bounded wait exceeds one lease (a released lock is acquirable) but stays under the never-block bar", () => { + expect(QUEUE_WAIT_TIMEOUT_MS).toBeGreaterThan(QUEUE_LEASE_TTL_MS + QUEUE_POLL_INTERVAL_MS); + expect(QUEUE_WAIT_TIMEOUT_MS).toBeLessThan(10 * 60_000); + }); + + it("the poll cadence is a poll, not a spin", () => { + expect(QUEUE_POLL_INTERVAL_MS).toBeGreaterThanOrEqual(100); + }); +}); + +// ── A3: the release is rename-to-tombstone — a release racing a reclaim ────── +// can never unlink the NEW owner's lock. The old release (read token → +// rm) had a TOCTOU between the check and the unlink; the rename-based +// release makes the invariant structural: whoever's lease the renamed +// file carries is who the release speaks for, and a foreign lease is +// RESTORED, not dropped. +describe("A3 — releaseQueueLock: rename-to-tombstone (token check and unlink are never two steps)", () => { + it("a release that finds a FOREIGN lease restores it — the new owner keeps holding (the TOCTOU window is closed)", async () => { + const r = root(); + const c = vclock(); + const a = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + if (!a.acquired) throw new Error("setup: A must acquire"); + // simulate the reclaim racing the release: the lease file now names a NEW owner + writeFileSync(lockPath(r), JSON.stringify({ token: "new-owner", acquired_at: c.nowMs(), expires_at: c.nowMs() + 60_000 }) + "\n"); + releaseQueueLock(a.lock); + // the new owner STILL holds: the file exists and names THEM + const held = JSON.parse(readFileSync(lockPath(r), "utf8")); + expect(held.token).toBe("new-owner"); + }); + + it("a release against a missing lock is a no-op that leaves no debris", async () => { + const r = root(); + const c = vclock(); + const a = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + if (!a.acquired) throw new Error("setup: A must acquire"); + rmSync(lockPath(r)); // someone reclaimed + released first + releaseQueueLock(a.lock); // must not throw, must not resurrect anything + expect(existsSync(lockPath(r))).toBe(false); + expect(readdirSync(r).filter((f) => f.includes(QUEUE_LOCK_NAME))).toEqual([]); // no tombstones left behind + }); + + it("a clean acquire→release cycle leaves NO tombstone debris in the sota root", async () => { + const r = root(); + const c = vclock(); + const a = await acquireQueueLock(r, { nowMs: c.nowMs, sleep: c.sleep }); + if (!a.acquired) throw new Error("setup: A must acquire"); + releaseQueueLock(a.lock); + expect(existsSync(lockPath(r))).toBe(false); + expect(readdirSync(r).filter((f) => f.includes(QUEUE_LOCK_NAME))).toEqual([]); + }); +}); diff --git a/packages/amico-run/test/sota_verb.test.ts b/packages/amico-run/test/sota_verb.test.ts new file mode 100644 index 00000000..f2ea82d1 --- /dev/null +++ b/packages/amico-run/test/sota_verb.test.ts @@ -0,0 +1,127 @@ +// sota_verb.test.ts — the `amico sota` verb (#820): the survey surface the +// sota-review skill drives. Hermetic end-to-end: the FETCH cache is seeded +// with fixture payloads (the arXiv Atom fixture + GitHub-shaped fixtures), +// so the verb exercises its REAL production path — cache → queue → parse → +// brief — with ZERO transports. The queue lock, TTL lease, and bounded wait +// are the lens tests' territory; this pins the VERB's contract: usage +// errors, the brief in the JSON, the named outcomes, and the codebase +// round's stamps + retire-or-confirm flags. +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sotaVerb } from "../src/sota_verb.js"; +import { arxivApiUrl, parseArxivAtom } from "../src/sota_papers.js"; +import { githubApiUrl, registryPath } from "../src/sota_codebase.js"; +import { cachePath } from "../src/sota_fetch.js"; +import { parseWatchedRepoRegistry } from "@amicode/schema"; +import { readFileSync as rf } from "node:fs"; + +const ATOM_FIXTURE = rf(join(__dirname, "fixtures", "sota", "arxiv-live-atom.xml"), "utf8"); // the recorded real-API payload +// B2: the verb path injects NO clock — the cache seed must be fresh against the +// REAL test clock (a fixed stamp goes stale past the 6h TTL and fires a live +// curl in CI). new Date() here is always fresh: the test runs in seconds. +const FETCHED_AT = new Date().toISOString(); + +const RELEASES_JSON = JSON.stringify([ + { + tag_name: "v0.9.0", + name: "v0.9.0 — trajectory rework", + published_at: "2026-08-20T00:00:00Z", + html_url: "https://github.com/example/piccolo-adjacent/releases/tag/v0.9.0", + body: "breaking: trajectory API now requires integrator selection", + }, +]); + +function root(): string { + return mkdtempSync(join(tmpdir(), "sota-verb-")); +} + +describe("`amico sota papers` — one on-demand query through the queue", () => { + it("serves the seeded REAL-API payload from the cache and returns the cited brief", async () => { + const r = root(); + const url = arxivApiUrl(["optimal control"], 5); + mkdirSync(join(r, "fetch-cache"), { recursive: true }); + writeFileSync(cachePath(r, url), JSON.stringify({ url, fetched_at: FETCHED_AT, body: ATOM_FIXTURE, count: parseArxivAtom(ATOM_FIXTURE).length }) + "\n"); + const res = await sotaVerb(["papers", "--query", "optimal control", "--top", "5", "--root", r]); + expect(res.code).toBe(0); + const j = res.json as { ok: boolean; via: string; results: number; brief: string; entries: { arxiv: string; url: string }[] }; + expect(j.ok).toBe(true); + expect(j.via).toBe("cache"); // the verb's own production path reads the cache — no transport ran + expect(j.results).toBe(parseArxivAtom(ATOM_FIXTURE).length); + expect(j.results).toBeGreaterThan(0); + for (const e of j.entries) expect(e.url).toBe(`https://arxiv.org/abs/${e.arxiv}`); // CITED + expect(j.brief).toMatch(/provenance:/); // STAMPED + expect(j.brief).toContain("source: arXiv export API over HTTPS"); + }); + + it("missing --query is a usage error (exit 64, never a wildcard firehose)", async () => { + const res = await sotaVerb(["papers", "--root", root()]); + expect(res.code).toBe(64); + }); + + it("an out-of-range --top is a usage error", async () => { + const res = await sotaVerb(["papers", "--query", "x", "--top", "0", "--root", root()]); + expect(res.code).toBe(64); + }); +}); + +describe("`amico sota codebase` — one watched-repo fetch round", () => { + const REGISTRY_TOML = ` +schema_version = "1" +failure_threshold = 2 + +[[repos]] +repo = "example/piccolo-adjacent" +why_watched = "API shifts adjacent to our authoring map" +domains = ["julia-optimal-control"] +fetch_surface = ["releases"] +match_keywords = ["trajectory"] +`; + + function seededRoot(): string { + const r = root(); + writeFileSync(registryPath(r), REGISTRY_TOML); + mkdirSync(join(r, "fetch-cache"), { recursive: true }); + const url = githubApiUrl("example/piccolo-adjacent", "releases"); + writeFileSync(cachePath(r, url), JSON.stringify({ url, fetched_at: new Date().toISOString(), body: RELEASES_JSON, count: 1 }) + "\n"); + return r; + } + + it("fetches via the cache, filters by match_keywords, cites the event, stamps the registry", async () => { + const r = seededRoot(); + const res = await sotaVerb(["codebase", "--root", r]); + expect(res.code).toBe(0); + const j = res.json as { ok: boolean; brief: string; repos: { repo: string; events: number; flagged_for_retire_or_confirm: boolean }[]; stamped_at: string }; + expect(j.ok).toBe(true); + expect(j.repos).toHaveLength(1); + expect(j.repos[0].events).toBe(1); // the trajectory release matched + expect(j.brief).toContain("https://github.com/example/piccolo-adjacent/releases/tag/v0.9.0"); // CITED + expect(j.brief).toContain("why-watched: API shifts adjacent to our authoring map"); + expect(j.brief).toMatch(/provenance:/); + // the stamps persisted to the living registry + const persisted = parseWatchedRepoRegistry(readFileSync(registryPath(r), "utf8")); + expect(persisted.repos[0].last_success).toBe(j.stamped_at); + expect(persisted.repos[0].consecutive_failures).toBe(0); + }); + + it("--repo filters the round to the named canonical repos", async () => { + const r = seededRoot(); + const res = await sotaVerb(["codebase", "--repo", "example/piccolo-adjacent", "--root", r]); + expect(res.code).toBe(0); + const j = res.json as { repos: { repo: string }[] }; + expect(j.repos.map((x) => x.repo)).toEqual(["example/piccolo-adjacent"]); + }); + + it("a non-canonical --repo (a local path smuggle) is a usage error", async () => { + const res = await sotaVerb(["codebase", "--repo", "../etc/passwd", "--root", root()]); + expect(res.code).toBe(64); + expect((res.json as { error: string }).error).toMatch(/owner\/name/); + }); + + it("an unknown lens head is a usage error with the usage text", async () => { + const res = await sotaVerb(["frobnicate", "--root", root()]); + expect(res.code).toBe(64); + expect((res.json as { error: string }).error).toMatch(/unknown lens/); + }); +}); diff --git a/packages/amico-run/vitest.config.ts b/packages/amico-run/vitest.config.ts index f9eac61f..94f42029 100644 --- a/packages/amico-run/vitest.config.ts +++ b/packages/amico-run/vitest.config.ts @@ -1,10 +1,28 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig, type Plugin } from "vitest/config"; +import { readFileSync } from "node:fs"; // The only reason this file exists is `setupFiles`: test/setup.ts installs the guard that stops // any test from spawning a real (billed) model call or appending to the developer's real ledger. // Both are failures that per-test discipline cannot prevent, because the risk is in the test // someone writes next. + +// Data-as-import parity with esbuild.config.mjs's `.toml: "text"` loader +// (#820): under vitest the same resources/*.toml files load as their text +// content, so the canonical seed has ONE copy and both runtimes read it. +function tomlAsText(): Plugin { + return { + name: "toml-as-text", + load(id) { + if (id.endsWith(".toml")) { + return `export default ${JSON.stringify(readFileSync(id, "utf8"))};`; + } + return undefined; + }, + }; +} + export default defineConfig({ + plugins: [tomlAsText()], test: { setupFiles: ["./test/setup.ts"], // Matches the historical `--exclude '**/slow/**'` in the package script; kept here so the diff --git a/packages/extension/skills/sota-review/SKILL.md b/packages/extension/skills/sota-review/SKILL.md new file mode 100644 index 00000000..4fdf0458 --- /dev/null +++ b/packages/extension/skills/sota-review/SKILL.md @@ -0,0 +1,133 @@ +--- +name: sota-review +description: Survey the outside world before designing — TWO lenses in one skill. Papers (vault/repo grep first, then the arXiv API over HTTPS, never search-engine scraping) and codebases (the GitHub API against the watched-repo registry's canonical repos, never a local fork checkout). Cited, provenance-stamped briefs. Use before specs, decompositions, and hypothesis rounds. +agents: [researcher, librarian, dreamer] +surface: public +source: amicode +revision: 1 +--- + +# SOTA review — the loops' external currency + +One survey, two lenses, one discipline: **read-only toward the world, +append-only toward the vault**. The survey seeds hypotheses and design +decisions; it never blocks the loop (a survey that cannot run records a +NAMED outcome and the loop proceeds), and its matches route through staging +for a human eye before they count as currency — this skill fetches and +reports; the staged routing is a later layer. + +## Usage + +`/sota-review` — survey papers and watched codebases for the topic at hand. +`/sota-review papers ` — the papers lens only. +`/sota-review codebase` — the codebase lens only (a watched-repo round). + +## Lens 1 — papers + +The recipe's rules, in order. **Step 1 always precedes step 2** — niche +queries are usually already answered in the vault, the demos, or the +package docs. + +1. **Vault/repo grep FIRST.** Grep the vault mounts and the repo checkouts + before touching the network: + `grep -rn -i "" ` + Vendors, collaborators, and papers-we-cite are typically already in + notes, reading cards, or package docs — a hit here is faster and better + than any web result. +2. **The arXiv API over HTTPS for papers** — scriptable, reliable, never + scraping. On-demand queries go through the fleet-wide serialized queue: + `amico sota papers --query "" [--top N]` + GOTCHA (mechanical, not advisory): the `http://` scheme on + export.arxiv.org **silently hangs** — always `https://`. The fetch seam + refuses `http://` by name; do not work around it. +3. **Direct domain or Wikipedia fetch** for companies and tools: fetch the + plausible domain or `en.wikipedia.org/wiki/`. A 404 is signal too. +4. **Ask the user** when 1–3 fail — a one-line question beats ten minutes + of scraper thrash. + +**Never search-engine scraping.** Bing or Google HTML endpoints are SEO +garbage for niche technical queries or bot-blocked outright — never fetch +them, never parse them. The sanctioned paths are the two above. + +### The queue discipline (one fetcher, one query queue) + +All live arXiv traffic rides the fetch cache plus ONE serialized query queue +— a lock-file queue at the fleet-shared sota root (the personal vault +mount's `amicode/sota` directory; `$AMICO_SOTA_ROOT` overrides). A per-host +lock serializes nothing: the fleet is the concurrency, so the queue lives +at the shared path. In practice: + +- If the cache holds the query, you get `via: cache` — no network ran. +- If another fetcher holds the queue, the wait is bounded; on timeout you + get the **named outcome** `queue-timeout` with the disclosed alternative + (read the fetch cache, or record the explicit waiver). Never retry in a + tight loop; never block the loop on the wire. +- **Known limit** (named, not hidden): the lock is atomic per host — + vault-sync propagation latency makes fleet-wide mutual exclusion + best-effort, and the under-lock cache re-check bounds the damage to a + duplicate fetch, never a correctness break. + +## Lens 2 — codebases + +Watch the repos that matter via the GitHub API **against canonical repos +(organizer/name), never a local fork checkout** — a local checkout sees +your drift, not the world's: + +`amico sota codebase [--repo owner/name]` + +The substrate is the **watched-repo registry** — validator-checked TOML +data living in the same fleet-shared sota root (`watched-repos.toml`), +seeded at first read with the shipped set: the canonical agent harnesses, +the Julia optimal-control stack adjacent to our authoring map, the +QEC/qLDPC challenge repos, and the quantum SDK release tracks. + +- **Adding a repo is a data edit, never code**: append a `[[repos]]` entry + (`repo`, `why_watched`, `domains`, `fetch_surface`, `match_keywords`). + A malformed registry fails LOUDLY — fix the TOML, never the validator. +- Each round stamps `last_success` and accrues `consecutive_failures` per + entry; after the registry's threshold (default 7) of consecutive failures + the brief flags the entry for **human retire-or-confirm** — confirm the + watch or retire it; the registry edit is yours, never the machinery's. +- Events surface only when they match the entry's keywords, and the brief + names which keywords matched. + +## Briefs — the PI register + +Every brief this skill emits is written for a research PI: **concise, +human-readable, details-informed**, every claim **cited** (the paper's abs +URL, the release/issue URL), and every render **provenance-stamped** — the +query, the source, the fetch time, and the path (`via: cache` or +`via: fetched`; a cache read never launders as a live fetch). Raw payloads +stay one level down; the brief ranks, it never dumps. + +**Quiet failures are the ones that matter:** + +- A successful fetch returning zero entries where the trailing 7-fetch-day + mean is nonzero renders **"scan returned nothing — anomalous"** — never + "nothing new". An empty scan against history is a signal, not a rest day. +- A failed fetch is a **named** failure in the brief (which repo, which + surface, which error) — never a silent skip. + +## Checklist — the recipe's rules (lint-pinned) + +Run through this list before publishing any brief: + +- [ ] Vault and repo greps ran FIRST, and their hits (if any) are cited in the brief. +- [ ] Papers came from the arXiv API over **HTTPS** — `https://export.arxiv.org/api/query`, never `http://` (it hangs), never search-engine HTML (no Bing, no Google scraping). +- [ ] Every live fetch rode the serialized queue (`amico sota …`); a `queue-timeout` rendered its named outcome and the disclosed alternative — the loop was never blocked. +- [ ] Codebase events came from the GitHub API against **canonical repos**, never a local fork checkout. +- [ ] The registry is treated as data: any repo change was a TOML edit; a flagged entry went to the human as retire-or-confirm. +- [ ] Every claim in the brief is cited and the render carries its provenance stamp (query, source, fetched time, via). +- [ ] An empty-but-successful scan rendered "scan returned nothing — anomalous" if the floor was armed — the brief never says "nothing new". + +## Honest degradation + +- **No sota root / no registry**: the codebase lens bootstraps the registry + from the shipped seed on first read; if even that fails, say so and name + the error — never pretend a survey ran. +- **Network unavailable**: the survey records the named outcome and the + loop proceeds (currency is a seed, not a stall). The brief renders the + failure and its provenance honestly. +- **This slice fetches and reports**: it does not stage matches into a + campaign ledger, render strategy compositions, or gate anything — those + layers exist later; do not simulate their effects. diff --git a/packages/extension/test/naming_records.test.ts b/packages/extension/test/naming_records.test.ts index c92381b2..100e8778 100644 --- a/packages/extension/test/naming_records.test.ts +++ b/packages/extension/test/naming_records.test.ts @@ -155,6 +155,7 @@ describe("naming records — the public workflow skills carry open-protocol voca "write-an-issue", "break-into-subissues", "autodev", + "sota-review", // #820 — the public SOTA survey skill joins the naming discipline ]; const blocklist = JSON.parse(readFileSync(BLOCKLIST_PATH, "utf8")) as { proprietary_strings: string[]; diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 400e02e0..42b428d6 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -17,6 +17,10 @@ const PUBLIC_WORKFLOW_SKILLS = [ "write-an-issue", "break-into-subissues", "autodev", + // #820 — the dual-lens SOTA survey skill (living-sota D1): public with the + // same shipping discipline; a dropped copy = the loops' external-currency + // survey never stages. + "sota-review", ] as const; const REQUIRED = [ @@ -83,6 +87,7 @@ const REQUIRED = [ "extension/skills/write-an-issue/SKILL.md", "extension/skills/break-into-subissues/SKILL.md", "extension/skills/autodev/SKILL.md", + "extension/skills/sota-review/SKILL.md", // #820 — the public SOTA survey skill must ship (living-sota D1) // #804 — the mode registry: the bundles the activation stager deploys and // the doctor probes ship in the vsix; a dropped modes/ = zero staged // bundles on every Marketplace machine (packaging.test runs on the built diff --git a/packages/extension/test/sota_review_skill.test.ts b/packages/extension/test/sota_review_skill.test.ts new file mode 100644 index 00000000..89cf4309 --- /dev/null +++ b/packages/extension/test/sota_review_skill.test.ts @@ -0,0 +1,168 @@ +// sota_review_skill.test.ts — the sota-review skill's lint (#820, spec +// spec-20260905-103000 living-sota D1 / S1): ONE public skill carrying BOTH +// lenses, with the web-search recipe's rules LINT-PINNED — the required +// rules grepped IN, the scraping patterns grepped OUT, and the recipe's +// machinery (queue, cache, canonical-repos-only) pinned against the real +// verb surface in amico-run. The content lens (blocklist + internal path +// shapes, the #809 discipline) applies to this new public skill exactly as +// it does to the workflow set — one discipline, every public surface. +import { describe, it, expect } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = join(HERE, ".."); +const SKILL = join(EXT, "skills", "sota-review", "SKILL.md"); +const AMICO_RUN = join(EXT, "..", "amico-run"); + +const skill = readFileSync(SKILL, "utf8"); +// prose assertions match on whitespace-NORMALIZED text (markdown reflows — +// a lint that breaks on line wrapping is a lint someone "fixes" by +// re-wrapping the skill); literal pins (commands, URLs) match the raw text. +const flat = skill.replace(/\s+/g, " "); + +// ── the recipe's rules, grepped IN (the skill is the loop's instruction — ──── +// if the rule isn't in the text, the rule doesn't exist) + +describe("the sota-review skill carries both lenses with the recipe verbatim (#820 D1)", () => { + it("ships with public surface + revision frontmatter (the shipping tiers of record)", () => { + expect(existsSync(SKILL)).toBe(true); + const fm = skill.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ""; + expect(fm).toMatch(/^surface:\s*public\b/m); + expect(fm).toMatch(/^source:\s*\S/m); + expect(fm).toMatch(/^revision:\s*[1-9]\d*\s*$/m); + }); + + it("rule 1 — vault/repo grep FIRST: the step precedes any network in the text", () => { + expect(flat).toMatch(/vault\/repo grep FIRST/i); + expect(skill).toMatch(/grep -rn -i/); + const grepIdx = skill.search(/Vault\/repo grep FIRST/i); + const arxivIdx = skill.search(/arXiv API over HTTPS/); + expect(grepIdx).toBeGreaterThanOrEqual(0); + expect(arxivIdx).toBeGreaterThan(grepIdx); // step 1 precedes step 2 — in the TEXT, in order + }); + + it("rule 2 — the arXiv API over HTTPS, with the http:// gotcha NAMED", () => { + expect(skill).toContain("https://export.arxiv.org/api/query"); + expect(skill).toMatch(/http:\/\//); // the gotcha is named + expect(skill).toMatch(/silently hangs/); // ...with its failure mode, verbatim from the recipe + }); + + it("rule 3 — never search-engine scraping: the ban is explicit and the targets are named", () => { + expect(flat).toMatch(/Never search-engine scraping/i); + expect(skill).toMatch(/Bing or Google HTML/); + expect(flat).toMatch(/never scraping|never fetch\s+them/i); + }); + + it("the queue discipline — one fetcher, one serialized queue; the named outcome, the disclosed alternative, and the KNOWN LIMIT named", () => { + expect(flat).toMatch(/serialized queue|one query queue/i); + expect(flat).toMatch(/per-host lock serializes nothing/i); // the fleet-wide invariant, stated + expect(skill).toMatch(/queue-timeout/); // the named outcome + expect(flat).toMatch(/never block the loop|the loop was never blocked/i); // the survey never blocks + expect(skill).toMatch(/via: cache/); // cache reads are honest about being cache reads + // A2 (review fold on #828): the mutual-exclusion limit is NAMED, not hidden + expect(flat).toMatch(/known limit/i); + expect(flat).toMatch(/best-effort/i); + expect(flat).toMatch(/atomic per host/i); + expect(flat).toMatch(/duplicate fetch/); // the bounded damage, named + }); + + it("the codebase lens — the GitHub API against CANONICAL repos, never a local fork checkout", () => { + expect(flat).toMatch(/never a local fork checkout/i); + expect(flat).toMatch(/canonical repos/i); + expect(skill).toMatch(/amico sota codebase/); + }); + + it("the registry is DATA — adding a repo is a data edit; retire-or-confirm is the human decision", () => { + expect(flat).toMatch(/data edit, never code/i); + expect(flat).toMatch(/validator-checked TOML/i); + expect(skill).toMatch(/retire-or-confirm/); + expect(flat).toMatch(/fails LOUDLY/i); + }); + + it("PI-register briefs — cited, provenance-stamped, the anomaly line pinned", () => { + expect(flat).toMatch(/provenance-stamped/i); + expect(skill).toContain("scan returned nothing — anomalous"); // the floor's render, verbatim + expect(flat).toMatch(/never "nothing new"|never says "nothing new"/i); // the silent-empty failure mode, banned verbatim + }); + + it("the skill drives the REAL verb surface (the commands exist in the amico-run verb registry)", () => { + const verbs = readFileSync(join(AMICO_RUN, "src", "verbs.ts"), "utf8"); + expect(verbs).toMatch(/name: "sota"/); + expect(skill).toMatch(/amico sota papers --query/); + expect(skill).toMatch(/amico sota codebase/); + }); + + it("honest scope — this slice fetches and reports (staging is a named later layer)", () => { + expect(flat).toMatch(/fetches and reports/i); + expect(flat).toMatch(/later layer|later slices/i); + }); +}); + +// ── the scraping patterns, grepped OUT (a fetch target the recipe bans ────── +// must appear NOWHERE — not in the skill, not in the lens code, not in the +// verb; the ban is structural, not advisory) + +describe("scraping patterns are banned by grep (#820 S1 — the recipe's rules, mechanically)", () => { + const SCRAPING_URL_PATTERNS = [ + /bing\.com/i, + /google\.com\/search/i, + /duckduckgo\.com/i, + /search\.yahoo\./i, + ]; + const SURFACES: Record = { + "the skill": skill, + "the papers lens (sota_papers.ts)": readFileSync(join(AMICO_RUN, "src", "sota_papers.ts"), "utf8"), + "the codebase lens (sota_codebase.ts)": readFileSync(join(AMICO_RUN, "src", "sota_codebase.ts"), "utf8"), + "the fetch seam (sota_fetch.ts)": readFileSync(join(AMICO_RUN, "src", "sota_fetch.ts"), "utf8"), + "the verb (sota_verb.ts)": readFileSync(join(AMICO_RUN, "src", "sota_verb.ts"), "utf8"), + }; + + for (const [name, text] of Object.entries(SURFACES)) { + for (const pat of SCRAPING_URL_PATTERNS) { + it(`${name} carries no scraper-target URL (${pat})`, () => { + expect(pat.test(text), `${name} must not contain ${pat}`).toBe(false); + }); + } + } + + it("the only network endpoints in the lens code are the sanctioned APIs (arXiv export over https; GitHub API)", () => { + const lensCode = [ + readFileSync(join(AMICO_RUN, "src", "sota_papers.ts"), "utf8"), + readFileSync(join(AMICO_RUN, "src", "sota_codebase.ts"), "utf8"), + ].join("\n"); + const httpsHosts = [...lensCode.matchAll(/https:\/\/([a-z0-9.-]+)/gi)].map((m) => m[1]); + const sanctioned = new Set(["export.arxiv.org", "arxiv.org", "api.github.com", "github.com"]); + const offenders = httpsHosts.filter((h) => !sanctioned.has(h)); + expect(offenders, `unsanctioned https hosts in the lenses: ${offenders.join(", ")}`).toEqual([]); + }); + + it("the skill's prose may NAME the banned engines but never as a fetch target — no curl/fetch + engine pairing", () => { + expect(/(curl|fetch|wget)[^\n]*bing/i.test(skill)).toBe(false); + expect(/(curl|fetch|wget)[^\n]*google/i.test(skill)).toBe(false); + }); +}); + +// ── the #809 content lens, applied to the new public surface (one ─────────── +// discipline, every public skill) + +describe("content lens — the sota-review skill carries no proprietary or internal-machine content (#809 discipline)", () => { + const BLOCKLIST = JSON.parse(readFileSync(join(EXT, "protocol-blocklist.json"), "utf8")) as { + proprietary_strings: string[]; + banned_names: string[]; + }; + + it("no blocklisted proprietary string or banned name", () => { + for (const s of [...BLOCKLIST.proprietary_strings, ...BLOCKLIST.banned_names]) { + expect(skill.toLowerCase(), `must not contain "${s}" on the public surface`).not.toContain(s.toLowerCase()); + } + }); + + it("no internal-machine path shape (mount paths, tool state trees, private layouts)", () => { + const INTERNAL_PATH_SHAPES = ["/home/", "/users/", "~/armonia", "~/.amico", ".amico/vaults", "armonissima", "repos/amico"]; + for (const shape of INTERNAL_PATH_SHAPES) { + expect(skill.toLowerCase(), `must not carry the internal path shape "${shape}"`).not.toContain(shape.toLowerCase()); + } + }); +}); diff --git a/packages/schema/schemas/watched-repo-registry.schema.json b/packages/schema/schemas/watched-repo-registry.schema.json new file mode 100644 index 00000000..af8c8afa --- /dev/null +++ b/packages/schema/schemas/watched-repo-registry.schema.json @@ -0,0 +1,93 @@ +{ + "$id": "https://harmoniqs.github.io/amicode/schemas/watched-repo-registry.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "description": "The watched-repo registry (watched-repos.toml, spec-20260905-103000 living-sota D3): the SOTA codebase lens's DATA — repos, why-watched, feeding-which-domains, fetch surface, match keywords, last-success stamps, consecutive-failure counters. Adding a repo is a data edit, never code; a malformed registry fails LOUDLY, never silently skips. The fetch surface rides the GitHub API against the CANONICAL repo (owner/name) — never a local fork checkout. N consecutive fetch failures (failure_threshold, default 7) flag the entry for human retire-or-confirm; the flag is DERIVED from consecutive_failures at read time, never stored.", + "properties": { + "failure_threshold": { + "default": 7, + "description": "Consecutive fetch failures after which an entry flags for human retire-or-confirm (never a silent unwatch).", + "minimum": 1, + "type": "integer" + }, + "repos": { + "description": "The watched set. One entry per canonical repo (uniqueness checked at registry level).", + "items": { + "additionalProperties": false, + "properties": { + "consecutive_failures": { + "default": 0, + "description": "Machinery-written: consecutive fetch failures since the last success. The retire-or-confirm flag derives from this counter.", + "minimum": 0, + "type": "integer" + }, + "domains": { + "description": "Which research domains this repo feeds (e.g. agent-harness, julia-optimal-control, quantum-sdk).", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "fetch_surface": { + "description": "The GitHub API surfaces this entry watches.", + "items": { + "enum": [ + "releases", + "changelog", + "issues" + ] + }, + "minItems": 1, + "type": "array" + }, + "last_success": { + "default": "", + "description": "Machinery-written: ISO-8601 stamp of the last successful fetch (empty until the first success).", + "pattern": "^$|^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})?)$", + "type": "string" + }, + "match_keywords": { + "description": "The vocabulary an event (release title, changelog line, issue title) must match to surface in the brief.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "repo": { + "description": "Canonical GitHub owner/name — the GitHub API fetch target, never a local fork checkout.", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "type": "string" + }, + "why_watched": { + "description": "The human reason this repo is watched — the retire-or-confirm decision reads this line.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "repo", + "why_watched", + "domains", + "fetch_surface", + "match_keywords" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "schema_version": { + "const": "1" + } + }, + "required": [ + "schema_version", + "repos" + ], + "title": "watched-repo registry (watched-repos.toml)", + "type": "object" +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 7a0e472f..6eee3ecb 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -143,6 +143,21 @@ export { type SupersedingSkillDeclineKind, } from "./skill_revision.js"; +// The watched-repo registry's ONE shared validator (#820, spec-20260905-103000 +// living-sota D3-data / S2): the SOTA codebase lens's data substrate — +// validator-checked TOML where adding a repo is a data edit, never code. Same +// documented root seam: amico-run's lenses and the extension's suite both +// import from here, so a registry that passes tests passes the machinery. +export { + DEFAULT_FAILURE_THRESHOLD, + validateWatchedRepoRegistry, + parseWatchedRepoRegistry, + flaggedForRetireOrConfirm, + type WatchedRepo, + type WatchedRepoRegistry, + type FetchSurface, +} from "./watched_repos.js"; + // ajv-formats ships a CJS default export; under NodeNext the default import can // bind the module namespace rather than the callable, so normalize defensively. const addFormats = (typeof addFormatsDefault === "function" diff --git a/packages/schema/src/watched_repos.ts b/packages/schema/src/watched_repos.ts new file mode 100644 index 00000000..9801761a --- /dev/null +++ b/packages/schema/src/watched_repos.ts @@ -0,0 +1,172 @@ +// watched_repos.ts — the watched-repo registry's ONE shared validator (#820, +// spec-20260905-103000 living-sota D3-data / S2): the registry under the sota +// root (and the shipped seed that bootstraps it) is TYPED DATA, and this +// module is the single code path that judges it — imported by BOTH amico-run's +// lenses (the codebase lens reads it; the stamp writer revalidates before any +// persist) and the extension's vitest suite, so a registry that passes tests +// passes the machinery (the mode_registry.ts idiom, H1). +// +// The registry is the SOTA codebase lens's whole substrate: repos, +// why-watched, feeding-which-domains, fetch surface, match keywords, +// last-success stamps, consecutive-failure counters. Adding a repo is a DATA +// EDIT, never code. The retire-or-confirm flag is DERIVED from the +// consecutive-failure counter against the registry's threshold (default 7, +// carried IN THE SCHEMA — DEFAULT_FAILURE_THRESHOLD reads the schema's own +// `default` keyword, so the schema file stays the one source of truth); it is +// never stored, so it can never disagree with the counter it reads. +import { parse as parseToml } from "smol-toml"; +import { Ajv, type ErrorObject, type ValidateFunction } from "ajv"; +import addFormatsDefault from "ajv-formats"; +import type { Validation } from "./index.js"; +import watchedRepoRegistrySchema from "../schemas/watched-repo-registry.schema.json" with { type: "json" }; + +// ajv-formats ships a CJS default export; under NodeNext the default import +// can bind the module namespace rather than the callable — normalize +// defensively (same idiom as src/index.ts and mode_registry.ts). +const addFormats = (typeof addFormatsDefault === "function" + ? addFormatsDefault + : (addFormatsDefault as unknown as { default: unknown }).default) as unknown as (ajv: Ajv) => void; + +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +const registryValidator = ajv.compile(watchedRepoRegistrySchema as object) as ValidateFunction; + +/** The schema's own `failure_threshold.default` — the default 7 lives in the + * schema (one source of truth); this constant just surfaces it. */ +export const DEFAULT_FAILURE_THRESHOLD: number = + (watchedRepoRegistrySchema as { + properties?: { failure_threshold?: { default?: number } }; + }).properties?.failure_threshold?.default ?? 7; + +export type FetchSurface = "releases" | "changelog" | "issues"; + +export interface WatchedRepo { + /** Canonical GitHub owner/name — the API fetch target, never a local fork checkout. */ + repo: string; + /** The human reason this repo is watched — the retire-or-confirm line. */ + why_watched: string; + /** Which research domains this repo feeds. */ + domains: string[]; + fetch_surface: FetchSurface[]; + match_keywords: string[]; + /** ISO-8601 stamp of the last successful fetch ("" until the first success). */ + last_success: string; + /** Consecutive fetch failures since the last success (machinery-written). */ + consecutive_failures: number; +} + +export interface WatchedRepoRegistry { + schema_version: string; + /** Consecutive-failure count that flags an entry for retire-or-confirm. */ + failure_threshold: number; + repos: WatchedRepo[]; +} + +function formatAjvError(e: ErrorObject): string { + const where = e.instancePath === "" ? "(root)" : e.instancePath; + switch (e.keyword) { + case "required": + return `${where}: missing required key "${(e.params as { missingProperty: string }).missingProperty}"`; + case "additionalProperties": + return `${where}: unknown key "${(e.params as { additionalProperty: string }).additionalProperty}"`; + case "enum": { + const allowed = (e.params as { allowedValues?: unknown[] }).allowedValues ?? []; + return `${where}: must be one of (${allowed.join(", ")})`; + } + default: + return `${where}: ${e.message ?? "invalid"}`; + } +} + +function ajvErrors(v: ValidateFunction, label: string): string[] { + return (v.errors ?? []).map((e) => `${label}${formatAjvError(e)}`); +} + +/** Parse + schema-validate + registry-level cross-checks, applying the + * IN-SCHEMA defaults for the machinery-written fields. Throws with + * field-precise errors on violation — a bad registry is a loud authoring + * failure, never a silent skip (the parseModeManifest idiom). */ +export function parseWatchedRepoRegistry(text: string): WatchedRepoRegistry { + let parsed: unknown; + try { + parsed = parseToml(text); + } catch (e) { + throw new Error(`watched-repos.toml: parse error — ${(e as Error).message}`); + } + const errors: string[] = []; + if (!registryValidator(parsed)) { + errors.push(...ajvErrors(registryValidator, "watched-repos.toml")); + } + const raw = parsed as { repos?: { repo?: string }[] }; + const seen = new Set(); + for (const r of raw.repos ?? []) { + if (typeof r.repo !== "string") continue; // already reported by the schema validator + if (seen.has(r.repo)) errors.push(`watched-repos.toml: duplicate repo entry "${r.repo}" — one repo, one entry`); + seen.add(r.repo); + } + if (errors.length > 0) { + throw new Error(`watched-repos.toml: schema violation — ${errors.join("; ")}`); + } + // apply the in-schema defaults so the machinery reads a normalized shape + const reg = parsed as { + schema_version: string; + failure_threshold?: number; + repos: Array<{ + repo: string; + why_watched: string; + domains: string[]; + fetch_surface: FetchSurface[]; + match_keywords: string[]; + last_success?: string; + consecutive_failures?: number; + }>; + }; + return { + schema_version: reg.schema_version, + failure_threshold: reg.failure_threshold ?? DEFAULT_FAILURE_THRESHOLD, + repos: reg.repos.map((r) => ({ + repo: r.repo, + why_watched: r.why_watched, + domains: r.domains, + fetch_surface: r.fetch_surface, + match_keywords: r.match_keywords, + last_success: r.last_success ?? "", + consecutive_failures: r.consecutive_failures ?? 0, + })), + }; +} + +/** The Validation-returning form (the validateGatePack idiom): parse (if a + * string) + schema-validate + cross-checks, errors field-precise. */ +export function validateWatchedRepoRegistry(textOrParsed: unknown): Validation { + let parsed = textOrParsed; + if (typeof textOrParsed === "string") { + try { + parsed = parseToml(textOrParsed); + } catch (e) { + return { ok: false, errors: [`watched-repos.toml: parse error — ${(e as Error).message}`] }; + } + } + const errors: string[] = []; + if (!registryValidator(parsed)) { + errors.push(...ajvErrors(registryValidator, "watched-repos.toml")); + } + const raw = parsed as { repos?: { repo?: string }[] }; + if (Array.isArray(raw?.repos)) { + const seen = new Set(); + for (const r of raw.repos) { + if (typeof r?.repo !== "string") continue; // already reported + if (seen.has(r.repo)) errors.push(`watched-repos.toml: duplicate repo entry "${r.repo}" — one repo, one entry`); + seen.add(r.repo); + } + } + return { ok: errors.length === 0, errors }; +} + +/** The derived retire-or-confirm flag: an entry whose consecutive-failure + * counter reached the registry's threshold. Derived at read time — never + * stored, never able to disagree with its counter (the flag the weekly + * brief renders for the human retire-or-confirm decision). */ +export function flaggedForRetireOrConfirm(entry: Pick, threshold: number): boolean { + return entry.consecutive_failures >= threshold; +} diff --git a/packages/schema/test/watched_repos.test.ts b/packages/schema/test/watched_repos.test.ts new file mode 100644 index 00000000..43e55b27 --- /dev/null +++ b/packages/schema/test/watched_repos.test.ts @@ -0,0 +1,164 @@ +// watched_repos.test.ts — S2, the watched-repo registry validator (#820, spec +// spec-20260905-103000 D3-data / watched_repo_registry_is_data): the registry +// is validator-checked TOML — repos, why-watched, feeding-which-domains, +// fetch surface, match keywords, last-success stamps — so adding a repo is a +// DATA EDIT, never code. A malformed registry fails LOUDLY with +// field-precise errors; N consecutive failures (default 7, IN THE SCHEMA) +// flag the entry for human retire-or-confirm (derived — the flag can never +// disagree with the counter it reads). +// +// Same idiom as mode_registry.test.ts: the ONE shared validator both the +// extension's vitest suite and amico-run's lenses import — a registry that +// passes tests passes the machinery. +import { describe, it, expect } from "vitest"; +import { + validateWatchedRepoRegistry, + parseWatchedRepoRegistry, + flaggedForRetireOrConfirm, + DEFAULT_FAILURE_THRESHOLD, + type WatchedRepo, +} from "../src/watched_repos.js"; + +const GOOD = ` +schema_version = "1" + +[[repos]] +repo = "anomalyco/opencode" +why_watched = "the canonical harness our vendored fork tracks — drift here is drift in the product" +domains = ["agent-harness"] +fetch_surface = ["releases", "issues"] +match_keywords = ["plugin", "permission", "session"] +last_success = "2026-09-05T01:02:03Z" +consecutive_failures = 0 +`; + +const MINIMAL = ` +schema_version = "1" + +[[repos]] +repo = "earendil-works/pi" +why_watched = "the minimal-core harness field the fork-split session surveyed" +domains = ["agent-harness"] +fetch_surface = ["releases"] +match_keywords = ["session"] +`; + +describe("validateWatchedRepoRegistry (S2 — validator-checked data)", () => { + it("a well-formed registry validates, zero errors", () => { + const v = validateWatchedRepoRegistry(GOOD); + expect(v.errors, v.errors.join("\n")).toEqual([]); + expect(v.ok).toBe(true); + }); + + it("accepts a parsed object (not just text) — the IO seam stays in amico-run", () => { + expect(validateWatchedRepoRegistry(parseWatchedRepoRegistry(GOOD)).ok).toBe(true); + }); + + it("a malformed TOML registry fails NAMED (parse error, never a silent empty)", () => { + const v = validateWatchedRepoRegistry("this is [not toml"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => /parse error/.test(e))).toBe(true); + }); + + it("a schema violation fails FIELD-PRECISE: a missing why_watched names the key", () => { + const bad = MINIMAL.replace('why_watched = "the minimal-core harness field the fork-split session surveyed"\n', ""); + const v = validateWatchedRepoRegistry(bad); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => /why_watched/.test(e))).toBe(true); + }); + + it("a bad fetch surface (outside the enum) fails, the allowed set rendered", () => { + const v = validateWatchedRepoRegistry(MINIMAL.replace('fetch_surface = ["releases"]', 'fetch_surface = ["wiki"]')); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => /releases/.test(e) && /changelog/.test(e))).toBe(true); + }); + + it("a non-canonical repo slug (no owner, or a path traversal) fails the pattern", () => { + for (const repo of ["opencode", "../etc/passwd", "a/b/c", ""]) { + const v = validateWatchedRepoRegistry(MINIMAL.replace("repo = \"earendil-works/pi\"", `repo = "${repo}"`)); + expect(v.ok, `repo="${repo}" must fail`).toBe(false); + expect(v.errors.some((e) => /repo/.test(e))).toBe(true); + } + }); + + it("an empty match_keywords array fails (a watched repo with no match vocabulary watches nothing)", () => { + const v = validateWatchedRepoRegistry(MINIMAL.replace('match_keywords = ["session"]', "match_keywords = []")); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => /match_keywords/.test(e))).toBe(true); + }); + + it("an unknown top-level key fails (additionalProperties: false — the registry is closed data)", () => { + const v = validateWatchedRepoRegistry(GOOD + "\nextra_key = true\n"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => /extra_key/.test(e))).toBe(true); + }); + + it("a duplicate repo entry fails at registry level (one repo, one entry — the dedup discipline)", () => { + const dup = MINIMAL + MINIMAL.slice(MINIMAL.indexOf("[[repos]]")); + const v = validateWatchedRepoRegistry(dup); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => /duplicate/.test(e))).toBe(true); + expect(() => parseWatchedRepoRegistry(dup)).toThrow(/duplicate/); + }); +}); + +describe("parseWatchedRepoRegistry — the machinery's typed read", () => { + it("parses a full registry: every field, byte-faithful", () => { + const r = parseWatchedRepoRegistry(GOOD); + expect(r.schema_version).toBe("1"); + expect(r.repos).toHaveLength(1); + expect(r.repos[0]).toMatchObject({ + repo: "anomalyco/opencode", + why_watched: "the canonical harness our vendored fork tracks — drift here is drift in the product", + domains: ["agent-harness"], + fetch_surface: ["releases", "issues"], + match_keywords: ["plugin", "permission", "session"], + last_success: "2026-09-05T01:02:03Z", + consecutive_failures: 0, + }); + }); + + it("applies the IN-SCHEMA defaults: failure_threshold 7; omitted stamps read as empty/zero", () => { + expect(DEFAULT_FAILURE_THRESHOLD).toBe(7); // the schema carries the default — one source of truth + const r = parseWatchedRepoRegistry(MINIMAL); + expect(r.failure_threshold).toBe(7); + expect(r.repos[0].last_success).toBe(""); + expect(r.repos[0].consecutive_failures).toBe(0); + }); + + it("an explicit failure_threshold overrides the default (per-fleet tuning is data too)", () => { + const r = parseWatchedRepoRegistry(`schema_version = "1"\nfailure_threshold = 3\n` + MINIMAL.split("\n").slice(2).join("\n")); + expect(r.failure_threshold).toBe(3); + }); + + it("an invalid registry THROWS field-precise (loud authoring failure, the parseModeManifest idiom)", () => { + expect(() => parseWatchedRepoRegistry("nope")).toThrow(/watched-repos\.toml/); + }); +}); + +describe("flaggedForRetireOrConfirm (N-failure flagging — derived, never stored)", () => { + const entry = (n: number): WatchedRepo => ({ + repo: "earendil-works/pi", + why_watched: "field survey", + domains: ["agent-harness"], + fetch_surface: ["releases"], + match_keywords: ["session"], + last_success: "", + consecutive_failures: n, + }); + + it("below the threshold: active, not flagged", () => { + expect(flaggedForRetireOrConfirm(entry(6), 7)).toBe(false); + expect(flaggedForRetireOrConfirm(entry(0), 7)).toBe(false); + }); + + it("AT and past the threshold: flagged for human retire-or-confirm (quiet failures are the ones that matter)", () => { + expect(flaggedForRetireOrConfirm(entry(7), 7)).toBe(true); + expect(flaggedForRetireOrConfirm(entry(9), 7)).toBe(true); + }); + + it("the flag respects a registry's explicit threshold (data, not code)", () => { + expect(flaggedForRetireOrConfirm(entry(3), 3)).toBe(true); + expect(flaggedForRetireOrConfirm(entry(2), 3)).toBe(false); + }); +});