viewable-one 0.0.0-stage → 0.12.2
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/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +767 -0
- package/lib/codexApps.mjs +344 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1640 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +145 -0
- package/lib/runnerWorkspace.mjs +177 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// ANSWERS AND MESSAGES OVER ROWS (brief 30, W2b, 2026-09-11).
|
|
2
|
+
//
|
|
3
|
+
// A runner-spawned child now reaches the board through the hosted endpoint
|
|
4
|
+
// (lib/hosted.mjs, brief 30 W2a) under its own run key (X-Glytch-Session,
|
|
5
|
+
// session.key) instead of a locally-spawned stdio server. This file is what
|
|
6
|
+
// replaces the mirror-and-receipt push runner.mjs used to make while the
|
|
7
|
+
// child was still running - `answerPoll`'s `latestGraphWriteMs` scan and
|
|
8
|
+
// `undeliveredAnswers` over `inbox/ops/*.result.txt` receipts and `canvas/`
|
|
9
|
+
// (see the dated retirement note above `undeliveredAnswers` in
|
|
10
|
+
// questions.mjs). The new door is the SAME one everything else on the hosted
|
|
11
|
+
// endpoint uses: `collect_session`, the tool `check_answer` already used to
|
|
12
|
+
// answer "did HE answer me", now asked for the whole run.
|
|
13
|
+
//
|
|
14
|
+
// `collect_session` (supabase/functions/board-mcp/tools.mjs) already matches
|
|
15
|
+
// "this run's own rows" server-side - every question stamped with THIS run's
|
|
16
|
+
// sessionKey/reportKey (`applyOps` stamps every node it creates with
|
|
17
|
+
// `data.sessionKey: store.sessionKey`, and `X-Glytch-Session` is what makes
|
|
18
|
+
// that the RUN's key rather than the runner's own OAuth token's), and every
|
|
19
|
+
// `session_messages` row addressed to it, drained (`delivered_at` stamped)
|
|
20
|
+
// on read (supabase/functions/_shared/rows.mjs `takeMessages`). What the
|
|
21
|
+
// endpoint does NOT deduplicate is an ANSWERED QUESTION: it returns every
|
|
22
|
+
// question this key ever asked that carries an answer, on every single call,
|
|
23
|
+
// so a poller has to remember which ones it has already delivered. A MESSAGE
|
|
24
|
+
// is different - the server marks it delivered the moment it is fetched, so
|
|
25
|
+
// `delivered_at` is what survives a runner restart for it, the same way a
|
|
26
|
+
// file's absence is what survives one for a reply file.
|
|
27
|
+
//
|
|
28
|
+
// THE TOOL'S ANSWER IS TEXT FOR A MODEL, not a second JSON API - there is no
|
|
29
|
+
// structured door into the same call, so this parses the exact shape the
|
|
30
|
+
// handler writes (its "ANSWERED QUESTIONS:" / "MESSAGES FROM OTHER SESSIONS:"
|
|
31
|
+
// / "next_since:" lines, `flat()`-ed to one line per row on that end already,
|
|
32
|
+
// which is what makes a per-line parser safe here). The sections also come in
|
|
33
|
+
// one fixed order, so the parser takes each header once and only moving
|
|
34
|
+
// forward: a header that turns up again or out of order is text, never a
|
|
35
|
+
// section. Messages come last, so nothing a peer sends (its label or its
|
|
36
|
+
// text) can open the answered section and pass as the human's ruling (#324).
|
|
37
|
+
//
|
|
38
|
+
// A MESSAGE'S IMAGES (2026-09-11, brief 30 Move 2 M1b) ride the same text
|
|
39
|
+
// reply, one line below the message they belong to - a bare JSON object,
|
|
40
|
+
// the same "put a JSON block in a text result" idiom get_node already uses
|
|
41
|
+
// for a node's own body. `parseCollected` below reads it back onto the
|
|
42
|
+
// message object as `images`. Keep the payload intact so each transport
|
|
43
|
+
// validates it before delivery; invalid pictures must not become text-only
|
|
44
|
+
// success or disappear from late-reply recovery.
|
|
45
|
+
|
|
46
|
+
import { createHash } from "node:crypto";
|
|
47
|
+
import { callTool as hostedCallTool } from "./hosted.mjs";
|
|
48
|
+
|
|
49
|
+
/** Parse collect_session's reply into rows a poller can act on. Tolerant of
|
|
50
|
+
* every shape the tool actually writes: the "Nothing collected" sentence
|
|
51
|
+
* (no section headers at all, just an intro line and `next_since:`), a
|
|
52
|
+
* subset of the four sections, or all of them.
|
|
53
|
+
*
|
|
54
|
+
* IMAGES (2026-09-11, brief 30 Move 2 M1b): a message that carries pictures
|
|
55
|
+
* gets one more line right after its own, ` {"images":[...]}` - the exact
|
|
56
|
+
* shape board-mcp/tools.mjs's collect_session handler writes. It is parsed
|
|
57
|
+
* here and folded onto the message object that line follows, as `images`;
|
|
58
|
+
* a message with none gets no such line and no such field, so a caller
|
|
59
|
+
* that has never heard of images reads this exactly as it always has. A
|
|
60
|
+
* malformed or misplaced JSON line (no preceding message in this poll,
|
|
61
|
+
* or not valid JSON) is ignored rather than thrown - the same tolerance
|
|
62
|
+
* this function already extends to a section header it does not
|
|
63
|
+
* recognize. */
|
|
64
|
+
// The order collect_session writes its sections in, and what each one is
|
|
65
|
+
// read as here (null: not read).
|
|
66
|
+
const SECTIONS = [
|
|
67
|
+
["ANSWERED QUESTIONS:", "answered"],
|
|
68
|
+
["STILL OPEN:", null],
|
|
69
|
+
["COMMENTS HE WROTE:", null],
|
|
70
|
+
["MESSAGES FROM OTHER SESSIONS:", "messages"],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export function parseCollected(text) {
|
|
74
|
+
const answered = [];
|
|
75
|
+
const messages = [];
|
|
76
|
+
let nextSince = 0;
|
|
77
|
+
let section = null;
|
|
78
|
+
let reached = -1;
|
|
79
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
80
|
+
const since = line.match(/^next_since:\s*(\d+)/);
|
|
81
|
+
if (since) {
|
|
82
|
+
nextSince = Number(since[1]);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const header = SECTIONS.findIndex(([marker]) => marker === line);
|
|
86
|
+
if (header !== -1) {
|
|
87
|
+
// Only forward, each once: a repeated or out-of-order header is a
|
|
88
|
+
// line someone wrote, not the server's (#324).
|
|
89
|
+
if (header > reached) {
|
|
90
|
+
reached = header;
|
|
91
|
+
section = SECTIONS[header][1];
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (section === "answered") {
|
|
96
|
+
const m = line.match(/^ {2}(\S+) "(.*)" -> (.*)$/);
|
|
97
|
+
if (m) answered.push({ id: m[1], question: m[2], decision: m[3] });
|
|
98
|
+
} else if (section === "messages") {
|
|
99
|
+
const m = line.match(/^ {2}\[message from (.*?)\] (.*)$/);
|
|
100
|
+
if (m) {
|
|
101
|
+
messages.push({ from: m[1], text: m[2] });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const imagesLine = line.match(/^ {2}(\{.*\})$/);
|
|
105
|
+
if (imagesLine && messages.length > 0) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(imagesLine[1]);
|
|
108
|
+
// The row's id (item 75): what the next call's `delivered` names.
|
|
109
|
+
if (typeof parsed.id === "number" || typeof parsed.id === "string") messages[messages.length - 1].id = parsed.id;
|
|
110
|
+
if (Object.hasOwn(parsed, "images")) {
|
|
111
|
+
messages[messages.length - 1].images = parsed.images;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* not the images line after all - leave the message as it was */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { answered, messages, nextSince };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One call to collect_session, resolved and parsed. Never throws: a network
|
|
123
|
+
* or auth failure is logged and read the same as an empty poll - a spawned
|
|
124
|
+
* child's own turn is not this call's to fail. */
|
|
125
|
+
export async function pollCollectSession({
|
|
126
|
+
callTool = hostedCallTool,
|
|
127
|
+
sessionKey,
|
|
128
|
+
since = 0,
|
|
129
|
+
env,
|
|
130
|
+
log = () => {},
|
|
131
|
+
// THE IDS THE CHILD TOOK since the last call (item 75). Always sent, so
|
|
132
|
+
// the endpoint hands messages back unstamped until one names them.
|
|
133
|
+
delivered = [],
|
|
134
|
+
} = {}) {
|
|
135
|
+
let result;
|
|
136
|
+
try {
|
|
137
|
+
result = await callTool("collect_session", { since, delivered }, { sessionKey, env });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
log(`collect_session failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
140
|
+
return { ok: false, answered: [], messages: [], nextSince: since };
|
|
141
|
+
}
|
|
142
|
+
if (result.isError) {
|
|
143
|
+
log(`collect_session refused: ${result.text}`);
|
|
144
|
+
return { ok: false, answered: [], messages: [], nextSince: since };
|
|
145
|
+
}
|
|
146
|
+
// IMAGES RIDE ALONGSIDE THE TEXT REPLY, WHOLE-CALL (item 6): there is no
|
|
147
|
+
// per-message attribution in a flattened text protocol, so they are
|
|
148
|
+
// attached below only when this poll's whole batch is unambiguous - see
|
|
149
|
+
// `createRowsAnswerPoll`. Converted to the SAME data-URL shape a reply
|
|
150
|
+
// file's `images` array already carries (`data:<mediaType>;base64,<data>`
|
|
151
|
+
// - src/folder/spawnRequest.ts:376, child.mjs's `imageAttachments`), so
|
|
152
|
+
// `userMessage`/`imageAttachments` need no second shape to understand.
|
|
153
|
+
const images = (result.images ?? []).map((i) => `data:${i.mediaType};base64,${i.data}`);
|
|
154
|
+
return { ok: true, ...parseCollected(result.text), images };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The words a child reads for a ruling that arrived after ask_user had
|
|
158
|
+
* already returned. Byte for byte what the old mirror-based `answerPoll`
|
|
159
|
+
* said, so a session told about a late answer reads the same sentence it
|
|
160
|
+
* always has. */
|
|
161
|
+
export const formatAnswer = (a) =>
|
|
162
|
+
`The human answered a question you asked on the board. It arrived after ` +
|
|
163
|
+
`ask_user had already returned, so this is the only place you will see it.\n\n` +
|
|
164
|
+
`Q: ${a.question}\n` +
|
|
165
|
+
`A: ${a.decision}\n\n` +
|
|
166
|
+
`That is his ruling. Act on it, and do not ask it again.`;
|
|
167
|
+
|
|
168
|
+
/** The same attribution wrapper a reply file's `from` field gets
|
|
169
|
+
* (child.mjs's `watchReplies`), so a peer's message reads identically
|
|
170
|
+
* whichever door delivered it. */
|
|
171
|
+
export const formatMessage = (m) => `[message from ${m.from}] ${m.text}`;
|
|
172
|
+
|
|
173
|
+
/** Preserve the complete attachment payload for transport validation and
|
|
174
|
+
* late-reply recovery. Presence, not validity, chooses the per-message
|
|
175
|
+
* payload over the legacy single-message fallback. */
|
|
176
|
+
function deliverable(message, fallback = []) {
|
|
177
|
+
return { text: formatMessage(message), images: message.images !== undefined ? message.images : fallback };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A stateful poller: one `since` watermark plus one delivered-question set,
|
|
181
|
+
* both scoped to this run and kept in memory only. A runner restart loses
|
|
182
|
+
* them - accepted the same way `scannedMirrorAt` accepted it before this
|
|
183
|
+
* (SPAWN.md, "a runner that dies mid-session leaks its concurrency slot") -
|
|
184
|
+
* and a MESSAGE needs no such set at all: the endpoint's own `delivered_at`
|
|
185
|
+
* is what keeps it from coming back, in this process or the next.
|
|
186
|
+
*
|
|
187
|
+
* `poll()` is answerPoll-shaped for child.mjs's `runStreamingChild`: an
|
|
188
|
+
* array of `{text, onDelivered}`. `onDelivered` marks a QUESTION delivered;
|
|
189
|
+
* a message needs no bookkeeping on success, because the server already
|
|
190
|
+
* will not hand it back. If `sendToChild` never calls `onDelivered` (the
|
|
191
|
+
* child's stdin was already gone), the question stays undelivered and is
|
|
192
|
+
* picked up by `promoteLateRows` below - a message in that same situation
|
|
193
|
+
* has already been marked delivered server-side and is the one gap this
|
|
194
|
+
* design accepts, spelled out where `promoteLateRows` is defined. */
|
|
195
|
+
/** How close a repeat of the last message must follow it to be dropped. */
|
|
196
|
+
export const REPEAT_WINDOW_MS = 30_000;
|
|
197
|
+
|
|
198
|
+
export function createRowsAnswerPoll({ callTool = hostedCallTool, sessionKey, env, log = () => {}, now = Date.now, realtime, pushPollMs = 60_000 } = {}) {
|
|
199
|
+
let since = 0;
|
|
200
|
+
let dirty = true;
|
|
201
|
+
let lastPoll = -Infinity;
|
|
202
|
+
let wake = null;
|
|
203
|
+
const unsubscribe = realtime?.subscribe?.((event) => {
|
|
204
|
+
if (event.type !== "joined" && event.type !== "left" &&
|
|
205
|
+
!(event.type === "reply" && event.payload?.session_key === sessionKey)) return;
|
|
206
|
+
dirty = true;
|
|
207
|
+
wake?.();
|
|
208
|
+
});
|
|
209
|
+
const delivered = new Set();
|
|
210
|
+
// DELIVERY IS THE CHILD TAKING IT (round 4 item 75): a message's id goes
|
|
211
|
+
// into `toAck` only once `sendToChild` accepted it (its onDelivered), and
|
|
212
|
+
// the next call carries those ids so the endpoint stamps them. Until then
|
|
213
|
+
// the endpoint keeps handing the row back; `offered` skips the ones already
|
|
214
|
+
// put in front of the child. A message with no id (an older endpoint) is
|
|
215
|
+
// stamped on read there, as before.
|
|
216
|
+
const offered = new Set();
|
|
217
|
+
let toAck = [];
|
|
218
|
+
const took = (m) => { if (m.id !== undefined) toAck.push(m.id); };
|
|
219
|
+
// The last message put in front of this session (brief 52 round 1, Codex
|
|
220
|
+
// review: two app instances deliver the same held note, since every open
|
|
221
|
+
// tab delivers). The same sender, words and pictures again within
|
|
222
|
+
// REPEAT_WINDOW_MS is that second copy, and is dropped.
|
|
223
|
+
let last = null;
|
|
224
|
+
const repeats = (m, images) => {
|
|
225
|
+
const key = JSON.stringify([m.from, m.text, images]);
|
|
226
|
+
const at = now();
|
|
227
|
+
if (last !== null && last.key === key && at - last.at <= REPEAT_WINDOW_MS) {
|
|
228
|
+
if (last.accepted) took(m);
|
|
229
|
+
else last.copies.push(m);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
last = { key, at, accepted: false, copies: [] };
|
|
233
|
+
return false;
|
|
234
|
+
};
|
|
235
|
+
const poll = async () => {
|
|
236
|
+
// Delivery acknowledgements also fetch the next queued batch. Only an
|
|
237
|
+
// idle session may wait for the push safety interval.
|
|
238
|
+
if (realtime?.joined && !dirty && toAck.length === 0 && now() - lastPoll < pushPollMs) return [];
|
|
239
|
+
dirty = false;
|
|
240
|
+
lastPoll = now();
|
|
241
|
+
// Only retire IDs serialized into this request, not deliveries that
|
|
242
|
+
// arrive while collect_session is in flight.
|
|
243
|
+
const acking = toAck.slice();
|
|
244
|
+
const collected = await pollCollectSession({ callTool, sessionKey, since, env, log, delivered: acking });
|
|
245
|
+
if (!collected.ok) { dirty = true; return []; }
|
|
246
|
+
toAck = toAck.filter((id) => !acking.includes(id));
|
|
247
|
+
if (collected.nextSince > since) since = collected.nextSince;
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const a of collected.answered) {
|
|
250
|
+
if (delivered.has(a.id)) continue;
|
|
251
|
+
out.push({
|
|
252
|
+
deliveryKey: `answer:${a.id}`,
|
|
253
|
+
text: formatAnswer(a),
|
|
254
|
+
onDelivered: async () => {
|
|
255
|
+
delivered.add(a.id);
|
|
256
|
+
log(`delivered the answer to ${a.id}`);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// IMAGES, PER MESSAGE (brief 52 ruling 10). collect_session writes a
|
|
261
|
+
// message's pictures on the line right under it, so `parseCollected` has
|
|
262
|
+
// already put them on the message they belong to. The old whole-call
|
|
263
|
+
// `collected.images` (native image blocks on the reply, which the tool
|
|
264
|
+
// does not send) is the fallback for a batch of exactly one message.
|
|
265
|
+
for (const m of collected.messages) {
|
|
266
|
+
if (m.id !== undefined && offered.has(m.id)) continue;
|
|
267
|
+
if (m.id !== undefined) offered.add(m.id);
|
|
268
|
+
const delivery = deliverable(m, collected.messages.length === 1 ? collected.images : []);
|
|
269
|
+
if (repeats(m, delivery.images)) {
|
|
270
|
+
// A copy is acknowledged only after the original was accepted.
|
|
271
|
+
// In particular, invalid images must leave every source row unacked.
|
|
272
|
+
log(`dropped a repeat of the last message from ${m.from}`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const pending = last;
|
|
276
|
+
out.push({
|
|
277
|
+
text: delivery.text,
|
|
278
|
+
...(!(Array.isArray(delivery.images) && delivery.images.length === 0) ? { images: delivery.images } : {}),
|
|
279
|
+
onDelivered: async () => {
|
|
280
|
+
if (pending.accepted) return;
|
|
281
|
+
pending.accepted = true;
|
|
282
|
+
took(m);
|
|
283
|
+
for (const copy of pending.copies) took(copy);
|
|
284
|
+
pending.copies.length = 0;
|
|
285
|
+
log(`delivered a message from ${m.from}`);
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
};
|
|
291
|
+
// A child installs its existing delivery callback; retries replace it.
|
|
292
|
+
poll.subscribe = (fn) => { wake = fn; };
|
|
293
|
+
return {
|
|
294
|
+
close: () => { wake = null; unsubscribe?.(); },
|
|
295
|
+
poll,
|
|
296
|
+
since: () => since,
|
|
297
|
+
hasDelivered: (id) => delivered.has(id),
|
|
298
|
+
/** A question the runner asked for ITSELF (a permission question, see
|
|
299
|
+
* permissionAsk.mjs) and reads the answer to on its own. Marked delivered
|
|
300
|
+
* so neither the live poll nor the late sweep hands it to the session as
|
|
301
|
+
* a message: its answer resolves the pending request instead. */
|
|
302
|
+
claim: (id) => { delivered.add(id); },
|
|
303
|
+
/** Ids the child took that no call has stamped yet; the late sweep sends them. */
|
|
304
|
+
pendingAcks: () => toAck.slice(),
|
|
305
|
+
acked: (ids) => { toAck = toAck.filter((id) => !ids.includes(id)); },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A row-derived id stable enough to survive a duplicate promotion attempt
|
|
310
|
+
* without filing twice - the same reservation trick `lateReplyRequestId`
|
|
311
|
+
* uses for a reply FILE (hash the thing that names it), spelled for a row
|
|
312
|
+
* instead of a filename. Messages carry no id of their own from
|
|
313
|
+
* collect_session's text reply, so their tag is a hash of who sent it and
|
|
314
|
+
* what it said; two identical messages from the same sender promote to the
|
|
315
|
+
* same request, which is the same content-addressed behaviour a repeated
|
|
316
|
+
* reply FILE already has under `lateReplyRequestId`. */
|
|
317
|
+
export const lateRowRequestId = (requestId, tag) =>
|
|
318
|
+
`spawn-reply-${createHash("sha256").update(`${requestId} ${tag}`).digest("hex")}`;
|
|
319
|
+
|
|
320
|
+
/** After the child has exited, ONE more collect_session call - the row
|
|
321
|
+
* equivalent of `promoteLateReplies` sweeping a `.reply-*.json` written
|
|
322
|
+
* after stdin closed. A late ANSWER or MESSAGE for a PERSISTENT session
|
|
323
|
+
* becomes a resume request through the exact function a late reply file
|
|
324
|
+
* already uses (`promoteReplyToRequest`, fed a row instead of a file) -
|
|
325
|
+
* the worktree, the resume session id, and "was this ever approved" are
|
|
326
|
+
* all its, unchanged; it already refuses (returns null) for a one-shot
|
|
327
|
+
* session via its own `acceptsMessages` check, which is what makes "log
|
|
328
|
+
* and drop, same as today" the natural result of calling it here too
|
|
329
|
+
* rather than a second branch to keep in sync with the first.
|
|
330
|
+
*
|
|
331
|
+
* KNOWN GAP, spelled out rather than hidden: a MESSAGE that arrived WHILE
|
|
332
|
+
* the child was still running, in the same poll tick the child happened to
|
|
333
|
+
* exit on, is marked delivered server-side by that poll (collect_session's
|
|
334
|
+
* own `takeMessages` stamps `delivered_at` on fetch, not on successful
|
|
335
|
+
* delivery to a pipe) whether or not `sendToChild` actually got to write
|
|
336
|
+
* it - `child.mjs`'s `watchAnswers` breaks out of its delivery loop the
|
|
337
|
+
* moment the child is settled, before calling `onDelivered`. Such a
|
|
338
|
+
* message will not be seen again by THIS poll (it is no longer
|
|
339
|
+
* undelivered on the server) or by this sweep (it is not "late" - it was
|
|
340
|
+
* already fetched). It is the one situation this design does not recover,
|
|
341
|
+
* and it is symmetric with why a message needs no in-memory delivered set
|
|
342
|
+
* at all: the same field that makes redelivery impossible also makes this
|
|
343
|
+
* one narrow race unrecoverable. An ANSWERED QUESTION has no such gap - it
|
|
344
|
+
* carries no delivered_at, so it is simply asked for again next time,
|
|
345
|
+
* here or on the next live poll. */
|
|
346
|
+
export async function promoteLateRows(
|
|
347
|
+
root,
|
|
348
|
+
id,
|
|
349
|
+
// `promote` has no default: the only caller left, `runnerCompletion.mjs`,
|
|
350
|
+
// always passes `runnerApi.resume` (item 6 of brief 30 Move 2b) - there is
|
|
351
|
+
// no file-based request to fall back to promoting any more
|
|
352
|
+
// (`lateReplies.mjs`, deleted the same pass).
|
|
353
|
+
{ poller, callTool = hostedCallTool, sessionKey, env, promote } = {},
|
|
354
|
+
log = () => {},
|
|
355
|
+
) {
|
|
356
|
+
// THE LAST ACKS GO WITH THE SWEEP (item 75): what the child took is
|
|
357
|
+
// stamped here, and what comes back is what never reached it.
|
|
358
|
+
const acking = poller.pendingAcks?.() ?? [];
|
|
359
|
+
const collected = await pollCollectSession({ callTool, sessionKey, since: poller.since(), env, log, delivered: acking });
|
|
360
|
+
if (!collected.ok) return { filed: [], dropped: [] };
|
|
361
|
+
poller.acked?.(acking);
|
|
362
|
+
const late = [];
|
|
363
|
+
for (const a of collected.answered) {
|
|
364
|
+
if (poller.hasDelivered(a.id)) continue;
|
|
365
|
+
late.push({ tag: `q:${a.id}`, text: formatAnswer(a) });
|
|
366
|
+
}
|
|
367
|
+
for (const m of collected.messages) {
|
|
368
|
+
const hash = createHash("sha256").update(`${m.from} ${m.text}`).digest("hex").slice(0, 16);
|
|
369
|
+
const delivery = deliverable(m, collected.messages.length === 1 ? collected.images : []);
|
|
370
|
+
late.push({ tag: `m:${hash}`, text: delivery.text, images: delivery.images, message: { from: m.from, text: m.text } });
|
|
371
|
+
}
|
|
372
|
+
const filed = [];
|
|
373
|
+
// The messages no run will carry (item 75): completion records them on
|
|
374
|
+
// the sidecar so the card can say "did not reach the session".
|
|
375
|
+
const dropped = [];
|
|
376
|
+
for (const item of late) {
|
|
377
|
+
const promotionId = lateRowRequestId(id, item.tag);
|
|
378
|
+
const newId = await promote(root, id, { text: item.text, attributed: item.text, images: item.images }, log, promotionId);
|
|
379
|
+
if (newId) {
|
|
380
|
+
filed.push(newId);
|
|
381
|
+
log(`[${id}] a late row (${item.tag}) arrived after the session ended; filed ${newId} to continue it`);
|
|
382
|
+
} else {
|
|
383
|
+
if (item.message) dropped.push(item.message);
|
|
384
|
+
log(`[${id}] a late row (${item.tag}) arrived after a one-shot session ended; logged and dropped`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { filed, dropped };
|
|
388
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Live transcript chunks are authoritative. Sends are serialized, and a
|
|
2
|
+
// failed batch retains its sequence number: board-runner upserts on
|
|
3
|
+
// (run_id, seq), so retrying after a lost HTTP response cannot duplicate it.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/** Stream a run's log to hosted rows. tick() is safe to call without awaiting
|
|
7
|
+
* it; settle() closes input and drains all earlier sends before returning.
|
|
8
|
+
* Failed events remain queued for the next tick or settlement retry. */
|
|
9
|
+
export function makeRowEventStreamer({ send, log = () => {} } = {}) {
|
|
10
|
+
let seq = 0;
|
|
11
|
+
let pending = "";
|
|
12
|
+
let closed = false;
|
|
13
|
+
let received = 0;
|
|
14
|
+
const streamedHash = createHash("sha256");
|
|
15
|
+
const queue = [];
|
|
16
|
+
let inFlight = null;
|
|
17
|
+
let settlement = null;
|
|
18
|
+
|
|
19
|
+
const enqueue = (kind, text) => {
|
|
20
|
+
if (text !== "") queue.push({ seq: ++seq, kind, text });
|
|
21
|
+
};
|
|
22
|
+
const flushPending = () => {
|
|
23
|
+
enqueue("log", pending);
|
|
24
|
+
pending = "";
|
|
25
|
+
};
|
|
26
|
+
const drain = () => {
|
|
27
|
+
if (inFlight) return inFlight;
|
|
28
|
+
inFlight = (async () => {
|
|
29
|
+
while (queue.length > 0) {
|
|
30
|
+
const row = queue[0];
|
|
31
|
+
try {
|
|
32
|
+
const outcome = await send([row]);
|
|
33
|
+
if (!outcome.ok) throw new Error(outcome.error ?? "unknown error");
|
|
34
|
+
} catch (error) {
|
|
35
|
+
log(`events flush failed (seq ${row.seq}): ${error?.message ?? error}`);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
queue.shift();
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
})().finally(() => { inFlight = null; });
|
|
42
|
+
return inFlight;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
onChunk(chunk) {
|
|
47
|
+
if (closed) return;
|
|
48
|
+
pending += chunk;
|
|
49
|
+
received += chunk.length;
|
|
50
|
+
streamedHash.update(chunk);
|
|
51
|
+
},
|
|
52
|
+
async tick() {
|
|
53
|
+
if (closed) return;
|
|
54
|
+
flushPending();
|
|
55
|
+
return drain();
|
|
56
|
+
},
|
|
57
|
+
settle(finalText, { dropped = 0 } = {}) {
|
|
58
|
+
if (settlement) return settlement;
|
|
59
|
+
if (!closed) {
|
|
60
|
+
closed = true;
|
|
61
|
+
flushPending();
|
|
62
|
+
const text = finalText ?? "";
|
|
63
|
+
// A capped ring or a retry receipt can rewrite the final text. It
|
|
64
|
+
// cannot be indexed by a live-stream offset. Only append a suffix
|
|
65
|
+
// when the final view has the exact streamed prefix; otherwise the
|
|
66
|
+
// live chunks already carry the transcript without those wrappers.
|
|
67
|
+
if (received === 0) enqueue("log", text);
|
|
68
|
+
else if (dropped === 0 && text.length > received &&
|
|
69
|
+
createHash("sha256").update(text.slice(0, received)).digest("hex") === streamedHash.digest("hex")) {
|
|
70
|
+
enqueue("log", text.slice(received));
|
|
71
|
+
}
|
|
72
|
+
if (dropped > 0) enqueue("cut", `[The local log summary elided ${dropped} characters; the streamed transcript is retained above.]`);
|
|
73
|
+
}
|
|
74
|
+
settlement = (async () => {
|
|
75
|
+
// An earlier drain may already have observed an empty queue before
|
|
76
|
+
// its promise settled; run a fresh drain for the closing rows too.
|
|
77
|
+
if (inFlight) await inFlight;
|
|
78
|
+
let ok = await drain();
|
|
79
|
+
// Retry once at exit, using the same immutable rows and sequence ids.
|
|
80
|
+
if (!ok) ok = await drain();
|
|
81
|
+
return { ok };
|
|
82
|
+
})().finally(() => { settlement = null; });
|
|
83
|
+
return settlement;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// RunEngine is the boundary shared by every coding engine. The runner owns
|
|
2
|
+
// approval, worktrees, receipts, and budgets; adapters own only their child
|
|
3
|
+
// protocol and translate its events to the existing session-log contract.
|
|
4
|
+
import { runChild, runStreamingChild } from "./child.mjs";
|
|
5
|
+
import { runAcpChild } from "./acp.mjs";
|
|
6
|
+
import { runCodexChild } from "./codex.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {{run: Function, streaming: Function, acceptsMessages: boolean}} RunEngine */
|
|
9
|
+
/** @type {Record<string, RunEngine>} */
|
|
10
|
+
export const RUN_ENGINES = {
|
|
11
|
+
"stream-json": { run: runChild, streaming: runStreamingChild, acceptsMessages: true },
|
|
12
|
+
acp: { run: runAcpChild, streaming: runAcpChild, acceptsMessages: true },
|
|
13
|
+
"codex-app-server": { run: runCodexChild, streaming: runCodexChild, acceptsMessages: true },
|
|
14
|
+
process: { run: runChild, streaming: runChild, acceptsMessages: false },
|
|
15
|
+
};
|
|
16
|
+
export const runEngineFor = (spec) => RUN_ENGINES[spec.protocol ?? (spec.streamingArgs ? "stream-json" : "process")];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Local proof of a running process, separate from package.json on disk.
|
|
2
|
+
// Written only after a successful hosted heartbeat. No credentials or content.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { mkdir } from "node:fs/promises";
|
|
6
|
+
import { writeFileAtomic } from "./atomic.mjs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { viewableHome } from "./registry.mjs";
|
|
9
|
+
|
|
10
|
+
export function readRunnerHealth(env = process.env, name = "health") {
|
|
11
|
+
try { return JSON.parse(readFileSync(join(viewableHome(env), `${name}.json`), "utf8")); }
|
|
12
|
+
catch { return null; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function writeRunnerHealth(value, env = process.env, name = "health") {
|
|
16
|
+
const home = viewableHome(env);
|
|
17
|
+
await mkdir(home, { recursive: true });
|
|
18
|
+
await writeFileAtomic(join(home, `${name}.json`), JSON.stringify(value), "utf8", { mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function makeRunnerHealth({ env = process.env, version, now = Date.now, instance = randomUUID() } = {}) {
|
|
22
|
+
const startedAt = now();
|
|
23
|
+
const paths = {};
|
|
24
|
+
let pending = Promise.resolve();
|
|
25
|
+
return {
|
|
26
|
+
beat(path) {
|
|
27
|
+
paths[path] = now();
|
|
28
|
+
const value = { instance, startedAt, version, paths: { ...paths } };
|
|
29
|
+
pending = pending.catch(() => {}).then(() => writeRunnerHealth(value, env));
|
|
30
|
+
return pending;
|
|
31
|
+
},
|
|
32
|
+
mark() {
|
|
33
|
+
const proof = readRunnerHealth(env, "cli");
|
|
34
|
+
return proof && proof.version === version ? { cliVersion: version, setupId: proof.setupId } : {};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function waitForRunnerHealth({
|
|
40
|
+
env = process.env, version, after = 0, previousInstance, root,
|
|
41
|
+
read = () => readRunnerHealth(env), now = Date.now,
|
|
42
|
+
pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
43
|
+
timeoutMs = 45_000, requireStartedAfter = true,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const deadline = now() + timeoutMs;
|
|
46
|
+
do {
|
|
47
|
+
const health = read();
|
|
48
|
+
const beats = root ? [health?.paths?.[root]] : Object.values(health?.paths ?? {});
|
|
49
|
+
if (health?.version === version && health.instance && health.instance !== previousInstance &&
|
|
50
|
+
(!requireStartedAfter || health.startedAt >= after) && beats.some((at) => typeof at === "number" && at >= after && now() - at < 30_000)) {
|
|
51
|
+
return { ok: true, health };
|
|
52
|
+
}
|
|
53
|
+
if (now() >= deadline) break;
|
|
54
|
+
await pause(500);
|
|
55
|
+
} while (now() <= deadline);
|
|
56
|
+
return { ok: false, error: "No fresh connection from the installed version. Run viewable doctor, then try again. Folders and sign-in were kept." };
|
|
57
|
+
}
|