parawatch 0.2.1 → 0.3.1

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/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, eventsPath, homeDir, readCursor, readEvents, writeCursor } from "./store.js";
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 = fold(readEvents());
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 = fold(readEvents()).cards.get(id);
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 = fold(readEvents()).cards.get(id);
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
- appendEvent(() => ({
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
- if (!fold(readEvents()).runs.has(id))
193
- throw new UserError(`no declared run "${id}"`);
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
- if (!fold(readEvents()).runs.has(id))
198
- throw new UserError(`no declared run "${id}"`);
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
- if (!fold(readEvents()).runs.has(id))
203
- throw new UserError(`no declared run "${id}"`);
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 = fold(readEvents()).claims.find((c) => c.session === input.session);
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 = fold(readEvents()).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 (!fold(readEvents()).prLinks.has(key))
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,26 +273,45 @@ 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
+ /** Two machines that both read GitHub report the same change; the later report is an echo. */
277
+ const ECHO_MS = 30 * 60_000;
255
278
  /**
256
- * Human answers and comments newer than `afterSeq`, on cards that are not closed, and news on pull
257
- * requests linked to the project.
279
+ * Human answers and comments after `after` (a position, or a seq on a single machine), on cards
280
+ * that are not closed, and news on pull requests linked to the project. An answer given on
281
+ * another machine before it saw an earlier answer is kept on the card but not delivered.
258
282
  */
259
- export function inboxFrom(events, afterSeq, filter) {
260
- const state = fold(events);
283
+ export function inboxFrom(events, after, filter, state = readState(events)) {
284
+ const pos = typeof after === "number" ? { [deviceName()]: after } : after;
261
285
  const out = [];
286
+ const reported = new Map();
287
+ const answerNo = new Map();
262
288
  for (const ev of events) {
263
- if (ev.seq <= afterSeq)
264
- continue;
265
289
  if (ev.type === "pr.change") {
290
+ const k = `${ev.pr.key}|${ev.change}|${ev.text ?? ""}`;
291
+ const prev = reported.get(k);
292
+ const at = Date.parse(ev.ts);
293
+ reported.set(k, { dev: ev.dev, at });
294
+ if (prev && prev.dev !== ev.dev && at - prev.at < ECHO_MS)
295
+ continue;
296
+ if (!isAfter(ev, pos))
297
+ continue;
266
298
  const link = state.prLinks.get(ev.pr.key);
267
299
  if (!link || filter.card || (filter.project && link.project !== filter.project))
268
300
  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 } });
301
+ 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
302
  continue;
271
303
  }
272
304
  if (ev.type !== "card.answer" && ev.type !== "card.comment")
273
305
  continue;
274
- if (ev.by !== HUMAN)
306
+ let late = false;
307
+ if (ev.type === "card.answer") {
308
+ const n = answerNo.get(ev.card) ?? 0;
309
+ answerNo.set(ev.card, n + 1);
310
+ late = typeof ev.seen === "number" && ev.seen < n;
311
+ }
312
+ if (!isAfter(ev, pos))
313
+ continue;
314
+ if (ev.by !== HUMAN || late)
275
315
  continue;
276
316
  const card = state.cards.get(ev.card);
277
317
  if (!card || card.status === "closed")
@@ -283,6 +323,7 @@ export function inboxFrom(events, afterSeq, filter) {
283
323
  if (ev.type === "card.answer") {
284
324
  out.push({
285
325
  seq: ev.seq,
326
+ dev: ev.dev,
286
327
  at: ev.ts,
287
328
  kind: "answer",
288
329
  card,
@@ -294,26 +335,50 @@ export function inboxFrom(events, afterSeq, filter) {
294
335
  });
295
336
  }
296
337
  else {
297
- out.push({ seq: ev.seq, at: ev.ts, kind: "comment", card, by: ev.by, comment: ev.text });
338
+ out.push({ seq: ev.seq, dev: ev.dev, at: ev.ts, kind: "comment", card, by: ev.by, comment: ev.text });
298
339
  }
299
340
  }
300
341
  return out;
301
342
  }
302
343
  // ---------------------------------------------------------------- listeners
344
+ /** Waits are visible to every machine when the log is shared. */
303
345
  function listenersDir() {
304
- return path.join(homeDir(), "listeners");
346
+ return path.join(sharedDir() ?? homeDir(), "listeners");
305
347
  }
348
+ /** How often a wait says it is still there, and when one on another machine counts as gone. */
349
+ const LISTENER_BEAT_MS = 60_000;
350
+ const LISTENER_GONE_MS = 3 * LISTENER_BEAT_MS;
306
351
  /** Mark this process as waiting, so cards can say whether an answer reaches an agent at once. */
307
352
  export function startListening(l) {
308
- const file = path.join(listenersDir(), `${process.pid}.json`);
353
+ const shared = !!sharedDir();
354
+ const file = path.join(listenersDir(), shared ? `${deviceName()}-${process.pid}.json` : `${process.pid}.json`);
309
355
  fs.mkdirSync(listenersDir(), { recursive: true });
310
- const me = { ...l, pid: process.pid, since: new Date().toISOString() };
311
- fs.writeFileSync(file, JSON.stringify(me) + "\n");
356
+ const me = { ...l, pid: process.pid, since: new Date().toISOString(), ...(shared ? { device: deviceName(), beat: new Date().toISOString() } : {}) };
357
+ const write = () => {
358
+ try {
359
+ const tmp = `${file}.tmp`;
360
+ fs.writeFileSync(tmp, JSON.stringify(me) + "\n");
361
+ fs.renameSync(tmp, file);
362
+ }
363
+ catch {
364
+ // the folder may be busy syncing; the next beat writes it again
365
+ }
366
+ };
367
+ write();
368
+ const timer = shared
369
+ ? setInterval(() => {
370
+ me.beat = new Date().toISOString();
371
+ write();
372
+ }, LISTENER_BEAT_MS)
373
+ : undefined;
374
+ timer?.unref();
312
375
  let done = false;
313
376
  const stop = () => {
314
377
  if (done)
315
378
  return;
316
379
  done = true;
380
+ if (timer)
381
+ clearInterval(timer);
317
382
  fs.rmSync(file, { force: true });
318
383
  };
319
384
  process.once("exit", stop);
@@ -334,8 +399,11 @@ function alive(pid) {
334
399
  return err.code === "EPERM";
335
400
  }
336
401
  }
337
- /** Waits running now. Files left by a process that died are removed. */
338
- export function listeners() {
402
+ /**
403
+ * Waits running now, on this machine and (when shared) the others. Files left by a process that
404
+ * died here are removed; a wait on another machine counts while its beat is fresh.
405
+ */
406
+ export function listeners(now = Date.now()) {
339
407
  let names;
340
408
  try {
341
409
  names = fs.readdirSync(listenersDir()).filter((f) => f.endsWith(".json"));
@@ -343,12 +411,17 @@ export function listeners() {
343
411
  catch {
344
412
  return [];
345
413
  }
414
+ const me = deviceName();
346
415
  const out = [];
347
416
  for (const n of names) {
348
417
  const file = path.join(listenersDir(), n);
349
418
  try {
350
419
  const l = JSON.parse(fs.readFileSync(file, "utf8"));
351
- if (alive(l.pid))
420
+ if (l.device && l.device !== me) {
421
+ if (l.beat && now - Date.parse(l.beat) < LISTENER_GONE_MS)
422
+ out.push(l);
423
+ }
424
+ else if (alive(l.pid))
352
425
  out.push(l);
353
426
  else
354
427
  fs.rmSync(file, { force: true });
@@ -366,12 +439,12 @@ export function listenersFor(card, ls) {
366
439
  export function consumerName(opts) {
367
440
  return opts.as || opts.project || (opts.card ? `card-${opts.card}` : "default");
368
441
  }
369
- export function readInbox(consumer, filter, advance) {
442
+ export function readInbox(consumer, filter, move) {
370
443
  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);
444
+ const pos = readCursor(consumer);
445
+ const items = inboxFrom(events, pos, filter);
446
+ if (move && items.length)
447
+ writeCursor(consumer, advance(pos, items));
375
448
  return items;
376
449
  }
377
450
  /**
@@ -383,7 +456,7 @@ export async function waitInbox(consumer, filter, timeoutMs, pollMs = 1000) {
383
456
  let watcher;
384
457
  let wake;
385
458
  try {
386
- const dir = path.dirname(eventsPath());
459
+ const dir = eventsDir();
387
460
  fs.mkdirSync(dir, { recursive: true });
388
461
  watcher = fs.watch(dir, () => wake?.());
389
462
  }
@@ -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 { fold } from "./state.js";
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 = fold(readEvents());
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 = fold(readEvents());
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. */
@@ -1,23 +1,38 @@
1
- /** Rebuild current state from the event log. */
2
- export function fold(events) {
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
- s.cards.set(ev.card.id, { ...ev.card, openedAt: ev.ts, status: "open", answers: [], comments: [] });
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
- c.answers.push({ seq: ev.seq, at: ev.ts, by: ev.by, choice: ev.choice, comment: ev.comment, attach: ev.attach });
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
- /** Next id with a prefix, counting the events that created that kind of thing. */
97
- export function nextId(events, prefix) {
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