parawatch 0.2.0 → 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.
@@ -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
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { SCHEMA_VERSION } from "./types.js";
5
- /** Directory holding all parawatch state for this user. */
4
+ import { UserError } from "./errors.js";
5
+ import { STORE_FORMAT } from "./types.js";
6
+ /** Directory holding all parawatch state for this user on this machine. */
6
7
  export function homeDir() {
7
8
  const explicit = process.env.PARAWATCH_HOME;
8
9
  if (explicit)
@@ -10,20 +11,109 @@ export function homeDir() {
10
11
  const xdg = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
11
12
  return path.join(xdg, "parawatch");
12
13
  }
13
- export function eventsPath() {
14
- return path.join(homeDir(), "events.jsonl");
14
+ export function configPath() {
15
+ return path.join(homeDir(), "config.json");
16
+ }
17
+ export function readConfig() {
18
+ try {
19
+ const raw = JSON.parse(fs.readFileSync(configPath(), "utf8"));
20
+ return raw && typeof raw === "object" ? raw : {};
21
+ }
22
+ catch {
23
+ return {};
24
+ }
15
25
  }
16
- function ensureHome() {
26
+ export function writeConfig(c) {
17
27
  fs.mkdirSync(homeDir(), { recursive: true });
28
+ const tmp = `${configPath()}.${process.pid}.tmp`;
29
+ fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + "\n");
30
+ fs.renameSync(tmp, configPath());
31
+ }
32
+ export function expandHome(p) {
33
+ return p === "~" ? os.homedir() : p.startsWith("~/") || p.startsWith("~\\") ? path.join(os.homedir(), p.slice(2)) : p;
34
+ }
35
+ export const DEVICE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
36
+ /** A device name made from the host name: lower case, letters, digits and dashes. */
37
+ export function defaultDevice(host = os.hostname()) {
38
+ const short = host.split(".")[0] ?? "";
39
+ const clean = short.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
40
+ return DEVICE_NAME.test(clean) ? clean : "this";
41
+ }
42
+ /** The name this machine writes under. */
43
+ export function deviceName() {
44
+ const env = process.env.PARAWATCH_DEVICE?.trim();
45
+ if (env)
46
+ return env;
47
+ const c = readConfig().device;
48
+ return typeof c === "string" && DEVICE_NAME.test(c) ? c : defaultDevice();
49
+ }
50
+ /**
51
+ * The shared folder this machine writes its own log into, when set. Something outside parawatch
52
+ * (Syncthing, a cloud folder) carries that folder between machines.
53
+ */
54
+ export function sharedDir() {
55
+ const env = process.env.PARAWATCH_SHARED?.trim();
56
+ if (env)
57
+ return path.resolve(expandHome(env));
58
+ const d = readConfig().sync?.dir;
59
+ return typeof d === "string" && d.trim() ? path.resolve(expandHome(d.trim())) : undefined;
60
+ }
61
+ // ------------------------------------------------------------------ files
62
+ /** Where the event logs live: the home alone, or `events/` in the shared folder. */
63
+ export function eventsDir() {
64
+ const shared = sharedDir();
65
+ return shared ? path.join(shared, "events") : homeDir();
66
+ }
67
+ /** The one file this machine appends to. */
68
+ export function eventsPath() {
69
+ const shared = sharedDir();
70
+ return shared ? path.join(shared, "events", `${deviceName()}.jsonl`) : path.join(homeDir(), "events.jsonl");
71
+ }
72
+ /** A device log: `<device>.jsonl`. Sync tools' temp and conflict copies never match. */
73
+ const DEVICE_FILE = /^([a-z0-9][a-z0-9-]{0,31})\.jsonl$/;
74
+ /** Every log to read: one per device in a shared folder, or the single local file. */
75
+ export function eventFiles() {
76
+ const shared = sharedDir();
77
+ if (!shared)
78
+ return [{ device: deviceName(), file: eventsPath() }];
79
+ const dir = path.join(shared, "events");
80
+ const me = deviceName();
81
+ const out = [];
82
+ let names = [];
83
+ try {
84
+ names = fs.readdirSync(dir);
85
+ }
86
+ catch {
87
+ // nothing shared yet
88
+ }
89
+ for (const n of names.sort()) {
90
+ const m = DEVICE_FILE.exec(n);
91
+ if (m)
92
+ out.push({ device: m[1], file: path.join(dir, n) });
93
+ }
94
+ if (!out.some((f) => f.device === me))
95
+ out.push({ device: me, file: eventsPath() });
96
+ return out.sort((a, b) => a.device.localeCompare(b.device));
18
97
  }
98
+ /** Where pasted attachments go: inside the shared folder when there is one, so every machine has them. */
99
+ export function attachmentsDir() {
100
+ return path.join(sharedDir() ?? homeDir(), "attachments");
101
+ }
102
+ function ensureDir(dir) {
103
+ fs.mkdirSync(dir, { recursive: true });
104
+ }
105
+ // ------------------------------------------------------------------ lock
19
106
  const LOCK_STALE_MS = 30_000;
20
107
  const LOCK_WAIT_MS = 10_000;
21
108
  function sleepSync(ms) {
22
109
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23
110
  }
24
- /** Run fn while holding an exclusive lock file. Several agents may write at once. */
111
+ /**
112
+ * Run fn while holding an exclusive lock file. Several agents on this machine may write at once.
113
+ * The lock is local: each machine only ever writes its own log, so machines never share one.
114
+ */
25
115
  export function withLock(fn) {
26
- ensureHome();
116
+ ensureDir(homeDir());
27
117
  const lock = path.join(homeDir(), "lock");
28
118
  const started = Date.now();
29
119
  let fd;
@@ -59,28 +149,64 @@ export function withLock(fn) {
59
149
  fs.rmSync(lock, { force: true });
60
150
  }
61
151
  }
62
- /** Parse every event line. Broken lines are skipped, not fatal. */
63
- export function readEvents() {
152
+ // ------------------------------------------------------------------ reading
153
+ function readFileEvents(f) {
64
154
  let text;
65
155
  try {
66
- text = fs.readFileSync(eventsPath(), "utf8");
156
+ text = fs.readFileSync(f.file, "utf8");
67
157
  }
68
158
  catch (err) {
69
159
  if (err.code === "ENOENT")
70
160
  return [];
71
161
  throw err;
72
162
  }
73
- return parseEventLines(text);
163
+ return parseEventLines(text, f.device);
164
+ }
165
+ /**
166
+ * Every event, from every machine, in time order. Each machine's own order is kept as written;
167
+ * between machines the clock decides, then the device name, so every machine sees the same order.
168
+ */
169
+ export function readEvents() {
170
+ const lists = eventFiles().map(readFileEvents).filter((l) => l.length);
171
+ if (lists.length <= 1)
172
+ return lists[0] ?? [];
173
+ return mergeLogs(lists);
174
+ }
175
+ /** Merge per-device logs, each already in its own order, into one. */
176
+ export function mergeLogs(lists) {
177
+ const at = lists.map(() => 0);
178
+ const out = [];
179
+ for (;;) {
180
+ let pick = -1;
181
+ for (let i = 0; i < lists.length; i++) {
182
+ const ev = lists[i][at[i]];
183
+ if (!ev)
184
+ continue;
185
+ if (pick < 0) {
186
+ pick = i;
187
+ continue;
188
+ }
189
+ const best = lists[pick][at[pick]];
190
+ if (ev.ts < best.ts || (ev.ts === best.ts && (ev.dev ?? "") < (best.dev ?? "")))
191
+ pick = i;
192
+ }
193
+ if (pick < 0)
194
+ return out;
195
+ out.push(lists[pick][at[pick]++]);
196
+ }
74
197
  }
75
- export function parseEventLines(text) {
198
+ export function parseEventLines(text, device) {
76
199
  const out = [];
77
200
  for (const line of text.split("\n")) {
78
201
  if (!line.trim())
79
202
  continue;
80
203
  try {
81
204
  const ev = JSON.parse(line);
82
- if (typeof ev.seq === "number" && typeof ev.type === "string")
83
- out.push(ev);
205
+ if (typeof ev.seq !== "number" || typeof ev.type !== "string")
206
+ continue;
207
+ if (device)
208
+ ev.dev = device;
209
+ out.push(ev);
84
210
  }
85
211
  catch {
86
212
  // a torn or hand-edited line; keep going
@@ -88,40 +214,107 @@ export function parseEventLines(text) {
88
214
  }
89
215
  return out;
90
216
  }
217
+ // ------------------------------------------------------------------ writing
91
218
  /** Append one event. `build` sees the current events so it can pick ids under the lock. */
92
219
  export function appendEvent(build) {
93
220
  return withLock(() => appendLocked((events) => [build(events)])[0]);
94
221
  }
222
+ /** The newest event format in the log. A reader may skip what it does not know; a writer may not. */
223
+ export function storeFormat(events) {
224
+ let v = 0;
225
+ for (const e of events)
226
+ if (typeof e.v === "number" && e.v > v)
227
+ v = e.v;
228
+ return v;
229
+ }
230
+ /**
231
+ * The format this machine writes. A log kept on one machine stays at format 1, which every
232
+ * earlier parawatch reads and writes. A shared folder writes format 2.
233
+ */
234
+ export function writeFormat() {
235
+ return sharedDir() ? 2 : 1;
236
+ }
95
237
  /** Append events while already holding the lock (inside `withLock`). */
96
238
  export function appendLocked(build) {
97
239
  const events = readEvents();
98
- let seq = events[events.length - 1]?.seq ?? 0;
240
+ const format = storeFormat(events);
241
+ if (format > STORE_FORMAT) {
242
+ throw new UserError(`${eventsDir()} was written by a newer parawatch (format ${format}; this one knows ${STORE_FORMAT}). Update this copy before it writes: npm i -g parawatch@latest, or update the app.`);
243
+ }
244
+ const me = deviceName();
245
+ const own = sharedDir() ? events.filter((e) => e.dev === me) : events;
246
+ let seq = 0;
247
+ for (const e of own)
248
+ if (e.seq > seq)
249
+ seq = e.seq;
99
250
  const ts = new Date().toISOString();
100
- const out = build(events).map((body) => ({ v: SCHEMA_VERSION, seq: ++seq, ts, ...body }));
101
- if (out.length)
251
+ const v = writeFormat();
252
+ const out = build(events).map((body) => ({ v, seq: ++seq, ts, ...body }));
253
+ if (out.length) {
254
+ ensureDir(path.dirname(eventsPath()));
102
255
  fs.appendFileSync(eventsPath(), out.map((e) => JSON.stringify(e) + "\n").join(""));
256
+ for (const e of out)
257
+ e.dev = me;
258
+ }
103
259
  return out;
104
260
  }
261
+ /** Whether an event comes after a position. */
262
+ export function isAfter(ev, pos) {
263
+ return ev.seq > (pos[ev.dev ?? deviceName()] ?? 0);
264
+ }
265
+ /** A position moved past these events. */
266
+ export function advance(pos, events) {
267
+ const next = { ...pos };
268
+ const me = deviceName();
269
+ for (const e of events) {
270
+ const d = e.dev ?? me;
271
+ if (e.seq > (next[d] ?? 0))
272
+ next[d] = e.seq;
273
+ }
274
+ return next;
275
+ }
276
+ /** The position at the end of these events. */
277
+ export function endOf(events) {
278
+ return advance({}, events);
279
+ }
280
+ /** A number given on the command line: the same seq in every device's log. */
281
+ export function everywhere(seq, events) {
282
+ const pos = { [deviceName()]: seq };
283
+ for (const e of events)
284
+ if (e.dev)
285
+ pos[e.dev] = seq;
286
+ return pos;
287
+ }
105
288
  const CURSOR_NAME = /^[A-Za-z0-9._-]+$/;
106
289
  function cursorPath(name) {
107
290
  if (!CURSOR_NAME.test(name))
108
291
  throw new Error(`parawatch: bad consumer name "${name}"`);
109
292
  return path.join(homeDir(), "cursors", `${name}.json`);
110
293
  }
294
+ /**
295
+ * A cursor file holds `seq` (this machine's log, as every version wrote it) and `pos` (every
296
+ * device). Cursors stay on the machine: two machines never share a reader.
297
+ */
111
298
  export function readCursor(name) {
112
299
  try {
113
300
  const data = JSON.parse(fs.readFileSync(cursorPath(name), "utf8"));
114
- return typeof data.seq === "number" ? data.seq : 0;
301
+ const me = deviceName();
302
+ const pos = data.pos && typeof data.pos === "object" && sharedDir() ? { ...data.pos } : {};
303
+ if (typeof data.seq === "number" && pos[me] === undefined)
304
+ pos[me] = data.seq;
305
+ return pos;
115
306
  }
116
307
  catch {
117
- return 0;
308
+ return {};
118
309
  }
119
310
  }
120
- export function writeCursor(name, seq) {
311
+ export function writeCursor(name, pos) {
121
312
  const file = cursorPath(name);
122
- fs.mkdirSync(path.dirname(file), { recursive: true });
313
+ ensureDir(path.dirname(file));
123
314
  const tmp = `${file}.${process.pid}.tmp`;
124
- fs.writeFileSync(tmp, JSON.stringify({ seq, at: new Date().toISOString() }) + "\n");
315
+ const seq = pos[deviceName()] ?? 0;
316
+ const body = sharedDir() ? { seq, pos, at: new Date().toISOString() } : { seq, at: new Date().toISOString() };
317
+ fs.writeFileSync(tmp, JSON.stringify(body) + "\n");
125
318
  fs.renameSync(tmp, file);
126
319
  }
127
320
  //# sourceMappingURL=store.js.map