@icebergsnow/patchwork-cli 0.0.0-stage → 0.2.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 +91 -2
- package/bin/patchwork.js +150 -0
- package/lib/board.js +630 -0
- package/lib/config.js +139 -0
- package/lib/connect.js +508 -0
- package/lib/connections.js +56 -0
- package/lib/github.js +391 -0
- package/lib/instructions.js +3 -0
- package/lib/mcp.js +108 -0
- package/lib/runtime.js +528 -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,528 @@
|
|
|
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
|
+
if ((await guard(this.file)).client === "codex") {
|
|
134
|
+
// Codex also runs hooks in ephemeral app helpers (e.g. composer
|
|
135
|
+
// suggestions). Only saved conversations belong on the board. Check
|
|
136
|
+
// native metadata before creating an identity, a lock, or any API calls;
|
|
137
|
+
// never read, store, or upload the transcript itself or its path.
|
|
138
|
+
if (input.transcript_path === null || input.transcript_path === "")
|
|
139
|
+
return {};
|
|
140
|
+
if (
|
|
141
|
+
typeof input.transcript_path !== "string" ||
|
|
142
|
+
!input.transcript_path.trim() ||
|
|
143
|
+
input.transcript_path.length > 32768 ||
|
|
144
|
+
input.transcript_path.includes("${") ||
|
|
145
|
+
input.transcript_path.includes("\0")
|
|
146
|
+
)
|
|
147
|
+
throw new Error(
|
|
148
|
+
"Codex transcript metadata is unavailable. Rerun connect with the current CLI, then restart/reload Codex and approve the updated hooks.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return locked(stateFile(this.file), async () => {
|
|
152
|
+
const c = await guard(this.file),
|
|
153
|
+
now = this.clock();
|
|
154
|
+
const store = (await readJSON(stateFile(this.file))) || { chats: {} };
|
|
155
|
+
const key = digest(`${c.client}\0${input.session_id}`);
|
|
156
|
+
let chat = store.chats[key];
|
|
157
|
+
if (!chat) {
|
|
158
|
+
const id = randomUUID();
|
|
159
|
+
chat = store.chats[key] = {
|
|
160
|
+
id,
|
|
161
|
+
chat_key: randomUUID(),
|
|
162
|
+
name: `${c.name} / ${id.slice(0, 6)}`,
|
|
163
|
+
ownerId: c.ownerId,
|
|
164
|
+
client: c.client,
|
|
165
|
+
registered: false,
|
|
166
|
+
registrationOperation: randomUUID(),
|
|
167
|
+
seen: {},
|
|
168
|
+
lastCheck: 0,
|
|
169
|
+
lastReminder: 0,
|
|
170
|
+
initialized: false,
|
|
171
|
+
pending: null,
|
|
172
|
+
};
|
|
173
|
+
// Save identity before a network write, so an uncertain response can retry safely.
|
|
174
|
+
await writeJSON(stateFile(this.file), store);
|
|
175
|
+
}
|
|
176
|
+
await guard(this.file, chat.id);
|
|
177
|
+
if (chat.retryAfter && now < chat.retryAfter) return {};
|
|
178
|
+
if (
|
|
179
|
+
input.hook_event_name === "PostToolUse" &&
|
|
180
|
+
now - chat.lastCheck < CHECK_INTERVAL
|
|
181
|
+
)
|
|
182
|
+
return {};
|
|
183
|
+
try {
|
|
184
|
+
const gh = this.client(c);
|
|
185
|
+
const { state, labelWarning } = await withBoard(
|
|
186
|
+
c,
|
|
187
|
+
gh,
|
|
188
|
+
async (sync) => {
|
|
189
|
+
await sync.poll(
|
|
190
|
+
input.hook_event_name === "PostToolUse"
|
|
191
|
+
? POLL_INTERVAL
|
|
192
|
+
: PROMPT_INTERVAL,
|
|
193
|
+
);
|
|
194
|
+
if (!chat.registered) {
|
|
195
|
+
const registration = await register(
|
|
196
|
+
gh,
|
|
197
|
+
c.board,
|
|
198
|
+
chat,
|
|
199
|
+
true,
|
|
200
|
+
chat.registrationOperation,
|
|
201
|
+
async () => {
|
|
202
|
+
await guard(this.file, chat.id);
|
|
203
|
+
await sync.authorize();
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
await sync.accept(registration);
|
|
207
|
+
chat.registered = true;
|
|
208
|
+
chat.retireOperation = null;
|
|
209
|
+
await writeJSON(stateFile(this.file), store);
|
|
210
|
+
}
|
|
211
|
+
const state = await sync.view();
|
|
212
|
+
if (!state.agents.some((a) => a.agent_id === chat.id && a.active))
|
|
213
|
+
throw new Error(
|
|
214
|
+
"This chat was retired on GitHub. Reconnect it explicitly.",
|
|
215
|
+
);
|
|
216
|
+
let labelWarning = false;
|
|
217
|
+
try {
|
|
218
|
+
await reconcileIntervention(
|
|
219
|
+
gh,
|
|
220
|
+
state,
|
|
221
|
+
async () => {
|
|
222
|
+
await guard(this.file, chat.id);
|
|
223
|
+
await sync.authorize();
|
|
224
|
+
},
|
|
225
|
+
(request) => sync.labels(request),
|
|
226
|
+
);
|
|
227
|
+
} catch {
|
|
228
|
+
labelWarning = true;
|
|
229
|
+
}
|
|
230
|
+
return { state, labelWarning };
|
|
231
|
+
},
|
|
232
|
+
this.clock,
|
|
233
|
+
);
|
|
234
|
+
const initial = !chat.initialized;
|
|
235
|
+
const hash = digest(
|
|
236
|
+
JSON.stringify([
|
|
237
|
+
state.instructions,
|
|
238
|
+
state.agents.map((a) => [a.agent_id, a.name, a.active]),
|
|
239
|
+
AGENT_INSTRUCTIONS,
|
|
240
|
+
]),
|
|
241
|
+
);
|
|
242
|
+
const fresh =
|
|
243
|
+
initial ||
|
|
244
|
+
chat.contextHash !== hash ||
|
|
245
|
+
input.hook_event_name === "SessionStart";
|
|
246
|
+
const open = state.requests.filter((r) => r.state === "open");
|
|
247
|
+
const requestHash = digest(
|
|
248
|
+
JSON.stringify(
|
|
249
|
+
open.map((r) => [
|
|
250
|
+
r.number,
|
|
251
|
+
r.acknowledgments,
|
|
252
|
+
r.needsHumanIntervention,
|
|
253
|
+
]),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
const due =
|
|
257
|
+
fresh ||
|
|
258
|
+
chat.requestHash !== requestHash ||
|
|
259
|
+
now - chat.lastReminder >= REMINDER_INTERVAL;
|
|
260
|
+
let context = "";
|
|
261
|
+
if (fresh)
|
|
262
|
+
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(
|
|
263
|
+
state.agents
|
|
264
|
+
.filter((a) => a.active)
|
|
265
|
+
.map((a) => ({ id: a.agent_id, name: a.name }))
|
|
266
|
+
.slice(0, 20),
|
|
267
|
+
)}. Full roster: read_messages.\n`;
|
|
268
|
+
if (due) {
|
|
269
|
+
context += `\nShared instructions: ${state.instructions || "(none; stop applying previously supplied shared instructions)"}\n`;
|
|
270
|
+
const pending = open.filter(
|
|
271
|
+
(r) =>
|
|
272
|
+
!r.acknowledgments.find((a) => a.agent_id === chat.id)?.response,
|
|
273
|
+
);
|
|
274
|
+
if (pending.length)
|
|
275
|
+
context += `\nRequests you have not evaluated: ${pending
|
|
276
|
+
.slice(0, 20)
|
|
277
|
+
.map((r) => `#${r.number}`)
|
|
278
|
+
.join(
|
|
279
|
+
", ",
|
|
280
|
+
)}${pending.length > 20 ? " (more remain)" : ""}. Read open requests and acknowledge each with your chat_key. Silence is not a decline.\n`;
|
|
281
|
+
const intervention = open.filter((r) => r.needsHumanIntervention);
|
|
282
|
+
if (intervention.length)
|
|
283
|
+
context += `\nNeeds Human Intervention: ${intervention
|
|
284
|
+
.slice(0, 20)
|
|
285
|
+
.map((r) => `#${r.number}`)
|
|
286
|
+
.join(
|
|
287
|
+
", ",
|
|
288
|
+
)}. Notify your user if this blocks you; continue independent work.\n`;
|
|
289
|
+
const noLongerUnanimous = open.filter(
|
|
290
|
+
(r) =>
|
|
291
|
+
!r.needsHumanIntervention &&
|
|
292
|
+
chat.interventions?.includes(r.number),
|
|
293
|
+
);
|
|
294
|
+
if (noLongerUnanimous.length)
|
|
295
|
+
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`;
|
|
296
|
+
if (open.length) {
|
|
297
|
+
const start = (chat.reminderOffset || 0) % open.length;
|
|
298
|
+
const batch = [...open.slice(start), ...open.slice(0, start)].slice(
|
|
299
|
+
0,
|
|
300
|
+
20,
|
|
301
|
+
);
|
|
302
|
+
context +=
|
|
303
|
+
"\nUnresolved requests (acknowledgment is not completion):\n";
|
|
304
|
+
for (const r of batch) {
|
|
305
|
+
const active = r.acknowledgments.filter((a) => a.active);
|
|
306
|
+
const count = (response) =>
|
|
307
|
+
active.filter((a) => a.response === response).length;
|
|
308
|
+
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`;
|
|
309
|
+
}
|
|
310
|
+
context +=
|
|
311
|
+
"Read replies and follow up on relevant pending work or clarification. If blocked, tell your user without waiting for all agents.\n";
|
|
312
|
+
chat.reminderOffset = (start + batch.length) % open.length;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (open.length && input.hook_event_name === "UserPromptSubmit")
|
|
316
|
+
context +=
|
|
317
|
+
"\nIf your user has explicitly expanded your scope, reevaluate affected open requests and update prior acknowledgments. Otherwise keep your existing scope.\n";
|
|
318
|
+
if (state.warnings.length)
|
|
319
|
+
context += `\n${state.warnings.slice(0, 5).join("\n")}\n`;
|
|
320
|
+
if (!initial) {
|
|
321
|
+
const ids = new Set(state.events.map((e) => e.id));
|
|
322
|
+
const removed = Object.keys(chat.seen).filter((id) => !ids.has(id));
|
|
323
|
+
if (removed.length)
|
|
324
|
+
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`;
|
|
325
|
+
for (const id of removed) delete chat.seen[id];
|
|
326
|
+
}
|
|
327
|
+
if (context.length > LIMIT - 1200)
|
|
328
|
+
context =
|
|
329
|
+
context.slice(0, LIMIT - 1300) +
|
|
330
|
+
"\nContext limited; use read_messages for remaining requests and warnings.\n";
|
|
331
|
+
if (initial) {
|
|
332
|
+
context +=
|
|
333
|
+
"\nWelcome: open requests and recent context. Full history is available with read_messages.\n";
|
|
334
|
+
let shown = 0;
|
|
335
|
+
for (const r of open.slice(0, 8)) {
|
|
336
|
+
const line = JSON.stringify({
|
|
337
|
+
...r,
|
|
338
|
+
body: r.body.slice(0, 220),
|
|
339
|
+
acknowledgments: undefined,
|
|
340
|
+
labels: undefined,
|
|
341
|
+
});
|
|
342
|
+
if (context.length + line.length < LIMIT - 1700) {
|
|
343
|
+
context += line + "\n";
|
|
344
|
+
shown++;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (open.length > shown)
|
|
348
|
+
context +=
|
|
349
|
+
"More open requests remain; use read_messages open_only=true and its nextCursor.\n";
|
|
350
|
+
for (const e of state.events.slice(-3)) {
|
|
351
|
+
const line = preview(e, 250);
|
|
352
|
+
if (context.length + line.length < LIMIT - 100)
|
|
353
|
+
context += line + "\n";
|
|
354
|
+
}
|
|
355
|
+
// Only mark events actually present in this complete snapshot, not a
|
|
356
|
+
// timestamp boundary that could swallow a concurrently created post.
|
|
357
|
+
for (const e of state.events) chat.seen[e.id] = e.version;
|
|
358
|
+
} else {
|
|
359
|
+
const selected = selectEvents(
|
|
360
|
+
state,
|
|
361
|
+
chat.seen,
|
|
362
|
+
LIMIT - context.length - 350,
|
|
363
|
+
);
|
|
364
|
+
for (const { event, line } of selected) {
|
|
365
|
+
context += `\n${line}\n`;
|
|
366
|
+
chat.seen[event.id] = event.version;
|
|
367
|
+
}
|
|
368
|
+
if (state.events.some((e) => chat.seen[e.id] !== e.version))
|
|
369
|
+
context +=
|
|
370
|
+
"\nMore updates remain. The next check continues delivery; read_messages can read them now.\n";
|
|
371
|
+
}
|
|
372
|
+
if (labelWarning)
|
|
373
|
+
context +=
|
|
374
|
+
"\nCould not refresh GitHub intervention labels; responses above remain authoritative.\n";
|
|
375
|
+
await guard(this.file, chat.id);
|
|
376
|
+
chat.initialized = true;
|
|
377
|
+
chat.contextHash = hash;
|
|
378
|
+
chat.requestHash = requestHash;
|
|
379
|
+
chat.interventions = open
|
|
380
|
+
.filter((r) => r.needsHumanIntervention)
|
|
381
|
+
.map((r) => r.number);
|
|
382
|
+
chat.lastCheck = now;
|
|
383
|
+
chat.lastSuccess = new Date(now).toISOString();
|
|
384
|
+
chat.retryAfter = 0;
|
|
385
|
+
chat.lastError = null;
|
|
386
|
+
if (due) chat.lastReminder = now;
|
|
387
|
+
await writeJSON(stateFile(this.file), store);
|
|
388
|
+
return context
|
|
389
|
+
? {
|
|
390
|
+
hookSpecificOutput: {
|
|
391
|
+
hookEventName: input.hook_event_name,
|
|
392
|
+
additionalContext: context,
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
: {};
|
|
396
|
+
} catch (e) {
|
|
397
|
+
// No failed snapshot advances delivery. A bounded cooldown prevents a
|
|
398
|
+
// tool loop from hammering GitHub while unavailable or rate limited.
|
|
399
|
+
const disk = await readJSON(stateFile(this.file));
|
|
400
|
+
if (disk?.chats[key]) {
|
|
401
|
+
disk.chats[key].retryAfter = Math.max(now + 60_000, e.retryAt || 0);
|
|
402
|
+
disk.chats[key].lastError = e.message;
|
|
403
|
+
await writeJSON(stateFile(this.file), disk);
|
|
404
|
+
}
|
|
405
|
+
throw e;
|
|
406
|
+
}
|
|
407
|
+
}).catch((e) => {
|
|
408
|
+
// A busy local reader is not a failed coding checkpoint. Leave this chat's
|
|
409
|
+
// cursor untouched and retry through the next normal hook.
|
|
410
|
+
if (e.code === "LOCK_BUSY") return {};
|
|
411
|
+
throw e;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
async tool(name, input) {
|
|
415
|
+
return locked(stateFile(this.file), async () => {
|
|
416
|
+
const c = await guard(this.file),
|
|
417
|
+
store = await readJSON(stateFile(this.file));
|
|
418
|
+
const chat = Object.values(store?.chats || {}).find(
|
|
419
|
+
(a) => a.chat_key === input.chat_key,
|
|
420
|
+
);
|
|
421
|
+
if (!chat)
|
|
422
|
+
throw new Error(
|
|
423
|
+
"Use the chat_key supplied by this chat's Patchwork hook. Start/reload the chat if no key has arrived.",
|
|
424
|
+
);
|
|
425
|
+
await guard(this.file, chat.id);
|
|
426
|
+
const gh = this.client(c);
|
|
427
|
+
const result = await withBoard(
|
|
428
|
+
c,
|
|
429
|
+
gh,
|
|
430
|
+
async (sync) => {
|
|
431
|
+
await sync.poll(PROMPT_INTERVAL);
|
|
432
|
+
const state = await sync.view();
|
|
433
|
+
if (!state.agents.some((a) => a.agent_id === chat.id && a.active))
|
|
434
|
+
throw new Error(
|
|
435
|
+
"This chat is no longer registered. Reconnect it explicitly.",
|
|
436
|
+
);
|
|
437
|
+
if (name === "read_messages") {
|
|
438
|
+
const items = input.open_only
|
|
439
|
+
? state.requests.filter((r) => r.state === "open")
|
|
440
|
+
: state.events;
|
|
441
|
+
const result = page(items, c, input);
|
|
442
|
+
return {
|
|
443
|
+
agents: state.agents,
|
|
444
|
+
instructions: state.instructions,
|
|
445
|
+
warnings: state.warnings,
|
|
446
|
+
...result,
|
|
447
|
+
requests: input.open_only
|
|
448
|
+
? result.messages
|
|
449
|
+
: state.requests.filter((r) => r.state === "open"),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const actions = {
|
|
453
|
+
post_message: "post",
|
|
454
|
+
acknowledge_request: "ack",
|
|
455
|
+
resolve_request: "resolve",
|
|
456
|
+
};
|
|
457
|
+
if (!actions[name]) throw new Error("Unknown board tool.");
|
|
458
|
+
const data = { ...input, chat_key: undefined, action: actions[name] };
|
|
459
|
+
const fingerprint = digest(JSON.stringify(data));
|
|
460
|
+
if (chat.pending && chat.pending.fingerprint !== fingerprint)
|
|
461
|
+
throw new Error(
|
|
462
|
+
"An earlier write has an uncertain result. Retry that exact operation first to avoid duplicate or reordered posts.",
|
|
463
|
+
);
|
|
464
|
+
const retry = !!chat.pending;
|
|
465
|
+
chat.pending ??= { fingerprint, id: randomUUID() };
|
|
466
|
+
await writeJSON(stateFile(this.file), store);
|
|
467
|
+
let result;
|
|
468
|
+
try {
|
|
469
|
+
result = await perform(
|
|
470
|
+
gh,
|
|
471
|
+
c,
|
|
472
|
+
chat,
|
|
473
|
+
data,
|
|
474
|
+
chat.pending.id,
|
|
475
|
+
async () => {
|
|
476
|
+
await guard(this.file, chat.id);
|
|
477
|
+
await sync.authorize();
|
|
478
|
+
},
|
|
479
|
+
retry,
|
|
480
|
+
);
|
|
481
|
+
} catch (e) {
|
|
482
|
+
// Validation and definite rejections can safely be corrected. Unknown
|
|
483
|
+
// network/5xx results retain the same operation ID for a safe retry.
|
|
484
|
+
if (
|
|
485
|
+
!e.uncertain &&
|
|
486
|
+
(!("status" in e) || (e.status >= 400 && e.status < 500))
|
|
487
|
+
) {
|
|
488
|
+
chat.pending = null;
|
|
489
|
+
await writeJSON(stateFile(this.file), store);
|
|
490
|
+
}
|
|
491
|
+
throw e;
|
|
492
|
+
}
|
|
493
|
+
let labelWarning;
|
|
494
|
+
try {
|
|
495
|
+
await sync.accept(result);
|
|
496
|
+
await reconcileIntervention(
|
|
497
|
+
gh,
|
|
498
|
+
await sync.view(),
|
|
499
|
+
async () => {
|
|
500
|
+
await guard(this.file, chat.id);
|
|
501
|
+
await sync.authorize();
|
|
502
|
+
},
|
|
503
|
+
(request) => sync.labels(request),
|
|
504
|
+
);
|
|
505
|
+
} catch {
|
|
506
|
+
labelWarning =
|
|
507
|
+
"Post saved; intervention labels will refresh on a later check.";
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
id: result.id,
|
|
511
|
+
number: result.number,
|
|
512
|
+
url: result.html_url,
|
|
513
|
+
unchanged: result.unchanged,
|
|
514
|
+
...(labelWarning ? { warning: labelWarning } : {}),
|
|
515
|
+
};
|
|
516
|
+
},
|
|
517
|
+
this.clock,
|
|
518
|
+
);
|
|
519
|
+
// Keep the operation ID until the shared cache was durably saved too.
|
|
520
|
+
// A crash/failure before this point retries the same GitHub write.
|
|
521
|
+
if (name !== "read_messages") {
|
|
522
|
+
chat.pending = null;
|
|
523
|
+
await writeJSON(stateFile(this.file), store);
|
|
524
|
+
}
|
|
525
|
+
return result;
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|