@turri-dev/cli 0.1.8-next.01c7342 → 0.1.8-next.a9bda1b
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/README.md +1 -1
- package/dist/cli.js +6 -2
- package/dist/config-edit.d.ts +1 -1
- package/dist/config-edit.js +4 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +28 -1
- package/dist/extension.js +75 -9
- package/dist/genome.js +2 -0
- package/dist/host.d.ts +3 -1
- package/dist/host.js +4 -1
- package/dist/init-config.d.ts +3 -0
- package/dist/init-config.js +15 -0
- package/dist/ledger-cli.d.ts +1 -0
- package/dist/ledger-cli.js +50 -0
- package/dist/ledger-tools.d.ts +10 -0
- package/dist/ledger-tools.js +64 -0
- package/dist/loop-cli.js +4 -0
- package/dist/pi.d.ts +2 -0
- package/dist/pi.js +24 -4
- package/dist/regenerate/activity.d.ts +9 -0
- package/dist/regenerate/activity.js +11 -0
- package/dist/regenerate/agent-env.d.ts +7 -0
- package/dist/regenerate/agent-env.js +126 -5
- package/dist/regenerate/agent-shell.js +5 -0
- package/dist/regenerate/chunk-plan.d.ts +0 -6
- package/dist/regenerate/chunk-plan.js +16 -4
- package/dist/regenerate/conformance.js +3 -0
- package/dist/regenerate/debug-log.d.ts +3 -1
- package/dist/regenerate/debug-log.js +10 -2
- package/dist/regenerate/diagnose.js +12 -2
- package/dist/regenerate/job-log.d.ts +3 -1
- package/dist/regenerate/job-log.js +8 -5
- package/dist/regenerate/jobs-tree.d.ts +7 -3
- package/dist/regenerate/jobs-tree.js +21 -6
- package/dist/regenerate/jobs.d.ts +9 -0
- package/dist/regenerate/jobs.js +51 -5
- package/dist/regenerate/ledger-view.d.ts +92 -0
- package/dist/regenerate/ledger-view.js +345 -0
- package/dist/regenerate/ledger.d.ts +275 -0
- package/dist/regenerate/ledger.js +349 -0
- package/dist/regenerate/lineage.js +18 -1
- package/dist/regenerate/loop.js +10 -1
- package/dist/regenerate/pulse-files.d.ts +14 -0
- package/dist/regenerate/pulse-files.js +63 -0
- package/dist/regenerate/regenerate.js +4 -2
- package/dist/regenerate/regression.js +2 -0
- package/dist/regenerate/services.js +3 -0
- package/dist/regenerate/side-panel.d.ts +2 -0
- package/dist/regenerate/side-panel.js +4 -3
- package/dist/regenerate/test-scripts.js +18 -5
- package/dist/regenerate/testing.js +3 -0
- package/dist/regenerate/unit-review.js +3 -0
- package/dist/run-report.js +9 -5
- package/docs/config.md +23 -2
- package/docs/development.md +3 -1
- package/docs/headless.md +5 -4
- package/docs/hosting.md +29 -0
- package/docs/installation.md +1 -0
- package/docs/regenerating.md +7 -7
- package/docs/sandbox.md +5 -5
- package/docs/using.md +33 -4
- package/package.json +1 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
5
|
+
import { readProjectConfig } from "../config.js";
|
|
6
|
+
import { turriIdentity } from "../environment.js";
|
|
7
|
+
import { currentPlace, onModelEvent } from "./activity.js";
|
|
8
|
+
import { maskSecrets } from "./masking.js";
|
|
9
|
+
import { ensureInScratch } from "./paths.js";
|
|
10
|
+
/**
|
|
11
|
+
* The ledger: what turri does, as typed facts, one JSON line each, written as it happens - to
|
|
12
|
+
* `.turri/ledger/` in the project, one file a run (a conversation, a headless run, a host's session).
|
|
13
|
+
*
|
|
14
|
+
* The job and debug logs keep the bodies - every prompt, reply and tool result, whole - for reading one
|
|
15
|
+
* pulse closely. The ledger keeps the facts about them, across pulses, sessions and runs: that a pulse
|
|
16
|
+
* was queued, started, went through its stages and ended, and how; what each agent's model calls and
|
|
17
|
+
* turns cost; each tool call and test run, how long it took and how it ended; the gates' rounds and
|
|
18
|
+
* verdicts; what turri refused or stopped; what its healers did; the history's commits. What a pulse
|
|
19
|
+
* from an earlier session did is looked up here (`pulses`, `diagnose`, `turri ledger`). Never stored:
|
|
20
|
+
* prompts, replies, thinking, tool arguments and results, file contents - and what strings an event
|
|
21
|
+
* carries are masked as the logs are.
|
|
22
|
+
*
|
|
23
|
+
* A host hears each event as it is recorded (`onLedgerEvent`), with the project it is about, rather than reading the
|
|
24
|
+
* file again - written or not, so a project that keeps no ledger is heard all the same.
|
|
25
|
+
*
|
|
26
|
+
* Observation, never control: turri may react to events as they happen, but never reads the ledger on
|
|
27
|
+
* disk to decide what to build - the code and tests records are that (records.ts). Deleting
|
|
28
|
+
* `.turri/ledger/` loses only what can be looked up. A ledger that cannot be written stops, and never
|
|
29
|
+
* fails the work.
|
|
30
|
+
*
|
|
31
|
+
* Closed runs are gzipped, and the oldest pruned past `ledger-keep:` megabytes (100 unset; 0 keeps none),
|
|
32
|
+
* when a run first writes to the project. `.turri` ignores itself, so none of it is ever committed.
|
|
33
|
+
*/
|
|
34
|
+
export const LEDGER_DIR = "ledger";
|
|
35
|
+
/** Megabytes of ledger a project keeps when `ledger-keep:` does not say. */
|
|
36
|
+
export const DEFAULT_LEDGER_KEEP_MB = 100;
|
|
37
|
+
let current;
|
|
38
|
+
let seq = 0;
|
|
39
|
+
const listeners = new Set();
|
|
40
|
+
/**
|
|
41
|
+
* Hear every event from now on, as it is recorded, with the project it is about - while the ledger is on. Returns
|
|
42
|
+
* what stops it. A listener that throws is left out of that event, never the work.
|
|
43
|
+
*/
|
|
44
|
+
export function onLedgerEvent(listener) {
|
|
45
|
+
listeners.add(listener);
|
|
46
|
+
return () => listeners.delete(listener);
|
|
47
|
+
}
|
|
48
|
+
function tell(entry, project) {
|
|
49
|
+
for (const listener of listeners) {
|
|
50
|
+
try {
|
|
51
|
+
listener(entry, project);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* a listener that fails does not take the work down */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Whether turri turns the ledger on as it starts: always, unless TURRI_LEDGER=off. */
|
|
59
|
+
export function ledgerWanted() {
|
|
60
|
+
return process.env.TURRI_LEDGER !== "off";
|
|
61
|
+
}
|
|
62
|
+
/** This run's id, while the ledger is on. */
|
|
63
|
+
export function ledgerRun() {
|
|
64
|
+
return current?.id;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Start the ledger for this process: from now on, what is recorded is written. `project` is the one events about no
|
|
68
|
+
* project of their own go to. `warn` is told, once a project, when its ledger cannot be written. Returns what stops it.
|
|
69
|
+
*/
|
|
70
|
+
export function startLedger(options) {
|
|
71
|
+
stopLedger();
|
|
72
|
+
const run = {
|
|
73
|
+
id: randomUUID().slice(0, 8),
|
|
74
|
+
mode: options.mode,
|
|
75
|
+
started: Date.now(),
|
|
76
|
+
...(options.project !== undefined ? { project: resolve(options.project) } : {}),
|
|
77
|
+
writers: new Map(),
|
|
78
|
+
...(options.warn !== undefined ? { warn: options.warn } : {}),
|
|
79
|
+
unsubscribe: () => { },
|
|
80
|
+
};
|
|
81
|
+
current = run;
|
|
82
|
+
run.unsubscribe = onModelEvent((event) => recordModelEvent(event));
|
|
83
|
+
process.once("exit", endRun);
|
|
84
|
+
return stopLedger;
|
|
85
|
+
}
|
|
86
|
+
/** Stop the ledger: each project this run wrote to is told it ended. */
|
|
87
|
+
export function stopLedger() {
|
|
88
|
+
if (current === undefined)
|
|
89
|
+
return;
|
|
90
|
+
endRun();
|
|
91
|
+
}
|
|
92
|
+
function endRun() {
|
|
93
|
+
const run = current;
|
|
94
|
+
if (run === undefined)
|
|
95
|
+
return;
|
|
96
|
+
process.removeListener("exit", endRun);
|
|
97
|
+
for (const project of run.writers.keys())
|
|
98
|
+
write(run, project, { kind: "run.ended", ms: Date.now() - run.started }, {});
|
|
99
|
+
run.unsubscribe();
|
|
100
|
+
current = undefined;
|
|
101
|
+
}
|
|
102
|
+
/** Record an event: where it happened is the job this code runs for, unless `place` says. Nothing while the ledger is off. */
|
|
103
|
+
export function record(event, place = {}) {
|
|
104
|
+
const run = current;
|
|
105
|
+
if (run === undefined)
|
|
106
|
+
return;
|
|
107
|
+
const here = currentPlace();
|
|
108
|
+
const project = place.project ?? here.project ?? run.project;
|
|
109
|
+
if (project === undefined)
|
|
110
|
+
return;
|
|
111
|
+
const job = place.job ?? here.job;
|
|
112
|
+
const module = place.module ?? here.module;
|
|
113
|
+
write(run, resolve(project), event, { ...(job !== undefined ? { job } : {}), ...(module !== undefined ? { module } : {}), ...(place.agent !== undefined ? { agent: place.agent } : {}) });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Run a healer - the debugger, the diagnosis of a failed pulse - and record it: started, ended with what it found, or
|
|
117
|
+
* failed and why. A healer that fails gives undefined, as it did before it was recorded, but is no longer silent.
|
|
118
|
+
*/
|
|
119
|
+
export async function healing(healer, work, found) {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
record({ kind: "healer.started", healer });
|
|
122
|
+
try {
|
|
123
|
+
const result = await work();
|
|
124
|
+
const said = result !== undefined && found !== undefined ? found(result) : undefined;
|
|
125
|
+
record({ kind: "healer.ended", healer, ms: Date.now() - started, ...(said !== undefined ? { found: firstLine(said, 240) } : {}) });
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
record({ kind: "healer.failed", healer, ms: Date.now() - started, error: firstLine(error instanceof Error ? error.message : String(error), 300) });
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** A failure's fingerprint, short: two runs failing the same way have the same one. */
|
|
134
|
+
export function signatureHash(signature) {
|
|
135
|
+
return createHash("sha1").update(signature).digest("hex").slice(0, 12);
|
|
136
|
+
}
|
|
137
|
+
/** The first line of a text, at most `max` characters: what an event keeps of an error or a reason. */
|
|
138
|
+
export function firstLine(text, max = 200) {
|
|
139
|
+
const line = text.trim().split("\n")[0] ?? "";
|
|
140
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
141
|
+
}
|
|
142
|
+
function recordModelEvent(event) {
|
|
143
|
+
const agent = { ...(event.role !== undefined ? { role: event.role } : {}), ...(event.label !== undefined ? { label: event.label } : {}), ...(event.model !== undefined ? { model: event.model } : {}) };
|
|
144
|
+
const place = { ...(event.job !== undefined ? { job: event.job } : {}), ...(Object.keys(agent).length > 0 ? { agent } : {}) };
|
|
145
|
+
switch (event.kind) {
|
|
146
|
+
case "call":
|
|
147
|
+
record({ kind: "model.call", ...(event.ms !== undefined ? { ms: event.ms } : {}), ...(event.usage !== undefined ? { usage: event.usage } : {}) }, place);
|
|
148
|
+
break;
|
|
149
|
+
case "turn":
|
|
150
|
+
record({ kind: "model.turn" }, place);
|
|
151
|
+
break;
|
|
152
|
+
case "compaction":
|
|
153
|
+
record({ kind: "model.compaction" }, place);
|
|
154
|
+
break;
|
|
155
|
+
case "provider_retry":
|
|
156
|
+
record({ kind: "provider.retry", error: firstLine(event.error ?? "", 300), attempt: event.attempt ?? 0, of: event.of ?? 0, waitMs: event.waitMs ?? 0 }, place);
|
|
157
|
+
break;
|
|
158
|
+
case "provider_stall":
|
|
159
|
+
record({ kind: "provider.stall", waitMs: event.waitMs ?? 0 }, place);
|
|
160
|
+
break;
|
|
161
|
+
case "stopped":
|
|
162
|
+
record({ kind: "turn.stopped", reason: firstLine(event.reason ?? "", 300) }, place);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** The project's ledger folder. */
|
|
167
|
+
export function ledgerDir(project) {
|
|
168
|
+
return join(resolve(project), ".turri", LEDGER_DIR);
|
|
169
|
+
}
|
|
170
|
+
/** How many megabytes of ledger the project keeps. */
|
|
171
|
+
function keepOf(project) {
|
|
172
|
+
try {
|
|
173
|
+
return readProjectConfig(project).ledgerKeep ?? DEFAULT_LEDGER_KEEP_MB;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return DEFAULT_LEDGER_KEEP_MB;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function write(run, project, event, where) {
|
|
180
|
+
let writer = run.writers.get(project);
|
|
181
|
+
if (writer === undefined) {
|
|
182
|
+
writer = openWriter(run, project);
|
|
183
|
+
run.writers.set(project, writer);
|
|
184
|
+
}
|
|
185
|
+
seq += 1;
|
|
186
|
+
const entry = { v: 1, ts: new Date().toISOString(), seq, run: run.id, ...where, ...masked(event) };
|
|
187
|
+
tell(entry, project);
|
|
188
|
+
if (writer.broken || writer.file === undefined)
|
|
189
|
+
return;
|
|
190
|
+
try {
|
|
191
|
+
appendFileSync(writer.file, `${JSON.stringify(entry)}\n`);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
writer.broken = true;
|
|
195
|
+
run.warn?.(`turri's ledger for ${project} cannot be written, and is not from now on: ${error instanceof Error ? error.message : String(error)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** The run's file in the project, begun with its run.started - or none, when the project keeps no ledger. */
|
|
199
|
+
function openWriter(run, project) {
|
|
200
|
+
const keep = keepOf(project);
|
|
201
|
+
const dir = ledgerDir(project);
|
|
202
|
+
const identity = turriIdentity();
|
|
203
|
+
seq += 1;
|
|
204
|
+
const started = {
|
|
205
|
+
kind: "run.started",
|
|
206
|
+
mode: run.mode,
|
|
207
|
+
version: identity.version,
|
|
208
|
+
commit: identity.git_commit,
|
|
209
|
+
dirty: identity.git_dirty,
|
|
210
|
+
pid: process.pid,
|
|
211
|
+
platform: process.platform,
|
|
212
|
+
node: process.versions.node,
|
|
213
|
+
};
|
|
214
|
+
const entry = { v: 1, ts: new Date().toISOString(), seq, run: run.id, ...started };
|
|
215
|
+
// Heard whether it is written or not: a project that keeps no ledger is still a run a host shows.
|
|
216
|
+
tell(entry, project);
|
|
217
|
+
try {
|
|
218
|
+
if (existsSync(dir))
|
|
219
|
+
tidy(dir, keep * 1024 * 1024);
|
|
220
|
+
if (keep <= 0)
|
|
221
|
+
return { broken: false };
|
|
222
|
+
ensureInScratch(join(project, ".turri"), dir);
|
|
223
|
+
const stamp = new Date(run.started).toISOString().replace(/[:.]/g, "-");
|
|
224
|
+
const file = join(dir, `${stamp}-${run.id}-p${process.pid}.jsonl`);
|
|
225
|
+
writeFileSync(file, `${JSON.stringify(entry)}\n`, { flag: "a" });
|
|
226
|
+
return { file, broken: false };
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
run.warn?.(`turri's ledger for ${project} cannot be written: ${error instanceof Error ? error.message : String(error)}`);
|
|
230
|
+
return { broken: true };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Every string an event carries, masked as the logs are. */
|
|
234
|
+
function masked(value) {
|
|
235
|
+
if (typeof value === "string")
|
|
236
|
+
return maskSecrets(value);
|
|
237
|
+
if (Array.isArray(value))
|
|
238
|
+
return value.map((item) => masked(item));
|
|
239
|
+
if (value !== null && typeof value === "object")
|
|
240
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, masked(item)]));
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
/** A run file's process, from its name: `<stamp>-<run>-p<pid>.jsonl`. */
|
|
244
|
+
function pidOf(name) {
|
|
245
|
+
const match = /-p(\d+)\.jsonl(?:\.gz)?$/.exec(name);
|
|
246
|
+
return match !== null ? Number(match[1]) : undefined;
|
|
247
|
+
}
|
|
248
|
+
function alive(pid) {
|
|
249
|
+
if (pid === process.pid)
|
|
250
|
+
return true;
|
|
251
|
+
try {
|
|
252
|
+
process.kill(pid, 0);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
// Not ours to signal, but there.
|
|
257
|
+
return error.code === "EPERM";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Keep the ledger folder within `keepBytes`: the files of runs that have ended gzipped, then the oldest pruned until
|
|
262
|
+
* the rest fit. A run still going - this one, or another turri's in the same project - is never touched.
|
|
263
|
+
*/
|
|
264
|
+
export function tidy(dir, keepBytes) {
|
|
265
|
+
const files = () => readdirSync(dir).filter((name) => /\.jsonl(?:\.gz)?$/.test(name)).sort();
|
|
266
|
+
const live = (name) => {
|
|
267
|
+
const pid = pidOf(name);
|
|
268
|
+
return name.endsWith(".jsonl") && (pid === undefined || alive(pid));
|
|
269
|
+
};
|
|
270
|
+
for (const name of files()) {
|
|
271
|
+
if (!name.endsWith(".jsonl") || live(name))
|
|
272
|
+
continue;
|
|
273
|
+
try {
|
|
274
|
+
const path = join(dir, name);
|
|
275
|
+
writeFileSync(`${path}.gz`, gzipSync(readFileSync(path)));
|
|
276
|
+
unlinkSync(path);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* left as it is - pruned like any other */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const sized = files().map((name) => ({ name, size: sizeOf(join(dir, name)) }));
|
|
283
|
+
let total = sized.reduce((sum, file) => sum + file.size, 0);
|
|
284
|
+
for (const file of sized) {
|
|
285
|
+
if (total <= keepBytes)
|
|
286
|
+
break;
|
|
287
|
+
if (live(file.name))
|
|
288
|
+
continue;
|
|
289
|
+
try {
|
|
290
|
+
unlinkSync(join(dir, file.name));
|
|
291
|
+
total -= file.size;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
/* kept */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function sizeOf(path) {
|
|
299
|
+
try {
|
|
300
|
+
return statSync(path).size;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The project's ledger, oldest first - every run's, the gzipped ones too, as `query` narrows it. A line that does not
|
|
308
|
+
* parse, or a kind this turri does not know, is skipped: the ledger is read as it is, whatever wrote it.
|
|
309
|
+
*/
|
|
310
|
+
export function readLedger(project, query = {}) {
|
|
311
|
+
const dir = ledgerDir(project);
|
|
312
|
+
if (!existsSync(dir))
|
|
313
|
+
return [];
|
|
314
|
+
const since = query.since?.toISOString();
|
|
315
|
+
const entries = [];
|
|
316
|
+
for (const name of readdirSync(dir).filter((file) => /\.jsonl(?:\.gz)?$/.test(file)).sort()) {
|
|
317
|
+
let text;
|
|
318
|
+
try {
|
|
319
|
+
const raw = readFileSync(join(dir, name));
|
|
320
|
+
text = name.endsWith(".gz") ? gunzipSync(raw).toString("utf8") : raw.toString("utf8");
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
for (const line of text.split("\n")) {
|
|
326
|
+
if (line.trim() === "")
|
|
327
|
+
continue;
|
|
328
|
+
let entry;
|
|
329
|
+
try {
|
|
330
|
+
entry = JSON.parse(line);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (entry.v !== 1 || typeof entry.kind !== "string")
|
|
336
|
+
continue;
|
|
337
|
+
if (query.run !== undefined && entry.run !== query.run)
|
|
338
|
+
continue;
|
|
339
|
+
if (query.job !== undefined && (entry.job === undefined || !entry.job.startsWith(query.job)))
|
|
340
|
+
continue;
|
|
341
|
+
if (query.kinds !== undefined && !query.kinds.includes(entry.kind))
|
|
342
|
+
continue;
|
|
343
|
+
if (since !== undefined && entry.ts < since)
|
|
344
|
+
continue;
|
|
345
|
+
entries.push(entry);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return entries;
|
|
349
|
+
}
|
|
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
|
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { reportActivity } from "./activity.js";
|
|
5
5
|
import { historyError } from "./environment-error.js";
|
|
6
|
+
import { firstLine, record } from "./ledger.js";
|
|
6
7
|
import { projectRootOf } from "./paths.js";
|
|
7
8
|
/**
|
|
8
9
|
* Version history for each module's generated code and tests.
|
|
@@ -84,7 +85,7 @@ function ensureHistory(moduleDir) {
|
|
|
84
85
|
*/
|
|
85
86
|
export function recordHistory(dir, subject, body) {
|
|
86
87
|
try {
|
|
87
|
-
return commitAll(dir, subject, body).commit;
|
|
88
|
+
return recorded(dir, subject, commitAll(dir, subject, body), false).commit;
|
|
88
89
|
}
|
|
89
90
|
catch {
|
|
90
91
|
return undefined;
|
|
@@ -105,10 +106,24 @@ export function landWork(dir, subject, body) {
|
|
|
105
106
|
catch (error) {
|
|
106
107
|
made = { failed: error instanceof Error ? error.message : String(error) };
|
|
107
108
|
}
|
|
109
|
+
recorded(dir, subject, made, true);
|
|
108
110
|
if (made.failed !== undefined)
|
|
109
111
|
throw historyError(moduleDir, made.failed);
|
|
110
112
|
return made.commit;
|
|
111
113
|
}
|
|
114
|
+
/** A commit made or failed, in the ledger - nothing for a step that changed nothing. */
|
|
115
|
+
function recorded(dir, subject, made, landing) {
|
|
116
|
+
const place = placeOf(moduleDirOf(dir));
|
|
117
|
+
if (made.failed !== undefined)
|
|
118
|
+
record({ kind: "history.failed", subject: firstLine(subject, 200), said: firstLine(made.failed, 400), landing }, place);
|
|
119
|
+
else if (made.commit !== undefined)
|
|
120
|
+
record({ kind: "history.committed", subject: firstLine(subject, 200), commit: made.commit }, place);
|
|
121
|
+
return made;
|
|
122
|
+
}
|
|
123
|
+
/** Where a module's history is, for the ledger: its project and its name. */
|
|
124
|
+
function placeOf(moduleDir) {
|
|
125
|
+
return { project: projectRootOf(moduleDir), module: basename(moduleDir) };
|
|
126
|
+
}
|
|
112
127
|
/** Commit everything the step changed in the module `dir` belongs to: the commit, nothing when nothing changed, or why git failed. */
|
|
113
128
|
function commitAll(dir, subject, body) {
|
|
114
129
|
const moduleDir = moduleDirOf(dir);
|
|
@@ -191,6 +206,7 @@ export function resetToLastCommit(dir) {
|
|
|
191
206
|
return [];
|
|
192
207
|
git(moduleDir, ["reset", "-q", "--hard", "HEAD"]);
|
|
193
208
|
git(moduleDir, ["clean", "-q", "-fd"]);
|
|
209
|
+
record({ kind: "history.reset", undone: changed.length, paths: changed.slice(0, 20) }, placeOf(moduleDir));
|
|
194
210
|
return changed;
|
|
195
211
|
}
|
|
196
212
|
catch {
|
|
@@ -270,6 +286,7 @@ export function abandonWork(start, moduleName, why) {
|
|
|
270
286
|
git(moduleDir, ["reset", "-q", "--hard", commit]);
|
|
271
287
|
git(moduleDir, ["clean", "-q", "-fd"]);
|
|
272
288
|
cleanTestsBuild(moduleDir);
|
|
289
|
+
record({ kind: "history.rolledBack", why: firstLine(why, 300) }, placeOf(moduleDir));
|
|
273
290
|
reportActivity(`${moduleName}: ${why} - ${basename(moduleDir)} is back where the work started`);
|
|
274
291
|
}
|
|
275
292
|
catch {
|
package/dist/regenerate/loop.js
CHANGED
|
@@ -6,13 +6,14 @@ import { ACCEPTANCE_TESTS, FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS, T
|
|
|
6
6
|
import { reportActivity, reportStage } from "./activity.js";
|
|
7
7
|
import { llmChunkAgents, MAX_LINES_PER_JOB, MAX_SPECS_PER_JOB, planChunks, stepsOf } from "./chunk-plan.js";
|
|
8
8
|
import { commitNotes, landWork, recordHistory, resetToLastCommit } from "./lineage.js";
|
|
9
|
+
import { firstLine, record } from "./ledger.js";
|
|
9
10
|
import { NOTES_FILE, recordPulse } from "./notes.js";
|
|
10
11
|
import { createJobRegistry, formatDuration } from "./jobs.js";
|
|
11
12
|
import { moduleLanes } from "./lanes.js";
|
|
12
13
|
import { adoptDigests, chainTree, codeTree, conformanceTarget, marksRegenerateAgain, REGENERATE_AGAIN, inheritedRecord, notBuilt, readRecord, RECORD_FILE, withResourceDigests, writeCodeRecord, writeTestsRecord, } from "./records.js";
|
|
13
14
|
import { describeSpecDiffConformance, renderSpecDiffConformance } from "./conform.js";
|
|
14
15
|
import { conformanceDirFor } from "./conformance.js";
|
|
15
|
-
import { modulesRoot } from "./paths.js";
|
|
16
|
+
import { modulesRoot, projectRootOf } from "./paths.js";
|
|
16
17
|
import { budgetFor, choosingWhen, configForSpec, conformanceSwitchedOn, stackForModule, stackPinned, withStack } from "../config.js";
|
|
17
18
|
import { describeSpecChange } from "./prompt.js";
|
|
18
19
|
import { resolveTemplateDirs } from "./paths.js";
|
|
@@ -191,6 +192,11 @@ export function createWorkLoop(options) {
|
|
|
191
192
|
// One tick at a time: each reads the records and the spec, and decides.
|
|
192
193
|
let ticking = Promise.resolve();
|
|
193
194
|
const tipEntry = (state) => tipOf(state.chain);
|
|
195
|
+
/** Where what the loop decides for a module is recorded: its tip's project, the module and the pulse it is about. */
|
|
196
|
+
const loopPlace = (state, job) => {
|
|
197
|
+
const tip = tipEntry(state);
|
|
198
|
+
return { project: projectRootOf(tip.buildDir), module: tip.moduleName, job };
|
|
199
|
+
};
|
|
194
200
|
/** `work` with the codebase's stack chosen - the one it was asked for with - when it has one pinned. */
|
|
195
201
|
const inStack = (state, work) => (state.stack !== undefined ? withStack(state.stack.name, work) : work());
|
|
196
202
|
const keyOf = (spec) => serializeTree(codeTree(spec.tree));
|
|
@@ -681,6 +687,7 @@ export function createWorkLoop(options) {
|
|
|
681
687
|
/* a spec that does not parse halts it all the same */
|
|
682
688
|
}
|
|
683
689
|
state.halted = { why: `${why} - nothing more is built on it until its conformance tests pass`, specKey: key, pulse: pulseOf(running), ...(settled?.status === "stopped" ? {} : causeOf(settled)) };
|
|
690
|
+
record({ kind: "loop.halted", why: firstLine(state.halted.why, 300), track: "conformance" }, loopPlace(state, running.jobId));
|
|
684
691
|
}
|
|
685
692
|
}
|
|
686
693
|
else if (running !== undefined && !landed) {
|
|
@@ -700,6 +707,7 @@ export function createWorkLoop(options) {
|
|
|
700
707
|
}
|
|
701
708
|
const why = settled?.status === "stopped" ? `pulse ${running.jobId} was stopped` : `pulse ${running.jobId} ("${running.planned.title}") did not land`;
|
|
702
709
|
state.halted = { why: `${why} - it runs again once the spec changes, or the module is asked for again`, specKey: key, pulse: pulseOf(running), ...(settled?.status === "stopped" ? {} : causeOf(settled)) };
|
|
710
|
+
record({ kind: "loop.halted", why: firstLine(state.halted.why, 300), track: "code" }, loopPlace(state, running.jobId));
|
|
703
711
|
}
|
|
704
712
|
}
|
|
705
713
|
else {
|
|
@@ -866,6 +874,7 @@ export function createWorkLoop(options) {
|
|
|
866
874
|
else {
|
|
867
875
|
state.running.stopping = true;
|
|
868
876
|
state.why = `pulse ${state.running.jobId} was stopped: ${why}, which it was regenerating from`;
|
|
877
|
+
record({ kind: "loop.stopped", why: firstLine(why, 300) }, loopPlace(state, state.running.jobId));
|
|
869
878
|
reportActivity(` ${tip.moduleName}: ${state.why}`);
|
|
870
879
|
options.registry.stop(state.running.jobId, `Stopped by turri, not by you: ${why}, which it was regenerating from. What it had done is rolled back, and the spec as it is now is regenerated instead. To have a running pulse finish first and the change regenerated on top of it, set allow-to-stop-jobs: false in config.turri.yaml.`);
|
|
871
880
|
return;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A file the running pulse wrote for a person to read - removed as it ends, unless in debug mode. Outside a pulse, nothing. */
|
|
2
|
+
export declare function pulseWrote(file: string, job?: string | undefined): void;
|
|
3
|
+
/**
|
|
4
|
+
* A pulse has ended. Unless the project is in debug mode, what it wrote for a person goes - its files, and its module's
|
|
5
|
+
* temporary folder - and so do the dependencies installed in the modules below its own in its chain. `lanes` are the
|
|
6
|
+
* pulse's (`plain_modules/<module>` of its whole chain), still held: no other pulse works in them. Returns whether
|
|
7
|
+
* everything was kept.
|
|
8
|
+
*/
|
|
9
|
+
export declare function pulseEnded(options: {
|
|
10
|
+
job: string;
|
|
11
|
+
project: string;
|
|
12
|
+
module?: string;
|
|
13
|
+
lanes: string[];
|
|
14
|
+
}): boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { debugMode } from "../config.js";
|
|
4
|
+
import { currentJob } from "./activity.js";
|
|
5
|
+
import { modulesScratchDir } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* What a pulse writes only for a person debugging turri, and when it goes.
|
|
8
|
+
*
|
|
9
|
+
* A pulse writes its own log, every one of its agents' whole logs, the testing scripts' and services' full outputs and
|
|
10
|
+
* its agents' temporary files. Nothing of turri reads them once the pulse is over: while it runs, its agents read their
|
|
11
|
+
* outputs (`read_output`, `service_logs`) and the diagnosis of a failure reads the logs - so they are written as before,
|
|
12
|
+
* and removed as it ends. In debug mode (`debug: true`, TURRI_DEBUG, `--debug`) they are kept, as they always were.
|
|
13
|
+
*
|
|
14
|
+
* So are the dependencies installed in the modules below a pulse's own in its chain: the chain is built in its tip, and
|
|
15
|
+
* what was installed in the codebases it left behind is only installed again - from the package cache - if one of them
|
|
16
|
+
* is ever built on again. The testing scripts install what they run with themselves.
|
|
17
|
+
*/
|
|
18
|
+
/** The files each pulse wrote for a person to read, by its id. */
|
|
19
|
+
const written = new Map();
|
|
20
|
+
/** A file the running pulse wrote for a person to read - removed as it ends, unless in debug mode. Outside a pulse, nothing. */
|
|
21
|
+
export function pulseWrote(file, job = currentJob()?.id) {
|
|
22
|
+
if (job === undefined)
|
|
23
|
+
return;
|
|
24
|
+
const files = written.get(job) ?? new Set();
|
|
25
|
+
files.add(file);
|
|
26
|
+
written.set(job, files);
|
|
27
|
+
}
|
|
28
|
+
/** Where a codebase's dependencies are installed: what a module its chain has left behind keeps no longer. */
|
|
29
|
+
const INSTALLED = ["node_modules", ".venv", "venv"];
|
|
30
|
+
/**
|
|
31
|
+
* A pulse has ended. Unless the project is in debug mode, what it wrote for a person goes - its files, and its module's
|
|
32
|
+
* temporary folder - and so do the dependencies installed in the modules below its own in its chain. `lanes` are the
|
|
33
|
+
* pulse's (`plain_modules/<module>` of its whole chain), still held: no other pulse works in them. Returns whether
|
|
34
|
+
* everything was kept.
|
|
35
|
+
*/
|
|
36
|
+
export function pulseEnded(options) {
|
|
37
|
+
const files = written.get(options.job) ?? new Set();
|
|
38
|
+
written.delete(options.job);
|
|
39
|
+
if (debugMode(options.project))
|
|
40
|
+
return true;
|
|
41
|
+
const gone = (path) => {
|
|
42
|
+
try {
|
|
43
|
+
rmSync(path, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* what cannot be removed stays: never a reason for the pulse to fail */
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
for (const file of files)
|
|
50
|
+
gone(file);
|
|
51
|
+
for (const lane of options.lanes) {
|
|
52
|
+
// A codebase of the chain: its folder holds `code` - a lane may also be a spec file, or a build folder.
|
|
53
|
+
if (!existsSync(join(lane, "code")))
|
|
54
|
+
continue;
|
|
55
|
+
const name = basename(lane);
|
|
56
|
+
if (name === options.module)
|
|
57
|
+
gone(join(modulesScratchDir(lane), "tmp", name));
|
|
58
|
+
else
|
|
59
|
+
for (const installed of INSTALLED)
|
|
60
|
+
gone(join(lane, "code", installed));
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
@@ -12,6 +12,7 @@ import { codebaseMap, diffFiles, digestFiles, wroteNothing } from "./codebase.js
|
|
|
12
12
|
import { confinedTools } from "./confine.js";
|
|
13
13
|
import { buildSpecDiffPrompt, describeSpecChange, reviewedSpec, sectionModules } from "./prompt.js";
|
|
14
14
|
import { abandonWork, landWork, startWork } from "./lineage.js";
|
|
15
|
+
import { healing, record, signatureHash } from "./ledger.js";
|
|
15
16
|
import { createWorkLedger, SUBMIT_WORK_NOTE } from "./submit-work.js";
|
|
16
17
|
import { CODER_COMPACTION } from "./compaction.js";
|
|
17
18
|
import { gapMark, gapsSince, NOTES_TURN, notesForSession, passedOutcome, recordNote, withNotes } from "./notes.js";
|
|
@@ -440,7 +441,7 @@ export async function renderSpecDiff(options) {
|
|
|
440
441
|
: timedOut && seen < STUCK_ROUNDS
|
|
441
442
|
? `the unit testing script did not finish in ${Math.round(verdict.run.timeoutMs / 1000)}s - a debugger works out whether something hangs`
|
|
442
443
|
: `the same failure ${seen} times - a debugger looks at it with fresh eyes`);
|
|
443
|
-
const found = await debugFailure({
|
|
444
|
+
const found = await healing("debugger", () => debugFailure({
|
|
444
445
|
buildDir,
|
|
445
446
|
failure: verdict.run.output,
|
|
446
447
|
times,
|
|
@@ -450,7 +451,7 @@ export async function renderSpecDiff(options) {
|
|
|
450
451
|
label: `${moduleName} spec change`,
|
|
451
452
|
...(debuggerModel !== undefined ? { model: debuggerModel } : {}),
|
|
452
453
|
onDelta: options.onDelta,
|
|
453
|
-
})
|
|
454
|
+
}), (finding) => finding?.rootCause);
|
|
454
455
|
// Nothing hangs: the suite needs more time than a test run may take, and no agent can give it that.
|
|
455
456
|
if (found?.tooSlow === true) {
|
|
456
457
|
stage(`the unit testing script needs more than the ${Math.round(verdict.run.timeoutMs / 1000)}s a test run may take - stopping`);
|
|
@@ -477,6 +478,7 @@ export async function renderSpecDiff(options) {
|
|
|
477
478
|
].join("\n");
|
|
478
479
|
}
|
|
479
480
|
}
|
|
481
|
+
record({ kind: "gate.round", gate: "unit tests", round: followUp + 1, of: MAX_FOLLOW_UPS, ...(key !== undefined ? { signature: signatureHash(key) } : {}) });
|
|
480
482
|
stage(`${verdict.staticOnly ? "static checks" : "unit tests"} not passing - fixing (round ${followUp + 1} of ${MAX_FOLLOW_UPS})`);
|
|
481
483
|
response = await ask(executor, prompt, options.onDelta);
|
|
482
484
|
continue;
|
|
@@ -7,6 +7,7 @@ import { confinedTools } from "./confine.js";
|
|
|
7
7
|
import { projectRootOf } from "./paths.js";
|
|
8
8
|
import { buildRegressionFixPrompt, sectionModules } from "./prompt.js";
|
|
9
9
|
import { abandonWork, landWork, markWork } from "./lineage.js";
|
|
10
|
+
import { record } from "./ledger.js";
|
|
10
11
|
import { missingToolchainError } from "./environment-error.js";
|
|
11
12
|
import { workspaceBlock } from "./workspace.js";
|
|
12
13
|
import { CODER_COMPACTION } from "./compaction.js";
|
|
@@ -161,6 +162,7 @@ export async function ensureRegressionPasses(input) {
|
|
|
161
162
|
if (round === maxRounds)
|
|
162
163
|
break;
|
|
163
164
|
input.onEvent?.({ type: "repair", frids: input.frids });
|
|
165
|
+
record({ kind: "gate.round", gate: "regression", round: round + 1, of: maxRounds });
|
|
164
166
|
reportStage(`${unitOf(input.moduleName)}: an earlier functionality broke - repairing (round ${round + 1} of ${maxRounds})`);
|
|
165
167
|
repairs.push(await repairRegression({
|
|
166
168
|
testsDirs: dirs,
|
|
@@ -8,6 +8,7 @@ import { agentShell } from "./agent-shell.js";
|
|
|
8
8
|
import { maskSecrets } from "./masking.js";
|
|
9
9
|
import { modulesScratchDir } from "./paths.js";
|
|
10
10
|
import { isRunning, startInBackground, stopGroup } from "./shell.js";
|
|
11
|
+
import { pulseWrote } from "./pulse-files.js";
|
|
11
12
|
/**
|
|
12
13
|
* What an agent starts and keeps running while it works - a server, a worker - to try the software as someone would,
|
|
13
14
|
* from outside: with curl, or what the stack's own client is. Whatever the stack: the agent gives the command that
|
|
@@ -83,6 +84,8 @@ export function createServiceTools(options) {
|
|
|
83
84
|
return say(`Not started: ${checked.reason}`);
|
|
84
85
|
mkdirSync(logsDir, { recursive: true });
|
|
85
86
|
const log = join(logsDir, `${name.replace(/[^\w.-]+/g, "_")}-${randomUUID().slice(0, 8)}.log`);
|
|
87
|
+
// Read through service_logs while the pulse runs, and by no one after it (pulse-files.ts).
|
|
88
|
+
pulseWrote(log);
|
|
86
89
|
const limit = Math.min(Math.max(seconds ?? DEFAULT_READY_SECONDS, 1), MAX_READY_SECONDS);
|
|
87
90
|
// Started and waited on under the audit: what it writes while it comes up, outside its role, is put back.
|
|
88
91
|
const { value: outcome, note } = await fenced.audited(async () => {
|
|
@@ -17,6 +17,8 @@ import type { TranscriptStyle } from "./transcript-view.ts";
|
|
|
17
17
|
export interface SidePanelOptions {
|
|
18
18
|
/** Every job, oldest first. */
|
|
19
19
|
jobs: () => Job[];
|
|
20
|
+
/** Earlier sessions' pulses, from the ledger: listed under this session's, in a section of their own. */
|
|
21
|
+
earlier?: () => Job[];
|
|
20
22
|
/** The regeneration's pages. Not given: the pulse list alone. */
|
|
21
23
|
map?: () => MarkedPage[];
|
|
22
24
|
/** Rows the panel may use. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
-
import { createJobsTreeState, jobsTreeLines, jobsTreeRows, keepCursorInView, scrollTree, treeKey } from "./jobs-tree.js";
|
|
2
|
+
import { createJobsTreeState, jobsTreeLines, jobsTreeRows, keepCursorInView, scrollTree, sectionOf, treeKey } from "./jobs-tree.js";
|
|
3
3
|
import { columnHeadings, createSpecMapState, DISCUSS_KEY, legendLines, minimap, shownPage, specMapHeader, specMapLines, switchTab, tabBar } from "./spec-map-view.js";
|
|
4
4
|
export function createSidePanel(options) {
|
|
5
5
|
const style = options.style;
|
|
@@ -71,7 +71,8 @@ export function createSidePanel(options) {
|
|
|
71
71
|
const bodyHeight = Math.max(3, rows - top.length - 2);
|
|
72
72
|
lastBodyHeight = bodyHeight;
|
|
73
73
|
// The tree scrolls to keep the cursor in sight, and only that far.
|
|
74
|
-
|
|
74
|
+
// This session's pulses, and under them the earlier sessions' the ledger has.
|
|
75
|
+
const tree_ = jobsTreeLines([...options.jobs(), ...(options.earlier?.() ?? [])], tree, width, style);
|
|
75
76
|
drawnTree = { rows: tree_.rows, rowStarts: tree_.rowStarts, lineCount: tree_.lines.length };
|
|
76
77
|
keepCursorInView(tree, tree_.rowStarts, tree_.lines.length, bodyHeight);
|
|
77
78
|
const visible = tree_.lines.slice(tree.top, tree.top + bodyHeight);
|
|
@@ -132,7 +133,7 @@ export function createSidePanel(options) {
|
|
|
132
133
|
const job = jobs.find((entry) => entry.id === id);
|
|
133
134
|
if (job === undefined)
|
|
134
135
|
return;
|
|
135
|
-
const section = job
|
|
136
|
+
const section = sectionOf(job);
|
|
136
137
|
tree.open[section] = true;
|
|
137
138
|
const at = jobsTreeRows(jobs, tree).findIndex((entry) => entry.kind === "job" && entry.job.id === id);
|
|
138
139
|
if (at !== -1)
|