parawatch 0.2.1 → 0.3.2
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 +38 -5
- package/dist/bin/cli.js +69 -22
- package/dist/core/doctor.js +33 -10
- package/dist/core/follow.js +36 -49
- package/dist/core/format.js +28 -2
- package/dist/core/github.js +19 -2
- package/dist/core/integrate.js +13 -9
- package/dist/core/ops.js +170 -48
- package/dist/core/runs.js +109 -15
- package/dist/core/snapshot.js +5 -5
- package/dist/core/state.js +40 -9
- package/dist/core/store.js +204 -24
- package/dist/core/sync.js +317 -0
- package/dist/core/syncthing.js +121 -0
- package/dist/core/types.js +7 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +10 -3
package/dist/core/ops.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { UserError } from "./errors.js";
|
|
4
|
-
import { appendEvent,
|
|
5
|
-
import { fold, nextId } from "./state.js";
|
|
4
|
+
import { advance, appendEvent, deviceName, eventsDir, homeDir, isAfter, readCursor, readEvents, sharedDir, writeCursor } from "./store.js";
|
|
5
|
+
import { findId, fold, nextId } from "./state.js";
|
|
6
|
+
import { pathMap } from "./sync.js";
|
|
6
7
|
import { CARD_KINDS, HUMAN, PR_CHANGES } from "./types.js";
|
|
7
8
|
export { UserError };
|
|
9
|
+
/** Current state, with other machines' paths made local. */
|
|
10
|
+
export function readState(events = readEvents()) {
|
|
11
|
+
return fold(events, pathMap());
|
|
12
|
+
}
|
|
13
|
+
/** The machine name ids carry, when the log is shared. */
|
|
14
|
+
function idDevice() {
|
|
15
|
+
return sharedDir() ? deviceName() : undefined;
|
|
16
|
+
}
|
|
8
17
|
function cleanList(xs) {
|
|
9
18
|
const out = (xs ?? []).map((x) => x.trim()).filter(Boolean);
|
|
10
19
|
return out.length ? out : undefined;
|
|
@@ -22,7 +31,7 @@ export function addProject(name, paths) {
|
|
|
22
31
|
}));
|
|
23
32
|
}
|
|
24
33
|
export function removeProject(name) {
|
|
25
|
-
const s =
|
|
34
|
+
const s = readState();
|
|
26
35
|
if (!s.projects.has(name))
|
|
27
36
|
throw new UserError(`no project named "${name}"`);
|
|
28
37
|
appendEvent(() => ({ type: "project.remove", name }));
|
|
@@ -80,7 +89,7 @@ export function openCard(input) {
|
|
|
80
89
|
const since = input.since?.trim() ? parseWhen(input.since) : undefined;
|
|
81
90
|
let id = "";
|
|
82
91
|
appendEvent((events) => {
|
|
83
|
-
id = nextId(events, "c");
|
|
92
|
+
id = nextId(events, "c", idDevice());
|
|
84
93
|
const session = input.session?.trim() || undefined;
|
|
85
94
|
const from = input.from?.trim() || sessionTitle(fold(events).claims, session);
|
|
86
95
|
return {
|
|
@@ -105,19 +114,31 @@ export function openCard(input) {
|
|
|
105
114
|
},
|
|
106
115
|
};
|
|
107
116
|
});
|
|
108
|
-
const card =
|
|
117
|
+
const card = readState().cards.get(id);
|
|
109
118
|
if (!card)
|
|
110
119
|
throw new Error("card vanished right after it was written");
|
|
111
120
|
return card;
|
|
112
121
|
}
|
|
122
|
+
/** A card by id; a shared id may leave out its machine (`c3` for `pc-c3`). */
|
|
123
|
+
export function findCard(state, id) {
|
|
124
|
+
const k = findId(state.cards, id);
|
|
125
|
+
return k ? state.cards.get(k) : undefined;
|
|
126
|
+
}
|
|
113
127
|
function requireCard(id) {
|
|
114
|
-
const card =
|
|
128
|
+
const card = findCard(readState(), id);
|
|
115
129
|
if (!card)
|
|
116
130
|
throw new UserError(`no card "${id}"`);
|
|
117
131
|
return card;
|
|
118
132
|
}
|
|
133
|
+
function requireRun(id) {
|
|
134
|
+
const k = findId(readState().runs, id);
|
|
135
|
+
if (!k)
|
|
136
|
+
throw new UserError(`no declared run "${id}"`);
|
|
137
|
+
return k;
|
|
138
|
+
}
|
|
119
139
|
export function answerCard(id, a) {
|
|
120
140
|
const card = requireCard(id);
|
|
141
|
+
id = card.id;
|
|
121
142
|
if (card.status === "closed")
|
|
122
143
|
throw new UserError(`card ${id} is already closed`);
|
|
123
144
|
if (a.choice === undefined && !a.comment?.trim() && !cleanList(a.attach)) {
|
|
@@ -129,23 +150,26 @@ export function answerCard(id, a) {
|
|
|
129
150
|
throw new UserError(n ? `--choice must be between 1 and ${n}` : `card ${id} has no options`);
|
|
130
151
|
}
|
|
131
152
|
}
|
|
132
|
-
|
|
153
|
+
const shared = !!sharedDir();
|
|
154
|
+
appendEvent((events) => ({
|
|
133
155
|
type: "card.answer",
|
|
134
156
|
card: id,
|
|
135
157
|
by: a.by || HUMAN,
|
|
136
158
|
choice: a.choice,
|
|
137
159
|
comment: a.comment?.trim() || undefined,
|
|
138
160
|
attach: absPaths(a.attach),
|
|
161
|
+
// what this machine knew when answering, so a crossing answer from another machine shows
|
|
162
|
+
seen: shared ? events.filter((e) => e.type === "card.answer" && e.card === id).length : undefined,
|
|
139
163
|
}));
|
|
140
164
|
}
|
|
141
165
|
export function commentCard(id, text, by) {
|
|
142
|
-
requireCard(id);
|
|
166
|
+
id = requireCard(id).id;
|
|
143
167
|
if (!text.trim())
|
|
144
168
|
throw new UserError("a comment needs text");
|
|
145
169
|
appendEvent(() => ({ type: "card.comment", card: id, by, text: text.trim() }));
|
|
146
170
|
}
|
|
147
171
|
export function editCard(id, set) {
|
|
148
|
-
requireCard(id);
|
|
172
|
+
id = requireCard(id).id;
|
|
149
173
|
const clean = {};
|
|
150
174
|
if (set.kind !== undefined)
|
|
151
175
|
clean.kind = checkKind(set.kind);
|
|
@@ -175,7 +199,7 @@ export function closeCard(id, note) {
|
|
|
175
199
|
const card = requireCard(id);
|
|
176
200
|
if (card.status === "closed")
|
|
177
201
|
return;
|
|
178
|
-
appendEvent(() => ({ type: "card.close", card: id, note: note?.trim() || undefined }));
|
|
202
|
+
appendEvent(() => ({ type: "card.close", card: card.id, note: note?.trim() || undefined }));
|
|
179
203
|
}
|
|
180
204
|
export function startRun(r) {
|
|
181
205
|
if (r.heartbeat !== undefined && (!Number.isFinite(r.heartbeat) || r.heartbeat <= 0))
|
|
@@ -183,25 +207,22 @@ export function startRun(r) {
|
|
|
183
207
|
const log = r.log?.trim() ? path.resolve(r.log) : undefined;
|
|
184
208
|
let id = "";
|
|
185
209
|
appendEvent((events) => {
|
|
186
|
-
id = nextId(events, "r");
|
|
210
|
+
id = nextId(events, "r", idDevice());
|
|
187
211
|
return { type: "run.start", run: { id, project: r.project, task: r.task, title: r.title, parent: r.parent, heartbeat: r.heartbeat, log } };
|
|
188
212
|
});
|
|
189
213
|
return id;
|
|
190
214
|
}
|
|
191
215
|
export function noteRun(id, text) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
appendEvent(() => ({ type: "run.note", run: id, text }));
|
|
216
|
+
const run = requireRun(id);
|
|
217
|
+
appendEvent(() => ({ type: "run.note", run, text }));
|
|
195
218
|
}
|
|
196
219
|
export function beatRun(id) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
appendEvent(() => ({ type: "run.beat", run: id }));
|
|
220
|
+
const run = requireRun(id);
|
|
221
|
+
appendEvent(() => ({ type: "run.beat", run }));
|
|
200
222
|
}
|
|
201
223
|
export function endRun(id, status, note) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
appendEvent(() => ({ type: "run.end", run: id, status, note }));
|
|
224
|
+
const run = requireRun(id);
|
|
225
|
+
appendEvent(() => ({ type: "run.end", run, status, note }));
|
|
205
226
|
}
|
|
206
227
|
/**
|
|
207
228
|
* Say which project and task a session works on. A claim without a project keeps the session's
|
|
@@ -210,7 +231,7 @@ export function endRun(id, status, note) {
|
|
|
210
231
|
export function claimSession(input) {
|
|
211
232
|
if (input.session.length < 6)
|
|
212
233
|
throw new UserError("give at least 6 characters of the session id");
|
|
213
|
-
const before =
|
|
234
|
+
const before = readState().claims.find((c) => c.session === input.session);
|
|
214
235
|
const project = input.project?.trim() || before?.project;
|
|
215
236
|
if (!project)
|
|
216
237
|
throw new UserError("this session has not claimed a project yet: give --project");
|
|
@@ -233,7 +254,7 @@ export function claimSession(input) {
|
|
|
233
254
|
}
|
|
234
255
|
// ---------------------------------------------------------------- pull requests
|
|
235
256
|
export function linkPr(link) {
|
|
236
|
-
const claims =
|
|
257
|
+
const claims = readState().claims;
|
|
237
258
|
const claim = link.session ? claims.find((c) => link.session.startsWith(c.session)) : undefined;
|
|
238
259
|
const project = link.project?.trim() || claim?.project;
|
|
239
260
|
appendEvent(() => ({
|
|
@@ -242,7 +263,7 @@ export function linkPr(link) {
|
|
|
242
263
|
}));
|
|
243
264
|
}
|
|
244
265
|
export function unlinkPr(key) {
|
|
245
|
-
if (!
|
|
266
|
+
if (!readState().prLinks.has(key))
|
|
246
267
|
throw new UserError(`no linked pull request ${key}`);
|
|
247
268
|
appendEvent(() => ({ type: "pr.unlink", pr: key }));
|
|
248
269
|
}
|
|
@@ -252,37 +273,67 @@ export function prEvent(pr, change, opts = {}) {
|
|
|
252
273
|
throw new UserError(`change must be one of ${PR_CHANGES.join(", ")}`);
|
|
253
274
|
appendEvent(() => ({ type: "pr.change", pr, change: change, by: opts.by, text: opts.text?.trim() || undefined, title: opts.title, source: "external" }));
|
|
254
275
|
}
|
|
276
|
+
/** Whether two session ids name the same session. A claim or a link may hold a short prefix. */
|
|
277
|
+
export function sameSession(a, b) {
|
|
278
|
+
return !!a && !!b && (a.startsWith(b) || b.startsWith(a));
|
|
279
|
+
}
|
|
280
|
+
/** Whether something owned by `owner` in `project` is for a reader with this filter. */
|
|
281
|
+
function inScope(owner, project, f) {
|
|
282
|
+
if (f.session)
|
|
283
|
+
return owner ? sameSession(owner, f.session) : !!f.project && project === f.project;
|
|
284
|
+
return !f.project || project === f.project;
|
|
285
|
+
}
|
|
286
|
+
/** Two machines that both read GitHub report the same change; the later report is an echo. */
|
|
287
|
+
const ECHO_MS = 30 * 60_000;
|
|
255
288
|
/**
|
|
256
|
-
* Human answers and comments
|
|
257
|
-
* requests linked to the project.
|
|
289
|
+
* Human answers and comments after `after` (a position, or a seq on a single machine), on cards
|
|
290
|
+
* that are not closed, and news on pull requests linked to the project. An answer given on
|
|
291
|
+
* another machine before it saw an earlier answer is kept on the card but not delivered.
|
|
258
292
|
*/
|
|
259
|
-
export function inboxFrom(events,
|
|
260
|
-
const
|
|
293
|
+
export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
294
|
+
const pos = typeof after === "number" ? { [deviceName()]: after } : after;
|
|
261
295
|
const out = [];
|
|
296
|
+
const reported = new Map();
|
|
297
|
+
const answerNo = new Map();
|
|
262
298
|
for (const ev of events) {
|
|
263
|
-
if (ev.seq <= afterSeq)
|
|
264
|
-
continue;
|
|
265
299
|
if (ev.type === "pr.change") {
|
|
300
|
+
const k = `${ev.pr.key}|${ev.change}|${ev.text ?? ""}`;
|
|
301
|
+
const prev = reported.get(k);
|
|
302
|
+
const at = Date.parse(ev.ts);
|
|
303
|
+
reported.set(k, { dev: ev.dev, at });
|
|
304
|
+
if (prev && prev.dev !== ev.dev && at - prev.at < ECHO_MS)
|
|
305
|
+
continue;
|
|
306
|
+
if (!isAfter(ev, pos))
|
|
307
|
+
continue;
|
|
266
308
|
const link = state.prLinks.get(ev.pr.key);
|
|
267
|
-
if (!link || filter.card || (
|
|
309
|
+
if (!link || filter.card || !inScope(link.session, link.project, filter))
|
|
268
310
|
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 } });
|
|
311
|
+
out.push({ seq: ev.seq, dev: ev.dev, 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
312
|
continue;
|
|
271
313
|
}
|
|
272
314
|
if (ev.type !== "card.answer" && ev.type !== "card.comment")
|
|
273
315
|
continue;
|
|
274
|
-
|
|
316
|
+
let late = false;
|
|
317
|
+
if (ev.type === "card.answer") {
|
|
318
|
+
const n = answerNo.get(ev.card) ?? 0;
|
|
319
|
+
answerNo.set(ev.card, n + 1);
|
|
320
|
+
late = typeof ev.seen === "number" && ev.seen < n;
|
|
321
|
+
}
|
|
322
|
+
if (!isAfter(ev, pos))
|
|
323
|
+
continue;
|
|
324
|
+
if (ev.by !== HUMAN || late)
|
|
275
325
|
continue;
|
|
276
326
|
const card = state.cards.get(ev.card);
|
|
277
327
|
if (!card || card.status === "closed")
|
|
278
328
|
continue;
|
|
279
|
-
if (
|
|
329
|
+
if (!inScope(card.session, card.project, filter))
|
|
280
330
|
continue;
|
|
281
331
|
if (filter.card && card.id !== filter.card)
|
|
282
332
|
continue;
|
|
283
333
|
if (ev.type === "card.answer") {
|
|
284
334
|
out.push({
|
|
285
335
|
seq: ev.seq,
|
|
336
|
+
dev: ev.dev,
|
|
286
337
|
at: ev.ts,
|
|
287
338
|
kind: "answer",
|
|
288
339
|
card,
|
|
@@ -294,26 +345,50 @@ export function inboxFrom(events, afterSeq, filter) {
|
|
|
294
345
|
});
|
|
295
346
|
}
|
|
296
347
|
else {
|
|
297
|
-
out.push({ seq: ev.seq, at: ev.ts, kind: "comment", card, by: ev.by, comment: ev.text });
|
|
348
|
+
out.push({ seq: ev.seq, dev: ev.dev, at: ev.ts, kind: "comment", card, by: ev.by, comment: ev.text });
|
|
298
349
|
}
|
|
299
350
|
}
|
|
300
351
|
return out;
|
|
301
352
|
}
|
|
302
353
|
// ---------------------------------------------------------------- listeners
|
|
354
|
+
/** Waits are visible to every machine when the log is shared. */
|
|
303
355
|
function listenersDir() {
|
|
304
|
-
return path.join(homeDir(), "listeners");
|
|
356
|
+
return path.join(sharedDir() ?? homeDir(), "listeners");
|
|
305
357
|
}
|
|
358
|
+
/** How often a wait says it is still there, and when one on another machine counts as gone. */
|
|
359
|
+
const LISTENER_BEAT_MS = 60_000;
|
|
360
|
+
const LISTENER_GONE_MS = 3 * LISTENER_BEAT_MS;
|
|
306
361
|
/** Mark this process as waiting, so cards can say whether an answer reaches an agent at once. */
|
|
307
362
|
export function startListening(l) {
|
|
308
|
-
const
|
|
363
|
+
const shared = !!sharedDir();
|
|
364
|
+
const file = path.join(listenersDir(), shared ? `${deviceName()}-${process.pid}.json` : `${process.pid}.json`);
|
|
309
365
|
fs.mkdirSync(listenersDir(), { recursive: true });
|
|
310
|
-
const me = { ...l, pid: process.pid, since: new Date().toISOString() };
|
|
311
|
-
|
|
366
|
+
const me = { ...l, pid: process.pid, since: new Date().toISOString(), ...(shared ? { device: deviceName(), beat: new Date().toISOString() } : {}) };
|
|
367
|
+
const write = () => {
|
|
368
|
+
try {
|
|
369
|
+
const tmp = `${file}.tmp`;
|
|
370
|
+
fs.writeFileSync(tmp, JSON.stringify(me) + "\n");
|
|
371
|
+
fs.renameSync(tmp, file);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// the folder may be busy syncing; the next beat writes it again
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
write();
|
|
378
|
+
const timer = shared
|
|
379
|
+
? setInterval(() => {
|
|
380
|
+
me.beat = new Date().toISOString();
|
|
381
|
+
write();
|
|
382
|
+
}, LISTENER_BEAT_MS)
|
|
383
|
+
: undefined;
|
|
384
|
+
timer?.unref();
|
|
312
385
|
let done = false;
|
|
313
386
|
const stop = () => {
|
|
314
387
|
if (done)
|
|
315
388
|
return;
|
|
316
389
|
done = true;
|
|
390
|
+
if (timer)
|
|
391
|
+
clearInterval(timer);
|
|
317
392
|
fs.rmSync(file, { force: true });
|
|
318
393
|
};
|
|
319
394
|
process.once("exit", stop);
|
|
@@ -334,8 +409,11 @@ function alive(pid) {
|
|
|
334
409
|
return err.code === "EPERM";
|
|
335
410
|
}
|
|
336
411
|
}
|
|
337
|
-
/**
|
|
338
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Waits running now, on this machine and (when shared) the others. Files left by a process that
|
|
414
|
+
* died here are removed; a wait on another machine counts while its beat is fresh.
|
|
415
|
+
*/
|
|
416
|
+
export function listeners(now = Date.now()) {
|
|
339
417
|
let names;
|
|
340
418
|
try {
|
|
341
419
|
names = fs.readdirSync(listenersDir()).filter((f) => f.endsWith(".json"));
|
|
@@ -343,12 +421,17 @@ export function listeners() {
|
|
|
343
421
|
catch {
|
|
344
422
|
return [];
|
|
345
423
|
}
|
|
424
|
+
const me = deviceName();
|
|
346
425
|
const out = [];
|
|
347
426
|
for (const n of names) {
|
|
348
427
|
const file = path.join(listenersDir(), n);
|
|
349
428
|
try {
|
|
350
429
|
const l = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
351
|
-
if (
|
|
430
|
+
if (l.device && l.device !== me) {
|
|
431
|
+
if (l.beat && now - Date.parse(l.beat) < LISTENER_GONE_MS)
|
|
432
|
+
out.push(l);
|
|
433
|
+
}
|
|
434
|
+
else if (alive(l.pid))
|
|
352
435
|
out.push(l);
|
|
353
436
|
else
|
|
354
437
|
fs.rmSync(file, { force: true });
|
|
@@ -361,17 +444,56 @@ export function listeners() {
|
|
|
361
444
|
}
|
|
362
445
|
/** The waits an answer to this card would wake right now. */
|
|
363
446
|
export function listenersFor(card, ls) {
|
|
364
|
-
return ls.filter((l) =>
|
|
447
|
+
return ls.filter((l) => l.card
|
|
448
|
+
? l.card === card.id
|
|
449
|
+
: l.scope === "session"
|
|
450
|
+
? card.session
|
|
451
|
+
? sameSession(l.session, card.session)
|
|
452
|
+
: !!l.project && l.project === card.project
|
|
453
|
+
: !l.project || l.project === card.project);
|
|
365
454
|
}
|
|
366
455
|
export function consumerName(opts) {
|
|
367
456
|
return opts.as || opts.project || (opts.card ? `card-${opts.card}` : "default");
|
|
368
457
|
}
|
|
369
|
-
|
|
458
|
+
/** The cursor a session reads with when it waits for its own news. */
|
|
459
|
+
export function sessionConsumer(session) {
|
|
460
|
+
return `session-${session.replace(/[^A-Za-z0-9._-]/g, "_")}`;
|
|
461
|
+
}
|
|
462
|
+
/** Per device, the further of two positions. */
|
|
463
|
+
export function furthest(a, b) {
|
|
464
|
+
const out = { ...a };
|
|
465
|
+
for (const [d, seq] of Object.entries(b))
|
|
466
|
+
if (seq > (out[d] ?? 0))
|
|
467
|
+
out[d] = seq;
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* What a wait or an inbox reads. `--card` and `--project` read what they name, with a cursor of
|
|
472
|
+
* that name. Called from an agent session without either, it reads that session's own cards and
|
|
473
|
+
* pull requests, plus unowned ones in the project the session claimed, with a cursor of its own:
|
|
474
|
+
* sessions in one project neither wake for each other's news nor take it from each other.
|
|
475
|
+
* Outside a session, it reads everything.
|
|
476
|
+
*/
|
|
477
|
+
export function inboxScope(opts, session) {
|
|
478
|
+
if (opts.card || opts.project || !session)
|
|
479
|
+
return { consumer: consumerName(opts), filter: { project: opts.project, card: opts.card } };
|
|
480
|
+
const claim = readState().claims.find((c) => sameSession(session, c.session));
|
|
481
|
+
const consumer = opts.as || sessionConsumer(session);
|
|
482
|
+
// A session that used to wait on its project starts where that wait stopped, so moving to its
|
|
483
|
+
// own cursor does not bring back news it has already handled.
|
|
484
|
+
if (claim && !Object.keys(readCursor(consumer)).length) {
|
|
485
|
+
const from = readCursor(claim.project);
|
|
486
|
+
if (Object.keys(from).length)
|
|
487
|
+
writeCursor(consumer, from);
|
|
488
|
+
}
|
|
489
|
+
return { consumer, filter: { session, project: claim?.project } };
|
|
490
|
+
}
|
|
491
|
+
export function readInbox(consumer, filter, move) {
|
|
370
492
|
const events = readEvents();
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
374
|
-
writeCursor(consumer,
|
|
493
|
+
const pos = readCursor(consumer);
|
|
494
|
+
const items = inboxFrom(events, pos, filter);
|
|
495
|
+
if (move && items.length)
|
|
496
|
+
writeCursor(consumer, advance(pos, items));
|
|
375
497
|
return items;
|
|
376
498
|
}
|
|
377
499
|
/**
|
|
@@ -383,7 +505,7 @@ export async function waitInbox(consumer, filter, timeoutMs, pollMs = 1000) {
|
|
|
383
505
|
let watcher;
|
|
384
506
|
let wake;
|
|
385
507
|
try {
|
|
386
|
-
const dir =
|
|
508
|
+
const dir = eventsDir();
|
|
387
509
|
fs.mkdirSync(dir, { recursive: true });
|
|
388
510
|
watcher = fs.watch(dir, () => wake?.());
|
|
389
511
|
}
|
package/dist/core/runs.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { scanAll } from "../adapters/index.js";
|
|
5
5
|
import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
|
|
6
|
+
import { deviceName, sharedDir } from "./store.js";
|
|
6
7
|
/** All checkouts of a project path: the path itself plus its git worktrees. */
|
|
7
8
|
function rootsOf(p, cache) {
|
|
8
9
|
const hit = cache.get(p);
|
|
@@ -96,27 +97,30 @@ function claimFor(id, state) {
|
|
|
96
97
|
* unless it only edits files of another project; its parent's project; the project whose files it
|
|
97
98
|
* edits most; a project path named in its first prompt.
|
|
98
99
|
*/
|
|
100
|
+
function applyClaim(r, claim) {
|
|
101
|
+
r.project = claim.project;
|
|
102
|
+
r.projectSource = "claim";
|
|
103
|
+
r.task = claim.task ?? r.task;
|
|
104
|
+
r.role = claim.role ?? r.role;
|
|
105
|
+
if (claim.title)
|
|
106
|
+
r.title = claim.title;
|
|
107
|
+
if (claim.kind === "scheduled")
|
|
108
|
+
r.scheduled = true;
|
|
109
|
+
// done until someone writes to the session again
|
|
110
|
+
if (claim.done && claim.at && (!r.lastPromptAt || r.lastPromptAt < claim.at)) {
|
|
111
|
+
r.done = claim.done;
|
|
112
|
+
if (r.state === "idle")
|
|
113
|
+
r.state = "done";
|
|
114
|
+
r.asks = undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
99
117
|
export function attribute(runs, state) {
|
|
100
118
|
const ms = matchers([...state.projects.values()]);
|
|
101
119
|
const byId = new Map(runs.map((r) => [r.id, r]));
|
|
102
120
|
for (const r of runs) {
|
|
103
121
|
const claim = claimFor(r.id, state);
|
|
104
122
|
if (claim) {
|
|
105
|
-
r
|
|
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
|
-
}
|
|
123
|
+
applyClaim(r, claim);
|
|
120
124
|
continue;
|
|
121
125
|
}
|
|
122
126
|
const here = projectForCwd(r.cwd, ms);
|
|
@@ -247,6 +251,92 @@ export function declaredRuns(state, now = Date.now()) {
|
|
|
247
251
|
};
|
|
248
252
|
});
|
|
249
253
|
}
|
|
254
|
+
// ---------------------------------------------------------------- other machines
|
|
255
|
+
/** How often a machine rewrites its runs for the others, and when its summary counts as gone. */
|
|
256
|
+
const SHARE_EVERY_MS = 30_000;
|
|
257
|
+
export const SHARED_RUNS_GONE_MS = 15 * 60_000;
|
|
258
|
+
const SHARED_RUNS_FILE = /^([a-z0-9][a-z0-9-]{0,31})\.json$/;
|
|
259
|
+
function sharedRunsDir() {
|
|
260
|
+
const s = sharedDir();
|
|
261
|
+
return s ? path.join(s, "runs") : undefined;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Write the runs read from this machine's agent logs where the other machines read them, at most
|
|
265
|
+
* every half minute. The logs themselves stay here; only the summary travels.
|
|
266
|
+
*/
|
|
267
|
+
export function shareRuns(runs, now = Date.now()) {
|
|
268
|
+
if (!shareRunsDue(now))
|
|
269
|
+
return;
|
|
270
|
+
const dir = sharedRunsDir();
|
|
271
|
+
const me = deviceName();
|
|
272
|
+
const file = path.join(dir, `${me}.json`);
|
|
273
|
+
// the folders it edited are paths on this machine, and only guide attribution here
|
|
274
|
+
const slim = runs.map(({ touched: _t, ...r }) => ({ ...r, prompt: r.prompt?.slice(0, 300) }));
|
|
275
|
+
const body = { v: 1, device: me, at: new Date(now).toISOString(), runs: slim };
|
|
276
|
+
try {
|
|
277
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
278
|
+
const tmp = path.join(dir, `.${me}.${process.pid}.tmp`);
|
|
279
|
+
fs.writeFileSync(tmp, JSON.stringify(body) + "\n");
|
|
280
|
+
fs.renameSync(tmp, file);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// the folder may be busy syncing; the next reading writes it again
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** Whether this machine's summary for the others is due (a shared folder, and none written lately). */
|
|
287
|
+
export function shareRunsDue(now = Date.now()) {
|
|
288
|
+
const dir = sharedRunsDir();
|
|
289
|
+
if (!dir)
|
|
290
|
+
return false;
|
|
291
|
+
try {
|
|
292
|
+
return now - fs.statSync(path.join(dir, `${deviceName()}.json`)).mtimeMs >= SHARE_EVERY_MS;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Runs other machines shared, as they last wrote them, marked with the machine. A machine that
|
|
300
|
+
* has not written for a while is left out: its agents may be long gone. Claims made since are
|
|
301
|
+
* applied here, and the activity chart slides by the summary's age.
|
|
302
|
+
*/
|
|
303
|
+
export function otherRuns(state, now = Date.now()) {
|
|
304
|
+
const dir = sharedRunsDir();
|
|
305
|
+
if (!dir)
|
|
306
|
+
return [];
|
|
307
|
+
const me = deviceName();
|
|
308
|
+
let names = [];
|
|
309
|
+
try {
|
|
310
|
+
names = fs.readdirSync(dir);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
const out = [];
|
|
316
|
+
for (const n of names.sort()) {
|
|
317
|
+
const m = SHARED_RUNS_FILE.exec(n);
|
|
318
|
+
if (!m || m[1] === me)
|
|
319
|
+
continue;
|
|
320
|
+
try {
|
|
321
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, n), "utf8"));
|
|
322
|
+
const at = Date.parse(s.at);
|
|
323
|
+
if (!Number.isFinite(at) || now - at > SHARED_RUNS_GONE_MS || !Array.isArray(s.runs))
|
|
324
|
+
continue;
|
|
325
|
+
const shift = Math.max(0, Math.floor((now - at) / 60_000));
|
|
326
|
+
for (const r of s.runs) {
|
|
327
|
+
const run = { ...r, device: m[1], activity: r.activity?.map((x) => x + shift).filter((x) => x < ACTIVITY_MINUTES) };
|
|
328
|
+
const claim = claimFor(run.id, state);
|
|
329
|
+
if (claim)
|
|
330
|
+
applyClaim(run, claim);
|
|
331
|
+
out.push(run);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// half synced; read it next time
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
250
340
|
const ENDED = new Set(["done", "idle", "failed"]);
|
|
251
341
|
const RECENT_END_MS = 30 * 60_000;
|
|
252
342
|
const OLD_STALL_MS = 3 * 3_600_000;
|
|
@@ -255,6 +345,10 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
255
345
|
// a log file can be touched (title records, snapshots) long after its last real activity
|
|
256
346
|
const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
|
|
257
347
|
let runs = attribute(scanAll({ sinceMs, now }, q.runtimes), state).filter(recent);
|
|
348
|
+
// a narrower reading (one runtime) would hide the rest from the other machines
|
|
349
|
+
if (!q.runtimes)
|
|
350
|
+
shareRuns(runs, now);
|
|
351
|
+
runs.push(...otherRuns(state, now).filter(recent));
|
|
258
352
|
runs.push(...declaredRuns(state, now).filter(recent));
|
|
259
353
|
attachPrs(runs, state);
|
|
260
354
|
if (q.project)
|
package/dist/core/snapshot.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { prViews, keyOf, parsePrRef, readCache, githubConfig } from "./github.js";
|
|
2
|
-
import { linkPr, listeners } from "./ops.js";
|
|
2
|
+
import { linkPr, listeners, readState } from "./ops.js";
|
|
3
3
|
import { collectRuns } from "./runs.js";
|
|
4
|
-
import {
|
|
5
|
-
import { readEvents } from "./store.js";
|
|
4
|
+
import { deviceName, eventFiles, sharedDir } from "./store.js";
|
|
6
5
|
import { SCHEMA_VERSION } from "./types.js";
|
|
7
6
|
/**
|
|
8
7
|
* `closed`: also carry the most recently closed cards, newest first, so a panel can show them
|
|
@@ -11,10 +10,10 @@ import { SCHEMA_VERSION } from "./types.js";
|
|
|
11
10
|
* GitHub is read from the last reading only; `syncGithub` refreshes it.
|
|
12
11
|
*/
|
|
13
12
|
export function snapshot(opts = {}) {
|
|
14
|
-
let state =
|
|
13
|
+
let state = readState();
|
|
15
14
|
const runs = collectRuns(state, { sinceHours: opts.sinceHours ?? 6, project: opts.project, keepRepeats: opts.keepRepeats });
|
|
16
15
|
if (linkCreatedPrs(runs, state))
|
|
17
|
-
state =
|
|
16
|
+
state = readState();
|
|
18
17
|
const all = [...state.cards.values()].filter((c) => !opts.project || c.project === opts.project);
|
|
19
18
|
const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)];
|
|
20
19
|
quietAnsweredAsks(runs, all);
|
|
@@ -32,6 +31,7 @@ export function snapshot(opts = {}) {
|
|
|
32
31
|
prs,
|
|
33
32
|
github: { enabled, fetchedAt: cache.fetchedAt, error: cache.error },
|
|
34
33
|
listeners: listeners(),
|
|
34
|
+
devices: sharedDir() ? { me: deviceName(), others: eventFiles().map((f) => f.device).filter((d) => d !== deviceName()) } : undefined,
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
/** A pull request a session's log shows it opened belongs to that session, unless linked already. */
|
package/dist/core/state.js
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
function mapAll(xs, map, from) {
|
|
2
|
+
return xs && map ? xs.map((x) => map(x, from)) : xs;
|
|
3
|
+
}
|
|
4
|
+
/** Rebuild current state from the event log. `map` makes other machines' paths local. */
|
|
5
|
+
export function fold(events, map) {
|
|
3
6
|
const s = { projects: new Map(), cards: new Map(), runs: new Map(), claims: [], prLinks: new Map(), lastSeq: 0 };
|
|
4
7
|
for (const ev of events) {
|
|
5
8
|
s.lastSeq = Math.max(s.lastSeq, ev.seq);
|
|
6
9
|
switch (ev.type) {
|
|
7
10
|
case "project.add":
|
|
8
|
-
s.projects.set(ev.project.name, ev.project);
|
|
11
|
+
s.projects.set(ev.project.name, { ...ev.project, paths: mapAll(ev.project.paths, map, ev.dev) ?? [] });
|
|
9
12
|
break;
|
|
10
13
|
case "project.remove":
|
|
11
14
|
s.projects.delete(ev.name);
|
|
12
15
|
break;
|
|
13
|
-
case "card.open":
|
|
14
|
-
|
|
16
|
+
case "card.open": {
|
|
17
|
+
const card = { ...ev.card, openedAt: ev.ts, status: "open", answers: [], comments: [] };
|
|
18
|
+
if (ev.v >= 2 && ev.dev)
|
|
19
|
+
card.dev = ev.dev;
|
|
20
|
+
card.attach = mapAll(card.attach, map, ev.dev);
|
|
21
|
+
card.open = mapAll(card.open, map, ev.dev);
|
|
22
|
+
s.cards.set(ev.card.id, card);
|
|
15
23
|
break;
|
|
24
|
+
}
|
|
16
25
|
case "card.answer": {
|
|
17
26
|
const c = s.cards.get(ev.card);
|
|
18
27
|
if (!c)
|
|
19
28
|
break;
|
|
20
|
-
|
|
29
|
+
const a = { seq: ev.seq, at: ev.ts, by: ev.by, choice: ev.choice, comment: ev.comment, attach: mapAll(ev.attach, map, ev.dev) };
|
|
30
|
+
if (ev.v >= 2 && ev.dev)
|
|
31
|
+
a.dev = ev.dev;
|
|
32
|
+
// written on another machine before that machine saw an earlier answer: the earlier one stands
|
|
33
|
+
if (typeof ev.seen === "number" && ev.seen < c.answers.length)
|
|
34
|
+
a.late = true;
|
|
35
|
+
c.answers.push(a);
|
|
21
36
|
if (c.status === "open")
|
|
22
37
|
c.status = "answered";
|
|
23
38
|
break;
|
|
@@ -93,15 +108,31 @@ export function fold(events) {
|
|
|
93
108
|
}
|
|
94
109
|
return s;
|
|
95
110
|
}
|
|
96
|
-
/**
|
|
97
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Next id with a prefix, counting the events that created that kind of thing. In a shared folder
|
|
113
|
+
* (`device` given) only that machine's own log counts and the id carries the machine's name, so
|
|
114
|
+
* two machines never pick the same id: `pc-c3`.
|
|
115
|
+
*/
|
|
116
|
+
export function nextId(events, prefix, device) {
|
|
98
117
|
let n = 0;
|
|
99
118
|
for (const ev of events) {
|
|
119
|
+
if (device && ev.dev !== device)
|
|
120
|
+
continue;
|
|
100
121
|
if (prefix === "c" && ev.type === "card.open")
|
|
101
122
|
n++;
|
|
102
123
|
if (prefix === "r" && ev.type === "run.start")
|
|
103
124
|
n++;
|
|
104
125
|
}
|
|
105
|
-
return `${prefix}${n + 1}`;
|
|
126
|
+
return device ? `${device}-${prefix}${n + 1}` : `${prefix}${n + 1}`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Find a card or run by id. A shared id may be given without its machine (`c3` for `pc-c3`) as
|
|
130
|
+
* long as only one machine has it; an exact id always wins.
|
|
131
|
+
*/
|
|
132
|
+
export function findId(map, id) {
|
|
133
|
+
if (map.has(id))
|
|
134
|
+
return id;
|
|
135
|
+
const hits = [...map.keys()].filter((k) => k.endsWith(`-${id}`));
|
|
136
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
106
137
|
}
|
|
107
138
|
//# sourceMappingURL=state.js.map
|