@turri-dev/cli 0.1.8-next.e553476 → 0.1.8
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 -3
- 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 +5 -6
- package/dist/regenerate/chunk-plan.js +43 -10
- package/dist/regenerate/conformance-agents.js +2 -2
- package/dist/regenerate/conformance.js +6 -3
- 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/environment-error.d.ts +6 -0
- package/dist/regenerate/environment-error.js +19 -1
- 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.d.ts +9 -1
- package/dist/regenerate/lineage.js +64 -12
- package/dist/regenerate/loop.js +12 -3
- package/dist/regenerate/pulse-files.d.ts +14 -0
- package/dist/regenerate/pulse-files.js +63 -0
- package/dist/regenerate/regenerate.js +7 -5
- package/dist/regenerate/regression.js +4 -2
- 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 +36 -5
- 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
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* A commit is made after every step that changes the module - a render, a regeneration, a
|
|
11
11
|
* conformance run, a regression repair, a spec change, a start from the module below - with
|
|
12
12
|
* a message saying what it was. History is a record of the work, never a gate on it: a git
|
|
13
|
-
* failure is swallowed, not raised
|
|
13
|
+
* failure is swallowed, not raised - except landing work (`landWork`), whose commit the next
|
|
14
|
+
* start would otherwise undo.
|
|
14
15
|
*/
|
|
15
16
|
export declare const HISTORY_DIR = ".plaingit";
|
|
16
17
|
/** The module folder a build or tests directory belongs to. */
|
|
@@ -20,6 +21,13 @@ export declare function moduleDirOf(dir: string): string;
|
|
|
20
21
|
* directory - belongs to. Nothing changed, nothing is committed. Returns the commit, if any.
|
|
21
22
|
*/
|
|
22
23
|
export declare function recordHistory(dir: string, subject: string, body?: string): string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Commit work that landed - the code or tests a pulse passed, with the record of what they are built from. Unlike any
|
|
26
|
+
* other step's, this commit is not only a record: the next unit of work puts the module back to its last commit, and
|
|
27
|
+
* would undo what landed with it. So a commit that cannot be made is raised, as the environment's, with what git said -
|
|
28
|
+
* the work left in place, uncommitted. Nothing changed, nothing is committed. Returns the commit, if any.
|
|
29
|
+
*/
|
|
30
|
+
export declare function landWork(dir: string, subject: string, body?: string): string | undefined;
|
|
23
31
|
/**
|
|
24
32
|
* The notes the harness writes once a pulse has ended, committed - or the next pulse, putting the module back to its last
|
|
25
33
|
* commit, would take them away. Into the pulse's own commit when it landed one - `landedSince` (ms), when it started - so
|
|
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { reportActivity } from "./activity.js";
|
|
5
|
+
import { historyError } from "./environment-error.js";
|
|
6
|
+
import { firstLine, record } from "./ledger.js";
|
|
5
7
|
import { projectRootOf } from "./paths.js";
|
|
6
8
|
/**
|
|
7
9
|
* Version history for each module's generated code and tests.
|
|
@@ -15,7 +17,8 @@ import { projectRootOf } from "./paths.js";
|
|
|
15
17
|
* A commit is made after every step that changes the module - a render, a regeneration, a
|
|
16
18
|
* conformance run, a regression repair, a spec change, a start from the module below - with
|
|
17
19
|
* a message saying what it was. History is a record of the work, never a gate on it: a git
|
|
18
|
-
* failure is swallowed, not raised
|
|
20
|
+
* failure is swallowed, not raised - except landing work (`landWork`), whose commit the next
|
|
21
|
+
* start would otherwise undo.
|
|
19
22
|
*/
|
|
20
23
|
export const HISTORY_DIR = ".plaingit";
|
|
21
24
|
const AUTHOR = ["-c", "user.name=turri", "-c", "user.email=turri@localhost", "-c", "commit.gpgsign=false"];
|
|
@@ -39,7 +42,10 @@ const EXCLUDED = [
|
|
|
39
42
|
".DS_Store",
|
|
40
43
|
];
|
|
41
44
|
function git(moduleDir, args, input) {
|
|
42
|
-
|
|
45
|
+
const gitDir = join(moduleDir, HISTORY_DIR);
|
|
46
|
+
// The history is turri's, not the person's: hooks their git config names - gitleaks, husky - would refuse some of its
|
|
47
|
+
// commits, and the next start would put back what landed. Its own hooks folder holds only git's samples.
|
|
48
|
+
return spawnSync("git", [...AUTHOR, "-c", `core.hooksPath=${join(gitDir, "hooks")}`, "--git-dir", gitDir, "--work-tree", moduleDir, ...args], {
|
|
43
49
|
cwd: moduleDir,
|
|
44
50
|
encoding: "utf8",
|
|
45
51
|
input,
|
|
@@ -79,21 +85,65 @@ function ensureHistory(moduleDir) {
|
|
|
79
85
|
*/
|
|
80
86
|
export function recordHistory(dir, subject, body) {
|
|
81
87
|
try {
|
|
82
|
-
|
|
83
|
-
if (!existsSync(moduleDir) || !ensureHistory(moduleDir))
|
|
84
|
-
return undefined;
|
|
85
|
-
git(moduleDir, ["add", "-A"]);
|
|
86
|
-
if (git(moduleDir, ["diff", "--cached", "--quiet"]).status === 0)
|
|
87
|
-
return undefined;
|
|
88
|
-
const message = body ? `${subject}\n\n${body.trim()}\n` : subject;
|
|
89
|
-
if (git(moduleDir, ["commit", "-q", "-F", "-"], message).status !== 0)
|
|
90
|
-
return undefined;
|
|
91
|
-
return head(dir);
|
|
88
|
+
return recorded(dir, subject, commitAll(dir, subject, body), false).commit;
|
|
92
89
|
}
|
|
93
90
|
catch {
|
|
94
91
|
return undefined;
|
|
95
92
|
}
|
|
96
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Commit work that landed - the code or tests a pulse passed, with the record of what they are built from. Unlike any
|
|
96
|
+
* other step's, this commit is not only a record: the next unit of work puts the module back to its last commit, and
|
|
97
|
+
* would undo what landed with it. So a commit that cannot be made is raised, as the environment's, with what git said -
|
|
98
|
+
* the work left in place, uncommitted. Nothing changed, nothing is committed. Returns the commit, if any.
|
|
99
|
+
*/
|
|
100
|
+
export function landWork(dir, subject, body) {
|
|
101
|
+
const moduleDir = moduleDirOf(dir);
|
|
102
|
+
let made;
|
|
103
|
+
try {
|
|
104
|
+
made = commitAll(dir, subject, body);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
made = { failed: error instanceof Error ? error.message : String(error) };
|
|
108
|
+
}
|
|
109
|
+
recorded(dir, subject, made, true);
|
|
110
|
+
if (made.failed !== undefined)
|
|
111
|
+
throw historyError(moduleDir, made.failed);
|
|
112
|
+
return made.commit;
|
|
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
|
+
}
|
|
127
|
+
/** Commit everything the step changed in the module `dir` belongs to: the commit, nothing when nothing changed, or why git failed. */
|
|
128
|
+
function commitAll(dir, subject, body) {
|
|
129
|
+
const moduleDir = moduleDirOf(dir);
|
|
130
|
+
if (!existsSync(moduleDir))
|
|
131
|
+
return {};
|
|
132
|
+
if (!ensureHistory(moduleDir))
|
|
133
|
+
return { failed: `git could not make the module's history in ${join(moduleDir, HISTORY_DIR)}` };
|
|
134
|
+
const failed = (result) => (result.error?.message ?? `${result.stderr}${result.stdout}`.trim()) || `git exited ${result.status}`;
|
|
135
|
+
const added = git(moduleDir, ["add", "-A"]);
|
|
136
|
+
if (added.status !== 0)
|
|
137
|
+
return { failed: failed(added) };
|
|
138
|
+
if (git(moduleDir, ["diff", "--cached", "--quiet"]).status === 0)
|
|
139
|
+
return {};
|
|
140
|
+
const message = body ? `${subject}\n\n${body.trim()}\n` : subject;
|
|
141
|
+
const committed = git(moduleDir, ["commit", "-q", "-F", "-"], message);
|
|
142
|
+
if (committed.status !== 0)
|
|
143
|
+
return { failed: failed(committed) };
|
|
144
|
+
const commit = head(dir);
|
|
145
|
+
return commit !== undefined ? { commit } : {};
|
|
146
|
+
}
|
|
97
147
|
/**
|
|
98
148
|
* The notes the harness writes once a pulse has ended, committed - or the next pulse, putting the module back to its last
|
|
99
149
|
* commit, would take them away. Into the pulse's own commit when it landed one - `landedSince` (ms), when it started - so
|
|
@@ -156,6 +206,7 @@ export function resetToLastCommit(dir) {
|
|
|
156
206
|
return [];
|
|
157
207
|
git(moduleDir, ["reset", "-q", "--hard", "HEAD"]);
|
|
158
208
|
git(moduleDir, ["clean", "-q", "-fd"]);
|
|
209
|
+
record({ kind: "history.reset", undone: changed.length, paths: changed.slice(0, 20) }, placeOf(moduleDir));
|
|
159
210
|
return changed;
|
|
160
211
|
}
|
|
161
212
|
catch {
|
|
@@ -235,6 +286,7 @@ export function abandonWork(start, moduleName, why) {
|
|
|
235
286
|
git(moduleDir, ["reset", "-q", "--hard", commit]);
|
|
236
287
|
git(moduleDir, ["clean", "-q", "-fd"]);
|
|
237
288
|
cleanTestsBuild(moduleDir);
|
|
289
|
+
record({ kind: "history.rolledBack", why: firstLine(why, 300) }, placeOf(moduleDir));
|
|
238
290
|
reportActivity(`${moduleName}: ${why} - ${basename(moduleDir)} is back where the work started`);
|
|
239
291
|
}
|
|
240
292
|
catch {
|
package/dist/regenerate/loop.js
CHANGED
|
@@ -5,14 +5,15 @@ import { STDLIB_ROOT } from "../plain/stdlib.js";
|
|
|
5
5
|
import { ACCEPTANCE_TESTS, FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS, TEST_REQUIREMENTS } from "../plain/spec.js";
|
|
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
|
-
import { commitNotes, recordHistory, resetToLastCommit } from "./lineage.js";
|
|
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;
|
|
@@ -1608,7 +1617,7 @@ const defaultConformanceRunner = async (job, onDelta) => {
|
|
|
1608
1617
|
});
|
|
1609
1618
|
if (result.passed) {
|
|
1610
1619
|
writeTestsRecord(result.conformanceDir, job.planned.step.tree);
|
|
1611
|
-
|
|
1620
|
+
landWork(job.buildDir, `conformance tests: ${job.planned.title}`);
|
|
1612
1621
|
}
|
|
1613
1622
|
return { landed: result.passed, report: describeSpecDiffConformance(result), ...(result.specGaps !== undefined ? { specGaps: result.specGaps } : {}) };
|
|
1614
1623
|
};
|
|
@@ -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
|
+
}
|