teapot-coding-agent 0.37.59 → 0.37.61
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/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +61 -2247
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +9 -310
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1180
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +166 -142
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- package/public/assets/shiki-BwDjoZdu.js.map +0 -1
package/dist/log/events.js
CHANGED
|
@@ -1,55 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Append-only JSONL event log.
|
|
3
|
-
*
|
|
4
|
-
* Design goals:
|
|
5
|
-
* - One file per agent (sessions.log.jsonl). Every conversation, including
|
|
6
|
-
* forks, lives in the SAME file as an interleaved event stream.
|
|
7
|
-
* - Each line is one self-contained JSON object; humans can `cat`/`jq` it.
|
|
8
|
-
* - Lineage is explicit: every event carries `session`, `branch`, and
|
|
9
|
-
* `parent` (the previous event on the same branch). A `fork` event records
|
|
10
|
-
* where the new branch started (fromSession/fromBranch/fromEvent), so any
|
|
11
|
-
* session can be reconstructed by filtering `branch === X` or by walking
|
|
12
|
-
* parent links from the fork point backwards into ancestor branches.
|
|
13
|
-
* - Append-only + monotonic `seq` makes corruption detectable (a torn final
|
|
14
|
-
* line is simply ignored on read).
|
|
15
|
-
*/
|
|
16
1
|
import { createWriteStream } from "node:fs";
|
|
17
2
|
import { mkdirSync } from "node:fs";
|
|
18
3
|
import path from "node:path";
|
|
19
|
-
/**
|
|
20
|
-
* How long an idle write stream is kept open (#143).
|
|
21
|
-
*
|
|
22
|
-
* The stream used to be opened in `load()` and closed only by `close()`, which
|
|
23
|
-
* runs on dispose and shutdown. A loaded agent therefore held a file descriptor
|
|
24
|
-
* for the whole process lifetime — MEASURED on a real master: 493 agents in
|
|
25
|
-
* config meant 493 open `chat.jsonl` descriptors, ~300 MB RSS, growing with
|
|
26
|
-
* every session the operator creates.
|
|
27
|
-
*
|
|
28
|
-
* The log is append-only, so a stream is never needed to keep state: reopening
|
|
29
|
-
* with `flags: "a"` continues exactly where the last write stopped. Closing it
|
|
30
|
-
* is therefore free, and re-opening on the next append costs one `open()` per
|
|
31
|
-
* burst of activity rather than one per session forever.
|
|
32
|
-
*
|
|
33
|
-
* 15s, because a working agent appends many times per second — holding the
|
|
34
|
-
* stream across that activity means file rotation / inspection outside the app
|
|
35
|
-
* still sees a quiet, closed file, and a burst of tool calls costs ONE open.
|
|
36
|
-
*/
|
|
37
4
|
const IDLE_CLOSE_MS = 15_000;
|
|
38
5
|
export class EventLog {
|
|
39
6
|
stream = null;
|
|
40
7
|
seq = 0;
|
|
41
8
|
chain = Promise.resolve();
|
|
42
|
-
/** branch -> last event id (in-memory reconstruction of parent chains) */
|
|
43
9
|
lastByBranch = new Map();
|
|
44
|
-
/** optional observer (e.g. console logger wired by the master) */
|
|
45
10
|
onEvent = null;
|
|
46
|
-
/** #143: timer that closes the stream once this log goes quiet */
|
|
47
11
|
idleTimer = null;
|
|
48
|
-
/**
|
|
49
|
-
* #143: set by close() so an append that resumes afterwards knows the log was
|
|
50
|
-
* DISPOSED rather than merely idle — an idle log must reopen, a disposed one
|
|
51
|
-
* must keep dropping (and saying so), which is the pre-existing contract.
|
|
52
|
-
*/
|
|
53
12
|
disposed = false;
|
|
54
13
|
filePath;
|
|
55
14
|
agentId;
|
|
@@ -57,26 +16,6 @@ export class EventLog {
|
|
|
57
16
|
this.filePath = filePath;
|
|
58
17
|
this.agentId = agentId;
|
|
59
18
|
}
|
|
60
|
-
/**
|
|
61
|
-
* Bytes read from the END of the log to rebuild the in-memory head.
|
|
62
|
-
*
|
|
63
|
-
* `load()` used to `readFile()` the ENTIRE log and `JSON.parse` every event,
|
|
64
|
-
* only to learn two things: the highest `seq`, and the last event id per
|
|
65
|
-
* branch. MEASURED on a real 813-session data dir (369 MB of logs): that read
|
|
66
|
-
* was the single largest boot cost — 1603 ms of `load()` against 493 agents,
|
|
67
|
-
* plus the GC pressure of holding one whole log as a string and its `split`
|
|
68
|
-
* array (~50 MB of temporary garbage for a 23 MB log).
|
|
69
|
-
*
|
|
70
|
-
* Both facts live at the END of an append-only file:
|
|
71
|
-
* - `seq` is assigned monotonically, so the last complete line holds the max
|
|
72
|
-
* - `lastEventId()` is only ever called for the CURRENT branch (fork/spawn),
|
|
73
|
-
* which is by definition the most recently written one
|
|
74
|
-
*
|
|
75
|
-
* So the tail is both sufficient and exact. A branch that has been dormant for
|
|
76
|
-
* more than WINDOW bytes loses its in-memory head; that only affects
|
|
77
|
-
* `parent:` on the next append to that stale branch, and the paths that matter
|
|
78
|
-
* (`fork`, child spawn) re-seed explicitly through `seedBranch()`.
|
|
79
|
-
*/
|
|
80
19
|
static LOAD_TAIL_BYTES = 512 * 1024;
|
|
81
20
|
async load() {
|
|
82
21
|
mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
@@ -85,7 +24,7 @@ export class EventLog {
|
|
|
85
24
|
const st = await stat(this.filePath);
|
|
86
25
|
let text;
|
|
87
26
|
if (st.size <= EventLog.LOAD_TAIL_BYTES) {
|
|
88
|
-
text = await readFile(this.filePath, "utf8");
|
|
27
|
+
text = await readFile(this.filePath, "utf8");
|
|
89
28
|
}
|
|
90
29
|
else {
|
|
91
30
|
const start = st.size - EventLog.LOAD_TAIL_BYTES;
|
|
@@ -98,7 +37,6 @@ export class EventLog {
|
|
|
98
37
|
finally {
|
|
99
38
|
await fh.close();
|
|
100
39
|
}
|
|
101
|
-
// the window starts mid-line: drop the partial first line
|
|
102
40
|
const nl = text.indexOf("\n");
|
|
103
41
|
text = nl === -1 ? "" : text.slice(nl + 1);
|
|
104
42
|
}
|
|
@@ -112,15 +50,11 @@ export class EventLog {
|
|
|
112
50
|
this.lastByBranch.set(e.branch, e.id);
|
|
113
51
|
}
|
|
114
52
|
catch {
|
|
115
|
-
/* torn trailing line: ignore */
|
|
116
53
|
}
|
|
117
54
|
}
|
|
118
55
|
}
|
|
119
56
|
catch {
|
|
120
|
-
/* new log */
|
|
121
57
|
}
|
|
122
|
-
// repair a torn tail: without this, the next append would fuse into the
|
|
123
|
-
// corrupt partial line and destroy two events instead of one
|
|
124
58
|
try {
|
|
125
59
|
const { stat, appendFile } = await import("node:fs/promises");
|
|
126
60
|
const st = await stat(this.filePath);
|
|
@@ -134,18 +68,8 @@ export class EventLog {
|
|
|
134
68
|
}
|
|
135
69
|
}
|
|
136
70
|
catch {
|
|
137
|
-
/* file may not exist yet */
|
|
138
71
|
}
|
|
139
|
-
// #143: NO stream here. Opening it in load() is what held a descriptor per
|
|
140
|
-
// loaded agent for the lifetime of the process (measured: 493 of them).
|
|
141
|
-
// `load()` still reads and repairs the file — it just does not keep it open.
|
|
142
72
|
}
|
|
143
|
-
/**
|
|
144
|
-
* Open the append stream if it is not already open (#143).
|
|
145
|
-
*
|
|
146
|
-
* Called from the write chain so an append cannot race a close: both run
|
|
147
|
-
* inside `this.chain`, which is a promise queue.
|
|
148
|
-
*/
|
|
149
73
|
openStream() {
|
|
150
74
|
if (this.stream)
|
|
151
75
|
return this.stream;
|
|
@@ -156,48 +80,29 @@ export class EventLog {
|
|
|
156
80
|
this.stream = s;
|
|
157
81
|
return s;
|
|
158
82
|
}
|
|
159
|
-
/** #143: close the stream once this log has been quiet for IDLE_CLOSE_MS. */
|
|
160
83
|
scheduleIdleClose() {
|
|
161
84
|
if (this.idleTimer)
|
|
162
|
-
return;
|
|
85
|
+
return;
|
|
163
86
|
this.idleTimer = setTimeout(() => this.fireIdleClose(), IDLE_CLOSE_MS);
|
|
164
|
-
// the timer must never be the reason a process cannot exit
|
|
165
87
|
this.idleTimer.unref?.();
|
|
166
88
|
}
|
|
167
|
-
/**
|
|
168
|
-
* What the idle timer does — separated so it can be driven directly.
|
|
169
|
-
*
|
|
170
|
-
* Clears the deadline FIRST so `releaseIdleStream` can tell "nothing new
|
|
171
|
-
* arrived" (close) from "an append re-armed it while I waited" (defer).
|
|
172
|
-
*/
|
|
173
89
|
fireIdleClose() {
|
|
174
90
|
this.idleTimer = null;
|
|
175
91
|
void this.releaseIdleStream();
|
|
176
92
|
}
|
|
177
|
-
/**
|
|
178
|
-
* Close the stream after a quiet period.
|
|
179
|
-
*
|
|
180
|
-
* Runs behind `chain`, so it cannot interleave with a queued write. An append
|
|
181
|
-
* that arrived while this was pending re-armed the timer, and that newer
|
|
182
|
-
* deadline owns the close — hence the check on `idleTimer` AFTER draining,
|
|
183
|
-
* not before.
|
|
184
|
-
*/
|
|
185
93
|
async releaseIdleStream() {
|
|
186
94
|
await this.chain.catch(() => { });
|
|
187
95
|
if (this.disposed)
|
|
188
96
|
return;
|
|
189
97
|
if (!this.stream)
|
|
190
98
|
return;
|
|
191
|
-
// a later append re-armed the timer — let that deadline own the close
|
|
192
99
|
if (this.idleTimer)
|
|
193
100
|
return;
|
|
194
101
|
const s = this.stream;
|
|
195
102
|
this.stream = null;
|
|
196
103
|
await new Promise((res) => s.end(res)).catch(() => {
|
|
197
|
-
/* the write error handler already reported it */
|
|
198
104
|
});
|
|
199
105
|
}
|
|
200
|
-
/** Append an event; resolves when it is handed to the OS (write flushed). */
|
|
201
106
|
append(type, session, branch, data) {
|
|
202
107
|
const evt = {
|
|
203
108
|
v: 1,
|
|
@@ -212,36 +117,13 @@ export class EventLog {
|
|
|
212
117
|
data,
|
|
213
118
|
};
|
|
214
119
|
this.lastByBranch.set(branch, evt.id);
|
|
215
|
-
// #54: WRITE FIRST, then notify.
|
|
216
|
-
//
|
|
217
|
-
// The observer used to fire BEFORE the write. The frontend receives a
|
|
218
|
-
// `kind:"event"` frame and — by design — treats it as "something changed"
|
|
219
|
-
// and re-reads `/events`. So the reload could read the log BEFORE this
|
|
220
|
-
// event reached disk, and the reload would simply not contain it.
|
|
221
|
-
//
|
|
222
|
-
// Measured against a real EventLog, reloading on every notification:
|
|
223
|
-
//
|
|
224
|
-
// appends: 30 WS-triggered reloads that MISSED the event: 30
|
|
225
|
-
//
|
|
226
|
-
// The 120ms debounce usually hides this, which is exactly why #54 felt
|
|
227
|
-
// random: a slow write misses, a fast one lands. Notifying after the write
|
|
228
|
-
// makes the event visible to any reader the observer triggers.
|
|
229
|
-
// `written` distinguishes a REAL write from the post-close drop path, which
|
|
230
|
-
// also resolves. #54: announcing a dropped event would tell the frontend to
|
|
231
|
-
// reload for something that will never appear — the row would flicker in and
|
|
232
|
-
// straight back out.
|
|
233
120
|
let written = false;
|
|
234
121
|
const p = new Promise((resolve, reject) => {
|
|
235
122
|
this.chain = this.chain.then(() => {
|
|
236
123
|
if (this.disposed) {
|
|
237
|
-
// log DISPOSED (not merely idle) — resolve so callers don't await
|
|
238
|
-
// forever, but say so: silent drops made post-shutdown writes look
|
|
239
|
-
// logged. #143: this is a distinct state from an idle stream, which
|
|
240
|
-
// must reopen instead of dropping.
|
|
241
124
|
console.error(`[teapot] event dropped after log close (${this.agentId}): ${type}`);
|
|
242
125
|
return resolve(evt);
|
|
243
126
|
}
|
|
244
|
-
// #143: open on demand — an idle log keeps no descriptor
|
|
245
127
|
this.openStream().write(JSON.stringify(evt) + "\n", "utf8", (err) => {
|
|
246
128
|
if (err)
|
|
247
129
|
return reject(err);
|
|
@@ -250,50 +132,31 @@ export class EventLog {
|
|
|
250
132
|
});
|
|
251
133
|
}, () => resolve(evt));
|
|
252
134
|
});
|
|
253
|
-
// #143: re-arm the idle close for every append. Set here rather than inside
|
|
254
|
-
// the write callback: the timer measures quiet time from the last APPEND, and
|
|
255
|
-
// a long write must not count as activity after the fact.
|
|
256
135
|
this.scheduleIdleClose();
|
|
257
136
|
this.chain = this.chain.then(() => { }, () => { });
|
|
258
|
-
// notify once the write is handed to the OS, so a reload the observer
|
|
259
|
-
// triggers can actually see this event
|
|
260
137
|
void p
|
|
261
138
|
.then(() => {
|
|
262
139
|
if (!written)
|
|
263
|
-
return;
|
|
140
|
+
return;
|
|
264
141
|
try {
|
|
265
142
|
this.onEvent?.(evt);
|
|
266
143
|
}
|
|
267
144
|
catch {
|
|
268
|
-
/* observer must never break the log */
|
|
269
145
|
}
|
|
270
146
|
})
|
|
271
147
|
.catch(() => {
|
|
272
|
-
/* a failed write must not notify — the event is not on disk */
|
|
273
148
|
});
|
|
274
149
|
return p;
|
|
275
150
|
}
|
|
276
151
|
lastEventId(branch) {
|
|
277
152
|
return this.lastByBranch.get(branch) ?? null;
|
|
278
153
|
}
|
|
279
|
-
/**
|
|
280
|
-
* Seed the parent link for a brand-new branch so its first event chains to
|
|
281
|
-
* the event that created it.
|
|
282
|
-
*
|
|
283
|
-
* `lastByBranch` is per branch, so without this a new branch's first event
|
|
284
|
-
* got `parent: null` — severing the chain. lineageOf() walks parents from the
|
|
285
|
-
* newest event, so the entire pre-fork history became unreachable and a
|
|
286
|
-
* restart rebuilt the conversation with none of it (#38).
|
|
287
|
-
*/
|
|
288
154
|
seedBranch(branch, parentEventId) {
|
|
289
155
|
if (!branch)
|
|
290
156
|
return;
|
|
291
157
|
this.lastByBranch.set(branch, parentEventId ?? "");
|
|
292
158
|
}
|
|
293
159
|
async close() {
|
|
294
|
-
// #143: mark DISPOSED before draining, so an append queued behind us drops
|
|
295
|
-
// loudly instead of reopening the log we are shutting down. This is the
|
|
296
|
-
// difference between "idle" (reopen on the next write) and "closed".
|
|
297
160
|
this.disposed = true;
|
|
298
161
|
if (this.idleTimer) {
|
|
299
162
|
clearTimeout(this.idleTimer);
|
|
@@ -307,7 +170,6 @@ export class EventLog {
|
|
|
307
170
|
await new Promise((res) => s.end(res));
|
|
308
171
|
}
|
|
309
172
|
}
|
|
310
|
-
/** Read all events from a JSONL file (tolerates a torn final line). */
|
|
311
173
|
export async function readEvents(filePath) {
|
|
312
174
|
try {
|
|
313
175
|
const { readFile } = await import("node:fs/promises");
|
|
@@ -319,7 +181,6 @@ export async function readEvents(filePath) {
|
|
|
319
181
|
out.push(JSON.parse(line));
|
|
320
182
|
}
|
|
321
183
|
catch {
|
|
322
|
-
/* skip bad line */
|
|
323
184
|
}
|
|
324
185
|
}
|
|
325
186
|
return out;
|
|
@@ -328,26 +189,7 @@ export async function readEvents(filePath) {
|
|
|
328
189
|
return [];
|
|
329
190
|
}
|
|
330
191
|
}
|
|
331
|
-
/** Default floor for a tail read, so a small `limit` cannot cause a tiny read. */
|
|
332
192
|
const TAIL_MIN_BYTES = 64 * 1024;
|
|
333
|
-
/**
|
|
334
|
-
* Read only the LAST `limit` events from an append-only JSONL log.
|
|
335
|
-
*
|
|
336
|
-
* #59: the web UI polls this endpoint every ~120 ms for the selected agent, and
|
|
337
|
-
* the full re-parse cost 348 ms on a 57 MB / 200 000-event log. The mtime cache
|
|
338
|
-
* could not help — a RUNNING agent appends constantly, so `size` and `mtimeMs`
|
|
339
|
-
* change on every single request and the cache only ever helped stopped agents.
|
|
340
|
-
*
|
|
341
|
-
* The log is strictly append-only: `EventLog.append` is the only writer and
|
|
342
|
-
* always writes a whole `JSON.stringify(evt) + "\n"` under a serialized chain,
|
|
343
|
-
* so an append never rewrites earlier bytes. That makes a tail read safe, and
|
|
344
|
-
* makes byte-offset caching legitimate.
|
|
345
|
-
*
|
|
346
|
-
* Anchored to the tail deliberately. The read starts at a byte offset chosen for
|
|
347
|
-
* the file's current size, so the FIRST line it sees is usually a fragment —
|
|
348
|
-
* dropped. `bytesScanned` is reported so callers (and tests) can assert the read
|
|
349
|
-
* really was partial rather than trusting a wall-clock number.
|
|
350
|
-
*/
|
|
351
193
|
export async function readEventsTail(filePath, limit) {
|
|
352
194
|
const empty = { events: [], total: 0, bytesScanned: 0, truncated: false };
|
|
353
195
|
if (limit <= 0)
|
|
@@ -359,19 +201,12 @@ export async function readEventsTail(filePath, limit) {
|
|
|
359
201
|
const st = await fh.stat();
|
|
360
202
|
if (st.size === 0)
|
|
361
203
|
return empty;
|
|
362
|
-
// grow the window until it holds `limit` whole lines. Start at a floor so a
|
|
363
|
-
// small limit still reads a sensible block, then double — bounded by the
|
|
364
|
-
// file size, because a pathological file must not make us read forever.
|
|
365
204
|
let want = Math.min(st.size, Math.max(TAIL_MIN_BYTES, limit * 512));
|
|
366
205
|
for (;;) {
|
|
367
206
|
const r = await readWindow(fh, st.size, want);
|
|
368
207
|
if (r.events.length >= limit || want >= st.size) {
|
|
369
208
|
return {
|
|
370
209
|
events: r.events.slice(-limit),
|
|
371
|
-
// `total` is an APPROXIMATE count: only the window was parsed, so this
|
|
372
|
-
// is "at least this many". /events uses it for the "load older" hint,
|
|
373
|
-
// where approximate is fine — an exact count would mean reading the
|
|
374
|
-
// entire file, which is the cost this function exists to avoid.
|
|
375
210
|
total: r.events.length,
|
|
376
211
|
bytesScanned: r.bytes,
|
|
377
212
|
truncated: r.bytes < st.size,
|
|
@@ -387,7 +222,6 @@ export async function readEventsTail(filePath, limit) {
|
|
|
387
222
|
await fh?.close().catch(() => { });
|
|
388
223
|
}
|
|
389
224
|
}
|
|
390
|
-
/** read the last `bytes` of the file, dropping the leading partial line */
|
|
391
225
|
async function readWindow(fh, size, bytes) {
|
|
392
226
|
const start = Math.max(0, size - bytes);
|
|
393
227
|
const buf = Buffer.alloc(size - start);
|
|
@@ -395,7 +229,6 @@ async function readWindow(fh, size, bytes) {
|
|
|
395
229
|
await fh.read(buf, 0, buf.length, start);
|
|
396
230
|
const text = buf.toString("utf8");
|
|
397
231
|
const nl = text.indexOf("\n");
|
|
398
|
-
// the first line is a fragment unless we started at 0
|
|
399
232
|
const body = start > 0 ? (nl === -1 ? "" : text.slice(nl + 1)) : text;
|
|
400
233
|
const events = [];
|
|
401
234
|
for (const line of body.split("\n")) {
|
|
@@ -405,38 +238,15 @@ async function readWindow(fh, size, bytes) {
|
|
|
405
238
|
events.push(JSON.parse(line));
|
|
406
239
|
}
|
|
407
240
|
catch {
|
|
408
|
-
/* torn or corrupt line: skip it, never return a fragment */
|
|
409
241
|
}
|
|
410
242
|
}
|
|
411
243
|
return { events, bytes: buf.length };
|
|
412
244
|
}
|
|
413
|
-
/**
|
|
414
|
-
* Keep one branch's rows out of a timeline, optionally including the history it
|
|
415
|
-
* inherited before it forked.
|
|
416
|
-
*
|
|
417
|
-
* #38. A fork is a new timeline, but the agent's CONTEXT on it still includes
|
|
418
|
-
* everything before the fork point — `rebuildMessagesFrom` walks the parent
|
|
419
|
-
* chain across branch boundaries precisely so a restarted fork keeps its
|
|
420
|
-
* history. Filtering the view by strict equality therefore showed *less* than
|
|
421
|
-
* the model actually reasons over, which is why a filtered fork looked like
|
|
422
|
-
* data loss even though nothing was missing from the log.
|
|
423
|
-
*
|
|
424
|
-
* `includeInherited` walks the parent links out of the branch's own events and
|
|
425
|
-
* keeps everything they reach, so the view matches the model's context. Strict
|
|
426
|
-
* remains the default: a caller that genuinely wants only a branch's own rows
|
|
427
|
-
* (counting, auditing) should not be handed inherited history silently.
|
|
428
|
-
*
|
|
429
|
-
* Pure and exported so the rule can be tested without an HTTP server.
|
|
430
|
-
*/
|
|
431
245
|
export function filterByBranch(events, branch, includeInherited = false) {
|
|
432
246
|
if (!includeInherited)
|
|
433
247
|
return events.filter((e) => e.branch === branch);
|
|
434
248
|
const byId = new Map(events.map((e) => [e.id, e]));
|
|
435
|
-
// every event belonging to the branch, plus everything reachable by `parent`
|
|
436
249
|
const wanted = new Set();
|
|
437
|
-
// A WORKLIST, not a loop over the growing set: iterating `wanted` while
|
|
438
|
-
// adding to it inside the same pass re-visits nodes and, on a parent cycle,
|
|
439
|
-
// never terminates.
|
|
440
250
|
const queue = [];
|
|
441
251
|
for (const e of events) {
|
|
442
252
|
if (e.branch !== branch)
|
|
@@ -448,8 +258,6 @@ export function filterByBranch(events, branch, includeInherited = false) {
|
|
|
448
258
|
}
|
|
449
259
|
while (queue.length) {
|
|
450
260
|
const cur = byId.get(queue.pop());
|
|
451
|
-
// walk up through the fork boundary — a fork's parent is the last event of
|
|
452
|
-
// the SOURCE branch, and that branch's history is inherited too
|
|
453
261
|
if (cur?.parent && !wanted.has(cur.parent)) {
|
|
454
262
|
wanted.add(cur.parent);
|
|
455
263
|
queue.push(cur.parent);
|