@staix/agent-research 0.0.0-stage → 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 +51 -0
- package/LICENSE +21 -0
- package/README.md +666 -2
- package/bin/ares.js +11 -0
- package/package.json +37 -4
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +308 -0
- package/src/commands-table.ts +75 -0
- package/src/commands.ts +561 -0
- package/src/console/registry.ts +97 -0
- package/src/copilot.ts +91 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +483 -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 +187 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +148 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +106 -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 +122 -0
- package/src/manifest.ts +39 -0
- package/src/mcp-tools.ts +210 -0
- package/src/mcp.ts +113 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +66 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +73 -0
- package/src/paper/check.ts +181 -0
- package/src/paper/placeholders.ts +118 -0
- package/src/paper/review.ts +127 -0
- package/src/plan/controller.ts +222 -0
- package/src/plan/definition.ts +168 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +270 -0
- package/src/playbook.ts +141 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study-export.ts +254 -0
- package/src/study.ts +324 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +161 -0
- package/src/ui/plugin.ts +375 -0
package/src/study.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { validateAutonomy, validateAutonomyMode, resolveAutonomy, ACCEPT_ARTIFACTS, type Autonomy, type Phase, type AcceptArtifact } from "./autonomy.ts";
|
|
2
|
+
export type { Autonomy } from "./autonomy.ts";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, mkdirSync, openSync, readSync, renameSync, statSync, unlinkSync, writeSync, appendFileSync, lstatSync } from "node:fs";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
export type Mode = "survey" | "experiment";
|
|
10
|
+
export type GateName = "scope" | "screen" | "design" | "final" | "review" | `plan:${string}` | `accept:${AcceptArtifact}`;
|
|
11
|
+
export const GATES: readonly GateName[] = ["scope", "screen", "design", "final", "review"];
|
|
12
|
+
export interface Study { v: 1 | 2; id: string; question: string; mode: Mode; autonomy: Autonomy; createdAt: string }
|
|
13
|
+
export interface Actor { kind: "person" | "agent" | "auto"; id?: string }
|
|
14
|
+
export function requireExperiment(study: Study): void {
|
|
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");
|
|
16
|
+
}
|
|
17
|
+
export interface LedgerEvent { v: 1; at: string; actor: Actor; type: string; data: Record<string, unknown> }
|
|
18
|
+
|
|
19
|
+
const EVENT_BYTES = 65_536, LEDGER_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
const LOCK_STALE_MS = 10 * 60_000;
|
|
21
|
+
const QUESTION_MAX = 2000;
|
|
22
|
+
export const STUDY_BYTES = 65_536;
|
|
23
|
+
|
|
24
|
+
/** Agent environment markers the family uses; the first match names the actor. */
|
|
25
|
+
const AGENT_MARKERS: readonly [string, string | undefined][] = [
|
|
26
|
+
["AGENTHUB_PEER_ID", undefined], ["CLAUDECODE", "claude"], ["CLAUDE_CODE_SESSION_ID", "claude"], ["CODEX_THREAD_ID", "codex"], ["PI_SESSION_ID", "pi"],
|
|
27
|
+
];
|
|
28
|
+
export function detectActor(env: NodeJS.ProcessEnv): Actor {
|
|
29
|
+
for (const [marker, name] of AGENT_MARKERS) {
|
|
30
|
+
const value = env[marker]?.trim();
|
|
31
|
+
if (value) return { kind: "agent", id: (name ?? value).slice(0, 64) };
|
|
32
|
+
}
|
|
33
|
+
return { kind: "person" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const sha256 = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write a file by temp + fsync + rename so readers never see a partial file. `maxBytes` must be the bound the
|
|
40
|
+
* file's reader uses, so nothing is written that the tool cannot read back.
|
|
41
|
+
*/
|
|
42
|
+
export function writeAtomic(path: string, text: string, maxBytes: number, mode = 0o644): void {
|
|
43
|
+
assertWritable(text, maxBytes, path);
|
|
44
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
45
|
+
const fd = openSync(tmp, "wx", mode);
|
|
46
|
+
try { writeSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
47
|
+
try { renameSync(tmp, path); } catch (error) { try { unlinkSync(tmp); } catch { /* already gone */ } throw error; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read a regular file, stopping one byte past `maxBytes`: special files and oversize files are refused without buffering them. */
|
|
51
|
+
export interface ReadOptions { noFollow?: boolean; uid?: number }
|
|
52
|
+
export function readBytesBounded(path: string, maxBytes: number, options: ReadOptions = {}): Buffer {
|
|
53
|
+
// Non-blocking open: a FIFO without a writer would otherwise block before the type check.
|
|
54
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | (options.noFollow ? constants.O_NOFOLLOW : 0));
|
|
55
|
+
try {
|
|
56
|
+
const info = fstatSync(fd);
|
|
57
|
+
if (!info.isFile()) throw new Error(`${path}: not a regular file`);
|
|
58
|
+
if (options.uid !== undefined && info.uid !== options.uid) throw new Error(`${path}: owned by another user`);
|
|
59
|
+
const buffer = Buffer.alloc(Math.min(maxBytes + 1, 1 << 20)), parts: Buffer[] = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const n = readSync(fd, buffer, 0, buffer.length, null);
|
|
63
|
+
if (n === 0) break;
|
|
64
|
+
total += n;
|
|
65
|
+
if (total > maxBytes) throw new Error(`${path}: larger than ${maxBytes} bytes`);
|
|
66
|
+
parts.push(Buffer.from(buffer.subarray(0, n)));
|
|
67
|
+
}
|
|
68
|
+
return Buffer.concat(parts);
|
|
69
|
+
} finally { closeSync(fd); }
|
|
70
|
+
}
|
|
71
|
+
export function readTextBounded(path: string, maxBytes: number, options: ReadOptions = {}): string {
|
|
72
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(readBytesBounded(path, maxBytes, options)); }
|
|
73
|
+
catch (error) { if (error instanceof TypeError) throw new Error(`${path}: not valid UTF-8`); throw error; }
|
|
74
|
+
}
|
|
75
|
+
export function readJsonFile(path: string, maxBytes: number): unknown {
|
|
76
|
+
const text = readTextBounded(path, maxBytes);
|
|
77
|
+
try { return JSON.parse(text); } catch { throw new Error(`${path}: not valid JSON`); }
|
|
78
|
+
}
|
|
79
|
+
/** Refuse a write that the matching bounded read could not load back. */
|
|
80
|
+
export function assertWritable(text: string, maxBytes: number, what: string): void {
|
|
81
|
+
if (Buffer.byteLength(text) > maxBytes) throw new Error(`${what} would exceed ${maxBytes} bytes`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
85
|
+
|
|
86
|
+
/** Refuse keys outside `allowed`, so a typo never silently drops a field. */
|
|
87
|
+
export function onlyKeys(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
|
|
88
|
+
const extra = Object.keys(value).filter(key => !allowed.includes(key));
|
|
89
|
+
if (extra.length) throw new Error(`${what}: unknown field ${extra[0]}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function boundedText(value: unknown, what: string, max: number, min = 1): string {
|
|
93
|
+
if (typeof value !== "string" || value.includes("\0")) throw new Error(`${what}: text required`);
|
|
94
|
+
const text = value.trim();
|
|
95
|
+
if (text.length < min || text.length > max) throw new Error(`${what}: ${min}..${max} characters`);
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function studyFile(dir: string, ...parts: string[]): string { return join(dir, ...parts); }
|
|
100
|
+
|
|
101
|
+
/** Resolve a study directory: it must hold a valid study.json. */
|
|
102
|
+
export function openStudy(dir: string): { dir: string; study: Study } {
|
|
103
|
+
const root = resolve(dir), file = join(root, "study.json");
|
|
104
|
+
if (!existsSync(file)) throw new Error(`${root}: no study.json (run ares init first)`);
|
|
105
|
+
return { dir: root, study: validStudy(readJsonFile(file, STUDY_BYTES)) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validStudy(raw: unknown): Study {
|
|
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;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function initStudy(dir: string, options: { question: string; mode?: string; autonomy?: string }, actor: Actor): Study {
|
|
120
|
+
const root = resolve(dir);
|
|
121
|
+
if (existsSync(join(root, "study.json"))) throw new Error(`${root}: a study already exists here`);
|
|
122
|
+
const mode = options.mode ?? "survey", autonomy = validateAutonomyMode(options.autonomy ?? "gates");
|
|
123
|
+
if (mode !== "survey" && mode !== "experiment") throw new Error("--mode: survey or experiment");
|
|
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() };
|
|
125
|
+
for (const sub of ["", "literature", "literature/cards", "refs"]) mkdirSync(join(root, sub), { recursive: true });
|
|
126
|
+
// link() refuses an existing target, so two concurrent inits cannot both create the study.
|
|
127
|
+
const text = JSON.stringify(study, null, 2) + "\n", tmp = join(root, `study.json.${randomUUID()}.tmp`);
|
|
128
|
+
assertWritable(text, STUDY_BYTES, "study.json");
|
|
129
|
+
const fd = openSync(tmp, "wx", 0o644);
|
|
130
|
+
try { writeSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
131
|
+
try { linkSync(tmp, join(root, "study.json")); }
|
|
132
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`${root}: a study already exists here`); throw error; }
|
|
133
|
+
finally { unlinkSync(tmp); }
|
|
134
|
+
appendEvent(root, actor, "study.init", { id: study.id, mode, autonomy });
|
|
135
|
+
return study;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Exclusive write lock for one study. A lock older than 10 minutes is treated as abandoned and replaced.
|
|
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.
|
|
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(); }
|
|
147
|
+
export class StudyLockBusyError extends Error {}
|
|
148
|
+
export async function withLock<T>(dir: string, fn: () => Promise<T> | T, inspectOwner: (fd: number) => ReturnType<typeof fstatSync> = fstatSync): Promise<T> {
|
|
149
|
+
const path = join(dir, ".ares.lock");
|
|
150
|
+
let fd: number;
|
|
151
|
+
try { fd = openSync(path, "wx", 0o600); }
|
|
152
|
+
catch (error) {
|
|
153
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
154
|
+
let age = 0;
|
|
155
|
+
try { age = Date.now() - statSync(path).mtimeMs; } catch { /* removed meanwhile */ }
|
|
156
|
+
if (age < LOCK_STALE_MS) throw new StudyLockBusyError(`study is locked by another ares command (${path}); retry when it finishes`);
|
|
157
|
+
try { unlinkSync(path); } catch { /* raced with another cleaner */ }
|
|
158
|
+
fd = openSync(path, "wx", 0o600);
|
|
159
|
+
}
|
|
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(); }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface PendingEvent { actor: Actor; type: string; data: Record<string, unknown> }
|
|
182
|
+
export type FileWrite = [path: string, text: string, maxBytes: number];
|
|
183
|
+
|
|
184
|
+
/** Validate command capacity without writing; callers can reserve receipt space before outward effects. */
|
|
185
|
+
export function prepareCommit(dir: string, files: FileWrite[], pending: PendingEvent[]): { events: LedgerEvent[]; lines: string[] } {
|
|
186
|
+
const at = new Date().toISOString(), events: LedgerEvent[] = pending.map(e => ({ v: 1, at, ...e }));
|
|
187
|
+
const lines = events.map(e => JSON.stringify(e) + "\n");
|
|
188
|
+
lines.forEach((line, i) => { if (Buffer.byteLength(line) > EVENT_BYTES) throw new Error(`ledger event ${events[i]!.type} exceeds ${EVENT_BYTES} bytes`); });
|
|
189
|
+
const ledger = join(dir, "ledger.jsonl"), size = existsSync(ledger) ? statSync(ledger).size : 0;
|
|
190
|
+
if (size + Buffer.byteLength(lines.join("")) > LEDGER_BYTES) throw new Error(`ledger.jsonl would exceed ${LEDGER_BYTES} bytes; start a new study`);
|
|
191
|
+
for (const [path, text, max] of files) assertWritable(text, max, path);
|
|
192
|
+
return { events, lines };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Apply one command's changes: every file and ledger bound is checked first (file sizes, event sizes, the ledger's
|
|
196
|
+
* total against its read limit), then the files are written and all events appended in one write. A failed check
|
|
197
|
+
* changes nothing.
|
|
198
|
+
* ponytail: no write-ahead log; a crash between the file writes and the append leaves files without their event.
|
|
199
|
+
*/
|
|
200
|
+
export function commit(dir: string, files: FileWrite[], pending: PendingEvent[]): LedgerEvent[] {
|
|
201
|
+
const { events, lines } = prepareCommit(dir, files, pending), ledger = join(dir, "ledger.jsonl");
|
|
202
|
+
for (const [path, text, max] of files) { mkdirSync(dirname(path), { recursive: true }); writeAtomic(path, text, max); }
|
|
203
|
+
if (lines.length) appendFileSync(ledger, lines.join(""), { mode: 0o644 });
|
|
204
|
+
return events;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function appendEvent(dir: string, actor: Actor, type: string, data: Record<string, unknown>): LedgerEvent {
|
|
208
|
+
return commit(dir, [], [{ actor, type, data }])[0]!;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Read the whole ledger; a malformed line fails closed with its line number. */
|
|
212
|
+
export function readLedger(dir: string): LedgerEvent[] {
|
|
213
|
+
const path = join(dir, "ledger.jsonl");
|
|
214
|
+
if (!existsSync(path)) return [];
|
|
215
|
+
const lines = readTextBounded(path, LEDGER_BYTES).split("\n");
|
|
216
|
+
const events: LedgerEvent[] = [];
|
|
217
|
+
lines.forEach((line, index) => {
|
|
218
|
+
if (!line.trim()) return;
|
|
219
|
+
let event: unknown;
|
|
220
|
+
try { event = JSON.parse(line); } catch { throw new Error(`ledger.jsonl line ${index + 1}: not JSON`); }
|
|
221
|
+
if (plain(event) && event.v !== 1) throw new Error(`ledger.jsonl line ${index + 1}: unsupported schema version; use an ares version that supports this ledger, keeping all original lines unchanged`);
|
|
222
|
+
if (!plain(event) || event.v !== 1 || typeof event.type !== "string" || !plain(event.data) || !plain(event.actor)) throw new Error(`ledger.jsonl line ${index + 1}: malformed event`);
|
|
223
|
+
events.push(event as unknown as LedgerEvent);
|
|
224
|
+
});
|
|
225
|
+
return events;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface GateState { gate: GateName; state: "none" | "pending" | "approved" | "rejected"; current?: string; decided?: { decision: "approve" | "reject"; binding: string; actor: Actor; at: string; reason?: string } }
|
|
229
|
+
|
|
230
|
+
/** The content a gate approval binds to. `undefined` means there is nothing to decide yet. */
|
|
231
|
+
export type Binder = (gate: GateName) => string | undefined;
|
|
232
|
+
|
|
233
|
+
export function gateState(gate: GateName, events: LedgerEvent[], binder: Binder): GateState {
|
|
234
|
+
const current = binder(gate);
|
|
235
|
+
let decided: GateState["decided"];
|
|
236
|
+
for (const event of events) {
|
|
237
|
+
if ((event.type === "gate.approve" || event.type === "gate.reject") && event.data.gate === gate && typeof event.data.binding === "string")
|
|
238
|
+
decided = { decision: event.type === "gate.approve" ? "approve" : "reject", binding: event.data.binding, actor: event.actor, at: event.at, ...(typeof event.data.reason === "string" ? { reason: event.data.reason } : {}) };
|
|
239
|
+
}
|
|
240
|
+
if (current === undefined) return { gate, state: "none", ...(decided ? { decided } : {}) };
|
|
241
|
+
if (!decided || decided.binding !== current) return { gate, state: "pending", current, ...(decided ? { decided } : {}) };
|
|
242
|
+
return { gate, state: decided.decision === "approve" ? "approved" : "rejected", current, decided };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function decideGate(dir: string, gate: GateName, decision: "approve" | "reject", actor: Actor, binder: Binder, reason?: string): GateState {
|
|
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`);
|
|
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}`);
|
|
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" : ""}`);
|
|
250
|
+
if (decision === "reject" && !reason) throw new Error(`gate ${gate}: a rejection needs --reason`);
|
|
251
|
+
appendEvent(dir, actor, decision === "approve" ? "gate.approve" : "gate.reject", { gate, binding: before.current!, ...(reason ? { reason } : {}) });
|
|
252
|
+
return gateState(gate, readLedger(dir), binder);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Pass when the gate is approved for the current content. In auto mode a pending gate passes too, and the returned
|
|
257
|
+
* `auto` approval goes into the command's own commit only when the command succeeds, so a failed command never
|
|
258
|
+
* changes a gate.
|
|
259
|
+
*/
|
|
260
|
+
export function requireGate(dir: string, study: Study, gate: GateName, binder: Binder, guardedPhase?: Phase): PendingEvent[] {
|
|
261
|
+
if (gate === "design" || gate === "final") requireExperiment(study);
|
|
262
|
+
const state = gateState(gate, readLedger(dir), binder);
|
|
263
|
+
if (state.state === "approved") return [];
|
|
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}`);
|
|
266
|
+
if (state.state === "none" && gate === "final") throw new Error("gate final: complete reserved seeds and close investigate/required ablate inputs before final approval");
|
|
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`);
|
|
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");
|
|
269
|
+
if (state.state === "rejected") throw new Error(`gate ${gate} is rejected${state.decided?.reason ? `: ${state.decided.reason}` : ""}; change the content and ask again`);
|
|
270
|
+
throw new Error(`gate ${gate} is pending: run ares gate approve <dir> ${gate}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
export interface PlanOwner { pid: number; host: string; startedAt: string; token: string }
|
|
275
|
+
/** Kernel process start identity, rather than lock age, detects a reused PID. */
|
|
276
|
+
export function processStart(pid: number): string | undefined {
|
|
277
|
+
const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 2000, maxBuffer: 4096, env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LC_ALL: "C" } });
|
|
278
|
+
if (result.status === 0 && result.stdout.trim()) return result.stdout.trim();
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
export function readPlanOwner(dir: string): PlanOwner | undefined {
|
|
282
|
+
const file = join(dir, ".ares.plan.lock");
|
|
283
|
+
let stat;
|
|
284
|
+
try { stat = lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
285
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("plan controller lock: regular file required; restore the study lock file without links before running ares run");
|
|
286
|
+
const raw = readJsonFile(file, 4096);
|
|
287
|
+
if (!plain(raw) || !Number.isSafeInteger(raw.pid) || (raw.pid as number) < 1 || typeof raw.host !== "string" || !raw.host || typeof raw.startedAt !== "string" || !raw.startedAt || typeof raw.token !== "string" || !raw.token) throw new Error("plan controller lock: malformed owner; inspect the owner process and restore a valid lock before ares run");
|
|
288
|
+
return raw as unknown as PlanOwner;
|
|
289
|
+
}
|
|
290
|
+
export function assertPlanOwner(dir: string, owner: PlanOwner): void {
|
|
291
|
+
const current = readPlanOwner(dir);
|
|
292
|
+
if (!current || current.token !== owner.token || current.pid !== owner.pid || current.host !== owner.host || current.startedAt !== owner.startedAt) throw new Error("plan controller: ownership changed; stop this controller and inspect ares plan show before restarting");
|
|
293
|
+
}
|
|
294
|
+
export async function withPlanLock<T>(dir: string, fn: (owner: PlanOwner) => Promise<T>): Promise<T> {
|
|
295
|
+
const startedAt = processStart(process.pid);
|
|
296
|
+
if (!startedAt) throw new Error("plan controller: process start identity unavailable; check local ps access before running ares run");
|
|
297
|
+
const owner: PlanOwner = { pid: process.pid, host: hostname(), startedAt, token: randomUUID() };
|
|
298
|
+
await withLock(dir, () => {
|
|
299
|
+
const previous = readPlanOwner(dir);
|
|
300
|
+
if (previous) {
|
|
301
|
+
const busy = () => new Error(`plan controller: owned by pid ${previous.pid} on ${previous.host} (started ${previous.startedAt}); stop that controller or run on its host after it exits`);
|
|
302
|
+
if (previous.host !== owner.host) throw busy();
|
|
303
|
+
let alive = true;
|
|
304
|
+
try { process.kill(previous.pid, 0); }
|
|
305
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") alive = false; else throw busy(); }
|
|
306
|
+
if (alive) {
|
|
307
|
+
const observed = processStart(previous.pid);
|
|
308
|
+
if (!observed || observed === previous.startedAt) throw busy();
|
|
309
|
+
}
|
|
310
|
+
// Short study lock serializes contenders. Never recover by age.
|
|
311
|
+
unlinkSync(join(dir, ".ares.plan.lock"));
|
|
312
|
+
}
|
|
313
|
+
const fd = openSync(join(dir, ".ares.plan.lock"), "wx", 0o600);
|
|
314
|
+
try { writeSync(fd, JSON.stringify(owner) + "\n"); fsyncSync(fd); } finally { closeSync(fd); }
|
|
315
|
+
});
|
|
316
|
+
try { return await fn(owner); }
|
|
317
|
+
finally {
|
|
318
|
+
// A live matching owner cannot be replaced by any valid contender.
|
|
319
|
+
try {
|
|
320
|
+
const current = readPlanOwner(dir);
|
|
321
|
+
if (current?.token === owner.token) unlinkSync(join(dir, ".ares.plan.lock"));
|
|
322
|
+
} catch { /* Preserve the original failure; never remove a malformed or unverified owner. */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/ui/panel.css
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.ares { display: flex; flex-direction: column; gap: var(--acon-space-3); min-width: 0; }
|
|
2
|
+
.ares-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--acon-space-2); }
|
|
3
|
+
.ares-bar h2 { flex: 1; margin: 0; min-width: 0; font-size: var(--acon-text-lg); overflow-wrap: anywhere; }
|
|
4
|
+
.ares-card { display: flex; flex-direction: column; gap: var(--acon-space-2); min-width: 0; }
|
|
5
|
+
.ares-card h3 { margin: 0; font-size: var(--acon-text-base, 1rem); }
|
|
6
|
+
.ares-card > p, .ares-prose { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
7
|
+
.ares-sub { margin: 0; color: var(--acon-text-2); overflow-wrap: anywhere; }
|
|
8
|
+
.ares-pre { margin: 0; max-height: 40vh; overflow: auto; padding: var(--acon-space-3); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--acon-surface-2); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); font: var(--acon-text-sm)/1.5 var(--acon-font-mono); }
|
|
9
|
+
.ares-list { margin: 0; padding-left: 1.5em; overflow-wrap: anywhere; }
|
|
10
|
+
.ares-list li + li { margin-top: var(--acon-space-1); }
|
|
11
|
+
@media (max-width: 600px) { .ares-bar { align-items: flex-start; } .ares-bar h2 { flex-basis: 100%; } }
|
package/src/ui/panel.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// acon api 1: the shell owns polling and confirmation; decision controls use host.ui and host.action.
|
|
2
|
+
export default (host) => {
|
|
3
|
+
const { ui } = host;
|
|
4
|
+
const { h } = ui;
|
|
5
|
+
const root = h("div", { class: "ares" });
|
|
6
|
+
host.mount.append(root);
|
|
7
|
+
let panel = "studies", params = {}, visible = true, destroyed = false, generation = 0, last = null;
|
|
8
|
+
const viewKey = () => JSON.stringify([panel, params]);
|
|
9
|
+
const text = (value, fallback = "Unavailable") => typeof value === "string" && value ? value : fallback;
|
|
10
|
+
const number = (value) => typeof value === "number" && Number.isFinite(value) ? String(value) : "Unavailable";
|
|
11
|
+
const rows = (value) => Array.isArray(value) ? value : [];
|
|
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)" : ""}`;
|
|
15
|
+
const line = (value, max = 180) => {
|
|
16
|
+
const valueText = text(value, "").replace(/\s+/g, " ").trim();
|
|
17
|
+
return valueText.length > max ? `${valueText.slice(0, max - 3)}...` : valueText;
|
|
18
|
+
};
|
|
19
|
+
const at = (value) => typeof value === "string" ? value.replace("T", " ").replace(/\.\d+Z$/, " UTC") : "Time unavailable";
|
|
20
|
+
const go = (target, extra = {}) => host.navigate(host.tool, target, extra);
|
|
21
|
+
const tone = (state) => ["accepted", "approved", "qualified", "succeeded", "ready"].includes(state) ? "ok"
|
|
22
|
+
: ["failed", "unknown", "unavailable"].includes(state) ? "err"
|
|
23
|
+
: ["pending", "proposed", "not_qualified", "rejected"].includes(state) ? "warn" : "neutral";
|
|
24
|
+
const decisionNotice = () => ui.notice("Each decision opens a preview. Nothing is written until you confirm.", "neutral");
|
|
25
|
+
const perform = async (input) => {
|
|
26
|
+
try { await host.action(input); }
|
|
27
|
+
catch { if (!destroyed) host.toast("The decision could not be requested. Refresh the study and retry."); }
|
|
28
|
+
};
|
|
29
|
+
const askDecision = (title, input, requirePr = false) => {
|
|
30
|
+
const body = host.sheet.open(title), errors = h("div", { "aria-live": "polite" });
|
|
31
|
+
const fields = [{ name: "reason", label: "Reason", required: true }, ...(requirePr ? [{ name: "pr", label: "Reviewed PR URL", required: true }] : [])];
|
|
32
|
+
const form = ui.form(fields, { label: "Review decision", primary: true }, (values) => {
|
|
33
|
+
const reason = text(values.reason, "").trim(), pr = text(values.pr, "").trim();
|
|
34
|
+
if (!reason) { errors.replaceChildren(ui.notice("Enter a reason before continuing.", "err")); return; }
|
|
35
|
+
if (requirePr) {
|
|
36
|
+
try { const url = new URL(pr); if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error(); }
|
|
37
|
+
catch { errors.replaceChildren(ui.notice("Enter the reviewed HTTP(S) PR URL without credentials.", "err")); return; }
|
|
38
|
+
}
|
|
39
|
+
host.sheet.close();
|
|
40
|
+
void perform({ ...input, reason, ...(requirePr ? { pr } : {}) });
|
|
41
|
+
});
|
|
42
|
+
body.append(ui.notice(input.action.startsWith("skill.") ? "Skill decisions are final. Review the evidence and reason before confirming." : "Review the evidence and reason. The next step shows the confirmation preview.", "neutral"), form, errors, ui.button("Cancel", () => host.sheet.close()));
|
|
43
|
+
};
|
|
44
|
+
const block = (title, value) => {
|
|
45
|
+
if (value === undefined || value === null) return ui.empty(`${title} unavailable`);
|
|
46
|
+
const content = JSON.stringify(value, null, 2), bound = 20_000;
|
|
47
|
+
return h("section", { class: "ares-card" }, h("h3", { text: title }),
|
|
48
|
+
h("pre", { class: "ares-pre", text: content.length > bound ? `${content.slice(0, bound)}\n...` : content }),
|
|
49
|
+
content.length > bound ? h("p", { class: "ares-sub", text: "Preview shortened. Inspect the complete records with the ares CLI." }) : null);
|
|
50
|
+
};
|
|
51
|
+
const refresh = async () => {
|
|
52
|
+
const current = generation;
|
|
53
|
+
try {
|
|
54
|
+
const snapshot = await host.snapshot({ ...params, panel, after: 0 });
|
|
55
|
+
if (!destroyed && visible && current === generation) render(snapshot);
|
|
56
|
+
} catch {
|
|
57
|
+
if (!destroyed && visible && current === generation) root.replaceChildren(ui.notice("Research could not refresh. Retry or inspect the console System view.", "err"));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const toolbar = (title, back = false) => h("div", { class: "ares-bar" },
|
|
61
|
+
back ? ui.button("All studies", () => go("studies")) : null,
|
|
62
|
+
h("h2", { text: title }), ui.button("Refresh", refresh));
|
|
63
|
+
const gateRows = (gates, study) => h("section", { class: "ares-card" },
|
|
64
|
+
ui.table({ caption: "Gate states", columns: [{ key: "gate", label: "Gate" }, { key: "state", label: "State" }],
|
|
65
|
+
rows: rows(gates).map((gate) => ({ gate: text(gate.gate), state: text(gate.state) })) }),
|
|
66
|
+
typeof study === "string" ? ui.rows(rows(gates).filter((gate) => gate.state === "pending" && typeof gate.gate === "string"), (gate) => ui.row({
|
|
67
|
+
title: gate.gate, sub: "Pending decision", chip: { text: "pending", tone: "warn" }, verbs: [
|
|
68
|
+
ui.button("Approve", () => perform({ action: "gate.approve", study, gate: gate.gate }), { tone: "primary" }),
|
|
69
|
+
ui.button("Reject", () => askDecision(`Reject ${gate.gate}`, { action: "gate.reject", study, gate: gate.gate }), { tone: "danger" }),
|
|
70
|
+
],
|
|
71
|
+
})) : null);
|
|
72
|
+
const attemptRows = (attempts) => {
|
|
73
|
+
const value = record(attempts), byStatus = record(value.byStatus);
|
|
74
|
+
if (value.available === false) return h("section", { class: "ares-card" }, h("h3", { text: "Attempts unavailable" }),
|
|
75
|
+
ui.notice(line(value.reason) || "Attempt records could not be read. Inspect the study records and refresh.", "err"));
|
|
76
|
+
return h("section", { class: "ares-card" }, h("h3", { text: `Attempts: ${number(value.total)}` }),
|
|
77
|
+
ui.rows(Object.entries(byStatus), ([state, count]) => ui.row({ title: state, sub: number(count), chip: { text: state, tone: tone(state) } })) ?? ui.empty("No attempt status details"));
|
|
78
|
+
};
|
|
79
|
+
const studies = (snapshot) => {
|
|
80
|
+
const list = rows(snapshot.studies);
|
|
81
|
+
return [toolbar("Research studies"), decisionNotice(),
|
|
82
|
+
ui.rows(list, (study) => {
|
|
83
|
+
const available = study.available !== false;
|
|
84
|
+
const finalState = study.final?.status, reviewState = study.review?.ready === true ? "Review ready" : study.review ? "Review pending" : "Review unavailable";
|
|
85
|
+
const watchState = study.lastWatch ? `${study.lastWatch.ok ? "Watch answered" : "Watch failed"}: ${number(study.lastWatch.added)} added` : "No watch run";
|
|
86
|
+
const attempts = study.attempts?.available === false ? `Attempts unavailable: ${line(study.attempts.reason) || "Attempt records could not be read."}` : `${number(study.attempts?.total)} attempts`;
|
|
87
|
+
const pending = rows(study.gates).filter((gate) => gate.state === "pending").length;
|
|
88
|
+
return ui.row({ title: line(study.question) || text(study.id, "Unavailable study"),
|
|
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."),
|
|
90
|
+
chip: { text: available ? text(study.mode, "study") : "unavailable", tone: available ? "neutral" : "err" },
|
|
91
|
+
...(typeof study.dir === "string" ? { onOpen: () => go("study", { study: study.dir }) } : {}) });
|
|
92
|
+
}) ?? ui.empty("No registered studies", "Register one with ares console add <dir>. Ares does not scan the disk for studies.")];
|
|
93
|
+
};
|
|
94
|
+
const skillRows = (skills, study) => ui.rows(rows(skills), (skill) => ui.detail({
|
|
95
|
+
kind: "Skill proposal", title: text(skill.name), state: { text: text(skill.state), tone: tone(skill.state) }, summary: text(skill.summary, ""),
|
|
96
|
+
actions: skill.state === "proposed" && typeof skill.name === "string" && typeof study === "string" ? [
|
|
97
|
+
ui.button("Accept...", () => askDecision(`Accept ${skill.name}`, { action: "skill.accept", study, name: skill.name }, true), { tone: "primary" }),
|
|
98
|
+
ui.button("Reject...", () => askDecision(`Reject ${skill.name}`, { action: "skill.reject", study, name: skill.name }), { tone: "danger" }),
|
|
99
|
+
ui.button("Withdraw...", () => askDecision(`Withdraw ${skill.name}`, { action: "skill.withdraw", study, name: skill.name }), { tone: "danger" }),
|
|
100
|
+
] : [],
|
|
101
|
+
sections: [{ title: "Steps", body: h("ol", { class: "ares-list" }, ...rows(skill.steps).map((step) => h("li", { text: String(step) }))) },
|
|
102
|
+
{ title: "Evidence", body: h("ul", { class: "ares-list" }, ...rows(skill.evidence).map((ref) => h("li", { text: String(ref) }))) },
|
|
103
|
+
...(skill.decision ? [{ title: "Recorded decision", body: h("pre", { class: "ares-pre", text: JSON.stringify(skill.decision, null, 2) }) }] : [])],
|
|
104
|
+
footer: typeof skill.sha256 === "string" ? [skill.sha256] : [],
|
|
105
|
+
})) ?? ui.empty("No skill proposals");
|
|
106
|
+
const studyView = (snapshot) => {
|
|
107
|
+
const study = snapshot.study;
|
|
108
|
+
if (!study) return [toolbar("Study", true), ui.notice(text(snapshot.error, "Choose a registered study from the studies panel."), "warn")];
|
|
109
|
+
const sections = [toolbar(text(study.question, "Study"), true), decisionNotice()];
|
|
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, "") })];
|
|
111
|
+
sections.push(h("div", { class: "ares-bar" }, ui.chip(text(study.mode), "neutral"), ui.chip(autonomyLabel(study.autonomy), "neutral")),
|
|
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));
|
|
114
|
+
const final = record(study.final);
|
|
115
|
+
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Final status" }),
|
|
116
|
+
typeof final.status === "string" ? ui.chip(final.status, tone(final.status)) : ui.empty("No current closed final"),
|
|
117
|
+
typeof final.reason === "string" ? h("p", { text: final.reason }) : null));
|
|
118
|
+
const review = record(study.review);
|
|
119
|
+
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Review readiness" }),
|
|
120
|
+
study.review ? ui.chip(review.ready === true ? "ready" : "not ready", review.ready === true ? "ok" : "warn") : ui.empty("Review unavailable"),
|
|
121
|
+
ui.rows(rows(review.unmet), (item) => ui.row({ title: text(item.code, "Review requirement"), sub: text(item.message, "") })),
|
|
122
|
+
rows(review.openBlocking).length ? ui.notice(`${review.openBlocking.length} blocking findings remain open.`, "warn") : null));
|
|
123
|
+
if (typeof study.manuscript?.id === "string") sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Built manuscript" }),
|
|
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"));
|
|
127
|
+
sections.push(block("Investigation", study.investigation), block("Posthoc exploration", study.posthoc));
|
|
128
|
+
const watch = record(study.watch), watchConfig = record(watch.config);
|
|
129
|
+
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Watch" }),
|
|
130
|
+
typeof watchConfig.rubric === "string" ? h("div", {}, h("strong", { text: "Display-only rubric" }), h("p", { class: "ares-prose", text: watchConfig.rubric })) : null,
|
|
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")));
|
|
132
|
+
const candidates = rows(record(watch.newCandidates).candidates).filter((candidate) => candidate.status === "new" && typeof candidate.id === "string");
|
|
133
|
+
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "New candidates" }),
|
|
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" ? [
|
|
135
|
+
ui.button("Include...", () => askDecision("Include candidate", { action: "screen.include", study: study.dir, ids: [candidate.id] }), { tone: "primary" }),
|
|
136
|
+
ui.button("Exclude...", () => askDecision("Exclude candidate", { action: "screen.exclude", study: study.dir, ids: [candidate.id] }), { tone: "danger" }),
|
|
137
|
+
] : [] })) ?? ui.empty("No new candidates waiting")));
|
|
138
|
+
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Skill proposals" }), skillRows(study.skills, study.dir)));
|
|
139
|
+
const pending = study.archive?.pendingCount;
|
|
140
|
+
sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Archive lessons" }),
|
|
141
|
+
Number.isSafeInteger(pending) && pending >= 0 ? h("p", { text: pending ? `${pending} pending lesson notes. Preview the next export batch before confirming.` : "No pending lesson notes." }) : ui.empty("Archive preview unavailable"),
|
|
142
|
+
typeof study.dir === "string" && Number.isSafeInteger(pending) && pending > 0 ? ui.button("Preview archive...", () => perform({ action: "archive.confirm", study: study.dir }), { tone: "primary" }) : null));
|
|
143
|
+
const errors = Object.entries(record(study.errors));
|
|
144
|
+
if (errors.length) sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Unavailable records" }),
|
|
145
|
+
ui.rows(errors, ([name, reason]) => ui.row({ title: name, sub: String(reason) }))));
|
|
146
|
+
return sections;
|
|
147
|
+
};
|
|
148
|
+
const render = (snapshot) => {
|
|
149
|
+
if (destroyed || !visible || snapshot.changed === false) return;
|
|
150
|
+
last = { key: viewKey(), snapshot };
|
|
151
|
+
const body = snapshot.error && panel !== "study" ? [ui.notice(String(snapshot.error), "err")]
|
|
152
|
+
: panel === "studies" ? studies(snapshot) : panel === "study" ? studyView(snapshot) : [ui.empty("Unknown research panel", "Choose Studies from the console.")];
|
|
153
|
+
root.replaceChildren(...body.filter(Boolean));
|
|
154
|
+
};
|
|
155
|
+
host.onSnapshot(render);
|
|
156
|
+
return {
|
|
157
|
+
show(id, input = {}) { generation++; panel = id; params = { ...input }; visible = true; root.hidden = false; if (last?.key === viewKey()) render(last.snapshot); else root.replaceChildren(ui.notice("Loading research view...", "neutral")); },
|
|
158
|
+
hide() { generation++; visible = false; root.hidden = true; },
|
|
159
|
+
destroy() { generation++; destroyed = true; root.remove(); },
|
|
160
|
+
};
|
|
161
|
+
};
|