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.
- package/README.md +55 -2
- package/dist/bin/cli.js +112 -21
- package/dist/core/clients.js +69 -0
- package/dist/core/doctor.js +165 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/follow.js +36 -49
- package/dist/core/format.js +27 -1
- package/dist/core/integrate.js +41 -4
- package/dist/core/ops.js +119 -46
- package/dist/core/semver.js +22 -0
- package/dist/core/skill.js +56 -0
- package/dist/core/snapshot.js +5 -5
- package/dist/core/state.js +40 -9
- package/dist/core/store.js +215 -22
- package/dist/core/sync.js +317 -0
- package/dist/core/syncthing.js +121 -0
- package/dist/core/types.js +7 -0
- package/dist/core/update.js +87 -0
- package/dist/core/version.js +20 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +4 -0
package/dist/core/follow.js
CHANGED
|
@@ -1,62 +1,49 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { endOf, eventFiles, eventsDir, isAfter, parseEventLines, readEvents } from "./store.js";
|
|
3
3
|
/**
|
|
4
|
-
* Call `onEvent` for every event appended after `
|
|
5
|
-
* undefined). Watches the
|
|
4
|
+
* Call `onEvent` for every event appended after `after` (or after the current end when it is
|
|
5
|
+
* undefined), in any machine's log. Watches the log folder and also checks once a second, so a
|
|
6
|
+
* log a sync tool brings in from another machine shows up too. Returns a stop function.
|
|
6
7
|
*/
|
|
7
|
-
export function followEvents(onEvent,
|
|
8
|
-
const
|
|
9
|
-
let
|
|
10
|
-
let lastSeq = afterSeq ?? Infinity;
|
|
11
|
-
try {
|
|
12
|
-
const text = fs.readFileSync(file, "utf8");
|
|
13
|
-
offset = Buffer.byteLength(text);
|
|
14
|
-
const events = parseEventLines(text);
|
|
15
|
-
const end = events[events.length - 1]?.seq ?? 0;
|
|
16
|
-
if (afterSeq === undefined)
|
|
17
|
-
lastSeq = end;
|
|
18
|
-
else {
|
|
19
|
-
for (const ev of events)
|
|
20
|
-
if (ev.seq > afterSeq)
|
|
21
|
-
onEvent(ev);
|
|
22
|
-
lastSeq = Math.max(afterSeq, end);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
lastSeq = afterSeq ?? 0;
|
|
27
|
-
}
|
|
8
|
+
export function followEvents(onEvent, after) {
|
|
9
|
+
const offsets = new Map();
|
|
10
|
+
let pos = after ?? endOf(readEvents());
|
|
28
11
|
const pump = () => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
offset = 0;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
12
|
+
for (const f of eventFiles()) {
|
|
13
|
+
let size = 0;
|
|
14
|
+
try {
|
|
15
|
+
size = fs.statSync(f.file).size;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
let offset = offsets.get(f.file) ?? 0;
|
|
21
|
+
if (size < offset)
|
|
22
|
+
offset = 0; // file replaced
|
|
23
|
+
if (size === offset)
|
|
24
|
+
continue;
|
|
25
|
+
const fd = fs.openSync(f.file, "r");
|
|
26
|
+
const buf = Buffer.alloc(size - offset);
|
|
27
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
28
|
+
fs.closeSync(fd);
|
|
29
|
+
const text = buf.toString("utf8");
|
|
30
|
+
const cut = text.lastIndexOf("\n");
|
|
31
|
+
if (cut < 0)
|
|
32
|
+
continue;
|
|
33
|
+
offsets.set(f.file, offset + Buffer.byteLength(text.slice(0, cut + 1)));
|
|
34
|
+
for (const ev of parseEventLines(text.slice(0, cut + 1), f.device)) {
|
|
35
|
+
if (!isAfter(ev, pos))
|
|
36
|
+
continue;
|
|
37
|
+
pos = { ...pos, [f.device]: ev.seq };
|
|
51
38
|
onEvent(ev);
|
|
52
|
-
lastSeq = ev.seq;
|
|
53
39
|
}
|
|
54
40
|
}
|
|
55
41
|
};
|
|
42
|
+
pump();
|
|
56
43
|
let watcher;
|
|
57
44
|
try {
|
|
58
|
-
fs.mkdirSync(
|
|
59
|
-
watcher = fs.watch(
|
|
45
|
+
fs.mkdirSync(eventsDir(), { recursive: true });
|
|
46
|
+
watcher = fs.watch(eventsDir(), () => pump());
|
|
60
47
|
}
|
|
61
48
|
catch {
|
|
62
49
|
watcher = undefined; // the timer alone still works
|
package/dist/core/format.js
CHANGED
|
@@ -138,9 +138,13 @@ export function cardDetail(c) {
|
|
|
138
138
|
if (c.from || c.session)
|
|
139
139
|
out.push(`From: ${[c.from, c.session ? `session ${c.session.slice(0, 8)}` : ""].filter(Boolean).join(", ")}`);
|
|
140
140
|
out.push(`Opened: ${new Date(c.openedAt).toLocaleString()}${c.since ? `, first came up ${new Date(c.since).toLocaleDateString()}` : ""}`);
|
|
141
|
+
if (c.dev)
|
|
142
|
+
out.push(`Machine: ${c.dev}`);
|
|
141
143
|
for (const a of c.answers) {
|
|
142
144
|
const choice = a.choice !== undefined ? ` chose ${a.choice}${c.options?.[a.choice - 1] ? ` (${c.options[a.choice - 1]})` : ""}` : "";
|
|
143
|
-
|
|
145
|
+
const where = a.dev ? ` on ${a.dev}` : "";
|
|
146
|
+
const late = a.late ? " (not used: given at the same time as the answer before it, which stands)" : "";
|
|
147
|
+
out.push("", `${a.by}${where}${choice}${a.comment ? `: ${a.comment}` : ""}${late}`);
|
|
144
148
|
for (const f of a.attach ?? [])
|
|
145
149
|
out.push(` attached ${f}`);
|
|
146
150
|
}
|
|
@@ -348,4 +352,26 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
|
|
|
348
352
|
}
|
|
349
353
|
return out.join("\n");
|
|
350
354
|
}
|
|
355
|
+
// ------------------------------------------------------------------ sharing
|
|
356
|
+
/** `parawatch sync status` as text. */
|
|
357
|
+
export function syncText(st, color = false, now = Date.now()) {
|
|
358
|
+
const v = { ...PLAIN, color };
|
|
359
|
+
if (!st)
|
|
360
|
+
return "This machine keeps its log to itself. To share it with another machine: parawatch sync setup <folder> --device <name>";
|
|
361
|
+
const lines = [
|
|
362
|
+
`${paint(v, st.device, "bold")} writes to ${st.dir}`,
|
|
363
|
+
`adapter ${st.adapter}${st.ok ? "" : paint(v, " (not reachable)", "yellow")}${st.lastSync ? ` · last in step ${ago(st.lastSync, now)} ago` : ""}${st.pending ? ` · ${st.pending} file(s) on the way` : ""}`,
|
|
364
|
+
];
|
|
365
|
+
if (!st.peers.length)
|
|
366
|
+
lines.push("peers none yet");
|
|
367
|
+
for (const p of st.peers) {
|
|
368
|
+
const state = p.connected === true ? paint(v, "connected", "green") : p.connected === false ? paint(v, "not connected", "yellow") : "seen";
|
|
369
|
+
lines.push(`peer ${p.name} ${state}${p.lastSeen && p.connected !== true ? ` · last ${ago(p.lastSeen, now)} ago` : ""}${p.address ? ` · ${p.address}` : ""}${p.version ? ` · parawatch ${p.version}` : ""}`);
|
|
370
|
+
}
|
|
371
|
+
if (st.conflicts.length)
|
|
372
|
+
lines.push(paint(v, `conflicts ${st.conflicts.length}:`, "yellow"), ...st.conflicts.map((c) => ` ${c}`));
|
|
373
|
+
if (st.error)
|
|
374
|
+
lines.push(paint(v, st.error, "yellow"));
|
|
375
|
+
return lines.join("\n");
|
|
376
|
+
}
|
|
351
377
|
//# sourceMappingURL=format.js.map
|
package/dist/core/integrate.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { readInbox, inboxFrom } from "./ops.js";
|
|
4
|
+
import { readInbox, inboxFrom, readState } from "./ops.js";
|
|
5
5
|
import { homeDir, readCursor, readEvents } from "./store.js";
|
|
6
|
-
import { fold } from "./state.js";
|
|
7
6
|
import { inboxLine } from "./format.js";
|
|
8
7
|
export const BEGIN = "<!-- parawatch:begin -->";
|
|
9
8
|
export const END = "<!-- parawatch:end -->";
|
|
@@ -52,6 +51,44 @@ export function writeInstructions(file, project) {
|
|
|
52
51
|
fs.writeFileSync(file, next);
|
|
53
52
|
return true;
|
|
54
53
|
}
|
|
54
|
+
/** Take the parawatch block out of an instruction file. Returns true when the file changed. */
|
|
55
|
+
export function removeInstructions(file) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = fs.readFileSync(file, "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err.code === "ENOENT")
|
|
62
|
+
return false;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
const a = text.indexOf(BEGIN);
|
|
66
|
+
const b = text.indexOf(END);
|
|
67
|
+
if (a < 0 || b < a)
|
|
68
|
+
return false;
|
|
69
|
+
const before = text.slice(0, a).replace(/\n+$/, "");
|
|
70
|
+
const after = text.slice(b + END.length).replace(/^\n+/, "");
|
|
71
|
+
const next = before && after ? `${before}\n\n${after}` : before ? `${before}\n` : after;
|
|
72
|
+
fs.writeFileSync(file, next);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/** Whether a file has the parawatch block, and whether it is the one this version writes. */
|
|
76
|
+
export function instructionState(file) {
|
|
77
|
+
let text;
|
|
78
|
+
try {
|
|
79
|
+
text = fs.readFileSync(file, "utf8");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return "absent";
|
|
83
|
+
}
|
|
84
|
+
const a = text.indexOf(BEGIN);
|
|
85
|
+
const b = text.indexOf(END);
|
|
86
|
+
if (a < 0 || b < a)
|
|
87
|
+
return "absent";
|
|
88
|
+
const block = text.slice(a, b + END.length);
|
|
89
|
+
const project = /--project (\S+)/.exec(block)?.[1];
|
|
90
|
+
return block === instructions(project === "<project>" ? undefined : project) ? "current" : "outdated";
|
|
91
|
+
}
|
|
55
92
|
/**
|
|
56
93
|
* Claude Code hook: given the hook's JSON on stdin, return text to add to the turn
|
|
57
94
|
* when the human has answered something for this session's project. Peeks only;
|
|
@@ -59,7 +96,7 @@ export function writeInstructions(file, project) {
|
|
|
59
96
|
*/
|
|
60
97
|
export function hookContext(input, project) {
|
|
61
98
|
const events = readEvents();
|
|
62
|
-
const state =
|
|
99
|
+
const state = readState(events);
|
|
63
100
|
let name = project;
|
|
64
101
|
if (!name && input.session_id)
|
|
65
102
|
name = state.claims.find((c) => input.session_id.startsWith(c.session))?.project;
|
|
@@ -70,7 +107,7 @@ export function hookContext(input, project) {
|
|
|
70
107
|
}
|
|
71
108
|
if (!name)
|
|
72
109
|
return undefined;
|
|
73
|
-
const items = inboxFrom(events, readCursor(name), { project: name });
|
|
110
|
+
const items = inboxFrom(events, readCursor(name), { project: name }, state);
|
|
74
111
|
if (!items.length)
|
|
75
112
|
return undefined;
|
|
76
113
|
const cards = items.filter((i) => i.kind !== "pr").length;
|
package/dist/core/ops.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { UserError } from "./errors.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";
|
|
5
7
|
import { CARD_KINDS, HUMAN, PR_CHANGES } from "./types.js";
|
|
6
|
-
export
|
|
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;
|
|
7
16
|
}
|
|
8
17
|
function cleanList(xs) {
|
|
9
18
|
const out = (xs ?? []).map((x) => x.trim()).filter(Boolean);
|
|
@@ -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,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
|
|
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,
|
|
260
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
338
|
-
|
|
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 (
|
|
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,
|
|
442
|
+
export function readInbox(consumer, filter, move) {
|
|
370
443
|
const events = readEvents();
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
374
|
-
writeCursor(consumer,
|
|
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 =
|
|
459
|
+
const dir = eventsDir();
|
|
387
460
|
fs.mkdirSync(dir, { recursive: true });
|
|
388
461
|
watcher = fs.watch(dir, () => wake?.());
|
|
389
462
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Comparing x.y.z versions; shared by the command line and the desktop app. */
|
|
2
|
+
/** Negative when a is older than b, positive when newer, 0 when the same. A pre-release is older than its release. */
|
|
3
|
+
export function compareVersions(a, b) {
|
|
4
|
+
const parse = (v) => {
|
|
5
|
+
const [core = "", pre] = v.trim().replace(/^v/, "").split("-", 2);
|
|
6
|
+
const nums = core.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
7
|
+
return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre };
|
|
8
|
+
};
|
|
9
|
+
const x = parse(a);
|
|
10
|
+
const y = parse(b);
|
|
11
|
+
for (let i = 0; i < 3; i++)
|
|
12
|
+
if (x.nums[i] !== y.nums[i])
|
|
13
|
+
return x.nums[i] - y.nums[i];
|
|
14
|
+
if (x.pre === y.pre)
|
|
15
|
+
return 0;
|
|
16
|
+
if (!x.pre)
|
|
17
|
+
return 1;
|
|
18
|
+
if (!y.pre)
|
|
19
|
+
return -1;
|
|
20
|
+
return x.pre < y.pre ? -1 : 1;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=semver.js.map
|
package/dist/core/skill.js
CHANGED
|
@@ -64,4 +64,60 @@ export function installSkill(targets, opts = {}) {
|
|
|
64
64
|
}
|
|
65
65
|
return out;
|
|
66
66
|
}
|
|
67
|
+
/** Where a skill link points, or undefined when `dest` is not a link. */
|
|
68
|
+
function linkTarget(dest) {
|
|
69
|
+
try {
|
|
70
|
+
if (!fs.lstatSync(dest).isSymbolicLink())
|
|
71
|
+
return undefined;
|
|
72
|
+
return path.resolve(path.dirname(dest), fs.readlinkSync(dest));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Remove the skill links `installSkill` made. Only links to a parawatch skill go; a real folder is
|
|
80
|
+
* left alone, since it may be someone's own copy.
|
|
81
|
+
*/
|
|
82
|
+
export function uninstallSkill(targets, opts = {}) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const t of targets) {
|
|
85
|
+
if (opts.only && !opts.only.includes(t.runtime))
|
|
86
|
+
continue;
|
|
87
|
+
const dest = path.join(t.dir, "parawatch");
|
|
88
|
+
let exists = true;
|
|
89
|
+
try {
|
|
90
|
+
fs.lstatSync(dest);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
exists = false;
|
|
94
|
+
}
|
|
95
|
+
if (!exists) {
|
|
96
|
+
out.push({ runtime: t.runtime, path: dest, result: "not installed" });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const target = linkTarget(dest);
|
|
100
|
+
if (!target) {
|
|
101
|
+
out.push({ runtime: t.runtime, path: dest, result: "skipped", note: "a folder, not a link parawatch made; remove it yourself if it is a copy" });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (path.basename(target) !== "parawatch" || path.basename(path.dirname(target)) !== "skills") {
|
|
105
|
+
out.push({ runtime: t.runtime, path: dest, result: "skipped", note: `links to ${target}, not a parawatch skill` });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
fs.unlinkSync(dest);
|
|
109
|
+
out.push({ runtime: t.runtime, path: dest, result: "removed", note: target === skillSource() ? undefined : `was ${target}` });
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** How the skill is installed for one runtime. */
|
|
114
|
+
export function skillState(t) {
|
|
115
|
+
const dest = path.join(t.dir, "parawatch");
|
|
116
|
+
const target = linkTarget(dest);
|
|
117
|
+
if (target)
|
|
118
|
+
return { state: target === skillSource() ? "linked" : "linked elsewhere", target };
|
|
119
|
+
if (fs.existsSync(dest))
|
|
120
|
+
return { state: "folder" };
|
|
121
|
+
return { state: fs.existsSync(t.home) ? "missing" : "no runtime" };
|
|
122
|
+
}
|
|
67
123
|
//# sourceMappingURL=skill.js.map
|