@turri-dev/cli 0.1.8-next.e553476 → 0.1.9-next.73bfddf
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 +28 -9
- package/dist/regenerate/chunk-plan.js +141 -17
- 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 +11 -9
- package/docs/sandbox.md +5 -5
- package/docs/using.md +36 -5
- package/package.json +1 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { type UsageReport } from "./activity.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The ledger: what turri does, as typed facts, one JSON line each, written as it happens - to
|
|
4
|
+
* `.turri/ledger/` in the project, one file a run (a conversation, a headless run, a host's session).
|
|
5
|
+
*
|
|
6
|
+
* The job and debug logs keep the bodies - every prompt, reply and tool result, whole - for reading one
|
|
7
|
+
* pulse closely. The ledger keeps the facts about them, across pulses, sessions and runs: that a pulse
|
|
8
|
+
* was queued, started, went through its stages and ended, and how; what each agent's model calls and
|
|
9
|
+
* turns cost; each tool call and test run, how long it took and how it ended; the gates' rounds and
|
|
10
|
+
* verdicts; what turri refused or stopped; what its healers did; the history's commits. What a pulse
|
|
11
|
+
* from an earlier session did is looked up here (`pulses`, `diagnose`, `turri ledger`). Never stored:
|
|
12
|
+
* prompts, replies, thinking, tool arguments and results, file contents - and what strings an event
|
|
13
|
+
* carries are masked as the logs are.
|
|
14
|
+
*
|
|
15
|
+
* A host hears each event as it is recorded (`onLedgerEvent`), with the project it is about, rather than reading the
|
|
16
|
+
* file again - written or not, so a project that keeps no ledger is heard all the same.
|
|
17
|
+
*
|
|
18
|
+
* Observation, never control: turri may react to events as they happen, but never reads the ledger on
|
|
19
|
+
* disk to decide what to build - the code and tests records are that (records.ts). Deleting
|
|
20
|
+
* `.turri/ledger/` loses only what can be looked up. A ledger that cannot be written stops, and never
|
|
21
|
+
* fails the work.
|
|
22
|
+
*
|
|
23
|
+
* Closed runs are gzipped, and the oldest pruned past `ledger-keep:` megabytes (100 unset; 0 keeps none),
|
|
24
|
+
* when a run first writes to the project. `.turri` ignores itself, so none of it is ever committed.
|
|
25
|
+
*/
|
|
26
|
+
export declare const LEDGER_DIR = "ledger";
|
|
27
|
+
/** Megabytes of ledger a project keeps when `ledger-keep:` does not say. */
|
|
28
|
+
export declare const DEFAULT_LEDGER_KEEP_MB = 100;
|
|
29
|
+
/** Who did it: the agent an event is about. */
|
|
30
|
+
export interface AgentRef {
|
|
31
|
+
role?: string;
|
|
32
|
+
label?: string;
|
|
33
|
+
model?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Every event's own fields, by kind. */
|
|
36
|
+
export type LedgerEvent = {
|
|
37
|
+
kind: "run.started";
|
|
38
|
+
mode: string;
|
|
39
|
+
version: string | null;
|
|
40
|
+
commit: string | null;
|
|
41
|
+
dirty: boolean | null;
|
|
42
|
+
pid: number;
|
|
43
|
+
platform: string;
|
|
44
|
+
node: string;
|
|
45
|
+
} | {
|
|
46
|
+
kind: "run.ended";
|
|
47
|
+
ms: number;
|
|
48
|
+
} | {
|
|
49
|
+
kind: "job.queued";
|
|
50
|
+
jobKind: string;
|
|
51
|
+
summary: string;
|
|
52
|
+
pulse?: number;
|
|
53
|
+
title?: string;
|
|
54
|
+
model?: string;
|
|
55
|
+
stack?: string;
|
|
56
|
+
waitingOn: string[];
|
|
57
|
+
follows?: string;
|
|
58
|
+
} | {
|
|
59
|
+
kind: "job.started";
|
|
60
|
+
waitedMs: number;
|
|
61
|
+
logFile?: string;
|
|
62
|
+
} | {
|
|
63
|
+
kind: "job.stage";
|
|
64
|
+
label: string;
|
|
65
|
+
} | {
|
|
66
|
+
kind: "job.retried";
|
|
67
|
+
attempt: number;
|
|
68
|
+
of: number;
|
|
69
|
+
failure: string;
|
|
70
|
+
} | {
|
|
71
|
+
kind: "job.finished";
|
|
72
|
+
status: string;
|
|
73
|
+
landed?: boolean;
|
|
74
|
+
passed?: boolean;
|
|
75
|
+
ms: number;
|
|
76
|
+
usage: UsageReport;
|
|
77
|
+
failedForGood?: boolean;
|
|
78
|
+
/** Who stopped it, for one stopped: the person, or turri itself, and why. */
|
|
79
|
+
stoppedBy?: "person" | "turri";
|
|
80
|
+
error?: string;
|
|
81
|
+
logFile?: string;
|
|
82
|
+
} | {
|
|
83
|
+
kind: "loop.halted";
|
|
84
|
+
why: string;
|
|
85
|
+
track: "code" | "conformance";
|
|
86
|
+
} | {
|
|
87
|
+
kind: "loop.stopped";
|
|
88
|
+
why: string;
|
|
89
|
+
} | {
|
|
90
|
+
kind: "planner.failed";
|
|
91
|
+
step: "group" | "place" | "title";
|
|
92
|
+
error: string;
|
|
93
|
+
} | {
|
|
94
|
+
kind: "planner.fellBack";
|
|
95
|
+
step: "group" | "place" | "title";
|
|
96
|
+
why: string;
|
|
97
|
+
} | {
|
|
98
|
+
kind: "model.call";
|
|
99
|
+
ms?: number;
|
|
100
|
+
usage?: UsageReport;
|
|
101
|
+
} | {
|
|
102
|
+
kind: "model.turn";
|
|
103
|
+
} | {
|
|
104
|
+
kind: "model.compaction";
|
|
105
|
+
} | {
|
|
106
|
+
kind: "provider.retry";
|
|
107
|
+
error: string;
|
|
108
|
+
attempt: number;
|
|
109
|
+
of: number;
|
|
110
|
+
waitMs: number;
|
|
111
|
+
} | {
|
|
112
|
+
kind: "provider.stall";
|
|
113
|
+
waitMs: number;
|
|
114
|
+
} | {
|
|
115
|
+
kind: "provider.error";
|
|
116
|
+
error: string;
|
|
117
|
+
transient: boolean;
|
|
118
|
+
} | {
|
|
119
|
+
kind: "turn.stopped";
|
|
120
|
+
reason: string;
|
|
121
|
+
} | {
|
|
122
|
+
kind: "tool.call";
|
|
123
|
+
tool: string;
|
|
124
|
+
ms: number;
|
|
125
|
+
ok: boolean;
|
|
126
|
+
resultChars: number;
|
|
127
|
+
} | {
|
|
128
|
+
kind: "script.run";
|
|
129
|
+
script: string;
|
|
130
|
+
exit: number;
|
|
131
|
+
ms: number;
|
|
132
|
+
timedOut: boolean;
|
|
133
|
+
inPlace: boolean;
|
|
134
|
+
mutated?: boolean;
|
|
135
|
+
signature?: string;
|
|
136
|
+
} | {
|
|
137
|
+
kind: "gate.round";
|
|
138
|
+
gate: "unit tests" | "conformance" | "regression";
|
|
139
|
+
round: number;
|
|
140
|
+
of: number;
|
|
141
|
+
signature?: string;
|
|
142
|
+
} | {
|
|
143
|
+
kind: "review.verdict";
|
|
144
|
+
reviewer: "unit";
|
|
145
|
+
review: number;
|
|
146
|
+
pass: boolean;
|
|
147
|
+
problems: number;
|
|
148
|
+
} | {
|
|
149
|
+
kind: "arbiter.ruling";
|
|
150
|
+
upheld: number;
|
|
151
|
+
dismissed: number;
|
|
152
|
+
} | {
|
|
153
|
+
kind: "judge.verdict";
|
|
154
|
+
culprit: string;
|
|
155
|
+
round: number;
|
|
156
|
+
reason: string;
|
|
157
|
+
} | {
|
|
158
|
+
kind: "guard.refused";
|
|
159
|
+
rule: "repeat" | "budget" | "restricted";
|
|
160
|
+
tool: string;
|
|
161
|
+
times: number;
|
|
162
|
+
} | {
|
|
163
|
+
kind: "command.refused";
|
|
164
|
+
rule: string;
|
|
165
|
+
} | {
|
|
166
|
+
kind: "audit.putBack";
|
|
167
|
+
putBack: number;
|
|
168
|
+
left: number;
|
|
169
|
+
lost: number;
|
|
170
|
+
conversation: number;
|
|
171
|
+
} | {
|
|
172
|
+
kind: "healer.started";
|
|
173
|
+
healer: string;
|
|
174
|
+
} | {
|
|
175
|
+
kind: "healer.ended";
|
|
176
|
+
healer: string;
|
|
177
|
+
ms: number;
|
|
178
|
+
found?: string;
|
|
179
|
+
} | {
|
|
180
|
+
kind: "healer.failed";
|
|
181
|
+
healer: string;
|
|
182
|
+
ms: number;
|
|
183
|
+
error: string;
|
|
184
|
+
} | {
|
|
185
|
+
kind: "history.committed";
|
|
186
|
+
subject: string;
|
|
187
|
+
commit?: string;
|
|
188
|
+
} | {
|
|
189
|
+
kind: "history.failed";
|
|
190
|
+
subject: string;
|
|
191
|
+
said: string;
|
|
192
|
+
landing: boolean;
|
|
193
|
+
} | {
|
|
194
|
+
kind: "history.reset";
|
|
195
|
+
undone: number;
|
|
196
|
+
paths: string[];
|
|
197
|
+
} | {
|
|
198
|
+
kind: "history.rolledBack";
|
|
199
|
+
why: string;
|
|
200
|
+
} | {
|
|
201
|
+
kind: "environment.error";
|
|
202
|
+
missing: string;
|
|
203
|
+
source: string;
|
|
204
|
+
sandbox: boolean;
|
|
205
|
+
};
|
|
206
|
+
export type LedgerKind = LedgerEvent["kind"];
|
|
207
|
+
/** One line of the ledger: the event, and where and when it happened. */
|
|
208
|
+
export type LedgerEntry = LedgerEvent & {
|
|
209
|
+
v: 1;
|
|
210
|
+
ts: string;
|
|
211
|
+
seq: number;
|
|
212
|
+
run: string;
|
|
213
|
+
job?: string;
|
|
214
|
+
module?: string;
|
|
215
|
+
agent?: AgentRef;
|
|
216
|
+
};
|
|
217
|
+
/** Where an event happened, when not where the code recording it runs: a job's own place is found by itself. */
|
|
218
|
+
export interface LedgerPlace {
|
|
219
|
+
project?: string;
|
|
220
|
+
job?: string;
|
|
221
|
+
module?: string;
|
|
222
|
+
agent?: AgentRef;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Hear every event from now on, as it is recorded, with the project it is about - while the ledger is on. Returns
|
|
226
|
+
* what stops it. A listener that throws is left out of that event, never the work.
|
|
227
|
+
*/
|
|
228
|
+
export declare function onLedgerEvent(listener: (entry: LedgerEntry, project: string) => void): () => void;
|
|
229
|
+
/** Whether turri turns the ledger on as it starts: always, unless TURRI_LEDGER=off. */
|
|
230
|
+
export declare function ledgerWanted(): boolean;
|
|
231
|
+
/** This run's id, while the ledger is on. */
|
|
232
|
+
export declare function ledgerRun(): string | undefined;
|
|
233
|
+
/**
|
|
234
|
+
* Start the ledger for this process: from now on, what is recorded is written. `project` is the one events about no
|
|
235
|
+
* project of their own go to. `warn` is told, once a project, when its ledger cannot be written. Returns what stops it.
|
|
236
|
+
*/
|
|
237
|
+
export declare function startLedger(options: {
|
|
238
|
+
mode: string;
|
|
239
|
+
project?: string;
|
|
240
|
+
warn?: (message: string) => void;
|
|
241
|
+
}): () => void;
|
|
242
|
+
/** Stop the ledger: each project this run wrote to is told it ended. */
|
|
243
|
+
export declare function stopLedger(): void;
|
|
244
|
+
/** Record an event: where it happened is the job this code runs for, unless `place` says. Nothing while the ledger is off. */
|
|
245
|
+
export declare function record(event: LedgerEvent, place?: LedgerPlace): void;
|
|
246
|
+
/**
|
|
247
|
+
* Run a healer - the debugger, the diagnosis of a failed pulse - and record it: started, ended with what it found, or
|
|
248
|
+
* failed and why. A healer that fails gives undefined, as it did before it was recorded, but is no longer silent.
|
|
249
|
+
*/
|
|
250
|
+
export declare function healing<T>(healer: string, work: () => Promise<T>, found?: (result: T) => string | undefined): Promise<T | undefined>;
|
|
251
|
+
/** A failure's fingerprint, short: two runs failing the same way have the same one. */
|
|
252
|
+
export declare function signatureHash(signature: string): string;
|
|
253
|
+
/** The first line of a text, at most `max` characters: what an event keeps of an error or a reason. */
|
|
254
|
+
export declare function firstLine(text: string, max?: number): string;
|
|
255
|
+
/** The project's ledger folder. */
|
|
256
|
+
export declare function ledgerDir(project: string): string;
|
|
257
|
+
/**
|
|
258
|
+
* Keep the ledger folder within `keepBytes`: the files of runs that have ended gzipped, then the oldest pruned until
|
|
259
|
+
* the rest fit. A run still going - this one, or another turri's in the same project - is never touched.
|
|
260
|
+
*/
|
|
261
|
+
export declare function tidy(dir: string, keepBytes: number): void;
|
|
262
|
+
/** What to read of the ledger. */
|
|
263
|
+
export interface LedgerQuery {
|
|
264
|
+
/** A pulse, by its id or the start of it. */
|
|
265
|
+
job?: string;
|
|
266
|
+
run?: string;
|
|
267
|
+
kinds?: LedgerKind[];
|
|
268
|
+
/** Only events at or after this time. */
|
|
269
|
+
since?: Date;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The project's ledger, oldest first - every run's, the gzipped ones too, as `query` narrows it. A line that does not
|
|
273
|
+
* parse, or a kind this turri does not know, is skipped: the ledger is read as it is, whatever wrote it.
|
|
274
|
+
*/
|
|
275
|
+
export declare function readLedger(project: string, query?: LedgerQuery): LedgerEntry[];
|
|
@@ -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
|