@turri-dev/cli 0.1.8-next.bcca62a → 0.1.8-next.bfcb24e
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 +0 -2
- package/dist/cli.js +2 -1
- package/dist/config-edit.d.ts +1 -1
- package/dist/config-edit.js +2 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +5 -1
- package/dist/extension.js +75 -9
- package/dist/host.d.ts +2 -0
- package/dist/host.js +3 -0
- package/dist/init-config.d.ts +1 -0
- package/dist/init-config.js +5 -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.js +17 -0
- package/dist/regenerate/activity.d.ts +9 -0
- package/dist/regenerate/activity.js +11 -0
- 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/diagnose.js +4 -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 +38 -3
- 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/regenerate.js +4 -2
- package/dist/regenerate/regression.js +2 -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/unit-review.js +3 -0
- package/docs/config.md +3 -1
- package/docs/development.md +1 -0
- package/docs/hosting.md +28 -0
- package/docs/installation.md +1 -0
- package/docs/regenerating.md +1 -1
- package/docs/using.md +29 -2
- 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;
|
|
@@ -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,
|
|
@@ -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)
|
|
@@ -4,6 +4,7 @@ import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync
|
|
|
4
4
|
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
5
5
|
import { CONFIG_FILE, CONFIG_KEYS, projectConfigFile, readProjectConfig, stackConfigFile, stackConfigFileIn, stackOf, stacksOf } from "../config.js";
|
|
6
6
|
import { reportActivity, reportLogNote, reportNote } from "./activity.js";
|
|
7
|
+
import { record, signatureHash } from "./ledger.js";
|
|
7
8
|
import { ensureInModulesScratch, modulesScratchDir } from "./paths.js";
|
|
8
9
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import { Type } from "typebox";
|
|
@@ -361,7 +362,7 @@ async function runScript(cwd, script, args, logDir, options = {}) {
|
|
|
361
362
|
const noted = withScriptLineNote(text, script);
|
|
362
363
|
// Its failures, counts and end are what the agent is shown; the whole is kept, a read_output away.
|
|
363
364
|
const log = keepTestOutput(logDir, basename(script), noted);
|
|
364
|
-
|
|
365
|
+
const run = {
|
|
365
366
|
command: shown(script),
|
|
366
367
|
exitCode: timedOut === true ? 124 : exitCode,
|
|
367
368
|
output: shownTestOutput(noted, log),
|
|
@@ -370,6 +371,18 @@ async function runScript(cwd, script, args, logDir, options = {}) {
|
|
|
370
371
|
elapsedMs: Date.now() - started,
|
|
371
372
|
...(log !== undefined ? { log } : {}),
|
|
372
373
|
};
|
|
374
|
+
record({
|
|
375
|
+
kind: "script.run",
|
|
376
|
+
script: basename(script),
|
|
377
|
+
exit: run.exitCode,
|
|
378
|
+
ms: run.elapsedMs,
|
|
379
|
+
timedOut: run.timedOut === true,
|
|
380
|
+
inPlace: options.named === undefined,
|
|
381
|
+
// On code changed for the run - can_it_fail's bug: failing is what it is for.
|
|
382
|
+
...(options.mutated === true ? { mutated: true } : {}),
|
|
383
|
+
...(run.exitCode !== 0 ? { signature: signatureHash(failureSignature(run)) } : {}),
|
|
384
|
+
});
|
|
385
|
+
return run;
|
|
373
386
|
}
|
|
374
387
|
/** What bash says when a line of the script itself cannot run: a command or file that is not there, or not runnable. */
|
|
375
388
|
const OWN_LINE_FAILURE = /: (No such file or directory|command not found|Permission denied)$/;
|
|
@@ -406,10 +419,10 @@ function quote(path) {
|
|
|
406
419
|
return `'${path.replace(/'/g, "'\\''")}'`;
|
|
407
420
|
}
|
|
408
421
|
/** A testing script run on a fresh copy of the code: the project's script copied fresh into the staging folder, beside it. */
|
|
409
|
-
async function runOnCopy(stagingRoot, script, args, logDir, limitMs) {
|
|
422
|
+
async function runOnCopy(stagingRoot, script, args, logDir, limitMs, mutated) {
|
|
410
423
|
const copy = join(stagingRoot, basename(script));
|
|
411
424
|
writeFileSync(copy, readFileSync(script));
|
|
412
|
-
return runScript(stagingRoot, script, args, logDir, { named: copy, ...(limitMs !== undefined ? { limitMs } : {}) });
|
|
425
|
+
return runScript(stagingRoot, script, args, logDir, { named: copy, ...(limitMs !== undefined ? { limitMs } : {}), ...(mutated === true ? { mutated } : {}) });
|
|
413
426
|
}
|
|
414
427
|
/**
|
|
415
428
|
* The harness's run of the unit testing script on the code as it is now - and run_unit_tests'.
|
|
@@ -475,7 +488,7 @@ limitMs) {
|
|
|
475
488
|
return run;
|
|
476
489
|
};
|
|
477
490
|
if (project.inPlace) {
|
|
478
|
-
const inPlace = () => runScript(project.root, script, [resolve(buildDir)], buildDir, limitMs !== undefined ? { limitMs } : {});
|
|
491
|
+
const inPlace = () => runScript(project.root, script, [resolve(buildDir)], buildDir, { ...(limitMs !== undefined ? { limitMs } : {}), ...(change !== undefined ? { mutated: true } : {}) });
|
|
479
492
|
const started = Date.now();
|
|
480
493
|
if (change === undefined)
|
|
481
494
|
return passed(await inPlace(), started);
|
|
@@ -490,7 +503,7 @@ limitMs) {
|
|
|
490
503
|
if (change !== undefined)
|
|
491
504
|
writeFileSync(join(staging.build, change.file), change.text);
|
|
492
505
|
const started = Date.now();
|
|
493
|
-
return passed(await runOnCopy(staging.root, script, [staging.build], buildDir, limitMs), started);
|
|
506
|
+
return passed(await runOnCopy(staging.root, script, [staging.build], buildDir, limitMs, change !== undefined), started);
|
|
494
507
|
}
|
|
495
508
|
finally {
|
|
496
509
|
rmSync(staging.root, { recursive: true, force: true });
|
|
@@ -3,6 +3,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { ask, createExecutor } from "../pi.js";
|
|
5
5
|
import { reportActivity } from "./activity.js";
|
|
6
|
+
import { record } from "./ledger.js";
|
|
6
7
|
import { diffFiles, digestFiles } from "./codebase.js";
|
|
7
8
|
import { confinedTools } from "./confine.js";
|
|
8
9
|
import { REVIEWER_CALLS, reviewKind } from "./conformance-agents.js";
|
|
@@ -288,6 +289,7 @@ async function reviewLoop(options, reviewer) {
|
|
|
288
289
|
letGo = [];
|
|
289
290
|
options.stage(`reviewing the code and its unit tests (review ${reviews + 1}): does the code do what the spec says, and does each test fail when that breaks`);
|
|
290
291
|
const review = await reviewer.review(request);
|
|
292
|
+
record({ kind: "review.verdict", reviewer: "unit", review: reviews + 1, pass: review.pass, problems: review.pass ? 0 : review.problems.length });
|
|
291
293
|
if (review.pass) {
|
|
292
294
|
options.stage("the review has nothing left to improve in the code or its unit tests");
|
|
293
295
|
return waived.length > 0 ? { passed: true, waived, reviews: reviews + 1 } : { passed: true, reviews: reviews + 1 };
|
|
@@ -316,6 +318,7 @@ async function reviewLoop(options, reviewer) {
|
|
|
316
318
|
});
|
|
317
319
|
// No ruling: the review stands, as it did before there was an arbiter.
|
|
318
320
|
if (ruling !== undefined) {
|
|
321
|
+
record({ kind: "arbiter.ruling", upheld: ruling.upheld.length, dismissed: ruling.dismissed.length });
|
|
319
322
|
waived.push(...ruling.dismissed.map((entry) => entry.problem));
|
|
320
323
|
if (ruling.dismissed.length > 0)
|
|
321
324
|
recordNote(options.buildDir, "pitfalls", dismissedNote(ruling, "unit test"), `${options.label} unit tests`);
|