@staix/agent-research 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +108 -14
- package/package.json +4 -2
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +18 -5
- package/src/commands-table.ts +6 -3
- package/src/commands.ts +56 -15
- package/src/copilot.ts +91 -0
- package/src/exp/attempts.ts +12 -4
- package/src/exp/investigate.ts +7 -2
- package/src/exp/protocol.ts +4 -1
- package/src/http.ts +46 -2
- package/src/lit/watch.ts +14 -1
- package/src/manifest.ts +3 -2
- package/src/mcp-tools.ts +18 -8
- package/src/mcp.ts +2 -0
- package/src/memory/archive.ts +3 -0
- package/src/paper/build.ts +4 -1
- package/src/paper/check.ts +4 -3
- package/src/paper/placeholders.ts +5 -3
- package/src/paper/review.ts +15 -3
- package/src/plan/controller.ts +42 -12
- package/src/plan/definition.ts +17 -5
- package/src/plan/state.ts +53 -17
- package/src/playbook.ts +14 -2
- package/src/study-export.ts +254 -0
- package/src/study.ts +45 -17
- package/src/ui/panel.js +8 -3
- package/src/ui/plugin.ts +21 -7
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve, isAbsolute, sep } from "node:path";
|
|
3
|
+
import { readAttemptRecords, type Attempt, type AttemptStatus } from "./exp/attempts.ts";
|
|
4
|
+
import { ablateResultPath, readClosedAblate } from "./exp/ablate.ts";
|
|
5
|
+
import { finalResultPath, readClosedFinal } from "./exp/final.ts";
|
|
6
|
+
import { readClosedInvestigation } from "./exp/investigate.ts";
|
|
7
|
+
import { readSelection } from "./exp/selection.ts";
|
|
8
|
+
import { designBinding, protocolHash, readProtocol } from "./exp/protocol.ts";
|
|
9
|
+
import { FORMAT_VERSIONS } from "./manifest.ts";
|
|
10
|
+
import { PAPER_EVIDENCE_BYTES, paperBuildPaths } from "./paper/build.ts";
|
|
11
|
+
import { assertWritable, openStudy, plain, readLedger, readTextBounded, sha256, withLock, writeAtomic, type LedgerEvent } from "./study.ts";
|
|
12
|
+
|
|
13
|
+
/** Reader bound for every bundle file; writeAtomic refuses anything larger. */
|
|
14
|
+
export const BUNDLE_FILE_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
export const BUNDLE_FILES = ["manifest.json", "ledger.jsonl", "attempts.jsonl", "results.json", "reviews.jsonl", "evidence.json"] as const;
|
|
16
|
+
const STATUSES: AttemptStatus[] = ["running", "succeeded", "failed", "cancelled", "unknown"];
|
|
17
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
18
|
+
const HEX = /^[a-f0-9]{16,64}$/;
|
|
19
|
+
const TOKEN = /^[a-z][a-z0-9_.:-]{0,47}$/;
|
|
20
|
+
const GIT_ID = /^[a-f0-9]{40}$/, ATTEMPT_ID = /^[a-f0-9]{16}$/;
|
|
21
|
+
const ACTOR_KINDS = new Set(["person", "agent", "auto"]), EVIDENCE_FAMILIES = new Set(["attempt", "posthoc", "investigate", "ablate", "final"]);
|
|
22
|
+
/** Exact-shape field or null: hand-edited free text in an identity field is never copied. */
|
|
23
|
+
const shaped = (value: unknown, pattern: RegExp): string | null => typeof value === "string" && pattern.test(value) ? value : null;
|
|
24
|
+
const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
25
|
+
const METRIC_KEYS = new Set(["name", "unit", "metric"]);
|
|
26
|
+
/** Closed data keys per ledger event type; unlisted types keep the envelope with empty data. Never host, containerId or hubProject. */
|
|
27
|
+
const LEDGER_KEYS: Record<string, readonly string[]> = {
|
|
28
|
+
"scope.set": ["sha256"], "autonomy.set": ["from", "to", "phase"], "gate.approve": ["gate", "binding"], "gate.reject": ["gate", "binding"],
|
|
29
|
+
"lit.search": ["sources", "added", "merged", "total"], "lit.screen": ["decision"], "lit.card": ["id", "sha256"], "lit.bib": ["entries", "sha256"], "lit.verify": ["verified", "failed"],
|
|
30
|
+
"archive.exported": ["fingerprint"], "skill.proposed": ["skillProposal", "sha256"], "skill.decided": ["skillProposal", "decision"], "watch.set": ["sha256"],
|
|
31
|
+
"plan.set": ["revision", "state", "sha256"], "plan.gates": ["revision"], "plan.paused": ["revision"], "plan.control": ["revision"], "node.observed": ["revision", "status"], "node.intent": ["revision"],
|
|
32
|
+
"executor.set": ["kind"], "protocol.set": ["sha256", "protocolHash", "treeHash"], "selection.set": ["sha256", "metric"], "final.seeds.set": ["sha256"],
|
|
33
|
+
"investigate.closed": ["run", "sha256", "ruleSha256", "winner"], "ablate.closed": ["sha256", "investigateSha256"], "final.closed": ["sha256", "status", "investigateSha256"],
|
|
34
|
+
"idea.decided": ["decision"], "paper.built": ["manuscriptSha256", "evidenceSha256", "sourceManuscriptSha256"], "review.added": [], "review.resolved": ["buildSha256"],
|
|
35
|
+
"attempt.started": ["id", "run", "seed", "commit", "treeId"],
|
|
36
|
+
};
|
|
37
|
+
for (const t of ["attempt.run", "attempt.snapshot", "attempt.stage.changed", "attempt.stage.evidence", "attempt.stage.recovered", "attempt.stage.started", "attempt.succeeded", "attempt.failed", "attempt.cancelled", "attempt.unknown"]) LEDGER_KEYS[t] = ["id", "run", "stage", "status"];
|
|
38
|
+
|
|
39
|
+
export interface BundleManifest {
|
|
40
|
+
bundle: 1; formats: typeof FORMAT_VERSIONS; studyIdSha256: string; mode: string; protocolHash: string | null; sealBinding: string | null;
|
|
41
|
+
window: { from: string | null; to: string | null }; counts: { attempts: number; byStatus: Record<AttemptStatus, number>; byRun: Record<string, Record<AttemptStatus, number>>; ledgerEvents: number; reviews: number };
|
|
42
|
+
files: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
export interface BundleListing { bundle: string; files: string[]; wouldWrite: true }
|
|
45
|
+
|
|
46
|
+
/** A closed scalar: numbers, booleans, hashes and enum-like tokens survive; free text, paths and URLs do not. */
|
|
47
|
+
function closed(value: unknown): value is number | boolean | string | null {
|
|
48
|
+
if (value === null || typeof value === "boolean") return true;
|
|
49
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
50
|
+
return typeof value === "string" && (HEX.test(value) || TOKEN.test(value));
|
|
51
|
+
}
|
|
52
|
+
function reduceData(data: Record<string, unknown>, keys: readonly string[]): Record<string, unknown> {
|
|
53
|
+
const out: Record<string, unknown> = {};
|
|
54
|
+
for (const key of keys) { const value = Object.hasOwn(data, key) ? data[key] : undefined; if (closed(value) || (METRIC_KEYS.has(key) && typeof value === "string")) out[key] = value; else if (Array.isArray(value) && value.every(closed)) out[key] = value; }
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** Result keys that hold free text even when a value happens to be one token. */
|
|
58
|
+
const FREE_TEXT_KEYS = new Set(["removes", "description", "note", "notes", "label", "text", "message", "cause", "retryWhen", "method", "question"]);
|
|
59
|
+
/** Keep numbers, booleans, null, hashes and closed tokens recursively; drop free-text keys and every other string. */
|
|
60
|
+
function project(value: unknown): unknown {
|
|
61
|
+
if (Array.isArray(value)) return value.map(project).filter(v => v !== undefined);
|
|
62
|
+
if (plain(value)) { const out: Record<string, unknown> = {}; for (const [k, v] of Object.entries(value)) { if (FREE_TEXT_KEYS.has(k)) continue; const p = METRIC_KEYS.has(k) && typeof v === "string" ? v : project(v); if (p !== undefined) out[k] = p; } return out; }
|
|
63
|
+
return closed(value) ? value : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** A parseable ISO timestamp or null. */
|
|
66
|
+
const isoAt = (value: unknown): string | null => typeof value === "string" && value.length <= 64 && /^\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)) ? value : null;
|
|
67
|
+
function reduceEvent(event: LedgerEvent) {
|
|
68
|
+
const actor = { kind: ACTOR_KINDS.has(event.actor?.kind as string) ? event.actor.kind : "other", ...(event.actor.id !== undefined ? { idSha256: sha256(event.actor.id) } : {}) };
|
|
69
|
+
const at = isoAt(event.at);
|
|
70
|
+
return { v: event.v, at, actor, type: TOKEN.test(event.type) ? event.type : "other", data: reduceData(plain(event.data) ? event.data : {}, (Object.hasOwn(LEDGER_KEYS, event.type) ? LEDGER_KEYS[event.type]! : [])) };
|
|
71
|
+
}
|
|
72
|
+
// Closed codes emitted by attempts.ts, metrics.ts and artifacts.ts; never derive a class from user prose.
|
|
73
|
+
const ATTEMPT_REASON_CODES = new Set([
|
|
74
|
+
"executor-start", "executor-start-uncertain", "preparation", "lost", "wall-clock", "candidate-exit", "evaluator-exit",
|
|
75
|
+
"evidence-read", "invalid-metrics", "malformed-metrics", "undeclared-metric", "missing-metric", "invalid-metric-value",
|
|
76
|
+
"nonfinite-metric", "metrics-limit", "duplicate-metric", "missing-metrics", "missing-receipt", "changed-output",
|
|
77
|
+
"artifact-limit", "artifact-read", "invalid-artifact", "invalid-log-state", "missing-logs",
|
|
78
|
+
]);
|
|
79
|
+
function reasonClass(reason: string | undefined, status: AttemptStatus): string | undefined {
|
|
80
|
+
if (reason === undefined) return undefined;
|
|
81
|
+
if (status === "cancelled") return "cancel-reason";
|
|
82
|
+
return ATTEMPT_REASON_CODES.has(reason) ? reason : "other";
|
|
83
|
+
}
|
|
84
|
+
function duration(start: unknown, end: unknown): number | null {
|
|
85
|
+
if (typeof start !== "string" || typeof end !== "string") return null;
|
|
86
|
+
const ms = Date.parse(end) - Date.parse(start);
|
|
87
|
+
return Number.isFinite(ms) ? ms : null;
|
|
88
|
+
}
|
|
89
|
+
function digest(image: unknown): string | null {
|
|
90
|
+
const match = typeof image === "string" ? /@(sha256:[a-f0-9]{64})$/.exec(image) : null;
|
|
91
|
+
return match?.[1] ?? null;
|
|
92
|
+
}
|
|
93
|
+
function attemptRow(a: Attempt) {
|
|
94
|
+
const stages: Record<string, unknown> = {};
|
|
95
|
+
for (const name of ["candidate", "evaluator"] as const) {
|
|
96
|
+
const stage = plain(a.stages) && Object.hasOwn(a.stages, name) ? a.stages[name] : undefined;
|
|
97
|
+
if (!plain(stage)) continue;
|
|
98
|
+
stages[name] = { exitCode: Number.isSafeInteger(stage.exitCode) ? stage.exitCode : null, durationMs: duration(stage.startedAt, stage.endedAt), network: stage.network === "none" ? "none" : "other", limits: plain(stage.limits) ? reduceData(stage.limits, ["cpus", "memoryMb", "pidsLimit", "wallSeconds", "gpus"]) : null, imageDigest: digest(stage.image) };
|
|
99
|
+
}
|
|
100
|
+
const reason = reasonClass(a.reason, a.status);
|
|
101
|
+
return {
|
|
102
|
+
id: a.id, run: a.run, studyIdSha256: sha256(String(a.studyId)), protocolHash: shaped(a.protocolHash, SHA256), sealBinding: shaped(a.sealBinding, SHA256), commit: shaped(a.commit, GIT_ID), treeId: shaped(a.treeId, GIT_ID),
|
|
103
|
+
snapshotSha256: shaped(a.snapshotSha256, SHA256), seed: Number.isSafeInteger(a.seed) ? a.seed : null, labelSha256: sha256(String(a.label ?? "")), retryOf: shaped(a.retryOf, ATTEMPT_ID), status: a.status, stage: a.stage, ...(reason ? { reasonClass: reason } : {}),
|
|
104
|
+
durationMs: duration(a.startedAt, a.endedAt), stages,
|
|
105
|
+
metrics: (Array.isArray(a.metrics) ? a.metrics : []).map(m => ({ name: m.name, unit: m.unit, value: finite(m.value), provenance: m.provenance === "ares-observed" ? "ares-observed" : "other" })),
|
|
106
|
+
outputs: (Array.isArray(a.outputs) ? a.outputs : []).map(o => ({ sha256: shaped(o.sha256, SHA256), bytes: finite(o.bytes) })),
|
|
107
|
+
sealing: plain(a.sealing) ? { separateContainers: a.sealing.separateContainers === true, evaluatorReadOnly: a.sealing.evaluatorReadOnly === true, candidateHasNoEvaluator: a.sealing.candidateHasNoEvaluator === true } : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function closedResult<T>(name: string, read: () => { result: T }): unknown {
|
|
111
|
+
try { return project(read().result); }
|
|
112
|
+
catch (error) { throw new Error(`study export: ${name} result is not a verified closed result (${(error as Error).message}); fix it as that message says, then export again`); }
|
|
113
|
+
}
|
|
114
|
+
function reviews(events: LedgerEvent[]) {
|
|
115
|
+
const resolved = new Map<string, number>();
|
|
116
|
+
for (const e of events) if (e.type === "review.resolved" && typeof e.data.reviewId === "string") resolved.set(e.data.reviewId, (resolved.get(e.data.reviewId) ?? 0) + 1);
|
|
117
|
+
return events.filter(e => e.type === "review.added" && plain(e.data.review)).map(e => {
|
|
118
|
+
const review = e.data.review as Record<string, unknown>, findings = Array.isArray(review.findings) ? review.findings as Record<string, unknown>[] : [];
|
|
119
|
+
const reviewer = plain(review.reviewer) ? review.reviewer : {}, kinds: Record<string, number> = {};
|
|
120
|
+
for (const f of findings) { const kind = typeof f.kind === "string" && TOKEN.test(f.kind) ? f.kind : "other"; kinds[kind] = (kinds[kind] ?? 0) + 1; }
|
|
121
|
+
const id = typeof e.data.id === "string" ? e.data.id : "";
|
|
122
|
+
const family = typeof reviewer.modelFamily === "string" ? reviewer.modelFamily.trim().toLowerCase() : "";
|
|
123
|
+
return { idSha256: sha256(id), at: isoAt(e.at), modelFamily: /^[a-z0-9][a-z0-9 ._-]{0,47}$/.test(family) ? family : "other", buildSha256: typeof review.buildSha256 === "string" && HEX.test(review.buildSha256) ? review.buildSha256 : null, findings: findings.length, blocking: findings.filter(f => f.blocking === true).length, kinds, resolved: resolved.get(id) ?? 0 };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function evidence(dir: string) {
|
|
127
|
+
const path = paperBuildPaths(dir).evidence;
|
|
128
|
+
if (!existsSync(path)) return null;
|
|
129
|
+
let raw: unknown;
|
|
130
|
+
try { raw = JSON.parse(readTextBounded(path, PAPER_EVIDENCE_BYTES)); } catch { throw new Error("study export: paper/build/evidence.json is unreadable, invalid JSON or oversized; rebuild the paper before exporting"); }
|
|
131
|
+
if (!plain(raw) || raw.v !== 1 || !Array.isArray(raw.placeholders)) throw new Error("study export: paper/build/evidence.json has an unsupported shape; rebuild the paper before exporting");
|
|
132
|
+
return {
|
|
133
|
+
v: 1, manuscriptSha256: typeof raw.manuscriptSha256 === "string" && SHA256.test(raw.manuscriptSha256) ? raw.manuscriptSha256 : null,
|
|
134
|
+
placeholders: (raw.placeholders as Record<string, unknown>[]).filter(p => p.kind === "number").map(p => {
|
|
135
|
+
const ref = typeof p.ref === "string" ? p.ref : "", attempt = /^(?:attempt|posthoc):([a-f0-9]{16})\./.exec(ref)?.[1];
|
|
136
|
+
const source = plain(p.source) ? p.source : {};
|
|
137
|
+
return { refSha256: sha256(ref), family: ((f: string | undefined) => f && EVIDENCE_FAMILIES.has(f) ? f : "other")(/^[a-z]+/.exec(ref)?.[0]), ...(attempt ? { attempt } : {}), sourceSha256: typeof source.sha256 === "string" && SHA256.test(source.sha256) ? source.sha256 : null };
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Path rules run on decoded keys and string values, so a boundary is a real character (a raw newline or tab included). */
|
|
143
|
+
const PATH_SCANS: RegExp[] = [
|
|
144
|
+
// POSIX: `/` or `~/` not preceded by a letter, digit or underscore; units such as tokens/s keep a word character there.
|
|
145
|
+
/(?<![\p{L}\p{N}_])(?:\/|~\/)/u,
|
|
146
|
+
/\b[A-Za-z]:\\/,
|
|
147
|
+
// Windows root-relative (\Users\x), bare \ and UNC (\\server): any backslash not preceded by a word character or backslash.
|
|
148
|
+
/(?<![\p{L}\p{N}_\\])\\/u,
|
|
149
|
+
];
|
|
150
|
+
/** URL, key-like and env rules, also run on decoded strings. */
|
|
151
|
+
const SCANS: [string, RegExp][] = [
|
|
152
|
+
["url", /\b[a-z][a-z0-9+.-]*:\/\/|\b(?:mailto|urn|file|data):/i],
|
|
153
|
+
["key-like token", /\b(?:sk|pk|rk)[-_][A-Za-z0-9_-]{12,}|\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{12,}|\bxox[abprs]-[A-Za-z0-9-]{8,}|\bAKIA[0-9A-Z]{12,}|\beyJ[A-Za-z0-9_-]{16,}/],
|
|
154
|
+
["key-like token", /(?<![A-Za-z0-9_-])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}(?![A-Za-z0-9_-])/],
|
|
155
|
+
["env value", /(?<![A-Za-z0-9_])[A-Za-z_][A-Za-z0-9_]*=[^\s"]/],
|
|
156
|
+
];
|
|
157
|
+
/** Every key and string value of a JSON document, or of each JSONL line; text that is not JSON is scanned as one string. */
|
|
158
|
+
function* decodedStrings(text: string): Generator<string> {
|
|
159
|
+
const walk = function* (value: unknown): Generator<string> {
|
|
160
|
+
if (typeof value === "string") yield value;
|
|
161
|
+
else if (Array.isArray(value)) for (const item of value) yield* walk(item);
|
|
162
|
+
else if (value && typeof value === "object") for (const [key, item] of Object.entries(value)) { yield key; yield* walk(item); }
|
|
163
|
+
};
|
|
164
|
+
let whole: unknown, parsed = false;
|
|
165
|
+
try { whole = JSON.parse(text); parsed = true; } catch { /* JSONL or plain text */ }
|
|
166
|
+
if (parsed) { yield* walk(whole); return; }
|
|
167
|
+
for (const line of text.split("\n")) {
|
|
168
|
+
if (!line) continue;
|
|
169
|
+
try { yield* walk(JSON.parse(line)); } catch { yield line; }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Refuse the whole bundle if any file carries a path, URL, env assignment or key-like token. */
|
|
173
|
+
export function scanBundle(files: Record<string, string>): void {
|
|
174
|
+
const refuse = (name: string, kind: string): never => { throw new Error(`study export: refused; ${name} would contain a ${kind}. Remove it from the study record (for example an attempt label or ledger field) and retry; nothing was written`); };
|
|
175
|
+
for (const [name, text] of Object.entries(files)) {
|
|
176
|
+
for (const value of decodedStrings(text)) {
|
|
177
|
+
if (PATH_SCANS.some(pattern => pattern.test(value))) refuse(name, "absolute path");
|
|
178
|
+
for (const [kind, pattern] of SCANS) if (pattern.test(value)) refuse(name, kind);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function emptyDir(path: string): void {
|
|
184
|
+
if (existsSync(path)) { if (!lstatSync(path).isDirectory()) throw new Error("study export: --bundle exists and is not a directory; choose a new or empty directory"); if (readdirSync(path).length) throw new Error("study export: --bundle directory is not empty; choose a new or empty directory"); }
|
|
185
|
+
else mkdirSync(path, { recursive: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Real path of the nearest existing ancestor with the missing tail re-appended, so symlinks cannot hide containment. */
|
|
189
|
+
function realTarget(path: string): string {
|
|
190
|
+
const tail: string[] = [];
|
|
191
|
+
let current = path;
|
|
192
|
+
while (!existsSync(current)) { const parent = dirname(current); if (parent === current) break; tail.unshift(basename(current)); current = parent; }
|
|
193
|
+
return join(realpathSync(current), ...tail);
|
|
194
|
+
}
|
|
195
|
+
/** Caller holds the study lock across every bounded source read. */
|
|
196
|
+
function readBundle(dirArg: string, maxFileBytes: number) {
|
|
197
|
+
const { dir, study } = openStudy(dirArg);
|
|
198
|
+
const events = readLedger(dir);
|
|
199
|
+
const zero = () => Object.fromEntries(STATUSES.map(s => [s, 0])) as Record<AttemptStatus, number>;
|
|
200
|
+
const byStatus = zero(), runCounts = new Map<string, Record<AttemptStatus, number>>(), attemptLines: string[] = [];
|
|
201
|
+
let attemptBytes = 0;
|
|
202
|
+
// One pass; only the projected row and counts outlive each raw record.
|
|
203
|
+
for (const a of study.mode === "experiment" ? readAttemptRecords(dir) : []) {
|
|
204
|
+
const line = JSON.stringify(attemptRow(a)) + "\n";
|
|
205
|
+
attemptBytes += Buffer.byteLength(line);
|
|
206
|
+
if (attemptBytes > maxFileBytes) throw new Error(`study export: attempts.jsonl would exceed ${maxFileBytes} bytes; nothing was written`);
|
|
207
|
+
attemptLines.push(line);
|
|
208
|
+
byStatus[a.status]++;
|
|
209
|
+
let row = runCounts.get(a.run); if (!row) runCounts.set(a.run, row = zero()); row[a.status]++;
|
|
210
|
+
}
|
|
211
|
+
const byRun: Record<string, Record<AttemptStatus, number>> = Object.create(null);
|
|
212
|
+
for (const [run, row] of [...runCounts].sort(([x], [y]) => x < y ? -1 : 1)) byRun[run] = row;
|
|
213
|
+
const experiment = study.mode === "experiment", current = experiment ? readProtocol(dir) : undefined, rule = current ? readSelection(dir, current) : undefined;
|
|
214
|
+
const investigate: Record<string, unknown> = {};
|
|
215
|
+
if (rule && existsSync(join(dir, "runs", rule.run, "investigate.json"))) investigate[rule.run] = closedResult("investigate", () => readClosedInvestigation(dir));
|
|
216
|
+
const reviewRows = reviews(events), protocol = study.mode === "experiment" ? readProtocol(dir) : undefined;
|
|
217
|
+
const lines = (rows: unknown[]) => rows.map(r => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : "");
|
|
218
|
+
const files: Record<string, string> = {
|
|
219
|
+
"ledger.jsonl": lines(events.map(reduceEvent)),
|
|
220
|
+
"attempts.jsonl": attemptLines.join(""),
|
|
221
|
+
"results.json": JSON.stringify({
|
|
222
|
+
investigate,
|
|
223
|
+
ablate: experiment && existsSync(ablateResultPath(dir)) ? closedResult("ablate", () => readClosedAblate(dir)) : null,
|
|
224
|
+
final: experiment && existsSync(finalResultPath(dir)) ? closedResult("final", () => readClosedFinal(dir)) : null,
|
|
225
|
+
}, null, 2) + "\n",
|
|
226
|
+
"reviews.jsonl": lines(reviewRows),
|
|
227
|
+
"evidence.json": JSON.stringify(evidence(dir), null, 2) + "\n",
|
|
228
|
+
};
|
|
229
|
+
const manifest: BundleManifest = {
|
|
230
|
+
bundle: 1, formats: { ...FORMAT_VERSIONS }, studyIdSha256: sha256(study.id), mode: study.mode,
|
|
231
|
+
protocolHash: protocol ? protocolHash(protocol) : null, sealBinding: study.mode === "experiment" ? designBinding(dir) ?? null : null,
|
|
232
|
+
window: (() => { const ats = events.map(e => isoAt(e.at)).filter((v): v is string => v !== null); return { from: ats[0] ?? null, to: ats.at(-1) ?? null }; })(),
|
|
233
|
+
counts: { attempts: attemptLines.length, byStatus, byRun, ledgerEvents: events.length, reviews: reviewRows.length },
|
|
234
|
+
files: Object.fromEntries(Object.entries(files).map(([name, text]) => [name, sha256(text)])),
|
|
235
|
+
};
|
|
236
|
+
files["manifest.json"] = JSON.stringify(manifest, null, 2) + "\n";
|
|
237
|
+
return { files, manifest };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Read one locked study snapshot, then validate and publish without holding the study lock. */
|
|
241
|
+
export async function exportBundle(dirArg: string, options: { bundle: string; list?: boolean }, maxFileBytes = BUNDLE_FILE_BYTES): Promise<BundleManifest | BundleListing> {
|
|
242
|
+
const dir = resolve(dirArg), target = resolve(options.bundle), inside = relative(realpathSync(dir), realTarget(target));
|
|
243
|
+
if (inside === "" || (!(inside === ".." || inside.startsWith(".." + sep)) && !isAbsolute(inside))) throw new Error("study export: --bundle is inside the study directory; choose a directory outside the study");
|
|
244
|
+
if (options.list) {
|
|
245
|
+
await withLock(dir, () => openStudy(dir));
|
|
246
|
+
return { bundle: options.bundle, files: [...BUNDLE_FILES], wouldWrite: true };
|
|
247
|
+
}
|
|
248
|
+
const { files, manifest } = await withLock(dir, () => readBundle(dir, maxFileBytes));
|
|
249
|
+
scanBundle(files);
|
|
250
|
+
for (const name of BUNDLE_FILES) assertWritable(files[name]!, maxFileBytes, `study export: ${name}; nothing was written`);
|
|
251
|
+
emptyDir(target);
|
|
252
|
+
for (const name of BUNDLE_FILES) writeAtomic(join(target, name), files[name]!, maxFileBytes);
|
|
253
|
+
return manifest;
|
|
254
|
+
}
|
package/src/study.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateAutonomy, validateAutonomyMode, resolveAutonomy, ACCEPT_ARTIFACTS, type Autonomy, type Phase, type AcceptArtifact } from "./autonomy.ts";
|
|
2
|
+
export type { Autonomy } from "./autonomy.ts";
|
|
1
3
|
import { hostname } from "node:os";
|
|
2
4
|
import { spawnSync } from "node:child_process";
|
|
3
5
|
import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, mkdirSync, openSync, readSync, renameSync, statSync, unlinkSync, writeSync, appendFileSync, lstatSync } from "node:fs";
|
|
@@ -5,10 +7,9 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
5
7
|
import { dirname, join, resolve } from "node:path";
|
|
6
8
|
|
|
7
9
|
export type Mode = "survey" | "experiment";
|
|
8
|
-
export type
|
|
9
|
-
export type GateName = "scope" | "screen" | "design" | "final" | "review" | `plan:${string}`;
|
|
10
|
+
export type GateName = "scope" | "screen" | "design" | "final" | "review" | `plan:${string}` | `accept:${AcceptArtifact}`;
|
|
10
11
|
export const GATES: readonly GateName[] = ["scope", "screen", "design", "final", "review"];
|
|
11
|
-
export interface Study { v: 1; id: string; question: string; mode: Mode; autonomy: Autonomy; createdAt: string }
|
|
12
|
+
export interface Study { v: 1 | 2; id: string; question: string; mode: Mode; autonomy: Autonomy; createdAt: string }
|
|
12
13
|
export interface Actor { kind: "person" | "agent" | "auto"; id?: string }
|
|
13
14
|
export function requireExperiment(study: Study): void {
|
|
14
15
|
if (study.mode !== "experiment") throw new Error("experiment command: study mode must be experiment; create a study with ares init <dir> --question TEXT --mode experiment");
|
|
@@ -105,19 +106,22 @@ export function openStudy(dir: string): { dir: string; study: Study } {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
function validStudy(raw: unknown): Study {
|
|
108
|
-
if (plain(raw) && raw.v !== 1) throw new Error("study.json: unsupported schema version; use an ares version that supports this study, keeping the original file unchanged");
|
|
109
|
-
if (!plain(raw) || raw.v !== 1 || typeof raw.id !== "string" || typeof raw.question !== "string" || typeof raw.createdAt !== "string" ||
|
|
110
|
-
!["survey", "experiment"].includes(raw.mode as string)
|
|
111
|
-
|
|
109
|
+
if (plain(raw) && raw.v !== 1 && raw.v !== 2) throw new Error("study.json: unsupported schema version; use an ares version that supports this study, keeping the original file unchanged");
|
|
110
|
+
if (!plain(raw) || (raw.v !== 1 && raw.v !== 2) || typeof raw.id !== "string" || typeof raw.question !== "string" || typeof raw.createdAt !== "string" ||
|
|
111
|
+
!["survey", "experiment"].includes(raw.mode as string)) throw new Error("study.json: malformed");
|
|
112
|
+
let autonomy: Autonomy;
|
|
113
|
+
try { autonomy = validateAutonomy(raw.autonomy); }
|
|
114
|
+
catch { throw new Error("study.json: unsupported or malformed autonomy policy; restore the last valid study.json or use a compatible ares version before retrying"); }
|
|
115
|
+
if (raw.v === 1 && (typeof autonomy !== "string" || autonomy === "copilot" || autonomy === "step")) throw new Error("study.json: this autonomy policy requires study v2; restore the valid v1 study and use ares autonomy set with a compatible ares version");
|
|
116
|
+
return { ...raw, autonomy } as unknown as Study;
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
export function initStudy(dir: string, options: { question: string; mode?: string; autonomy?: string }, actor: Actor): Study {
|
|
115
120
|
const root = resolve(dir);
|
|
116
121
|
if (existsSync(join(root, "study.json"))) throw new Error(`${root}: a study already exists here`);
|
|
117
|
-
const mode = options.mode ?? "survey", autonomy = options.autonomy ?? "gates";
|
|
122
|
+
const mode = options.mode ?? "survey", autonomy = validateAutonomyMode(options.autonomy ?? "gates");
|
|
118
123
|
if (mode !== "survey" && mode !== "experiment") throw new Error("--mode: survey or experiment");
|
|
119
|
-
|
|
120
|
-
const study: Study = { v: 1, id: randomUUID(), question: boundedText(options.question, "--question", QUESTION_MAX), mode, autonomy, createdAt: new Date().toISOString() };
|
|
124
|
+
const study: Study = { v: autonomy === "auto" || autonomy === "gates" ? 1 : 2, id: randomUUID(), question: boundedText(options.question, "--question", QUESTION_MAX), mode, autonomy, createdAt: new Date().toISOString() };
|
|
121
125
|
for (const sub of ["", "literature", "literature/cards", "refs"]) mkdirSync(join(root, sub), { recursive: true });
|
|
122
126
|
// link() refuses an existing target, so two concurrent inits cannot both create the study.
|
|
123
127
|
const text = JSON.stringify(study, null, 2) + "\n", tmp = join(root, `study.json.${randomUUID()}.tmp`);
|
|
@@ -133,10 +137,15 @@ export function initStudy(dir: string, options: { question: string; mode?: strin
|
|
|
133
137
|
|
|
134
138
|
/**
|
|
135
139
|
* Exclusive write lock for one study. A lock older than 10 minutes is treated as abandoned and replaced.
|
|
136
|
-
* ponytail: mtime-based staleness, no owner liveness check
|
|
140
|
+
* ponytail: mtime-based staleness, no owner liveness check. The inode check before unlink is best-effort:
|
|
141
|
+
* a replacement between lstat and unlink can still be removed, after stale takeover of a live owner has
|
|
142
|
+
* already broken exclusion. Upgrade path: an owner liveness probe, including shared-host ownership.
|
|
137
143
|
*/
|
|
144
|
+
const ownedStudyLocks = new Set<() => void>();
|
|
145
|
+
/** MCP termination only: call immediately before process exit, never while continuing async work. */
|
|
146
|
+
export function releaseStudyLocksForExit(): void { for (const release of [...ownedStudyLocks]) release(); }
|
|
138
147
|
export class StudyLockBusyError extends Error {}
|
|
139
|
-
export async function withLock<T>(dir: string, fn: () => Promise<T> | T): Promise<T> {
|
|
148
|
+
export async function withLock<T>(dir: string, fn: () => Promise<T> | T, inspectOwner: (fd: number) => ReturnType<typeof fstatSync> = fstatSync): Promise<T> {
|
|
140
149
|
const path = join(dir, ".ares.lock");
|
|
141
150
|
let fd: number;
|
|
142
151
|
try { fd = openSync(path, "wx", 0o600); }
|
|
@@ -148,8 +157,25 @@ export async function withLock<T>(dir: string, fn: () => Promise<T> | T): Promis
|
|
|
148
157
|
try { unlinkSync(path); } catch { /* raced with another cleaner */ }
|
|
149
158
|
fd = openSync(path, "wx", 0o600);
|
|
150
159
|
}
|
|
151
|
-
|
|
152
|
-
try {
|
|
160
|
+
let owner: ReturnType<typeof fstatSync>;
|
|
161
|
+
try { owner = inspectOwner(fd); }
|
|
162
|
+
catch (error) {
|
|
163
|
+
try { unlinkSync(path); } catch { /* the just-created lock may already be absent */ }
|
|
164
|
+
finally { try { closeSync(fd); } catch { /* preserve the original probe failure */ } }
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
let released = false;
|
|
168
|
+
const release = () => {
|
|
169
|
+
if (released) return; released = true;
|
|
170
|
+
try { const current = lstatSync(path); if (current.dev === owner.dev && current.ino === owner.ino) unlinkSync(path); }
|
|
171
|
+
catch { /* absent or replaced; never remove a different owner's lock */ }
|
|
172
|
+
finally { try { closeSync(fd); } catch { /* process exit also closes owned descriptors */ } ownedStudyLocks.delete(release); }
|
|
173
|
+
};
|
|
174
|
+
ownedStudyLocks.add(release);
|
|
175
|
+
try { writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`); }
|
|
176
|
+
catch (error) { release(); throw error; }
|
|
177
|
+
// Keep the descriptor open until release so an unlinked inode cannot be reused by a replacement lock.
|
|
178
|
+
try { return await fn(); } finally { release(); }
|
|
153
179
|
}
|
|
154
180
|
|
|
155
181
|
export interface PendingEvent { actor: Actor; type: string; data: Record<string, unknown> }
|
|
@@ -217,8 +243,9 @@ export function gateState(gate: GateName, events: LedgerEvent[], binder: Binder)
|
|
|
217
243
|
}
|
|
218
244
|
|
|
219
245
|
export function decideGate(dir: string, gate: GateName, decision: "approve" | "reject", actor: Actor, binder: Binder, reason?: string): GateState {
|
|
220
|
-
if (!GATES.includes(gate) && !gate.startsWith("plan:")) throw new Error(`unknown gate: ${gate}; run ares gate list to select a gate`);
|
|
246
|
+
if (!GATES.includes(gate) && !ACCEPT_ARTIFACTS.some(artifact => gate === `accept:${artifact}`) && !gate.startsWith("plan:")) throw new Error(`unknown gate: ${gate}; run ares gate list to select a gate`);
|
|
221
247
|
const before = gateState(gate, readLedger(dir), binder);
|
|
248
|
+
if (before.state === "none" && gate.startsWith("accept:")) throw new Error(`gate ${gate}: no current copilot draft; inspect ares autonomy show, create the phase artifact, then use ares gate list and ares gate approve <dir> ${gate}`);
|
|
222
249
|
if (before.state === "none") throw new Error(`gate ${gate}: nothing to decide yet${gate.startsWith("plan:") ? "; finish every upstream node with output references, then run ares gate list" : ""}`);
|
|
223
250
|
if (decision === "reject" && !reason) throw new Error(`gate ${gate}: a rejection needs --reason`);
|
|
224
251
|
appendEvent(dir, actor, decision === "approve" ? "gate.approve" : "gate.reject", { gate, binding: before.current!, ...(reason ? { reason } : {}) });
|
|
@@ -230,11 +257,12 @@ export function decideGate(dir: string, gate: GateName, decision: "approve" | "r
|
|
|
230
257
|
* `auto` approval goes into the command's own commit only when the command succeeds, so a failed command never
|
|
231
258
|
* changes a gate.
|
|
232
259
|
*/
|
|
233
|
-
export function requireGate(dir: string, study: Study, gate: GateName, binder: Binder): PendingEvent[] {
|
|
260
|
+
export function requireGate(dir: string, study: Study, gate: GateName, binder: Binder, guardedPhase?: Phase): PendingEvent[] {
|
|
234
261
|
if (gate === "design" || gate === "final") requireExperiment(study);
|
|
235
262
|
const state = gateState(gate, readLedger(dir), binder);
|
|
236
263
|
if (state.state === "approved") return [];
|
|
237
|
-
if (state.state === "pending" && study.autonomy === "auto") return [{ actor: { kind: "auto" }, type: "gate.approve", data: { gate, binding: state.current! } }];
|
|
264
|
+
if (state.state === "pending" && !gate.startsWith("accept:") && resolveAutonomy(study.autonomy, gate, guardedPhase) === "auto") return [{ actor: { kind: "auto" }, type: "gate.approve", data: { gate, binding: state.current! } }];
|
|
265
|
+
if (state.state === "none" && gate.startsWith("accept:")) throw new Error(`gate ${gate}: no current copilot draft; create the phase artifact, then run ares gate list and ares gate approve <dir> ${gate}`);
|
|
238
266
|
if (state.state === "none" && gate === "final") throw new Error("gate final: complete reserved seeds and close investigate/required ablate inputs before final approval");
|
|
239
267
|
if (state.state === "none" && gate.startsWith("plan:")) throw new Error(`gate ${gate}: upstream outputs are not complete; finish every upstream node and run ares gate list again`);
|
|
240
268
|
if (state.state === "none") throw new Error(gate === "scope" ? "gate scope: no scope yet (run ares scope)" : gate === "design" ? "gate design: no protocol yet (run ares protocol)" : "gate screen: no included candidates yet");
|
package/src/ui/panel.js
CHANGED
|
@@ -10,6 +10,8 @@ export default (host) => {
|
|
|
10
10
|
const number = (value) => typeof value === "number" && Number.isFinite(value) ? String(value) : "Unavailable";
|
|
11
11
|
const rows = (value) => Array.isArray(value) ? value : [];
|
|
12
12
|
const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
13
|
+
const autonomyLabel = (value) => typeof value === "string" ? text(value)
|
|
14
|
+
: `${text(record(value).default)}${Object.keys(record(record(value).phases)).length ? " (phase overrides)" : ""}`;
|
|
13
15
|
const line = (value, max = 180) => {
|
|
14
16
|
const valueText = text(value, "").replace(/\s+/g, " ").trim();
|
|
15
17
|
return valueText.length > max ? `${valueText.slice(0, max - 3)}...` : valueText;
|
|
@@ -84,7 +86,7 @@ export default (host) => {
|
|
|
84
86
|
const attempts = study.attempts?.available === false ? `Attempts unavailable: ${line(study.attempts.reason) || "Attempt records could not be read."}` : `${number(study.attempts?.total)} attempts`;
|
|
85
87
|
const pending = rows(study.gates).filter((gate) => gate.state === "pending").length;
|
|
86
88
|
return ui.row({ title: line(study.question) || text(study.id, "Unavailable study"),
|
|
87
|
-
sub: available ? [text(study.mode),
|
|
89
|
+
sub: available ? [text(study.mode), autonomyLabel(study.autonomy), attempts, `${pending} pending gates`, finalState ? `Final ${finalState}` : "Final unavailable", reviewState, watchState].join(" | ") : text(study.reason, "Study cannot be read; its registration is retained."),
|
|
88
90
|
chip: { text: available ? text(study.mode, "study") : "unavailable", tone: available ? "neutral" : "err" },
|
|
89
91
|
...(typeof study.dir === "string" ? { onOpen: () => go("study", { study: study.dir }) } : {}) });
|
|
90
92
|
}) ?? ui.empty("No registered studies", "Register one with ares console add <dir>. Ares does not scan the disk for studies.")];
|
|
@@ -106,8 +108,9 @@ export default (host) => {
|
|
|
106
108
|
if (!study) return [toolbar("Study", true), ui.notice(text(snapshot.error, "Choose a registered study from the studies panel."), "warn")];
|
|
107
109
|
const sections = [toolbar(text(study.question, "Study"), true), decisionNotice()];
|
|
108
110
|
if (study.available === false) return [...sections, ui.notice(text(study.reason, "Study unavailable; its registration is retained."), "err"), h("p", { class: "ares-sub", text: text(study.dir, "") })];
|
|
109
|
-
sections.push(h("div", { class: "ares-bar" }, ui.chip(text(study.mode), "neutral"), ui.chip(
|
|
111
|
+
sections.push(h("div", { class: "ares-bar" }, ui.chip(text(study.mode), "neutral"), ui.chip(autonomyLabel(study.autonomy), "neutral")),
|
|
110
112
|
h("p", { class: "ares-sub", text: text(study.dir, "") }), gateRows(study.gates, study.dir), attemptRows(study.attempts));
|
|
113
|
+
if (study.autonomy && typeof study.autonomy === "object") sections.push(block("Autonomy policy", study.autonomy));
|
|
111
114
|
const final = record(study.final);
|
|
112
115
|
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Final status" }),
|
|
113
116
|
typeof final.status === "string" ? ui.chip(final.status, tone(final.status)) : ui.empty("No current closed final"),
|
|
@@ -119,11 +122,13 @@ export default (host) => {
|
|
|
119
122
|
rows(review.openBlocking).length ? ui.notice(`${review.openBlocking.length} blocking findings remain open.`, "warn") : null));
|
|
120
123
|
if (typeof study.manuscript?.id === "string") sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Built manuscript" }),
|
|
121
124
|
ui.link({ asset: study.manuscript.id }, "Open manuscript text")));
|
|
125
|
+
const investigationArtifact = record(study.investigationArtifact);
|
|
126
|
+
if (investigationArtifact.state === "missing") sections.push(ui.notice(text(investigationArtifact.message, "Investigation artifact missing; displayed result is recalculated. Restore the artifact or run ares investigate close."), "warn"));
|
|
122
127
|
sections.push(block("Investigation", study.investigation), block("Posthoc exploration", study.posthoc));
|
|
123
128
|
const watch = record(study.watch), watchConfig = record(watch.config);
|
|
124
129
|
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Watch" }),
|
|
125
130
|
typeof watchConfig.rubric === "string" ? h("div", {}, h("strong", { text: "Display-only rubric" }), h("p", { class: "ares-prose", text: watchConfig.rubric })) : null,
|
|
126
|
-
ui.rows(rows(watch.runs), (run) => ui.row({ title: run.ok ? "Source answered" : "All sources failed", sub: `${at(run.at)} | ${number(run.added)} added | ${number(run.addedIdsTotal)} recorded ids`, chip: { text: run.ok ? "answered" : "failed", tone: run.ok ? "ok" : "err" } })) ?? ui.empty("No watch runs")));
|
|
131
|
+
ui.rows(rows(watch.runs), (run) => ui.row({ title: run.ok ? "Source answered" : run.cause === undefined ? "All sources failed" : ["terminated", "filesystem", "internal", "network", "rate", "server", "client", "format"].includes(run.cause) ? `Watch run failed (${run.cause})` : "Watch run failed (unknown cause)", sub: `${at(run.at)} | ${number(run.added)} added | ${number(run.addedIdsTotal)} recorded ids`, chip: { text: run.ok ? "answered" : "failed", tone: run.ok ? "ok" : "err" } })) ?? ui.empty("No watch runs")));
|
|
127
132
|
const candidates = rows(record(watch.newCandidates).candidates).filter((candidate) => candidate.status === "new" && typeof candidate.id === "string");
|
|
128
133
|
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "New candidates" }),
|
|
129
134
|
ui.rows(candidates, (candidate) => ui.row({ title: line(candidate.title) || candidate.id, sub: `${candidate.id}${typeof candidate.year === "number" ? ` | ${candidate.year}` : ""}`, verbs: typeof study.dir === "string" ? [
|
package/src/ui/plugin.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { previewArchive, ARCHIVE_NOTES, ArchivePartialError } from "../memory/ar
|
|
|
12
12
|
import { spawnRunner, type ProcessRunner } from "../exp/executor.ts";
|
|
13
13
|
import { readProtocol } from "../exp/protocol.ts";
|
|
14
14
|
import { readSelection, selectionPath } from "../exp/selection.ts";
|
|
15
|
+
import { investigateResultPath } from "../exp/investigate.ts";
|
|
15
16
|
import { readClosedFinal, finalResultPath } from "../exp/final.ts";
|
|
16
17
|
import { readCurrentBuild, paperBuildPaths } from "../paper/build.ts";
|
|
17
18
|
import { loadCandidates } from "../lit/candidates.ts";
|
|
@@ -39,12 +40,13 @@ const stamp = (value: unknown, fallback = EPOCH) => typeof value === "string" &&
|
|
|
39
40
|
function readCall<T>(name: string, fn: () => T, errors: Record<string, string>): T | null {
|
|
40
41
|
try { return fn(); } catch (cause) { errors[name] = error(cause); return null; }
|
|
41
42
|
}
|
|
42
|
-
interface GateInputs { selectionRun?: string; review?: { manuscriptSha256: string; evidenceSha256: string; ids: ReadonlySet<string> } }
|
|
43
|
+
export interface GateInputs { selectionRun?: string; review?: { manuscriptSha256: string; evidenceSha256: string; ids: ReadonlySet<string> } }
|
|
43
44
|
/** Every gate advances only when an input consumed by its binder changes, never merely on an event kind. */
|
|
44
|
-
function gateWaitingTimes(events: LedgerEvent[], inputs: GateInputs): Map<string, string> {
|
|
45
|
+
export function gateWaitingTimes(events: LedgerEvent[], inputs: GateInputs, observeSignature?: (gate: string, signature: string | undefined) => void): Map<string, string> {
|
|
45
46
|
const waiting = new Map<string, string>(), previous = new Map<string, string | undefined>();
|
|
46
47
|
const observe = (gate: string, input: unknown, at: string) => {
|
|
47
48
|
const signature = input === undefined ? undefined : canonical(input);
|
|
49
|
+
observeSignature?.(gate, signature);
|
|
48
50
|
if (previous.get(gate) === signature) return;
|
|
49
51
|
previous.set(gate, signature);
|
|
50
52
|
if (signature === undefined) waiting.delete(gate); else waiting.set(gate, at);
|
|
@@ -85,8 +87,8 @@ function gateWaitingTimes(events: LedgerEvent[], inputs: GateInputs): Map<string
|
|
|
85
87
|
experiment(event.at); break;
|
|
86
88
|
case "selection.set": if (typeof data.sha256 === "string") selection = data.sha256; experiment(event.at); break;
|
|
87
89
|
case "final.seeds.set": if (typeof data.sha256 === "string") reserved = data.sha256; experiment(event.at); break;
|
|
88
|
-
case "investigate.closed": if (data.run === inputs.selectionRun && typeof data.sha256 === "string") investigate = data.sha256; experiment(event.at); break;
|
|
89
|
-
case "
|
|
90
|
+
case "investigate.closed": if (data.run === inputs.selectionRun && typeof data.sha256 === "string") investigate = typeof data.winner === "string" && /^[a-f0-9]{40}$/.test(data.winner) ? data.sha256 : undefined; experiment(event.at); break;
|
|
91
|
+
case "ablation.added": hasAblations = true; experiment(event.at); break;
|
|
90
92
|
case "ablate.closed": if (typeof data.sha256 === "string") ablate = data.sha256; experiment(event.at); break;
|
|
91
93
|
case "paper.built":
|
|
92
94
|
if (typeof data.manuscriptSha256 === "string" && typeof data.evidenceSha256 === "string") paper = [data.manuscriptSha256, data.evidenceSha256];
|
|
@@ -124,9 +126,16 @@ function loadStudy(dir: string, features: readonly string[], input: { panel: str
|
|
|
124
126
|
};
|
|
125
127
|
const status = ops.status(dir), gates = ops.gateList(dir);
|
|
126
128
|
const attempt = study.mode === "experiment" ? readCall("attempts", () => attempts(dir, inbox), errors) : null;
|
|
129
|
+
let investigationArtifact: { state: "missing"; recalculated: true; message: string } | undefined;
|
|
127
130
|
const investigation = detailed && study.mode === "experiment" ? readCall("investigation", () => {
|
|
128
131
|
const configured = recordPresent(selectionPath(dir)) || events.some(event => event.type === "selection.set" || event.type === "investigate.closed");
|
|
129
|
-
|
|
132
|
+
if (!configured) return null;
|
|
133
|
+
const result = ops.investigateShow(dir);
|
|
134
|
+
if (events.some(event => event.type === "investigate.closed" && event.data.run === result.run) && !recordPresent(investigateResultPath(dir, result.run))) {
|
|
135
|
+
investigationArtifact = { state: "missing", recalculated: true,
|
|
136
|
+
message: `Missing receipted artifact runs/${result.run}/investigate.json; this investigation result is recalculated from current inputs. Restore the artifact or run ares investigate close before using a closed result.` };
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
130
139
|
}, errors) : null;
|
|
131
140
|
const final = study.mode === "experiment" ? readCall("final", () => {
|
|
132
141
|
const recorded = recordPresent(finalResultPath(dir)) || events.some(event => event.type === "final.closed");
|
|
@@ -169,7 +178,12 @@ function loadStudy(dir: string, features: readonly string[], input: { panel: str
|
|
|
169
178
|
const event = attemptEvents.get(`${row.id}:${row.status}`);
|
|
170
179
|
add("attempt", row.id, "alert", `${row.status} attempt ${row.id}`, event?.at ?? at, row.reason ?? "Inspect ares attempt show before retrying");
|
|
171
180
|
}
|
|
172
|
-
for (const [index, event] of events.entries()) if (event.type === "watch.ran" && event.data.ok === false)
|
|
181
|
+
for (const [index, event] of events.entries()) if (event.type === "watch.ran" && event.data.ok === false) {
|
|
182
|
+
const cause = event.data.cause, known = typeof cause === "string" && ["terminated", "filesystem", "internal", "network", "rate", "server", "client", "format"].includes(cause);
|
|
183
|
+
const title = cause === undefined ? "Every watch source failed" : known ? `Watch run failed (${cause})` : "Watch run failed (unknown cause)";
|
|
184
|
+
const detail = cause === undefined ? "Inspect ares watch show and retry after restoring source access" : "Inspect ares watch show and lit list; restore study storage or source access as needed, then rerun ares watch run";
|
|
185
|
+
add("watch", event.at + index, "alert", title, event.at, detail);
|
|
186
|
+
}
|
|
173
187
|
}
|
|
174
188
|
const archive = detailed ? readCall("archive", () => { const preview = previewArchive(dir); return { pendingCount: preview.notes.length, fingerprint: preview.fingerprint }; }, errors) : null;
|
|
175
189
|
const manuscript = detailed && readCall("manuscript", () => {
|
|
@@ -186,7 +200,7 @@ function loadStudy(dir: string, features: readonly string[], input: { panel: str
|
|
|
186
200
|
attempts: attempt ? { total: attempt.total, byStatus: attempt.byStatus } : study.mode === "experiment" ? { available: false, reason: errors.attempts ?? "Attempt records could not be read; inspect ares attempt list and restore readable records, then refresh" } : { total: 0, byStatus: {} },
|
|
187
201
|
final: final ? { status: final.status, ...(final.reason ? { reason: final.reason } : {}) } : null,
|
|
188
202
|
review: review ? { ready: review.ready, unmet: review.unmet } : null, lastWatch };
|
|
189
|
-
return { summary, targets, detail: detailed ? { ...summary, investigation, final, posthoc, review, skills, watch, archive, manuscript, errors } : null, items, at };
|
|
203
|
+
return { summary, targets, detail: detailed ? { ...summary, investigation, investigationArtifact: investigationArtifact ?? null, final, posthoc, review, skills, watch, archive, manuscript, errors } : null, items, at };
|
|
190
204
|
}
|
|
191
205
|
interface Selection { wireAction: string; action: "gate" | "screen" | "skill.decide" | "archive.confirm"; study: string; gate?: string; ids?: string[]; name?: string; decision?: string; reason?: string; pr?: string }
|
|
192
206
|
const ACTIONS: Record<string, { action: Selection["action"]; decision?: string; fields: readonly string[] }> = {
|