@staix/agent-research 0.0.0-stage → 0.1.0
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 +21 -0
- package/LICENSE +21 -0
- package/README.md +572 -2
- package/bin/ares.js +11 -0
- package/package.json +35 -4
- package/src/cli.ts +295 -0
- package/src/commands-table.ts +72 -0
- package/src/commands.ts +520 -0
- package/src/console/registry.ts +97 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +475 -0
- package/src/exp/executor.ts +207 -0
- package/src/exp/final.ts +197 -0
- package/src/exp/ideas.ts +117 -0
- package/src/exp/investigate.ts +182 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +145 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +62 -0
- package/src/index.ts +4 -0
- package/src/lit/bibtex.ts +61 -0
- package/src/lit/candidates.ts +148 -0
- package/src/lit/cards.ts +21 -0
- package/src/lit/cite.ts +26 -0
- package/src/lit/sources.ts +122 -0
- package/src/lit/verify.ts +110 -0
- package/src/lit/watch.ts +109 -0
- package/src/manifest.ts +38 -0
- package/src/mcp-tools.ts +200 -0
- package/src/mcp.ts +111 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +63 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +70 -0
- package/src/paper/check.ts +180 -0
- package/src/paper/placeholders.ts +116 -0
- package/src/paper/review.ts +115 -0
- package/src/plan/controller.ts +192 -0
- package/src/plan/definition.ts +156 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +234 -0
- package/src/playbook.ts +129 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study.ts +296 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +156 -0
- package/src/ui/plugin.ts +361 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { addStudy, removeStudy, readRegistry } from "./console/registry.ts";
|
|
3
|
+
import { readWatchSnapshot, setWatch, runWatch, showWatch, type WatchSnapshot } from "./lit/watch.ts";
|
|
4
|
+
import { runSetup, type SetupMode } from "./setup.ts";
|
|
5
|
+
import { proposeSkill, decideSkill, listSkills } from "./memory/skills.ts";
|
|
6
|
+
import { rankLessons, type LessonRankOptions } from "./memory/lessons.ts";
|
|
7
|
+
import { createAmemClient } from "./memory/amem.ts";
|
|
8
|
+
import { previewArchive, exportArchive, type ArchivePreview, type ArchiveResult } from "./memory/archive.ts";
|
|
9
|
+
import { addReview, resolveReview, reviewReadiness, reviewBinding, requireReviewReady } from "./paper/review.ts";
|
|
10
|
+
import { buildPaper } from "./paper/build.ts";
|
|
11
|
+
import { checkPaper } from "./paper/check.ts";
|
|
12
|
+
import { addAblation, showAblate, closeAblate } from "./exp/ablate.ts";
|
|
13
|
+
import { setFinalSeeds, finalGateBinding, requireFinalInputs, closeFinal, showPosthoc } from "./exp/final.ts";
|
|
14
|
+
import { addIdea, decideIdea } from "./exp/ideas.ts";
|
|
15
|
+
import { showInvestigation, closeInvestigation } from "./exp/investigate.ts";
|
|
16
|
+
import { setSelection } from "./exp/selection.ts";
|
|
17
|
+
import { createHubClient } from "./plan/hub.ts";
|
|
18
|
+
import { runPlan, type ControllerRuntime, type Observation, type StepNode } from "./plan/controller.ts";
|
|
19
|
+
import { resolveSnapshot } from "./exp/snapshot.ts";
|
|
20
|
+
import { spawnRunner } from "./exp/executor.ts";
|
|
21
|
+
import { foldPlan, readPlanState, planGateBinding, prepareControl, type ControlRequest, type ControlResult } from "./plan/state.ts";
|
|
22
|
+
import { setPlan } from "./plan/definition.ts";
|
|
23
|
+
export { manifest } from "./manifest.ts";
|
|
24
|
+
import { existsSync, readdirSync, utimesSync } from "node:fs";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
import type { Http } from "./http.ts";
|
|
27
|
+
import { readScope, scopeHash, setScope } from "./scope.ts";
|
|
28
|
+
import { boundedText, commit, decideGate, gateState, GATES, initStudy, openStudy, readBytesBounded, readLedger, readTextBounded, requireGate, sha256, withLock, readPlanOwner, type Actor, type Binder, type GateName, type Study } from "./study.ts";
|
|
29
|
+
import { candidatesFile, includedHash, isVerified, loadCandidates, mergeCandidates, sameWork, verifySubject, type Candidate } from "./lit/candidates.ts";
|
|
30
|
+
import { searchSource, SOURCES, type SourceName } from "./lit/sources.ts";
|
|
31
|
+
import { verifyCandidates } from "./lit/verify.ts";
|
|
32
|
+
import { assignCitekeys, renderBib } from "./lit/bibtex.ts";
|
|
33
|
+
import { checkCitations } from "./lit/cite.ts";
|
|
34
|
+
import { cardPath, validateCard } from "./lit/cards.ts";
|
|
35
|
+
import { designBinding, protocolHash, readDesign, setProtocol } from "./exp/protocol.ts";
|
|
36
|
+
import { requireExperiment } from "./study.ts";
|
|
37
|
+
import { checkExecutor, setExecutor } from "./exp/executor.ts";
|
|
38
|
+
import { cancelAttempt, listAttempts, listAttemptSummaries, reconcileAttempts, runAttempt, showAttempt, type AttemptContext, type AttemptListOptions, type AttemptOptions } from "./exp/attempts.ts";
|
|
39
|
+
import { readAttemptLogs, type LogOptions } from "./exp/logs.ts";
|
|
40
|
+
|
|
41
|
+
export type ExperimentRuntime = Omit<AttemptContext, "actor">;
|
|
42
|
+
export interface Ctx extends ExperimentRuntime { actor: Actor; http: Http; signal?: AbortSignal }
|
|
43
|
+
const MAX_IDS = 200, DRAFT_BYTES = 2 * 1024 * 1024, BIB_BYTES = 16 * 1024 * 1024, CARD_BYTES = 65_536;
|
|
44
|
+
|
|
45
|
+
export function binder(dir: string): Binder {
|
|
46
|
+
return gate => gate === "review" ? reviewBinding(dir) : gate === "final" ? finalGateBinding(dir) : gate.startsWith("plan:") ? planGateBinding(readPlanState(dir, readLedger(dir)), gate) : gate === "scope" ? scopeHash(dir) : gate === "design" ? designBinding(dir) : includedHash(loadCandidates(dir));
|
|
47
|
+
}
|
|
48
|
+
function gateName(value: string): GateName {
|
|
49
|
+
if (!GATES.includes(value as GateName) && !value.startsWith("plan:")) throw new Error(`unknown gate: ${value} (${GATES.join(", ")})`);
|
|
50
|
+
return value as GateName;
|
|
51
|
+
}
|
|
52
|
+
function ids(values: string[]): string[] {
|
|
53
|
+
if (!values.length) throw new Error("at least one candidate id required");
|
|
54
|
+
if (values.length > MAX_IDS) throw new Error(`at most ${MAX_IDS} ids per call`);
|
|
55
|
+
return [...new Set(values.map(v => boundedText(v, "id", 300)))];
|
|
56
|
+
}
|
|
57
|
+
function find(list: Candidate[], id: string): Candidate {
|
|
58
|
+
const c = list.find(x => x.id === id);
|
|
59
|
+
if (!c) throw new Error(`unknown candidate: ${id}`);
|
|
60
|
+
return c;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function init(dir: string, options: { question: string; mode?: string; autonomy?: string }, ctx: Pick<Ctx, "actor">) {
|
|
64
|
+
const study = initStudy(dir, options, ctx.actor);
|
|
65
|
+
return { created: resolve(dir), study };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function protocol(dirArg: string, raw: unknown, ctx: Ctx) {
|
|
69
|
+
const { dir } = openStudy(dirArg);
|
|
70
|
+
return withLock(dir, () => {
|
|
71
|
+
const value = setProtocol(dir, raw, ctx.actor);
|
|
72
|
+
return { protocol: value, protocolHash: protocolHash(value), gate: gateState("design", readLedger(dir), binder(dir)) };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export async function executorSet(dirArg: string, raw: unknown, ctx: Ctx) {
|
|
76
|
+
const { dir } = openStudy(dirArg);
|
|
77
|
+
return withLock(dir, () => setExecutor(dir, raw, ctx.actor));
|
|
78
|
+
}
|
|
79
|
+
export const executorCheck = (dir: string, ctx: Ctx) => checkExecutor(dir, ctx.runner);
|
|
80
|
+
export const attemptRun = (dir: string, options: AttemptOptions, ctx: Ctx) => runAttempt(dir, options, ctx);
|
|
81
|
+
export const attemptList = (dir: string, options: AttemptListOptions = {}) => listAttemptSummaries(dir, options);
|
|
82
|
+
export const attemptShow = (dir: string, id: string) => showAttempt(dir, id);
|
|
83
|
+
export const attemptCancel = (dir: string, id: string, reason: string | undefined, ctx: Ctx) => cancelAttempt(dir, id, reason, ctx);
|
|
84
|
+
export const attemptReconcile = (dir: string, ctx: Ctx) => reconcileAttempts(dir, ctx);
|
|
85
|
+
export const attemptLogs = (dir: string, id: string, options: LogOptions, ctx: Ctx) => readAttemptLogs(dir, id, options, ctx.runner);
|
|
86
|
+
|
|
87
|
+
export function status(dirArg: string) {
|
|
88
|
+
const { dir, study } = openStudy(dirArg), events = readLedger(dir), list = loadCandidates(dir), bind = binder(dir);
|
|
89
|
+
const cardsDir = join(dir, "literature", "cards"), bibPath = join(dir, "refs", "references.bib");
|
|
90
|
+
const count = (s: Candidate["status"]) => list.filter(c => c.status === s).length;
|
|
91
|
+
const included = list.filter(c => c.status === "included");
|
|
92
|
+
return {
|
|
93
|
+
study: dir, question: study.question, mode: study.mode, autonomy: study.autonomy,
|
|
94
|
+
scope: { set: scopeHash(dir) !== undefined },
|
|
95
|
+
gates: GATES.filter(g => (g !== "design" && g !== "final") || study.mode === "experiment").map(g => gateState(g, events, bind)),
|
|
96
|
+
literature: {
|
|
97
|
+
candidates: list.length, new: count("new"), included: included.length, excluded: count("excluded"),
|
|
98
|
+
verified: included.filter(isVerified).length, unverified: included.filter(c => !isVerified(c)).length,
|
|
99
|
+
cards: existsSync(cardsDir) ? readdirSync(cardsDir).filter(f => f.endsWith(".json")).length : 0,
|
|
100
|
+
},
|
|
101
|
+
bib: existsSync(bibPath) ? { path: bibPath, sha256: sha256(readBytesBounded(bibPath, BIB_BYTES)) } : null,
|
|
102
|
+
events: events.length,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function scope(dirArg: string, raw: unknown, ctx: Ctx) {
|
|
107
|
+
const { dir } = openStudy(dirArg);
|
|
108
|
+
return withLock(dir, () => {
|
|
109
|
+
const result = setScope(dir, raw, ctx.actor);
|
|
110
|
+
return { ...result, gate: gateState("scope", readLedger(dir), binder(dir)) };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function gateList(dirArg: string) {
|
|
115
|
+
const { dir, study } = openStudy(dirArg), events = readLedger(dir), bind = binder(dir);
|
|
116
|
+
const plan = foldPlan(events);
|
|
117
|
+
const gates: GateName[] = [...GATES.filter(g => (g !== "design" && g !== "final") || study.mode === "experiment"), ...(plan?.nodes.filter(n => n.kind === "gate").map(n => `plan:${n.id}` as GateName) ?? [])];
|
|
118
|
+
return gates.map(g => gateState(g, events, bind));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function gateDecide(dirArg: string, gate: string, decision: "approve" | "reject", reason: string | undefined, ctx: Ctx, guard?: () => void) {
|
|
122
|
+
const { dir, study } = openStudy(dirArg), name = gateName(gate), why = reason === undefined ? undefined : boundedText(reason, "--reason", 500);
|
|
123
|
+
if (name === "design" || name === "final") requireExperiment(study);
|
|
124
|
+
return withLock(dir, () => {
|
|
125
|
+
guard?.();
|
|
126
|
+
if (name === "final") requireFinalInputs(dir);
|
|
127
|
+
if (name === "review" && decision === "approve") requireReviewReady(dir);
|
|
128
|
+
if (name.startsWith("plan:") && !readPlanState(dir, readLedger(dir)).definition.nodes.some(n => n.id === name.slice(5) && n.kind === "gate")) throw new Error(`gate ${name}: no gate node ${name.slice(5)} in the plan; run ares gate list and select an existing gate`);
|
|
129
|
+
return decideGate(dir, name, decision, ctx.actor, binder(dir), why);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function search(dirArg: string, query: string, options: { source?: string; limit?: number; yearFrom?: number }, ctx: Ctx) {
|
|
134
|
+
const { dir } = openStudy(dirArg); return withLock(dir, () => searchLocked(dir, query, options, ctx));
|
|
135
|
+
}
|
|
136
|
+
async function searchLocked(dirArg: string, query: string, options: { source?: string; limit?: number; yearFrom?: number }, ctx: Ctx) {
|
|
137
|
+
const { dir, study } = openStudy(dirArg), q = boundedText(query, "query", 500);
|
|
138
|
+
const source = options.source ?? "all";
|
|
139
|
+
if (source !== "all" && !SOURCES.includes(source as SourceName)) throw new Error("--source: openalex, s2, arxiv or all");
|
|
140
|
+
const limit = options.limit ?? 20;
|
|
141
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error("--limit: 1..50");
|
|
142
|
+
if (options.yearFrom !== undefined && (!Number.isSafeInteger(options.yearFrom) || options.yearFrom < 1000 || options.yearFrom > 2100)) throw new Error("--year-from: year 1000..2100");
|
|
143
|
+
const sc = readScope(dir);
|
|
144
|
+
if (!sc) throw new Error("gate scope: no scope yet (run ares scope)");
|
|
145
|
+
const yearFrom = options.yearFrom ?? sc.yearFrom;
|
|
146
|
+
if (yearFrom !== undefined && sc.yearTo !== undefined && yearFrom > sc.yearTo) throw new Error(`--year-from: after the scope's yearTo ${sc.yearTo}`);
|
|
147
|
+
const approval = requireGate(dir, study, "scope", binder(dir));
|
|
148
|
+
const names = source === "all" ? SOURCES : [source as SourceName];
|
|
149
|
+
const results = [];
|
|
150
|
+
for (const name of names) results.push(await searchSource(ctx.http, name, q, { limit, ...(yearFrom !== undefined ? { yearFrom } : {}), ...(sc.yearTo !== undefined ? { yearTo: sc.yearTo } : {}) }));
|
|
151
|
+
const list = loadCandidates(dir), counts = mergeCandidates(list, results.flatMap(r => r.records), sc.maxCandidates);
|
|
152
|
+
const sources = results.map(r => ({ source: r.source, ok: r.ok, returned: r.records.length, ...(r.error ? { error: r.error } : {}) }));
|
|
153
|
+
// A search where every source failed is recorded but is not a success, so it approves nothing.
|
|
154
|
+
const answered = sources.some(r => r.ok);
|
|
155
|
+
commit(dir, [candidatesFile(dir, list)], [...(answered ? approval : []), { actor: ctx.actor, type: "lit.search", data: { query: q, sources, ...counts } }]);
|
|
156
|
+
return { query: q, sources, ...counts, candidates: list.length };
|
|
157
|
+
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function list(dirArg: string, options: { status?: string; limit?: number }) {
|
|
161
|
+
const { dir } = openStudy(dirArg), limit = options.limit ?? 50;
|
|
162
|
+
if (options.status !== undefined && !["new", "included", "excluded"].includes(options.status)) throw new Error("--status: new, included or excluded");
|
|
163
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) throw new Error("--limit: 1..500");
|
|
164
|
+
const all = loadCandidates(dir).filter(c => !options.status || c.status === options.status);
|
|
165
|
+
return {
|
|
166
|
+
total: all.length,
|
|
167
|
+
candidates: all.slice(0, limit).map(c => ({
|
|
168
|
+
id: c.id, title: c.title, authors: c.authors.slice(0, 5), year: c.year, venue: c.venue, sources: c.sources, status: c.status,
|
|
169
|
+
...(c.reason ? { reason: c.reason } : {}), ...(c.citekey ? { citekey: c.citekey } : {}), ...(c.duplicates?.length ? { duplicates: c.duplicates } : {}),
|
|
170
|
+
verified: isVerified(c), ...(c.verify ? { verify: { syntax: c.verify.syntax, exists: c.verify.exists, match: c.verify.match } } : {}),
|
|
171
|
+
...(c.abstract ? { abstract: c.abstract.slice(0, 600) } : {}),
|
|
172
|
+
})),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function screen(dirArg: string, values: string[], decision: "include" | "exclude", reason: string | undefined, ctx: Ctx, guard?: () => void) {
|
|
177
|
+
const { dir, study } = openStudy(dirArg), wanted = ids(values), why = reason === undefined ? undefined : boundedText(reason, "--reason", 500);
|
|
178
|
+
return withLock(dir, () => {
|
|
179
|
+
guard?.();
|
|
180
|
+
const all = loadCandidates(dir), at = new Date().toISOString(), status = decision === "include" ? "included" : "excluded";
|
|
181
|
+
for (const id of wanted) find(all, id);
|
|
182
|
+
const approval = requireGate(dir, study, "scope", binder(dir));
|
|
183
|
+
for (const id of wanted) {
|
|
184
|
+
const c = find(all, id);
|
|
185
|
+
c.status = status; c.decidedBy = ctx.actor; c.decidedAt = at;
|
|
186
|
+
if (why) c.reason = why; else delete c.reason;
|
|
187
|
+
}
|
|
188
|
+
commit(dir, [candidatesFile(dir, all)], [...approval, { actor: ctx.actor, type: "lit.screen", data: { ids: wanted, decision, ...(why ? { reason: why } : {}) } }]);
|
|
189
|
+
return { decision, ids: wanted, gate: gateState("screen", readLedger(dir), binder(dir)) };
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function card(dirArg: string, id: string, raw: unknown, ctx: Ctx) {
|
|
194
|
+
const { dir, study } = openStudy(dirArg), value = validateCard(raw);
|
|
195
|
+
return withLock(dir, () => {
|
|
196
|
+
const c = find(loadCandidates(dir), id);
|
|
197
|
+
if (c.status !== "included") throw new Error(`${id}: cards are recorded for included candidates only`);
|
|
198
|
+
const approval = requireGate(dir, study, "scope", binder(dir));
|
|
199
|
+
const path = cardPath(dir, id), text = JSON.stringify({ id, ...value }, null, 2) + "\n";
|
|
200
|
+
commit(dir, [[path, text, CARD_BYTES]], [...approval, { actor: ctx.actor, type: "lit.card", data: { id, sha256: sha256(text) } }]);
|
|
201
|
+
return { id, path };
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Without ids: included candidates never checked, checked against other identifiers, or left unknown, up to 200.
|
|
207
|
+
* Final results (no identifier, not found, title or year mismatch) are skipped so the batch always advances;
|
|
208
|
+
* re-check one by naming its id.
|
|
209
|
+
*/
|
|
210
|
+
export async function verify(dirArg: string, values: string[], ctx: Ctx) {
|
|
211
|
+
const { dir, study } = openStudy(dirArg), wanted = values.length ? ids(values) : undefined;
|
|
212
|
+
return withLock(dir, async () => {
|
|
213
|
+
const all = loadCandidates(dir);
|
|
214
|
+
const open = (c: Candidate) => !c.verify || c.verify.subject !== verifySubject(c) || (c.verify.syntax === "ok" && c.verify.exists === "unknown");
|
|
215
|
+
const pending = wanted ? wanted.map(id => find(all, id)) : all.filter(c => c.status === "included" && !isVerified(c) && open(c));
|
|
216
|
+
const approval = requireGate(dir, study, "scope", binder(dir));
|
|
217
|
+
const batch = pending.slice(0, MAX_IDS);
|
|
218
|
+
const results = await verifyCandidates(ctx.http, batch, ctx.sleep ? { sleep: ctx.sleep } : {});
|
|
219
|
+
for (const c of batch) { const v = results.get(c.id); if (v) c.verify = v; }
|
|
220
|
+
const tally = { checked: batch.length, verified: batch.filter(isVerified).length, notFound: batch.filter(c => c.verify?.exists === "no").length,
|
|
221
|
+
unknown: batch.filter(c => c.verify?.exists === "unknown").length, mismatched: batch.filter(c => c.verify?.exists === "yes" && c.verify.match !== "yes").length,
|
|
222
|
+
noIdentifier: batch.filter(c => c.verify?.syntax !== "ok").length, remaining: pending.length - batch.length,
|
|
223
|
+
final: wanted ? 0 : all.filter(c => c.status === "included" && !isVerified(c) && !open(c)).length };
|
|
224
|
+
commit(dir, [candidatesFile(dir, all)], [...approval, { actor: ctx.actor, type: "lit.verify", data: tally }]);
|
|
225
|
+
return { ...tally, results: batch.map(c => ({ id: c.id, ...c.verify })) };
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function bib(dirArg: string, ctx: Ctx) {
|
|
230
|
+
const { dir, study } = openStudy(dirArg);
|
|
231
|
+
return withLock(dir, () => {
|
|
232
|
+
const approval = requireGate(dir, study, "screen", binder(dir));
|
|
233
|
+
const all = loadCandidates(dir), eligible = all.filter(c => c.status === "included" && isVerified(c));
|
|
234
|
+
const twins = sameWork(all).map(g => g.filter(id => all.find(c => c.id === id)!.status === "included")).filter(g => g.length > 1);
|
|
235
|
+
if (twins.length) throw new Error(`included candidates that are the same work: ${twins.map(g => g.join(" = ")).join("; ")}; exclude all but one of each`);
|
|
236
|
+
const assigned = assignCitekeys(all, eligible);
|
|
237
|
+
const { text, entries } = renderBib(all), path = join(dir, "refs", "references.bib"), hash = sha256(text);
|
|
238
|
+
commit(dir, [...(assigned ? [candidatesFile(dir, all)] : []), [path, text, BIB_BYTES]], [...approval, { actor: ctx.actor, type: "lit.bib", data: { entries, sha256: hash } }]);
|
|
239
|
+
const left = all.filter(c => c.status === "included" && !isVerified(c)).map(c => ({ id: c.id, verify: c.verify ? `${c.verify.syntax}/${c.verify.exists}/${c.verify.match}` : "not checked" }));
|
|
240
|
+
return { path, entries, sha256: hash, newKeys: assigned, notInBib: left };
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function cite(dirArg: string, draft: string) {
|
|
245
|
+
const { dir } = openStudy(dirArg), path = resolve(draft);
|
|
246
|
+
return { draft: path, ...checkCitations(readTextBounded(path, DRAFT_BYTES), loadCandidates(dir)) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function planSet(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
250
|
+
const { dir } = openStudy(dirArg);
|
|
251
|
+
return withLock(dir, () => setPlan(dir, raw, ctx.actor));
|
|
252
|
+
}
|
|
253
|
+
export function planShow(dirArg: string) {
|
|
254
|
+
const { dir } = openStudy(dirArg);
|
|
255
|
+
let controller: ReturnType<typeof readPlanOwner> | "none" | { error: string };
|
|
256
|
+
try { controller = readPlanOwner(dir) ?? "none"; }
|
|
257
|
+
catch { controller = { error: "controller owner unreadable; inspect .ares.plan.lock before ares run" }; }
|
|
258
|
+
return { ...readPlanState(dir, readLedger(dir)), controller };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function planControl(dirArg: string, request: ControlRequest, ctx: Ctx): Promise<ControlResult> {
|
|
262
|
+
const { dir } = openStudy(dirArg);
|
|
263
|
+
const prepared = await withLock(dir, () => {
|
|
264
|
+
const events = readLedger(dir), state = readPlanState(dir, events);
|
|
265
|
+
const prepared = prepareControl(state, events, request, ctx.actor);
|
|
266
|
+
if (!prepared.event) return { result: prepared.result };
|
|
267
|
+
const node = state.nodes.find(n => n.id === request.node);
|
|
268
|
+
const attempt = node?.attemptId ? listAttempts(dir).attempts.find(a => a.id === node.attemptId) : undefined;
|
|
269
|
+
if (request.action === "cancel" && attempt) return { attemptId: attempt.id };
|
|
270
|
+
// A missing record after a crash is uncertain, and explicit retry/cancel may release it.
|
|
271
|
+
if ((request.action === "retry" || request.action === "skip") && attempt?.status === "running") throw new Error(`plan node ${node!.id}: attempt is still running; cancel it or run ares attempt reconcile before retry`);
|
|
272
|
+
commit(dir, [], [prepared.event]);
|
|
273
|
+
return { result: prepared.result };
|
|
274
|
+
});
|
|
275
|
+
if (prepared.result) return prepared.result;
|
|
276
|
+
let result: ControlResult | undefined;
|
|
277
|
+
await cancelAttempt(dir, prepared.attemptId!, "plan cancellation", ctx, () => {
|
|
278
|
+
const events = readLedger(dir), state = readPlanState(dir, events);
|
|
279
|
+
const prepared = prepareControl(state, events, request, ctx.actor);
|
|
280
|
+
result = prepared.result;
|
|
281
|
+
return prepared.event ? [prepared.event] : [];
|
|
282
|
+
});
|
|
283
|
+
return result!;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
function stepText(node: StepNode, key: string): string {
|
|
288
|
+
return boundedText(node.args[key], `plan step ${node.id}.${key}`, 4096);
|
|
289
|
+
}
|
|
290
|
+
function stepNumber(node: StepNode, key: string): number | undefined {
|
|
291
|
+
const value = node.args[key];
|
|
292
|
+
if (value === undefined) return undefined;
|
|
293
|
+
if ((typeof value !== "string" && typeof value !== "number") || !/^-?\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error(`plan step ${node.id}.${key}: integer required; correct the argument with ares plan set`);
|
|
294
|
+
return Number(value);
|
|
295
|
+
}
|
|
296
|
+
export async function planRun(dirArg: string, options: { parallel?: number }, ctx: Ctx) {
|
|
297
|
+
const { dir } = openStudy(dirArg);
|
|
298
|
+
const attemptOptions = (node: StepNode, retryOf?: string) => {
|
|
299
|
+
const parent = retryOf ?? (node.args.retry !== undefined ? stepText(node, "retry") : undefined);
|
|
300
|
+
const retry = parent && listAttempts(dir).attempts.some(a => a.id === parent) ? parent : undefined;
|
|
301
|
+
return {
|
|
302
|
+
repo: resolve(dir, stepText(node, "repo")), commit: stepText(node, "commit"),
|
|
303
|
+
...(stepNumber(node, "seed") !== undefined ? { seed: stepNumber(node, "seed")! } : {}),
|
|
304
|
+
...(node.args.run !== undefined ? { run: stepText(node, "run") } : {}),
|
|
305
|
+
...(node.args.label !== undefined ? { label: stepText(node, "label") } : {}),
|
|
306
|
+
...(retry ? { retry } : {}), detach: true,
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
const observed = (attempt: ReturnType<typeof showAttempt>): Observation => ({
|
|
310
|
+
progress: sha256(JSON.stringify(attempt)),
|
|
311
|
+
status: attempt.status === "running" ? "dispatched" : attempt.status === "succeeded" ? "done" : attempt.status === "unknown" ? "uncertain" : attempt.status,
|
|
312
|
+
...(attempt.status === "succeeded" ? { output: { attemptId: attempt.id, sha256: sha256(JSON.stringify(attempt)) } } : {}),
|
|
313
|
+
...(attempt.status !== "running" && attempt.status !== "succeeded" ? { reason: `attempt ${attempt.status}; inspect ares attempt show before retrying this node` } : {}),
|
|
314
|
+
});
|
|
315
|
+
const runtime: ControllerRuntime = {
|
|
316
|
+
hub: createHubClient(dir, ctx.runner),
|
|
317
|
+
actor: ctx.actor, ...(ctx.signal ? { signal: ctx.signal } : {}), ...(ctx.sleep ? { sleep: ctx.sleep } : {}),
|
|
318
|
+
prepare: async (node, state) => {
|
|
319
|
+
if (node.command !== "attempt.run") return {};
|
|
320
|
+
const design = readDesign(dir);
|
|
321
|
+
if (!design) throw new Error("plan attempt: protocol missing; run ares protocol before resume");
|
|
322
|
+
const options = attemptOptions(node, state.retryOf), snapshot = await resolveSnapshot(options.repo, options.commit, ctx.runner ?? spawnRunner);
|
|
323
|
+
const { attemptId } = await import("./exp/attempts.ts");
|
|
324
|
+
return { attemptId: attemptId(protocolHash(design.protocol), snapshot.commit, snapshot.treeId, options.seed ?? design.protocol.seeds[0]!, options.run ?? "main", options.retry) };
|
|
325
|
+
},
|
|
326
|
+
execute: async (node, state) => {
|
|
327
|
+
if (node.command === "attempt.run") return observed(await runAttempt(dir, { ...attemptOptions(node, state.retryOf), expectedId: state.attemptId! }, ctx));
|
|
328
|
+
let result: unknown, ok = true;
|
|
329
|
+
switch (node.command) {
|
|
330
|
+
case "watch.run": {
|
|
331
|
+
const result = await watchRun(dir, ctx);
|
|
332
|
+
return result.ok ? { status: "done", output: { added: result.added } } : { status: "failed", reason: "watch: every source failed; inspect ares watch show and retry the plan node" };
|
|
333
|
+
}
|
|
334
|
+
case "lit.search": {
|
|
335
|
+
const value = await search(dir, stepText(node, "query"), { ...(node.args.source !== undefined ? { source: stepText(node, "source") } : {}), ...(stepNumber(node, "limit") !== undefined ? { limit: stepNumber(node, "limit")! } : {}), ...(stepNumber(node, "year-from") !== undefined ? { yearFrom: stepNumber(node, "year-from")! } : {}) }, ctx);
|
|
336
|
+
ok = value.sources.some(s => s.ok); result = value; break;
|
|
337
|
+
}
|
|
338
|
+
case "lit.verify": result = await verify(dir, (node.args.ids as string[] | undefined) ?? [], ctx); break;
|
|
339
|
+
case "lit.bib": result = await bib(dir, ctx); break;
|
|
340
|
+
case "investigate.close": {
|
|
341
|
+
const result = await investigateClose(dir, ctx);
|
|
342
|
+
return { status: "done", output: { sha256: sha256(JSON.stringify(result, null, 2) + "\n"), winner: result.winner } };
|
|
343
|
+
}
|
|
344
|
+
case "ablate.close": {
|
|
345
|
+
const result = await ablateClose(dir, ctx);
|
|
346
|
+
return { status: "done", output: { sha256: sha256(JSON.stringify(result, null, 2) + "\n"), status: "closed" } };
|
|
347
|
+
}
|
|
348
|
+
case "final.close": {
|
|
349
|
+
const result = await finalClose(dir, ctx);
|
|
350
|
+
return { status: "done", output: { sha256: sha256(JSON.stringify(result, null, 2) + "\n"), status: result.status } };
|
|
351
|
+
}
|
|
352
|
+
case "paper.build": {
|
|
353
|
+
const result = await paperBuild(dir, ctx);
|
|
354
|
+
return { status: "done", output: { sha256: result.evidenceSha256 } };
|
|
355
|
+
}
|
|
356
|
+
case "cite.check": { const value = cite(dir, resolve(dir, stepText(node, "draft"))); result = value; ok = value.ok; break; }
|
|
357
|
+
}
|
|
358
|
+
return { status: ok ? "done" : "failed", ...(ok ? { output: { sha256: sha256(JSON.stringify(result)) } } : { reason: "step reported failure; inspect its command result and inputs before retry" }) };
|
|
359
|
+
},
|
|
360
|
+
reconcile: async (_node, state) => {
|
|
361
|
+
if (!state.attemptId || !listAttempts(dir).attempts.some(a => a.id === state.attemptId)) return { status: "uncertain", reason: "expected attempt record missing; explicitly retry or skip this node" };
|
|
362
|
+
const before = showAttempt(dir, state.attemptId);
|
|
363
|
+
if (before.status === "running") await reconcileAttempts(dir, ctx);
|
|
364
|
+
return observed(showAttempt(dir, state.attemptId));
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
return runPlan(dir, options, runtime);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
export async function selection(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
372
|
+
const { dir, study } = openStudy(dirArg); requireExperiment(study);
|
|
373
|
+
return withLock(dir, () => {
|
|
374
|
+
const design = readDesign(dir);
|
|
375
|
+
if (!design) throw new Error("selection: protocol missing; run ares protocol <dir> --file PATH before setting a selection");
|
|
376
|
+
const result = setSelection(dir, raw, design.protocol, ctx.actor);
|
|
377
|
+
return { ...result, gate: gateState("design", readLedger(dir), binder(dir)) };
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
export const investigateShow = (dir: string) => showInvestigation(dir);
|
|
383
|
+
export async function investigateClose(dirArg: string, ctx: Pick<Ctx, "actor">) {
|
|
384
|
+
const { dir, study } = openStudy(dirArg); requireExperiment(study);
|
|
385
|
+
return withLock(dir, () => closeInvestigation(dir, ctx.actor));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
export async function ideaAdd(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
390
|
+
const { dir, study } = openStudy(dirArg); requireExperiment(study);
|
|
391
|
+
return withLock(dir, () => addIdea(dir, raw, ctx.actor));
|
|
392
|
+
}
|
|
393
|
+
export async function ideaDecide(dirArg: string, id: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
394
|
+
const { dir, study } = openStudy(dirArg); requireExperiment(study);
|
|
395
|
+
return withLock(dir, () => decideIdea(dir, id, raw, ctx.actor));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
export async function finalSeeds(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
400
|
+
const { dir, study } = openStudy(dirArg); requireExperiment(study);
|
|
401
|
+
return withLock(dir, () => {
|
|
402
|
+
const design = readDesign(dir);
|
|
403
|
+
if (!design) throw new Error("final seeds: protocol missing; run ares protocol <dir> --file PATH first");
|
|
404
|
+
const result = setFinalSeeds(dir, raw, design.protocol, ctx.actor, () => {
|
|
405
|
+
const attempts = listAttempts(dir).attempts;
|
|
406
|
+
if (attempts.some(attempt => !Number.isSafeInteger(attempt.seed))) throw new Error("final seeds: attempt seed metadata is malformed; restore valid attempt records before reserving seeds");
|
|
407
|
+
return new Set(attempts.map(attempt => attempt.seed));
|
|
408
|
+
});
|
|
409
|
+
return { ...result, gate: gateState("design", readLedger(dir), binder(dir)) };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
export async function ablateAdd(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
415
|
+
const { dir, study } = openStudy(dirArg); requireExperiment(study);
|
|
416
|
+
return withLock(dir, () => addAblation(dir, raw, ctx.actor));
|
|
417
|
+
}
|
|
418
|
+
export const ablateShow = (dir: string) => showAblate(dir);
|
|
419
|
+
export async function ablateClose(dirArg: string, ctx: Pick<Ctx, "actor">) {
|
|
420
|
+
const { dir, study } = openStudy(dirArg); requireExperiment(study);
|
|
421
|
+
return withLock(dir, () => closeAblate(dir, ctx.actor));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
export async function finalClose(dirArg: string, ctx: Pick<Ctx, "actor">) {
|
|
426
|
+
const { dir, study } = openStudy(dirArg); requireExperiment(study);
|
|
427
|
+
return withLock(dir, () => closeFinal(dir, ctx.actor));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export const posthocShow = (dir: string) => showPosthoc(dir);
|
|
431
|
+
|
|
432
|
+
export const paperCheck = (dir: string) => checkPaper(dir);
|
|
433
|
+
|
|
434
|
+
export async function paperBuild(dirArg: string, ctx: Pick<Ctx, "actor">) {
|
|
435
|
+
const { dir } = openStudy(dirArg); return withLock(dir, () => buildPaper(dir, ctx.actor));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function reviewAdd(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
439
|
+
const { dir } = openStudy(dirArg); return withLock(dir, () => addReview(dir, raw, ctx.actor));
|
|
440
|
+
}
|
|
441
|
+
export async function reviewResolve(dirArg: string, target: string, reason: string, ctx: Pick<Ctx, "actor">) {
|
|
442
|
+
const { dir } = openStudy(dirArg); return withLock(dir, () => resolveReview(dir, target, reason, ctx.actor));
|
|
443
|
+
}
|
|
444
|
+
export const reviewShow = (dir: string) => reviewReadiness(dir);
|
|
445
|
+
|
|
446
|
+
/** Preview is read-only; confirmed outward writes and receipts share the study lock. */
|
|
447
|
+
export async function archive(dirArg: string, options: { confirm?: string }, ctx: Pick<Ctx, "actor" | "runner">, env: NodeJS.ProcessEnv = process.env, guard?: () => void): Promise<ArchivePreview | ArchiveResult> {
|
|
448
|
+
const { dir } = openStudy(dirArg);
|
|
449
|
+
if (options.confirm === undefined) return previewArchive(dir);
|
|
450
|
+
return withLock(dir, () => { guard?.(); return exportArchive(dir, options.confirm!, ctx.actor, ctx.runner, env); });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The study cwd binds amem's project; search and optional intent brief never write study files. */
|
|
454
|
+
export async function lessons(dirArg: string, options: LessonRankOptions & { for?: string }, ctx: Pick<Ctx, "runner" | "now"> = {}, env: NodeJS.ProcessEnv = process.env) {
|
|
455
|
+
const { dir, study } = openStudy(dirArg), now = ctx.now?.() ?? Date.now();
|
|
456
|
+
rankLessons([], now, options);
|
|
457
|
+
if (options.for !== undefined && (!options.for.trim() || options.for.includes("\0") || options.for.length > 1000)) throw new Error("lessons intent: blank, NUL or over 1000 characters; pass --for with nonblank intent text within that bound");
|
|
458
|
+
const client = createAmemClient(dir, ctx.runner, env), hits = await client.search(options.for ?? study.question);
|
|
459
|
+
const ranked = rankLessons(hits, now, options);
|
|
460
|
+
const brief = options.for === undefined ? undefined : await client.brief(options.for, 2048);
|
|
461
|
+
return { lessons: ranked, ...(brief === undefined ? {} : { brief }) };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export async function skillPropose(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
465
|
+
const { dir } = openStudy(dirArg); return withLock(dir, () => proposeSkill(dir, raw, ctx.actor));
|
|
466
|
+
}
|
|
467
|
+
export async function skillDecide(dirArg: string, name: string, decision: string, reason: string, pr: string | undefined, ctx: Pick<Ctx, "actor">, guard?: () => void) {
|
|
468
|
+
const { dir } = openStudy(dirArg); return withLock(dir, () => { guard?.(); return decideSkill(dir, name, decision, reason, pr, ctx.actor); });
|
|
469
|
+
}
|
|
470
|
+
export const skillList = (dir: string) => listSkills(dir);
|
|
471
|
+
|
|
472
|
+
export const setup = (target: string, mode: SetupMode, env: NodeJS.ProcessEnv, options: { cwd?: string; root?: string } = {}) => runSetup(target, mode, { ...options, env });
|
|
473
|
+
|
|
474
|
+
export async function watchSet(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
|
|
475
|
+
const { dir } = openStudy(dirArg); return withLock(dir, () => setWatch(dir, raw, ctx.actor));
|
|
476
|
+
}
|
|
477
|
+
function watchPreflight(dir: string, study: Study): WatchSnapshot {
|
|
478
|
+
const snapshot = readWatchSnapshot(dir), scope = readScope(dir);
|
|
479
|
+
if (!snapshot) throw new Error("watch: no config; run ares watch set <dir> --file PATH before watch run");
|
|
480
|
+
if (!scope) throw new Error("gate scope: no scope yet; run ares scope before watch run");
|
|
481
|
+
requireGate(dir, study, "scope", binder(dir));
|
|
482
|
+
for (const query of snapshot.config.queries) if ((query.yearFrom ?? scope.yearFrom ?? 1000) > (scope.yearTo ?? 2100)) throw new Error(`watch query ${query.id}: yearFrom exceeds scope yearTo; correct that query or the scope before running`);
|
|
483
|
+
return snapshot;
|
|
484
|
+
}
|
|
485
|
+
function watchRunLocked(dir: string, snapshot: WatchSnapshot, ctx: Ctx) {
|
|
486
|
+
return runWatch(dir, snapshot, ctx.actor, query => {
|
|
487
|
+
// Refresh the held lock between bounded searches, with no recurring timer.
|
|
488
|
+
const time = new Date(); utimesSync(join(dir, ".ares.lock"), time, time);
|
|
489
|
+
return searchLocked(dir, query.query, query, ctx);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
export async function watchRun(dirArg: string, ctx: Ctx) {
|
|
493
|
+
const { dir, study } = openStudy(dirArg);
|
|
494
|
+
return withLock(dir, () => watchRunLocked(dir, watchPreflight(dir, study), ctx));
|
|
495
|
+
}
|
|
496
|
+
export interface WatchStarted { started: true; configSha256: string }
|
|
497
|
+
/** Acknowledge only validated admission; the finite pass keeps the same lock until its receipt is written. */
|
|
498
|
+
export function watchRunDetached(dirArg: string, ctx: Ctx, track?: (completion: Promise<void>) => void): Promise<WatchStarted> {
|
|
499
|
+
const { dir, study } = openStudy(dirArg);
|
|
500
|
+
let accept!: (value: WatchStarted) => void, reject!: (cause: unknown) => void, started = false;
|
|
501
|
+
const admission = new Promise<WatchStarted>((resolve, refuse) => { accept = resolve; reject = refuse; });
|
|
502
|
+
const completion = withLock(dir, async () => {
|
|
503
|
+
const snapshot = watchPreflight(dir, study);
|
|
504
|
+
started = true; accept({ started: true, configSha256: snapshot.sha256 });
|
|
505
|
+
await watchRunLocked(dir, snapshot, ctx);
|
|
506
|
+
}).then(() => {}, cause => {
|
|
507
|
+
if (!started) reject(cause);
|
|
508
|
+
else console.error("ares watch: background pass failed; inspect ares watch show and lit list before retrying");
|
|
509
|
+
});
|
|
510
|
+
track?.(completion);
|
|
511
|
+
return admission;
|
|
512
|
+
}
|
|
513
|
+
export const watchShow = (dir: string) => ({ ...showWatch(dir), newCandidates: list(dir, { status: "new", limit: 500 }) });
|
|
514
|
+
|
|
515
|
+
function consoleHome(env: NodeJS.ProcessEnv): string {
|
|
516
|
+
return env.AGENTHUB_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".agenthub");
|
|
517
|
+
}
|
|
518
|
+
export const consoleAdd = (dir: string, env: NodeJS.ProcessEnv) => addStudy(consoleHome(env), dir);
|
|
519
|
+
export const consoleRemove = (dir: string, env: NodeJS.ProcessEnv) => removeStudy(consoleHome(env), dir);
|
|
520
|
+
export const consoleList = (env: NodeJS.ProcessEnv) => readRegistry(consoleHome(env));
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, realpathSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { assertWritable, openStudy, plain, readTextBounded, writeAtomic } from "../study.ts";
|
|
4
|
+
|
|
5
|
+
export interface StudyRegistry { v: 1; studies: string[] }
|
|
6
|
+
export const STUDY_REGISTRY_BYTES = 1024 * 1024, MAX_REGISTERED_STUDIES = 200;
|
|
7
|
+
function fail(message: string): never { throw new Error(`study registry: ${message}; restore the bounded v1 registry or correct AGENTHUB_HOME and retry ares console add/remove/list`); }
|
|
8
|
+
function absolute(value: string, label: string): string {
|
|
9
|
+
if (!isAbsolute(value) || value.includes("\0")) fail(`${label} must be an absolute path`);
|
|
10
|
+
return resolve(value);
|
|
11
|
+
}
|
|
12
|
+
function paths(home: string) {
|
|
13
|
+
const base = absolute(home, "family home"), dir = join(base, "research"), file = join(dir, "studies.json");
|
|
14
|
+
return { base, dir, file, lock: `${file}.lock` };
|
|
15
|
+
}
|
|
16
|
+
/** Root-owned system ancestors are allowed; the mutable family/registry paths must belong to this user. */
|
|
17
|
+
function guard(home: string, file: string): void {
|
|
18
|
+
const uid = process.getuid!(), { base, dir } = paths(home), ancestors: string[] = [];
|
|
19
|
+
for (let current = resolve(file); ; current = dirname(current)) { ancestors.unshift(current); if (current === dirname(current)) break; }
|
|
20
|
+
for (const current of ancestors) {
|
|
21
|
+
let info;
|
|
22
|
+
try { info = lstatSync(current); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
23
|
+
if (info.isSymbolicLink()) fail("links are refused in registry paths");
|
|
24
|
+
if (current === file) {
|
|
25
|
+
if (!info.isFile()) fail("registry or lock is not a regular file");
|
|
26
|
+
if (info.uid !== uid) fail("registry or lock is owned by another user");
|
|
27
|
+
} else {
|
|
28
|
+
if (!info.isDirectory()) fail("registry parent is not a directory");
|
|
29
|
+
if (info.uid !== uid && (current === base || current === dir || info.uid !== 0)) fail("registry parent is owned by another user");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function validate(raw: unknown): StudyRegistry {
|
|
34
|
+
if (!plain(raw) || raw.v !== 1) fail("unsupported registry version or malformed object; keep studies.json unchanged and use a compatible ares version");
|
|
35
|
+
if (!Array.isArray(raw.studies) || raw.studies.length > MAX_REGISTERED_STUDIES) fail("studies must be an array of at most 200 directories");
|
|
36
|
+
if (raw.studies.some(value => typeof value !== "string" || !isAbsolute(value) || value.includes("\0") || value !== resolve(value))) fail("stored study directories must be absolute normalized paths");
|
|
37
|
+
if (new Set(raw.studies).size !== raw.studies.length) fail("duplicate study directory in registry");
|
|
38
|
+
return { ...raw, v: 1, studies: [...raw.studies] } as StudyRegistry;
|
|
39
|
+
}
|
|
40
|
+
export function readRegistry(home: string): StudyRegistry {
|
|
41
|
+
const { file } = paths(home); guard(home, file);
|
|
42
|
+
let content: string;
|
|
43
|
+
try { content = readTextBounded(file, STUDY_REGISTRY_BYTES, { noFollow: true, uid: process.getuid!() }); }
|
|
44
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { v: 1, studies: [] }; fail("studies.json is unreadable, non-regular or over 1 MiB"); }
|
|
45
|
+
let raw: unknown; try { raw = JSON.parse(content); } catch { fail("studies.json is not valid JSON"); }
|
|
46
|
+
return validate(raw);
|
|
47
|
+
}
|
|
48
|
+
function update(home: string, change: (current: StudyRegistry) => StudyRegistry): StudyRegistry {
|
|
49
|
+
const { dir, file, lock } = paths(home); guard(home, file); guard(home, lock);
|
|
50
|
+
const busy = () => fail(`registry is locked at ${lock}; wait for the active registry command, or remove this stale lock only after confirming no registry command is running`);
|
|
51
|
+
if (existsSync(lock)) busy();
|
|
52
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 }); guard(home, file); chmodSync(dir, 0o700);
|
|
53
|
+
let fd: number;
|
|
54
|
+
try { fd = openSync(lock, "wx", 0o600); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") busy(); throw error; }
|
|
55
|
+
let owner: { dev: number; ino: number } | undefined;
|
|
56
|
+
try {
|
|
57
|
+
// Capture the created path before descriptor inspection so a failed fstat still has guarded cleanup.
|
|
58
|
+
try { owner = lstatSync(lock); } catch (error) {
|
|
59
|
+
try { owner = fstatSync(fd); } catch { /* Unknown identity must leave a stale lock for manual recovery. */ }
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
owner = fstatSync(fd);
|
|
63
|
+
const current = readRegistry(home), next = change(current);
|
|
64
|
+
if (next === current) return current;
|
|
65
|
+
const content = JSON.stringify(next, null, 2) + "\n"; assertWritable(content, STUDY_REGISTRY_BYTES, "study registry");
|
|
66
|
+
guard(home, file); writeAtomic(file, content, STUDY_REGISTRY_BYTES, 0o600); return next;
|
|
67
|
+
} finally {
|
|
68
|
+
try { closeSync(fd); } finally {
|
|
69
|
+
if (owner) {
|
|
70
|
+
try { const current = lstatSync(lock); if (current.dev === owner.dev && current.ino === owner.ino) unlinkSync(lock); }
|
|
71
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function canonical(dir: string, mustExist: boolean): string {
|
|
77
|
+
if (dir.includes("\0")) fail("study directory contains NUL");
|
|
78
|
+
const full = resolve(dir);
|
|
79
|
+
try { return realpathSync(full); } catch (error) { if (!mustExist && (error as NodeJS.ErrnoException).code === "ENOENT") return full; fail("study directory is unreadable; select a readable study for add or use its listed absolute directory for remove"); }
|
|
80
|
+
}
|
|
81
|
+
export function addStudy(home: string, dir: string): StudyRegistry {
|
|
82
|
+
const target = canonical(dir, true); openStudy(target);
|
|
83
|
+
const current = readRegistry(home); if (current.studies.includes(target)) return current;
|
|
84
|
+
if (current.studies.length >= MAX_REGISTERED_STUDIES) fail("200-study limit reached; remove an entry before adding another study");
|
|
85
|
+
return update(home, current => {
|
|
86
|
+
if (current.studies.includes(target)) return current;
|
|
87
|
+
if (current.studies.length >= MAX_REGISTERED_STUDIES) fail("200-study limit reached; remove an entry before adding another study");
|
|
88
|
+
return { ...current, studies: [...current.studies, target] };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export function removeStudy(home: string, dir: string): StudyRegistry {
|
|
92
|
+
const listed = resolve(dir), current = readRegistry(home);
|
|
93
|
+
// A moved or unreadable registered directory remains removable by its exact listed path.
|
|
94
|
+
const target = current.studies.includes(listed) ? listed : canonical(dir, false);
|
|
95
|
+
if (!current.studies.includes(target)) return current;
|
|
96
|
+
return update(home, current => current.studies.includes(target) ? { ...current, studies: current.studies.filter(value => value !== target) } : current);
|
|
97
|
+
}
|