parawatch 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,415 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { appendEvent, eventsPath, homeDir, readCursor, readEvents, writeCursor } from "./store.js";
4
+ import { fold, nextId } from "./state.js";
5
+ import { CARD_KINDS, HUMAN, PR_CHANGES } from "./types.js";
6
+ export class UserError extends Error {
7
+ }
8
+ function cleanList(xs) {
9
+ const out = (xs ?? []).map((x) => x.trim()).filter(Boolean);
10
+ return out.length ? out : undefined;
11
+ }
12
+ function absPaths(xs) {
13
+ // attachments and things to open are kept as given when they look like URLs
14
+ return cleanList(xs)?.map((x) => (/^[a-z]+:\/\//i.test(x) ? x : path.resolve(x)));
15
+ }
16
+ export function addProject(name, paths) {
17
+ if (!/^[A-Za-z0-9._-]+$/.test(name))
18
+ throw new UserError(`project name "${name}" may only use letters, digits, . _ -`);
19
+ appendEvent(() => ({
20
+ type: "project.add",
21
+ project: { name, paths: paths.map((p) => path.resolve(p)), addedAt: new Date().toISOString() },
22
+ }));
23
+ }
24
+ export function removeProject(name) {
25
+ const s = fold(readEvents());
26
+ if (!s.projects.has(name))
27
+ throw new UserError(`no project named "${name}"`);
28
+ appendEvent(() => ({ type: "project.remove", name }));
29
+ }
30
+ const PREVIEW_MAX = 20_000;
31
+ function readPreview(file) {
32
+ if (!file?.trim())
33
+ return undefined;
34
+ let text;
35
+ try {
36
+ text = fs.readFileSync(path.resolve(file), "utf8");
37
+ }
38
+ catch {
39
+ throw new UserError(`cannot read the preview file ${file}`);
40
+ }
41
+ return text.length > PREVIEW_MAX ? text.slice(0, PREVIEW_MAX) + "\n…" : text;
42
+ }
43
+ /** A date or timestamp as ISO text; a bare date counts as local midnight. */
44
+ export function parseWhen(s) {
45
+ const t = s.trim();
46
+ const d = /^\d{4}-\d{2}-\d{2}$/.test(t) ? new Date(`${t}T00:00:00`) : new Date(t);
47
+ if (Number.isNaN(d.getTime()))
48
+ throw new UserError(`"${s}" is not a date (use YYYY-MM-DD)`);
49
+ return d.toISOString();
50
+ }
51
+ /** The claimed name of a session (claims may hold an id prefix). */
52
+ export function sessionTitle(claims, session) {
53
+ if (!session)
54
+ return undefined;
55
+ const c = claims.find((x) => session.startsWith(x.session) || x.session.startsWith(session));
56
+ return c?.title;
57
+ }
58
+ function checkKind(kind) {
59
+ if (!CARD_KINDS.includes(kind))
60
+ throw new UserError(`kind must be one of ${CARD_KINDS.join(", ")}`);
61
+ return kind;
62
+ }
63
+ export function openCard(input) {
64
+ if (!input.title.trim())
65
+ throw new UserError("a card needs a title");
66
+ if (!input.project.trim())
67
+ throw new UserError("a card needs --project");
68
+ const action = input.action?.trim() || undefined;
69
+ // an approval with no choices of its own is a yes or a no
70
+ const options = cleanList(input.options) ?? (action ? ["Do it", "Don't"] : undefined);
71
+ const preview = readPreview(input.preview);
72
+ if (input.recommend !== undefined) {
73
+ if (!options)
74
+ throw new UserError("--recommend needs --option");
75
+ if (!Number.isInteger(input.recommend) || input.recommend < 1 || input.recommend > options.length) {
76
+ throw new UserError(`--recommend must be between 1 and ${options.length}`);
77
+ }
78
+ }
79
+ checkKind(input.kind);
80
+ const since = input.since?.trim() ? parseWhen(input.since) : undefined;
81
+ let id = "";
82
+ appendEvent((events) => {
83
+ id = nextId(events, "c");
84
+ const session = input.session?.trim() || undefined;
85
+ const from = input.from?.trim() || sessionTitle(fold(events).claims, session);
86
+ return {
87
+ type: "card.open",
88
+ card: {
89
+ id,
90
+ kind: input.kind,
91
+ project: input.project,
92
+ task: input.task || undefined,
93
+ title: input.title.trim(),
94
+ body: input.body?.trim() || undefined,
95
+ options,
96
+ recommend: input.recommend,
97
+ attach: absPaths(input.attach),
98
+ open: absPaths(input.open),
99
+ look: input.look?.trim() || undefined,
100
+ from,
101
+ session,
102
+ since,
103
+ action,
104
+ preview,
105
+ },
106
+ };
107
+ });
108
+ const card = fold(readEvents()).cards.get(id);
109
+ if (!card)
110
+ throw new Error("card vanished right after it was written");
111
+ return card;
112
+ }
113
+ function requireCard(id) {
114
+ const card = fold(readEvents()).cards.get(id);
115
+ if (!card)
116
+ throw new UserError(`no card "${id}"`);
117
+ return card;
118
+ }
119
+ export function answerCard(id, a) {
120
+ const card = requireCard(id);
121
+ if (card.status === "closed")
122
+ throw new UserError(`card ${id} is already closed`);
123
+ if (a.choice === undefined && !a.comment?.trim() && !cleanList(a.attach)) {
124
+ throw new UserError("an answer needs --choice, --comment or --attach");
125
+ }
126
+ if (a.choice !== undefined) {
127
+ const n = card.options?.length ?? 0;
128
+ if (!Number.isInteger(a.choice) || a.choice < 1 || a.choice > n) {
129
+ throw new UserError(n ? `--choice must be between 1 and ${n}` : `card ${id} has no options`);
130
+ }
131
+ }
132
+ appendEvent(() => ({
133
+ type: "card.answer",
134
+ card: id,
135
+ by: a.by || HUMAN,
136
+ choice: a.choice,
137
+ comment: a.comment?.trim() || undefined,
138
+ attach: absPaths(a.attach),
139
+ }));
140
+ }
141
+ export function commentCard(id, text, by) {
142
+ requireCard(id);
143
+ if (!text.trim())
144
+ throw new UserError("a comment needs text");
145
+ appendEvent(() => ({ type: "card.comment", card: id, by, text: text.trim() }));
146
+ }
147
+ export function editCard(id, set) {
148
+ requireCard(id);
149
+ const clean = {};
150
+ if (set.kind !== undefined)
151
+ clean.kind = checkKind(set.kind);
152
+ if (set.title !== undefined) {
153
+ if (!set.title.trim())
154
+ throw new UserError("a card needs a title");
155
+ clean.title = set.title.trim();
156
+ }
157
+ // an empty value clears the field; null survives JSON where undefined would vanish
158
+ const cleared = null;
159
+ if (set.body !== undefined)
160
+ clean.body = set.body.trim() || cleared;
161
+ if (set.task !== undefined)
162
+ clean.task = set.task.trim() || cleared;
163
+ if (set.from !== undefined)
164
+ clean.from = set.from.trim() || cleared;
165
+ if (set.session !== undefined)
166
+ clean.session = set.session.trim() || cleared;
167
+ if (set.since !== undefined)
168
+ clean.since = set.since.trim() ? parseWhen(set.since) : cleared;
169
+ if (!Object.keys(clean).length)
170
+ throw new UserError("nothing to change");
171
+ appendEvent(() => ({ type: "card.edit", card: id, set: clean }));
172
+ return requireCard(id);
173
+ }
174
+ export function closeCard(id, note) {
175
+ const card = requireCard(id);
176
+ if (card.status === "closed")
177
+ return;
178
+ appendEvent(() => ({ type: "card.close", card: id, note: note?.trim() || undefined }));
179
+ }
180
+ export function startRun(r) {
181
+ if (r.heartbeat !== undefined && (!Number.isFinite(r.heartbeat) || r.heartbeat <= 0))
182
+ throw new UserError("--heartbeat must be a number of seconds above 0");
183
+ const log = r.log?.trim() ? path.resolve(r.log) : undefined;
184
+ let id = "";
185
+ appendEvent((events) => {
186
+ id = nextId(events, "r");
187
+ return { type: "run.start", run: { id, project: r.project, task: r.task, title: r.title, parent: r.parent, heartbeat: r.heartbeat, log } };
188
+ });
189
+ return id;
190
+ }
191
+ export function noteRun(id, text) {
192
+ if (!fold(readEvents()).runs.has(id))
193
+ throw new UserError(`no declared run "${id}"`);
194
+ appendEvent(() => ({ type: "run.note", run: id, text }));
195
+ }
196
+ export function beatRun(id) {
197
+ if (!fold(readEvents()).runs.has(id))
198
+ throw new UserError(`no declared run "${id}"`);
199
+ appendEvent(() => ({ type: "run.beat", run: id }));
200
+ }
201
+ export function endRun(id, status, note) {
202
+ if (!fold(readEvents()).runs.has(id))
203
+ throw new UserError(`no declared run "${id}"`);
204
+ appendEvent(() => ({ type: "run.end", run: id, status, note }));
205
+ }
206
+ /**
207
+ * Say which project and task a session works on. A claim without a project keeps the session's
208
+ * earlier claim and only changes what is given (for `--done`). A claim of a new task clears `done`.
209
+ */
210
+ export function claimSession(input) {
211
+ if (input.session.length < 6)
212
+ throw new UserError("give at least 6 characters of the session id");
213
+ const before = fold(readEvents()).claims.find((c) => c.session === input.session);
214
+ const project = input.project?.trim() || before?.project;
215
+ if (!project)
216
+ throw new UserError("this session has not claimed a project yet: give --project");
217
+ const sameWork = !input.project || (input.project === before?.project && (input.task ?? before?.task) === before?.task);
218
+ const claim = {
219
+ session: input.session,
220
+ project,
221
+ task: input.task ?? (sameWork ? before?.task : undefined),
222
+ role: input.role ?? before?.role,
223
+ title: input.title ?? before?.title,
224
+ kind: input.kind ?? before?.kind,
225
+ // any new claim means work goes on; only --done marks it finished
226
+ done: input.done?.trim() || undefined,
227
+ };
228
+ for (const k of Object.keys(claim))
229
+ if (claim[k] === undefined)
230
+ delete claim[k];
231
+ appendEvent(() => ({ type: "run.claim", claim }));
232
+ return claim;
233
+ }
234
+ // ---------------------------------------------------------------- pull requests
235
+ export function linkPr(link) {
236
+ const claims = fold(readEvents()).claims;
237
+ const claim = link.session ? claims.find((c) => link.session.startsWith(c.session)) : undefined;
238
+ const project = link.project?.trim() || claim?.project;
239
+ appendEvent(() => ({
240
+ type: "pr.link",
241
+ link: { pr: link.pr, session: link.session, project, task: link.task?.trim() || claim?.task },
242
+ }));
243
+ }
244
+ export function unlinkPr(key) {
245
+ if (!fold(readEvents()).prLinks.has(key))
246
+ throw new UserError(`no linked pull request ${key}`);
247
+ appendEvent(() => ({ type: "pr.unlink", pr: key }));
248
+ }
249
+ /** Something another tool saw on a PR (a merge bot, a ticket system), recorded like GitHub's own. */
250
+ export function prEvent(pr, change, opts = {}) {
251
+ if (!PR_CHANGES.includes(change))
252
+ throw new UserError(`change must be one of ${PR_CHANGES.join(", ")}`);
253
+ appendEvent(() => ({ type: "pr.change", pr, change: change, by: opts.by, text: opts.text?.trim() || undefined, title: opts.title, source: "external" }));
254
+ }
255
+ /**
256
+ * Human answers and comments newer than `afterSeq`, on cards that are not closed, and news on pull
257
+ * requests linked to the project.
258
+ */
259
+ export function inboxFrom(events, afterSeq, filter) {
260
+ const state = fold(events);
261
+ const out = [];
262
+ for (const ev of events) {
263
+ if (ev.seq <= afterSeq)
264
+ continue;
265
+ if (ev.type === "pr.change") {
266
+ const link = state.prLinks.get(ev.pr.key);
267
+ if (!link || filter.card || (filter.project && link.project !== filter.project))
268
+ continue;
269
+ out.push({ seq: ev.seq, at: ev.ts, kind: "pr", by: ev.by ?? ev.source, pr: { ref: ev.pr, change: ev.change, title: ev.title, text: ev.text, project: link.project } });
270
+ continue;
271
+ }
272
+ if (ev.type !== "card.answer" && ev.type !== "card.comment")
273
+ continue;
274
+ if (ev.by !== HUMAN)
275
+ continue;
276
+ const card = state.cards.get(ev.card);
277
+ if (!card || card.status === "closed")
278
+ continue;
279
+ if (filter.project && card.project !== filter.project)
280
+ continue;
281
+ if (filter.card && card.id !== filter.card)
282
+ continue;
283
+ if (ev.type === "card.answer") {
284
+ out.push({
285
+ seq: ev.seq,
286
+ at: ev.ts,
287
+ kind: "answer",
288
+ card,
289
+ by: ev.by,
290
+ choice: ev.choice,
291
+ choiceText: ev.choice !== undefined ? card.options?.[ev.choice - 1] : undefined,
292
+ comment: ev.comment,
293
+ attach: ev.attach,
294
+ });
295
+ }
296
+ else {
297
+ out.push({ seq: ev.seq, at: ev.ts, kind: "comment", card, by: ev.by, comment: ev.text });
298
+ }
299
+ }
300
+ return out;
301
+ }
302
+ // ---------------------------------------------------------------- listeners
303
+ function listenersDir() {
304
+ return path.join(homeDir(), "listeners");
305
+ }
306
+ /** Mark this process as waiting, so cards can say whether an answer reaches an agent at once. */
307
+ export function startListening(l) {
308
+ const file = path.join(listenersDir(), `${process.pid}.json`);
309
+ fs.mkdirSync(listenersDir(), { recursive: true });
310
+ const me = { ...l, pid: process.pid, since: new Date().toISOString() };
311
+ fs.writeFileSync(file, JSON.stringify(me) + "\n");
312
+ let done = false;
313
+ const stop = () => {
314
+ if (done)
315
+ return;
316
+ done = true;
317
+ fs.rmSync(file, { force: true });
318
+ };
319
+ process.once("exit", stop);
320
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
321
+ process.once(sig, () => {
322
+ stop();
323
+ process.exit(128 + (sig === "SIGINT" ? 2 : sig === "SIGTERM" ? 15 : 1));
324
+ });
325
+ }
326
+ return stop;
327
+ }
328
+ function alive(pid) {
329
+ try {
330
+ process.kill(pid, 0);
331
+ return true;
332
+ }
333
+ catch (err) {
334
+ return err.code === "EPERM";
335
+ }
336
+ }
337
+ /** Waits running now. Files left by a process that died are removed. */
338
+ export function listeners() {
339
+ let names;
340
+ try {
341
+ names = fs.readdirSync(listenersDir()).filter((f) => f.endsWith(".json"));
342
+ }
343
+ catch {
344
+ return [];
345
+ }
346
+ const out = [];
347
+ for (const n of names) {
348
+ const file = path.join(listenersDir(), n);
349
+ try {
350
+ const l = JSON.parse(fs.readFileSync(file, "utf8"));
351
+ if (alive(l.pid))
352
+ out.push(l);
353
+ else
354
+ fs.rmSync(file, { force: true });
355
+ }
356
+ catch {
357
+ // half written; read it next time
358
+ }
359
+ }
360
+ return out;
361
+ }
362
+ /** The waits an answer to this card would wake right now. */
363
+ export function listenersFor(card, ls) {
364
+ return ls.filter((l) => (l.card ? l.card === card.id : !l.project || l.project === card.project));
365
+ }
366
+ export function consumerName(opts) {
367
+ return opts.as || opts.project || (opts.card ? `card-${opts.card}` : "default");
368
+ }
369
+ export function readInbox(consumer, filter, advance) {
370
+ const events = readEvents();
371
+ const items = inboxFrom(events, readCursor(consumer), filter);
372
+ const last = items[items.length - 1];
373
+ if (advance && last)
374
+ writeCursor(consumer, last.seq);
375
+ return items;
376
+ }
377
+ /**
378
+ * Block until the consumer has something in its inbox, or the timeout passes.
379
+ * Returns the items (cursor advanced) or [] on timeout.
380
+ */
381
+ export async function waitInbox(consumer, filter, timeoutMs, pollMs = 1000) {
382
+ const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Infinity;
383
+ let watcher;
384
+ let wake;
385
+ try {
386
+ const dir = path.dirname(eventsPath());
387
+ fs.mkdirSync(dir, { recursive: true });
388
+ watcher = fs.watch(dir, () => wake?.());
389
+ }
390
+ catch {
391
+ watcher = undefined; // polling alone still works
392
+ }
393
+ try {
394
+ for (;;) {
395
+ const items = readInbox(consumer, filter, true);
396
+ if (items.length)
397
+ return items;
398
+ const left = deadline - Date.now();
399
+ if (left <= 0)
400
+ return [];
401
+ await new Promise((resolve) => {
402
+ const t = setTimeout(resolve, Math.min(pollMs, left));
403
+ wake = () => {
404
+ clearTimeout(t);
405
+ resolve();
406
+ };
407
+ });
408
+ wake = undefined;
409
+ }
410
+ }
411
+ finally {
412
+ watcher?.close();
413
+ }
414
+ }
415
+ //# sourceMappingURL=ops.js.map
@@ -0,0 +1,284 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { scanAll } from "../adapters/index.js";
5
+ import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
6
+ /** All checkouts of a project path: the path itself plus its git worktrees. */
7
+ function rootsOf(p, cache) {
8
+ const hit = cache.get(p);
9
+ if (hit)
10
+ return hit;
11
+ const roots = [p];
12
+ try {
13
+ const git = (args) => execFileSync("git", ["-C", p, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
14
+ // a folder inside a bigger repo (a notes vault, a monorepo package) must not claim the whole repo
15
+ const top = git(["rev-parse", "--show-toplevel"]).trim();
16
+ if (path.resolve(top) !== path.resolve(p)) {
17
+ cache.set(p, roots);
18
+ return roots;
19
+ }
20
+ const out = git(["worktree", "list", "--porcelain"]);
21
+ for (const line of out.split("\n")) {
22
+ if (line.startsWith("worktree ")) {
23
+ const w = line.slice("worktree ".length).trim();
24
+ if (w && !roots.includes(w))
25
+ roots.push(w);
26
+ }
27
+ }
28
+ }
29
+ catch {
30
+ // not a git checkout, or git missing; the path alone still matches
31
+ }
32
+ cache.set(p, roots);
33
+ return roots;
34
+ }
35
+ function inside(child, parent) {
36
+ const rel = path.relative(parent, child);
37
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
38
+ }
39
+ function matchers(projects) {
40
+ const cache = new Map();
41
+ return projects.map((p) => ({ project: p.name, roots: p.paths.flatMap((x) => rootsOf(x, cache)) }));
42
+ }
43
+ /** The project whose checkout holds this directory; the deepest root wins. */
44
+ function projectForCwd(cwd, ms) {
45
+ if (!cwd)
46
+ return undefined;
47
+ let best;
48
+ for (const m of ms) {
49
+ for (const r of m.roots) {
50
+ if (inside(cwd, r) && (!best || r.length > best.depth))
51
+ best = { project: m.project, depth: r.length };
52
+ }
53
+ }
54
+ return best?.project;
55
+ }
56
+ /** A project whose path the run's first prompt names, when exactly one does. */
57
+ function projectForPrompt(prompt, ms) {
58
+ if (!prompt)
59
+ return undefined;
60
+ const hits = new Set();
61
+ for (const m of ms) {
62
+ for (const r of m.roots)
63
+ if (prompt.includes(r))
64
+ hits.add(m.project);
65
+ }
66
+ return hits.size === 1 ? [...hits][0] : undefined;
67
+ }
68
+ /** The project most of these folders belong to (ties go to the most recent), with its count. */
69
+ function projectForEdits(dirs, ms) {
70
+ const counts = new Map();
71
+ for (const d of dirs ?? []) {
72
+ const p = projectForCwd(d, ms);
73
+ if (p)
74
+ counts.set(p, (counts.get(p) ?? 0) + 1);
75
+ }
76
+ return counts;
77
+ }
78
+ function topOf(counts, dirs, ms) {
79
+ let best;
80
+ let n = 0;
81
+ // walk newest first so a tie goes to where the run worked last
82
+ for (const d of [...(dirs ?? [])].reverse()) {
83
+ const p = projectForCwd(d, ms);
84
+ if (p && (counts.get(p) ?? 0) > n) {
85
+ best = p;
86
+ n = counts.get(p);
87
+ }
88
+ }
89
+ return best;
90
+ }
91
+ function claimFor(id, state) {
92
+ return state.claims.find((c) => id.startsWith(c.session));
93
+ }
94
+ /**
95
+ * Give each observed run a project, in this order: an explicit claim; the checkout it runs in,
96
+ * unless it only edits files of another project; its parent's project; the project whose files it
97
+ * edits most; a project path named in its first prompt.
98
+ */
99
+ export function attribute(runs, state) {
100
+ const ms = matchers([...state.projects.values()]);
101
+ const byId = new Map(runs.map((r) => [r.id, r]));
102
+ for (const r of runs) {
103
+ const claim = claimFor(r.id, state);
104
+ if (claim) {
105
+ r.project = claim.project;
106
+ r.projectSource = "claim";
107
+ r.task = claim.task ?? r.task;
108
+ r.role = claim.role ?? r.role;
109
+ if (claim.title)
110
+ r.title = claim.title;
111
+ if (claim.kind === "scheduled")
112
+ r.scheduled = true;
113
+ // done until someone writes to the session again
114
+ if (claim.done && claim.at && (!r.lastPromptAt || r.lastPromptAt < claim.at)) {
115
+ r.done = claim.done;
116
+ if (r.state === "idle")
117
+ r.state = "done";
118
+ r.asks = undefined;
119
+ }
120
+ continue;
121
+ }
122
+ const here = projectForCwd(r.cwd, ms);
123
+ const edits = projectForEdits(r.touched, ms);
124
+ if (here && !r.parent && edits.size && !edits.has(here)) {
125
+ r.project = topOf(edits, r.touched, ms);
126
+ r.projectSource = "edits";
127
+ }
128
+ else if (here) {
129
+ r.project = here;
130
+ r.projectSource = "cwd";
131
+ }
132
+ }
133
+ // parents may themselves get a project only from a claim, so resolve children afterwards
134
+ for (let pass = 0; pass < 3; pass++) {
135
+ for (const r of runs) {
136
+ if (r.project || !r.parent)
137
+ continue;
138
+ const p = byId.get(r.parent);
139
+ const project = p?.project ?? claimFor(r.parent, state)?.project;
140
+ if (project) {
141
+ r.project = project;
142
+ r.projectSource = "parent";
143
+ }
144
+ }
145
+ }
146
+ for (const r of runs) {
147
+ if (r.project)
148
+ continue;
149
+ const edits = topOf(projectForEdits(r.touched, ms), r.touched, ms);
150
+ const prompt = edits ? undefined : projectForPrompt(r.prompt, ms);
151
+ r.project = edits ?? prompt;
152
+ if (r.project)
153
+ r.projectSource = edits ? "edits" : "prompt";
154
+ }
155
+ return runs;
156
+ }
157
+ /**
158
+ * Fold repeated runs of a scheduled task into the newest one, with a count. Runs claimed as
159
+ * scheduled group by project and task; unclaimed top-level sessions group by an identical first
160
+ * prompt. A run still working is never folded away.
161
+ */
162
+ export function foldRepeats(runs) {
163
+ const groups = new Map();
164
+ for (const r of runs) {
165
+ if (r.kind !== "session" || r.parent)
166
+ continue;
167
+ const key = r.scheduled ? `s:${r.project}:${r.task ?? r.title}` : r.projectSource === "claim" || !r.prompt ? undefined : `p:${r.prompt}`;
168
+ if (!key)
169
+ continue;
170
+ if (!groups.has(key))
171
+ groups.set(key, []);
172
+ groups.get(key).push(r);
173
+ }
174
+ const drop = new Set();
175
+ for (const g of groups.values()) {
176
+ if (g.length < 2)
177
+ continue;
178
+ g.sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
179
+ const [newest, ...older] = g;
180
+ let n = 0;
181
+ for (const r of older) {
182
+ if (r.state === "working" || r.state === "waiting" || r.asks)
183
+ continue;
184
+ drop.add(r);
185
+ n++;
186
+ }
187
+ if (n) {
188
+ newest.repeats = n;
189
+ newest.scheduled = true;
190
+ }
191
+ }
192
+ // children of a folded run go with it
193
+ const gone = new Set([...drop].map((r) => r.id));
194
+ return runs.filter((r) => !drop.has(r) && !(r.parent && gone.has(r.parent)));
195
+ }
196
+ /** Linked pull requests, put on the run that owns them. */
197
+ function attachPrs(runs, state) {
198
+ for (const link of state.prLinks.values()) {
199
+ if (!link.session)
200
+ continue;
201
+ const r = runs.find((x) => x.id.startsWith(link.session));
202
+ if (r)
203
+ (r.prs ??= []).push(link.pr.key);
204
+ }
205
+ }
206
+ /** A declared run is busy from its start until it ends (or now, while it runs). */
207
+ function declaredActivity(start, end, now) {
208
+ const from = Date.parse(start);
209
+ const to = end ? Date.parse(end) : now;
210
+ const times = [];
211
+ for (let t = Math.max(from, now - ACTIVITY_MINUTES * 60_000); t <= to; t += 60_000)
212
+ times.push(t);
213
+ if (to >= from)
214
+ times.push(to);
215
+ return activeMinutes(times, now);
216
+ }
217
+ /** When a declared run last showed life: its last beat or note, or its log file changing. */
218
+ function lastSign(d) {
219
+ if (!d.log)
220
+ return d.lastActivity;
221
+ try {
222
+ const m = new Date(fs.statSync(d.log).mtimeMs).toISOString();
223
+ return m > d.lastActivity ? m : d.lastActivity;
224
+ }
225
+ catch {
226
+ return d.lastActivity;
227
+ }
228
+ }
229
+ export function declaredRuns(state, now = Date.now()) {
230
+ return [...state.runs.values()].map((d) => {
231
+ const last = d.status === "running" ? lastSign(d) : d.lastActivity;
232
+ const quiet = d.status === "running" && d.heartbeat !== undefined && now - Date.parse(last) > d.heartbeat * 1000;
233
+ return {
234
+ id: d.id,
235
+ runtime: "declared",
236
+ kind: "declared",
237
+ title: d.title,
238
+ project: d.project,
239
+ task: d.task,
240
+ parent: d.parent,
241
+ startedAt: d.startedAt,
242
+ lastActivity: last,
243
+ state: quiet ? "stalled" : d.status === "running" ? "working" : d.status,
244
+ action: quiet ? `no sign of life for ${Math.round((now - Date.parse(last)) / 60_000)}m${d.note ? ` · ${d.note}` : ""}` : d.note,
245
+ projectSource: "claim",
246
+ activity: declaredActivity(d.startedAt, d.status === "running" ? undefined : (d.endedAt ?? d.lastActivity), now),
247
+ };
248
+ });
249
+ }
250
+ const ENDED = new Set(["done", "idle", "failed"]);
251
+ const RECENT_END_MS = 30 * 60_000;
252
+ const OLD_STALL_MS = 3 * 3_600_000;
253
+ export function collectRuns(state, q, now = Date.now()) {
254
+ const sinceMs = now - q.sinceHours * 3_600_000;
255
+ // a log file can be touched (title records, snapshots) long after its last real activity
256
+ const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
257
+ let runs = attribute(scanAll({ sinceMs, now }, q.runtimes), state).filter(recent);
258
+ runs.push(...declaredRuns(state, now).filter(recent));
259
+ attachPrs(runs, state);
260
+ if (q.project)
261
+ runs = runs.filter((r) => r.project === q.project);
262
+ if (!q.includeEnded) {
263
+ // keep what is moving, plus things that finished in the last half hour and parents of live children
264
+ const live = new Set(runs.filter((r) => !ENDED.has(r.state)).map((r) => r.parent).filter(Boolean));
265
+ runs = runs.filter((r) => {
266
+ // a run quiet for hours is almost always gone, not stuck; --all still shows it
267
+ if (r.state === "stalled")
268
+ return r.lastActivity ? now - Date.parse(r.lastActivity) < OLD_STALL_MS : true;
269
+ if (!ENDED.has(r.state))
270
+ return true;
271
+ if (r.asks)
272
+ return true; // still waiting for the human to read it
273
+ if (live.has(r.id))
274
+ return true;
275
+ if (r.role === "lead")
276
+ return true;
277
+ return r.lastActivity ? now - Date.parse(r.lastActivity) < RECENT_END_MS : false;
278
+ });
279
+ }
280
+ if (!q.keepRepeats)
281
+ runs = foldRepeats(runs);
282
+ return runs.sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
283
+ }
284
+ //# sourceMappingURL=runs.js.map