@icebergsnow/patchwork-cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/bin/patchwork.js +148 -0
- package/lib/board.js +630 -0
- package/lib/config.js +139 -0
- package/lib/connect.js +502 -0
- package/lib/connections.js +55 -0
- package/lib/github.js +391 -0
- package/lib/instructions.js +3 -0
- package/lib/mcp.js +104 -0
- package/lib/runtime.js +510 -0
- package/lib/store.js +185 -0
- package/lib/sync.js +364 -0
- package/package.json +29 -4
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { GitHub } from "./github.js";
|
|
3
|
+
import { register, reconcileIntervention, perform } from "./board.js";
|
|
4
|
+
import { withBoard, POLL_INTERVAL, PROMPT_INTERVAL } from "./sync.js";
|
|
5
|
+
import {
|
|
6
|
+
digest,
|
|
7
|
+
read,
|
|
8
|
+
readJSON,
|
|
9
|
+
writeJSON,
|
|
10
|
+
locked,
|
|
11
|
+
loadConnection,
|
|
12
|
+
} from "./store.js";
|
|
13
|
+
import { AGENT_INSTRUCTIONS } from "./instructions.js";
|
|
14
|
+
|
|
15
|
+
export const CHECK_INTERVAL = POLL_INTERVAL,
|
|
16
|
+
REMINDER_INTERVAL = 300_000;
|
|
17
|
+
const LIMIT = 12_000;
|
|
18
|
+
export const disabledFile = (file, id) =>
|
|
19
|
+
`${file}${id ? `.${id}` : ""}.disabled`;
|
|
20
|
+
export const stateFile = (file) => `${file}.sessions.json`;
|
|
21
|
+
export async function guard(file, id) {
|
|
22
|
+
if (
|
|
23
|
+
(await read(disabledFile(file))) ||
|
|
24
|
+
(id && (await read(disabledFile(file, id))))
|
|
25
|
+
)
|
|
26
|
+
throw new Error("This Patchwork connection or chat is disconnected.");
|
|
27
|
+
return loadConnection(file);
|
|
28
|
+
}
|
|
29
|
+
function preview(e, max = 650) {
|
|
30
|
+
return JSON.stringify({
|
|
31
|
+
...e,
|
|
32
|
+
body: e.body.slice(0, max),
|
|
33
|
+
...(e.body.length > max ? { truncated: true } : {}),
|
|
34
|
+
version: undefined,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function selectEvents(state, seen, maximum = LIMIT - 1000) {
|
|
38
|
+
const selected = [];
|
|
39
|
+
let size = 0;
|
|
40
|
+
for (const event of state.events) {
|
|
41
|
+
if (seen[event.id] === event.version) continue;
|
|
42
|
+
const line = preview(event);
|
|
43
|
+
if (size + line.length > maximum) break;
|
|
44
|
+
selected.push({ event, line });
|
|
45
|
+
size += line.length + 2;
|
|
46
|
+
}
|
|
47
|
+
return selected;
|
|
48
|
+
}
|
|
49
|
+
// Keyset pagination survives earlier requests closing or comments disappearing.
|
|
50
|
+
// Bound this traversal to its original upper key; new/reopened older items are
|
|
51
|
+
// available on a fresh traversal and through each chat's independent hook.
|
|
52
|
+
function page(items, c, input) {
|
|
53
|
+
const open = !!input.open_only;
|
|
54
|
+
const key = (item) => (open ? [item.number] : [item.time, item.id]);
|
|
55
|
+
const compare = (a, b) =>
|
|
56
|
+
open
|
|
57
|
+
? a[0] - b[0]
|
|
58
|
+
: a[0].localeCompare(b[0]) ||
|
|
59
|
+
a[1].localeCompare(b[1], "en", { numeric: true });
|
|
60
|
+
const valid = (k) =>
|
|
61
|
+
Array.isArray(k) &&
|
|
62
|
+
(open
|
|
63
|
+
? k.length === 1 && Number.isSafeInteger(k[0]) && k[0] > 0
|
|
64
|
+
: k.length === 2 &&
|
|
65
|
+
k.every((v) => typeof v === "string" && v.length < 100));
|
|
66
|
+
let after,
|
|
67
|
+
upper = items.length ? key(items.at(-1)) : null;
|
|
68
|
+
if (input.cursor) {
|
|
69
|
+
try {
|
|
70
|
+
if (input.cursor.length > 2000) throw 0;
|
|
71
|
+
const v = JSON.parse(Buffer.from(input.cursor, "base64url").toString());
|
|
72
|
+
if (
|
|
73
|
+
v.version !== 2 ||
|
|
74
|
+
v.repo !== c.repoId ||
|
|
75
|
+
v.board !== c.board ||
|
|
76
|
+
v.open !== open ||
|
|
77
|
+
!valid(v.after) ||
|
|
78
|
+
!valid(v.upper) ||
|
|
79
|
+
compare(v.after, v.upper) > 0
|
|
80
|
+
)
|
|
81
|
+
throw 0;
|
|
82
|
+
({ after, upper } = v);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"Invalid or expired cursor for this board/filter. Restart from the first page.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const remaining = items.filter(
|
|
90
|
+
(item) =>
|
|
91
|
+
(!after || compare(key(item), after) > 0) &&
|
|
92
|
+
upper &&
|
|
93
|
+
compare(key(item), upper) <= 0,
|
|
94
|
+
);
|
|
95
|
+
const batch = remaining.slice(0, 30),
|
|
96
|
+
hasMore = remaining.length > 30;
|
|
97
|
+
return {
|
|
98
|
+
messages: batch,
|
|
99
|
+
hasMore,
|
|
100
|
+
nextCursor: hasMore
|
|
101
|
+
? Buffer.from(
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
version: 2,
|
|
104
|
+
repo: c.repoId,
|
|
105
|
+
board: c.board,
|
|
106
|
+
open,
|
|
107
|
+
after: key(batch.at(-1)),
|
|
108
|
+
upper,
|
|
109
|
+
}),
|
|
110
|
+
).toString("base64url")
|
|
111
|
+
: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export class Runtime {
|
|
115
|
+
constructor(file, { github, clock = Date.now } = {}) {
|
|
116
|
+
this.file = file;
|
|
117
|
+
this.github = github;
|
|
118
|
+
this.clock = clock;
|
|
119
|
+
}
|
|
120
|
+
client(c) {
|
|
121
|
+
return (this.github ||= new GitHub(c.repo));
|
|
122
|
+
}
|
|
123
|
+
async hook(input) {
|
|
124
|
+
if (
|
|
125
|
+
typeof input.session_id !== "string" ||
|
|
126
|
+
!input.session_id ||
|
|
127
|
+
input.session_id.length > 300 ||
|
|
128
|
+
!["SessionStart", "UserPromptSubmit", "PostToolUse"].includes(
|
|
129
|
+
input.hook_event_name,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
throw new Error("Invalid hook session metadata.");
|
|
133
|
+
return locked(stateFile(this.file), async () => {
|
|
134
|
+
const c = await guard(this.file),
|
|
135
|
+
now = this.clock();
|
|
136
|
+
const store = (await readJSON(stateFile(this.file))) || { chats: {} };
|
|
137
|
+
const key = digest(`${c.client}\0${input.session_id}`);
|
|
138
|
+
let chat = store.chats[key];
|
|
139
|
+
if (!chat) {
|
|
140
|
+
const id = randomUUID();
|
|
141
|
+
chat = store.chats[key] = {
|
|
142
|
+
id,
|
|
143
|
+
chat_key: randomUUID(),
|
|
144
|
+
name: `${c.name} / ${id.slice(0, 6)}`,
|
|
145
|
+
ownerId: c.ownerId,
|
|
146
|
+
client: c.client,
|
|
147
|
+
registered: false,
|
|
148
|
+
registrationOperation: randomUUID(),
|
|
149
|
+
seen: {},
|
|
150
|
+
lastCheck: 0,
|
|
151
|
+
lastReminder: 0,
|
|
152
|
+
initialized: false,
|
|
153
|
+
pending: null,
|
|
154
|
+
};
|
|
155
|
+
// Save identity before a network write, so an uncertain response can retry safely.
|
|
156
|
+
await writeJSON(stateFile(this.file), store);
|
|
157
|
+
}
|
|
158
|
+
await guard(this.file, chat.id);
|
|
159
|
+
if (chat.retryAfter && now < chat.retryAfter) return {};
|
|
160
|
+
if (
|
|
161
|
+
input.hook_event_name === "PostToolUse" &&
|
|
162
|
+
now - chat.lastCheck < CHECK_INTERVAL
|
|
163
|
+
)
|
|
164
|
+
return {};
|
|
165
|
+
try {
|
|
166
|
+
const gh = this.client(c);
|
|
167
|
+
const { state, labelWarning } = await withBoard(
|
|
168
|
+
c,
|
|
169
|
+
gh,
|
|
170
|
+
async (sync) => {
|
|
171
|
+
await sync.poll(
|
|
172
|
+
input.hook_event_name === "PostToolUse"
|
|
173
|
+
? POLL_INTERVAL
|
|
174
|
+
: PROMPT_INTERVAL,
|
|
175
|
+
);
|
|
176
|
+
if (!chat.registered) {
|
|
177
|
+
const registration = await register(
|
|
178
|
+
gh,
|
|
179
|
+
c.board,
|
|
180
|
+
chat,
|
|
181
|
+
true,
|
|
182
|
+
chat.registrationOperation,
|
|
183
|
+
async () => {
|
|
184
|
+
await guard(this.file, chat.id);
|
|
185
|
+
await sync.authorize();
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
await sync.accept(registration);
|
|
189
|
+
chat.registered = true;
|
|
190
|
+
chat.retireOperation = null;
|
|
191
|
+
await writeJSON(stateFile(this.file), store);
|
|
192
|
+
}
|
|
193
|
+
const state = await sync.view();
|
|
194
|
+
if (!state.agents.some((a) => a.agent_id === chat.id && a.active))
|
|
195
|
+
throw new Error(
|
|
196
|
+
"This chat was retired on GitHub. Reconnect it explicitly.",
|
|
197
|
+
);
|
|
198
|
+
let labelWarning = false;
|
|
199
|
+
try {
|
|
200
|
+
await reconcileIntervention(
|
|
201
|
+
gh,
|
|
202
|
+
state,
|
|
203
|
+
async () => {
|
|
204
|
+
await guard(this.file, chat.id);
|
|
205
|
+
await sync.authorize();
|
|
206
|
+
},
|
|
207
|
+
(request) => sync.labels(request),
|
|
208
|
+
);
|
|
209
|
+
} catch {
|
|
210
|
+
labelWarning = true;
|
|
211
|
+
}
|
|
212
|
+
return { state, labelWarning };
|
|
213
|
+
},
|
|
214
|
+
this.clock,
|
|
215
|
+
);
|
|
216
|
+
const initial = !chat.initialized;
|
|
217
|
+
const hash = digest(
|
|
218
|
+
JSON.stringify([
|
|
219
|
+
state.instructions,
|
|
220
|
+
state.agents.map((a) => [a.agent_id, a.name, a.active]),
|
|
221
|
+
AGENT_INSTRUCTIONS,
|
|
222
|
+
]),
|
|
223
|
+
);
|
|
224
|
+
const fresh =
|
|
225
|
+
initial ||
|
|
226
|
+
chat.contextHash !== hash ||
|
|
227
|
+
input.hook_event_name === "SessionStart";
|
|
228
|
+
const open = state.requests.filter((r) => r.state === "open");
|
|
229
|
+
const requestHash = digest(
|
|
230
|
+
JSON.stringify(
|
|
231
|
+
open.map((r) => [
|
|
232
|
+
r.number,
|
|
233
|
+
r.acknowledgments,
|
|
234
|
+
r.needsHumanIntervention,
|
|
235
|
+
]),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
const due =
|
|
239
|
+
fresh ||
|
|
240
|
+
chat.requestHash !== requestHash ||
|
|
241
|
+
now - chat.lastReminder >= REMINDER_INTERVAL;
|
|
242
|
+
let context = "";
|
|
243
|
+
if (fresh)
|
|
244
|
+
context = `Patchwork: ${c.repo}, board #${c.board}. Your agent ID: ${chat.id}. Your private chat_key for board tools: ${chat.chat_key}. Never use another chat\'s key.\n\n${AGENT_INSTRUCTIONS}\n\nRegistered chats: ${JSON.stringify(
|
|
245
|
+
state.agents
|
|
246
|
+
.filter((a) => a.active)
|
|
247
|
+
.map((a) => ({ id: a.agent_id, name: a.name }))
|
|
248
|
+
.slice(0, 20),
|
|
249
|
+
)}. Full roster: read_messages.\n`;
|
|
250
|
+
if (due) {
|
|
251
|
+
context += `\nShared instructions: ${state.instructions || "(none; stop applying previously supplied shared instructions)"}\n`;
|
|
252
|
+
const pending = open.filter(
|
|
253
|
+
(r) =>
|
|
254
|
+
!r.acknowledgments.find((a) => a.agent_id === chat.id)?.response,
|
|
255
|
+
);
|
|
256
|
+
if (pending.length)
|
|
257
|
+
context += `\nRequests you have not evaluated: ${pending
|
|
258
|
+
.slice(0, 20)
|
|
259
|
+
.map((r) => `#${r.number}`)
|
|
260
|
+
.join(
|
|
261
|
+
", ",
|
|
262
|
+
)}${pending.length > 20 ? " (more remain)" : ""}. Read open requests and acknowledge each with your chat_key. Silence is not a decline.\n`;
|
|
263
|
+
const intervention = open.filter((r) => r.needsHumanIntervention);
|
|
264
|
+
if (intervention.length)
|
|
265
|
+
context += `\nNeeds Human Intervention: ${intervention
|
|
266
|
+
.slice(0, 20)
|
|
267
|
+
.map((r) => `#${r.number}`)
|
|
268
|
+
.join(
|
|
269
|
+
", ",
|
|
270
|
+
)}. Notify your user if this blocks you; continue independent work.\n`;
|
|
271
|
+
const noLongerUnanimous = open.filter(
|
|
272
|
+
(r) =>
|
|
273
|
+
!r.needsHumanIntervention &&
|
|
274
|
+
chat.interventions?.includes(r.number),
|
|
275
|
+
);
|
|
276
|
+
if (noLongerUnanimous.length)
|
|
277
|
+
context += `\nStill unresolved: ${noLongerUnanimous.map((r) => `#${r.number}`).join(", ")}. Needs Human Intervention no longer has unanimous declines (for example, a new chat is unanswered). This does not mean help is available or the blocker is resolved.\n`;
|
|
278
|
+
if (open.length) {
|
|
279
|
+
const start = (chat.reminderOffset || 0) % open.length;
|
|
280
|
+
const batch = [...open.slice(start), ...open.slice(0, start)].slice(
|
|
281
|
+
0,
|
|
282
|
+
20,
|
|
283
|
+
);
|
|
284
|
+
context +=
|
|
285
|
+
"\nUnresolved requests (acknowledgment is not completion):\n";
|
|
286
|
+
for (const r of batch) {
|
|
287
|
+
const active = r.acknowledgments.filter((a) => a.active);
|
|
288
|
+
const count = (response) =>
|
|
289
|
+
active.filter((a) => a.response === response).length;
|
|
290
|
+
context += `#${r.number}: your response=${r.acknowledgments.find((a) => a.agent_id === chat.id)?.response || "unanswered"}; can handle=${count("can_handle")}, cannot handle=${count("cannot_handle")}, need clarification=${count("need_clarification")}, unanswered=${count(null)}.\n`;
|
|
291
|
+
}
|
|
292
|
+
context +=
|
|
293
|
+
"Read replies and follow up on relevant pending work or clarification. If blocked, tell your user without waiting for all agents.\n";
|
|
294
|
+
chat.reminderOffset = (start + batch.length) % open.length;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (open.length && input.hook_event_name === "UserPromptSubmit")
|
|
298
|
+
context +=
|
|
299
|
+
"\nIf your user has explicitly expanded your scope, reevaluate affected open requests and update prior acknowledgments. Otherwise keep your existing scope.\n";
|
|
300
|
+
if (state.warnings.length)
|
|
301
|
+
context += `\n${state.warnings.slice(0, 5).join("\n")}\n`;
|
|
302
|
+
if (!initial) {
|
|
303
|
+
const ids = new Set(state.events.map((e) => e.id));
|
|
304
|
+
const removed = Object.keys(chat.seen).filter((id) => !ids.has(id));
|
|
305
|
+
if (removed.length)
|
|
306
|
+
context += `\nRemoved from the board since your last check: ${removed.slice(0, 20).join(", ")}${removed.length > 20 ? " (more removed)" : ""}. Read current requests before relying on earlier context.\n`;
|
|
307
|
+
for (const id of removed) delete chat.seen[id];
|
|
308
|
+
}
|
|
309
|
+
if (context.length > LIMIT - 1200)
|
|
310
|
+
context =
|
|
311
|
+
context.slice(0, LIMIT - 1300) +
|
|
312
|
+
"\nContext limited; use read_messages for remaining requests and warnings.\n";
|
|
313
|
+
if (initial) {
|
|
314
|
+
context +=
|
|
315
|
+
"\nWelcome: open requests and recent context. Full history is available with read_messages.\n";
|
|
316
|
+
let shown = 0;
|
|
317
|
+
for (const r of open.slice(0, 8)) {
|
|
318
|
+
const line = JSON.stringify({
|
|
319
|
+
...r,
|
|
320
|
+
body: r.body.slice(0, 220),
|
|
321
|
+
acknowledgments: undefined,
|
|
322
|
+
labels: undefined,
|
|
323
|
+
});
|
|
324
|
+
if (context.length + line.length < LIMIT - 1700) {
|
|
325
|
+
context += line + "\n";
|
|
326
|
+
shown++;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (open.length > shown)
|
|
330
|
+
context +=
|
|
331
|
+
"More open requests remain; use read_messages open_only=true and its nextCursor.\n";
|
|
332
|
+
for (const e of state.events.slice(-3)) {
|
|
333
|
+
const line = preview(e, 250);
|
|
334
|
+
if (context.length + line.length < LIMIT - 100)
|
|
335
|
+
context += line + "\n";
|
|
336
|
+
}
|
|
337
|
+
// Only mark events actually present in this complete snapshot, not a
|
|
338
|
+
// timestamp boundary that could swallow a concurrently created post.
|
|
339
|
+
for (const e of state.events) chat.seen[e.id] = e.version;
|
|
340
|
+
} else {
|
|
341
|
+
const selected = selectEvents(
|
|
342
|
+
state,
|
|
343
|
+
chat.seen,
|
|
344
|
+
LIMIT - context.length - 350,
|
|
345
|
+
);
|
|
346
|
+
for (const { event, line } of selected) {
|
|
347
|
+
context += `\n${line}\n`;
|
|
348
|
+
chat.seen[event.id] = event.version;
|
|
349
|
+
}
|
|
350
|
+
if (state.events.some((e) => chat.seen[e.id] !== e.version))
|
|
351
|
+
context +=
|
|
352
|
+
"\nMore updates remain. The next check continues delivery; read_messages can read them now.\n";
|
|
353
|
+
}
|
|
354
|
+
if (labelWarning)
|
|
355
|
+
context +=
|
|
356
|
+
"\nCould not refresh GitHub intervention labels; responses above remain authoritative.\n";
|
|
357
|
+
await guard(this.file, chat.id);
|
|
358
|
+
chat.initialized = true;
|
|
359
|
+
chat.contextHash = hash;
|
|
360
|
+
chat.requestHash = requestHash;
|
|
361
|
+
chat.interventions = open
|
|
362
|
+
.filter((r) => r.needsHumanIntervention)
|
|
363
|
+
.map((r) => r.number);
|
|
364
|
+
chat.lastCheck = now;
|
|
365
|
+
chat.lastSuccess = new Date(now).toISOString();
|
|
366
|
+
chat.retryAfter = 0;
|
|
367
|
+
chat.lastError = null;
|
|
368
|
+
if (due) chat.lastReminder = now;
|
|
369
|
+
await writeJSON(stateFile(this.file), store);
|
|
370
|
+
return context
|
|
371
|
+
? {
|
|
372
|
+
hookSpecificOutput: {
|
|
373
|
+
hookEventName: input.hook_event_name,
|
|
374
|
+
additionalContext: context,
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
: {};
|
|
378
|
+
} catch (e) {
|
|
379
|
+
// No failed snapshot advances delivery. A bounded cooldown prevents a
|
|
380
|
+
// tool loop from hammering GitHub while unavailable or rate limited.
|
|
381
|
+
const disk = await readJSON(stateFile(this.file));
|
|
382
|
+
if (disk?.chats[key]) {
|
|
383
|
+
disk.chats[key].retryAfter = Math.max(now + 60_000, e.retryAt || 0);
|
|
384
|
+
disk.chats[key].lastError = e.message;
|
|
385
|
+
await writeJSON(stateFile(this.file), disk);
|
|
386
|
+
}
|
|
387
|
+
throw e;
|
|
388
|
+
}
|
|
389
|
+
}).catch((e) => {
|
|
390
|
+
// A busy local reader is not a failed coding checkpoint. Leave this chat's
|
|
391
|
+
// cursor untouched and retry through the next normal hook.
|
|
392
|
+
if (e.code === "LOCK_BUSY") return {};
|
|
393
|
+
throw e;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
async tool(name, input) {
|
|
397
|
+
return locked(stateFile(this.file), async () => {
|
|
398
|
+
const c = await guard(this.file),
|
|
399
|
+
store = await readJSON(stateFile(this.file));
|
|
400
|
+
const chat = Object.values(store?.chats || {}).find(
|
|
401
|
+
(a) => a.chat_key === input.chat_key,
|
|
402
|
+
);
|
|
403
|
+
if (!chat)
|
|
404
|
+
throw new Error(
|
|
405
|
+
"Use the chat_key supplied by this chat's Patchwork hook. Start/reload the chat if no key has arrived.",
|
|
406
|
+
);
|
|
407
|
+
await guard(this.file, chat.id);
|
|
408
|
+
const gh = this.client(c);
|
|
409
|
+
const result = await withBoard(
|
|
410
|
+
c,
|
|
411
|
+
gh,
|
|
412
|
+
async (sync) => {
|
|
413
|
+
await sync.poll(PROMPT_INTERVAL);
|
|
414
|
+
const state = await sync.view();
|
|
415
|
+
if (!state.agents.some((a) => a.agent_id === chat.id && a.active))
|
|
416
|
+
throw new Error(
|
|
417
|
+
"This chat is no longer registered. Reconnect it explicitly.",
|
|
418
|
+
);
|
|
419
|
+
if (name === "read_messages") {
|
|
420
|
+
const items = input.open_only
|
|
421
|
+
? state.requests.filter((r) => r.state === "open")
|
|
422
|
+
: state.events;
|
|
423
|
+
const result = page(items, c, input);
|
|
424
|
+
return {
|
|
425
|
+
agents: state.agents,
|
|
426
|
+
instructions: state.instructions,
|
|
427
|
+
warnings: state.warnings,
|
|
428
|
+
...result,
|
|
429
|
+
requests: input.open_only
|
|
430
|
+
? result.messages
|
|
431
|
+
: state.requests.filter((r) => r.state === "open"),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const actions = {
|
|
435
|
+
post_message: "post",
|
|
436
|
+
acknowledge_request: "ack",
|
|
437
|
+
resolve_request: "resolve",
|
|
438
|
+
};
|
|
439
|
+
if (!actions[name]) throw new Error("Unknown board tool.");
|
|
440
|
+
const data = { ...input, chat_key: undefined, action: actions[name] };
|
|
441
|
+
const fingerprint = digest(JSON.stringify(data));
|
|
442
|
+
if (chat.pending && chat.pending.fingerprint !== fingerprint)
|
|
443
|
+
throw new Error(
|
|
444
|
+
"An earlier write has an uncertain result. Retry that exact operation first to avoid duplicate or reordered posts.",
|
|
445
|
+
);
|
|
446
|
+
const retry = !!chat.pending;
|
|
447
|
+
chat.pending ??= { fingerprint, id: randomUUID() };
|
|
448
|
+
await writeJSON(stateFile(this.file), store);
|
|
449
|
+
let result;
|
|
450
|
+
try {
|
|
451
|
+
result = await perform(
|
|
452
|
+
gh,
|
|
453
|
+
c,
|
|
454
|
+
chat,
|
|
455
|
+
data,
|
|
456
|
+
chat.pending.id,
|
|
457
|
+
async () => {
|
|
458
|
+
await guard(this.file, chat.id);
|
|
459
|
+
await sync.authorize();
|
|
460
|
+
},
|
|
461
|
+
retry,
|
|
462
|
+
);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
// Validation and definite rejections can safely be corrected. Unknown
|
|
465
|
+
// network/5xx results retain the same operation ID for a safe retry.
|
|
466
|
+
if (
|
|
467
|
+
!e.uncertain &&
|
|
468
|
+
(!("status" in e) || (e.status >= 400 && e.status < 500))
|
|
469
|
+
) {
|
|
470
|
+
chat.pending = null;
|
|
471
|
+
await writeJSON(stateFile(this.file), store);
|
|
472
|
+
}
|
|
473
|
+
throw e;
|
|
474
|
+
}
|
|
475
|
+
let labelWarning;
|
|
476
|
+
try {
|
|
477
|
+
await sync.accept(result);
|
|
478
|
+
await reconcileIntervention(
|
|
479
|
+
gh,
|
|
480
|
+
await sync.view(),
|
|
481
|
+
async () => {
|
|
482
|
+
await guard(this.file, chat.id);
|
|
483
|
+
await sync.authorize();
|
|
484
|
+
},
|
|
485
|
+
(request) => sync.labels(request),
|
|
486
|
+
);
|
|
487
|
+
} catch {
|
|
488
|
+
labelWarning =
|
|
489
|
+
"Post saved; intervention labels will refresh on a later check.";
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
id: result.id,
|
|
493
|
+
number: result.number,
|
|
494
|
+
url: result.html_url,
|
|
495
|
+
unchanged: result.unchanged,
|
|
496
|
+
...(labelWarning ? { warning: labelWarning } : {}),
|
|
497
|
+
};
|
|
498
|
+
},
|
|
499
|
+
this.clock,
|
|
500
|
+
);
|
|
501
|
+
// Keep the operation ID until the shared cache was durably saved too.
|
|
502
|
+
// A crash/failure before this point retries the same GitHub write.
|
|
503
|
+
if (name !== "read_messages") {
|
|
504
|
+
chat.pending = null;
|
|
505
|
+
await writeJSON(stateFile(this.file), store);
|
|
506
|
+
}
|
|
507
|
+
return result;
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import {
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
unlink,
|
|
9
|
+
lstat,
|
|
10
|
+
realpath,
|
|
11
|
+
readdir,
|
|
12
|
+
rmdir,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
|
|
17
|
+
export const digest = (value) =>
|
|
18
|
+
createHash("sha256").update(value).digest("hex");
|
|
19
|
+
export const home = () =>
|
|
20
|
+
path.resolve(
|
|
21
|
+
process.env.PATCHWORK_HOME || path.join(os.homedir(), ".patchwork"),
|
|
22
|
+
);
|
|
23
|
+
export async function regular(file) {
|
|
24
|
+
try {
|
|
25
|
+
const s = await lstat(file);
|
|
26
|
+
if (!s.isFile() || s.isSymbolicLink() || s.nlink !== 1)
|
|
27
|
+
throw new Error(`Refusing linked or non-regular file: ${file}`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (e.code !== "ENOENT") throw e;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function directory(dir) {
|
|
33
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
if (
|
|
35
|
+
(await lstat(dir)).isSymbolicLink() ||
|
|
36
|
+
(await realpath(dir)) !== path.resolve(dir)
|
|
37
|
+
)
|
|
38
|
+
throw new Error("Refusing a linked configuration directory.");
|
|
39
|
+
}
|
|
40
|
+
export async function read(file) {
|
|
41
|
+
await regular(file);
|
|
42
|
+
try {
|
|
43
|
+
return await readFile(file, "utf8");
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e.code === "ENOENT") return "";
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function write(file, value) {
|
|
50
|
+
await directory(path.dirname(file));
|
|
51
|
+
await regular(file);
|
|
52
|
+
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
53
|
+
const handle = await open(temp, "wx", 0o600);
|
|
54
|
+
try {
|
|
55
|
+
await handle.writeFile(value);
|
|
56
|
+
await handle.sync();
|
|
57
|
+
await handle.close();
|
|
58
|
+
await rename(temp, file);
|
|
59
|
+
} finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
await unlink(temp).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export const readJSON = async (file) => {
|
|
65
|
+
const text = await read(file);
|
|
66
|
+
return text ? JSON.parse(text) : null;
|
|
67
|
+
};
|
|
68
|
+
export const writeJSON = (file, value) =>
|
|
69
|
+
write(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
70
|
+
export const connectionFile = (root, client) =>
|
|
71
|
+
path.join(home(), "connections", `${digest(`${root}\0${client}`)}.json`);
|
|
72
|
+
|
|
73
|
+
const heldLocks = new AsyncLocalStorage();
|
|
74
|
+
function dead(pid) {
|
|
75
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, 0);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return e.code === "ESRCH";
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
// Lock order: connection -> sessions -> GitHub account. Never steal a live
|
|
84
|
+
// process's lock. Publish a nonempty directory atomically, so a crash cannot
|
|
85
|
+
// leave an ownerless acquisition. Unique owner names make stale cleanup safe
|
|
86
|
+
// even when another waiter has already replaced the abandoned directory.
|
|
87
|
+
export async function locked(file, fn) {
|
|
88
|
+
file = path.resolve(file);
|
|
89
|
+
const held = heldLocks.getStore() || new Set();
|
|
90
|
+
if (held.has(file)) throw new Error("Recursive Patchwork lock acquisition.");
|
|
91
|
+
await directory(path.dirname(file));
|
|
92
|
+
const lock = `${file}.lock`;
|
|
93
|
+
const owner = `${process.pid}-${crypto.randomUUID()}`;
|
|
94
|
+
const candidate = `${lock}.${owner}`;
|
|
95
|
+
await mkdir(candidate, { mode: 0o700 });
|
|
96
|
+
const handle = await open(path.join(candidate, owner), "wx", 0o600);
|
|
97
|
+
await handle.close();
|
|
98
|
+
const started = Date.now();
|
|
99
|
+
try {
|
|
100
|
+
for (;;) {
|
|
101
|
+
try {
|
|
102
|
+
await rename(candidate, lock);
|
|
103
|
+
break;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (
|
|
106
|
+
!["EEXIST", "ENOTEMPTY", "ENOTDIR", "EPERM", "EACCES"].includes(
|
|
107
|
+
e.code,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
throw e;
|
|
111
|
+
try {
|
|
112
|
+
const stat = await lstat(lock);
|
|
113
|
+
if (stat.isSymbolicLink()) throw new Error("Refusing a linked lock.");
|
|
114
|
+
if (stat.isDirectory()) {
|
|
115
|
+
const owners = await readdir(lock);
|
|
116
|
+
for (const name of owners) {
|
|
117
|
+
if (
|
|
118
|
+
/^\d+-[a-f0-9-]{36}$/.test(name) &&
|
|
119
|
+
dead(Number(name.split("-")[0]))
|
|
120
|
+
)
|
|
121
|
+
await unlink(path.join(lock, name)).catch((e) => {
|
|
122
|
+
if (e.code !== "ENOENT") throw e;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// rmdir cannot remove a newly published, nonempty lock.
|
|
126
|
+
await rmdir(lock).catch((e) => {
|
|
127
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(e.code)) throw e;
|
|
128
|
+
});
|
|
129
|
+
} else if (stat.isFile()) {
|
|
130
|
+
// Compatibility with the previous PID-file lock. unlink cannot
|
|
131
|
+
// remove a new directory lock if another cleaner won the race.
|
|
132
|
+
if (dead(Number(await read(lock))))
|
|
133
|
+
await unlink(lock).catch(() => {});
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error("Invalid Patchwork lock.");
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// Windows can report a sharing violation while another waiter is
|
|
139
|
+
// removing the stale directory. Retry the entire acquisition under
|
|
140
|
+
// the existing deadline; never treat a failed cleanup as ownership.
|
|
141
|
+
if (
|
|
142
|
+
e.code !== "ENOENT" &&
|
|
143
|
+
!(
|
|
144
|
+
process.platform === "win32" &&
|
|
145
|
+
["EPERM", "EACCES", "EBUSY"].includes(e.code)
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
if (Date.now() - started >= 60_000) {
|
|
151
|
+
const error = new Error(
|
|
152
|
+
"Another Patchwork operation is still running. Retry shortly; no delivery cursor was advanced.",
|
|
153
|
+
);
|
|
154
|
+
error.code = "LOCK_BUSY";
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
await new Promise((resolve) =>
|
|
158
|
+
setTimeout(resolve, 75 + Math.random() * 50),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return await heldLocks.run(new Set([...held, file]), fn);
|
|
164
|
+
} finally {
|
|
165
|
+
await unlink(path.join(lock, owner));
|
|
166
|
+
await rmdir(lock).catch((e) => {
|
|
167
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(e.code)) throw e;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
await unlink(path.join(candidate, owner)).catch(() => {});
|
|
172
|
+
await rmdir(candidate).catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function loadConnection(file, enabled = true) {
|
|
177
|
+
const c = await readJSON(file);
|
|
178
|
+
if (!c || c.version !== 1)
|
|
179
|
+
throw new Error("Run Patchwork connect in this project first.");
|
|
180
|
+
if (enabled && !c.enabled)
|
|
181
|
+
throw new Error(
|
|
182
|
+
"Patchwork is disconnected in this project. Run connect to enable it.",
|
|
183
|
+
);
|
|
184
|
+
return c;
|
|
185
|
+
}
|