@rubytech/create-realagent-code 0.1.575 → 0.1.577
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/dist/__tests__/preserve-house-cloudflare.test.js +2 -72
- package/dist/index.js +8 -59
- package/dist/preserve-house-cloudflare.js +2 -49
- package/package.json +1 -1
- package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/active-rules/dist/index.js +32 -1
- package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
- package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
- package/payload/platform/lib/active-rules/src/index.ts +36 -2
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
- package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
- package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
- package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
- package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
- package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
- package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +5 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +29 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
- package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
- package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/server/server.js +670 -155
- package/dist/__tests__/converge-client-admins.test.js +0 -50
|
@@ -114,20 +114,133 @@
|
|
|
114
114
|
column order above. */
|
|
115
115
|
var HOUR_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
116
116
|
|
|
117
|
-
/*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
/* Whether the account declared this move (Task 2511).
|
|
118
|
+
|
|
119
|
+
Absent from the published list means refused, and the list travels with the
|
|
120
|
+
week, so a drag is refused with the install offline. `visit-status.ts`
|
|
121
|
+
refuses it a second time from the same list; that refusal is the binding
|
|
122
|
+
one, and this one exists so the operator is told now rather than in sixty
|
|
123
|
+
seconds. An unchanged status is NOT a move, which is the same rule the door
|
|
124
|
+
applies: `[from, to]` declares a move and no account declares a
|
|
125
|
+
self-transition, so without this every pure re-timing would be refused. */
|
|
126
|
+
function declared(meta, from, to) {
|
|
127
|
+
if (from === to) return true;
|
|
128
|
+
var list = (meta && meta.transitions) || [];
|
|
129
|
+
for (var i = 0; i < list.length; i++) {
|
|
130
|
+
if (list[i][0] === from && list[i][1] === to) return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* The same wall-clock time on a different day, IN THE ACCOUNT'S ZONE.
|
|
136
|
+
|
|
137
|
+
Built by asking the account's own formatter what instant renders as the
|
|
138
|
+
wanted wall time, never from the browser's offset: a page that guesses the
|
|
139
|
+
zone re-times a visit to a plausible wrong hour, which is the Task 2280
|
|
140
|
+
defect. Two passes, because the offset either side of a daylight-saving
|
|
141
|
+
transition differs and the first correction can land on the far side of it.
|
|
142
|
+
|
|
143
|
+
Returns the shape the door admits, which is what `toISOString` produces. */
|
|
144
|
+
function retimed(iso, dayKey, zone) {
|
|
145
|
+
var wall = new Intl.DateTimeFormat('en-GB', {
|
|
146
|
+
timeZone: zone,
|
|
147
|
+
hour: '2-digit',
|
|
148
|
+
minute: '2-digit',
|
|
149
|
+
hour12: false,
|
|
150
|
+
});
|
|
151
|
+
var held = wall.format(new Date(iso));
|
|
152
|
+
var guess = new Date(dayKey + 'T' + held + ':00.000Z');
|
|
153
|
+
for (var i = 0; i < 2; i++) {
|
|
154
|
+
var seen = wall.format(guess);
|
|
155
|
+
if (seen === held) break;
|
|
156
|
+
var drift =
|
|
157
|
+
(Number(held.slice(0, 2)) - Number(seen.slice(0, 2))) * 60 +
|
|
158
|
+
(Number(held.slice(3)) - Number(seen.slice(3)));
|
|
159
|
+
guess = new Date(guess.getTime() + drift * 60000);
|
|
160
|
+
}
|
|
161
|
+
return guess.toISOString();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* One visit. Clicking moves it to the next declared status; dragging re-times
|
|
165
|
+
or re-assigns it. Both post through the portal's existing write door, and
|
|
166
|
+
both are refused here first against the published transition list. */
|
|
167
|
+
function visitButton(v, data, meta, zone) {
|
|
121
168
|
var b = document.createElement('button');
|
|
122
169
|
b.type = 'button';
|
|
123
170
|
b.className = 'pl-visit';
|
|
171
|
+
b.draggable = true;
|
|
124
172
|
b.textContent = [timeOf(v.startDate, zone), v.status, v.purpose].filter(Boolean).join(' · ');
|
|
125
173
|
b.addEventListener('click', function () {
|
|
126
174
|
advance(v, meta);
|
|
127
175
|
});
|
|
176
|
+
/* The panel is a second gesture on the same card, so the click keeps
|
|
177
|
+
meaning what it meant before this task. */
|
|
178
|
+
b.addEventListener('contextmenu', function (e) {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
openVisit(v, data, meta, zone);
|
|
181
|
+
});
|
|
182
|
+
b.addEventListener('dragstart', function (e) {
|
|
183
|
+
dragging = v;
|
|
184
|
+
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
|
185
|
+
});
|
|
186
|
+
b.addEventListener('dragend', function () {
|
|
187
|
+
dragging = null;
|
|
188
|
+
});
|
|
128
189
|
return b;
|
|
129
190
|
}
|
|
130
191
|
|
|
192
|
+
/* The visit under the pointer during a drag. Held here rather than in
|
|
193
|
+
dataTransfer, which only carries strings and would mean looking the visit
|
|
194
|
+
back up by id on every drop. */
|
|
195
|
+
var dragging = null;
|
|
196
|
+
|
|
197
|
+
function move(v, meta, zone, toOwnerId, toDayKey) {
|
|
198
|
+
var body = { visitId: v.visitId, status: v.status };
|
|
199
|
+
if (toOwnerId !== v.ownerId) body.assignedOwnerId = toOwnerId;
|
|
200
|
+
if (toDayKey !== dayOf(v.startDate, zone)) {
|
|
201
|
+
body.startDate = retimed(v.startDate, toDayKey, zone);
|
|
202
|
+
}
|
|
203
|
+
/* A drop that changes neither is not a write. */
|
|
204
|
+
if (body.assignedOwnerId === undefined && body.startDate === undefined) return;
|
|
205
|
+
say('Saving…');
|
|
206
|
+
api('/api/visit-status', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json' },
|
|
209
|
+
body: JSON.stringify(body),
|
|
210
|
+
})
|
|
211
|
+
.then(function (r) {
|
|
212
|
+
return r.json().then(function (d) {
|
|
213
|
+
return { s: r.status, d: d };
|
|
214
|
+
});
|
|
215
|
+
})
|
|
216
|
+
.then(function (res) {
|
|
217
|
+
if (!res.d.ok) {
|
|
218
|
+
console.error('[planner] move refused', { status: res.s, error: res.d.error });
|
|
219
|
+
say(
|
|
220
|
+
res.s === 404
|
|
221
|
+
? 'That is not yours to change.'
|
|
222
|
+
: res.d.error === 'illegal-transition'
|
|
223
|
+
? 'That move is not one this account allows.'
|
|
224
|
+
: 'That change was refused.',
|
|
225
|
+
);
|
|
226
|
+
/* The card returns to origin by redrawing what the device last
|
|
227
|
+
published. Leaving it where it fell would be the page telling the
|
|
228
|
+
operator something happened that did not. */
|
|
229
|
+
if (latest) render(latest);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (body.assignedOwnerId !== undefined) v.ownerId = body.assignedOwnerId;
|
|
233
|
+
if (body.startDate !== undefined) v.startDate = body.startDate;
|
|
234
|
+
say('Saved. It reaches the office within a minute.');
|
|
235
|
+
if (latest) render(latest);
|
|
236
|
+
})
|
|
237
|
+
.catch(function (err) {
|
|
238
|
+
console.error('[planner] move failed', { status: 'network', error: err });
|
|
239
|
+
say('Could not reach the server.');
|
|
240
|
+
if (latest) render(latest);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
131
244
|
function advance(v, meta) {
|
|
132
245
|
var names = ((meta && meta.statuses) || []).map(function (s) {
|
|
133
246
|
return s.name;
|
|
@@ -137,6 +250,10 @@
|
|
|
137
250
|
return;
|
|
138
251
|
}
|
|
139
252
|
var next = names[(names.indexOf(v.status) + 1) % names.length];
|
|
253
|
+
if (!declared(meta, v.status, next)) {
|
|
254
|
+
say('That move is not one this account allows.');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
140
257
|
say('Saving…');
|
|
141
258
|
api('/api/visit-status', {
|
|
142
259
|
method: 'POST',
|
|
@@ -169,6 +286,60 @@
|
|
|
169
286
|
});
|
|
170
287
|
}
|
|
171
288
|
|
|
289
|
+
/* Who may be sent to this record, why the others may not, and what has been
|
|
290
|
+
recorded against it.
|
|
291
|
+
|
|
292
|
+
Rendered from what /api/visits returned and nothing else. The install
|
|
293
|
+
computed the offers and the exclusions with the one implementation of
|
|
294
|
+
qualification and clash; re-deciding any of it here would be a second rule
|
|
295
|
+
to get wrong, which is what `functions/api/visits.ts` rules out. A
|
|
296
|
+
candidate carries no travel estimate today and is rendered without one
|
|
297
|
+
rather than dropped. */
|
|
298
|
+
function openVisit(v, data, meta, zone) {
|
|
299
|
+
var mine = (data.candidates || []).filter(function (c) {
|
|
300
|
+
return c.visitId === v.visitId;
|
|
301
|
+
});
|
|
302
|
+
document.getElementById('pl-allocate-head').textContent =
|
|
303
|
+
[timeOf(v.startDate, zone), v.status, v.purpose].filter(Boolean).join(' · ');
|
|
304
|
+
|
|
305
|
+
var list = document.getElementById('pl-allocate-list');
|
|
306
|
+
list.replaceChildren();
|
|
307
|
+
if (!mine.length) {
|
|
308
|
+
/* Never an empty box. Nobody offered and nobody excluded is a different
|
|
309
|
+
fact from an unstaffable record, and the operator cannot tell them
|
|
310
|
+
apart from a blank panel. */
|
|
311
|
+
var none = cell('pl-candidate', 'Nobody has been offered for this, and nobody was excluded.');
|
|
312
|
+
none.setAttribute('data-kind', 'excluded');
|
|
313
|
+
list.appendChild(none);
|
|
314
|
+
}
|
|
315
|
+
mine.forEach(function (c) {
|
|
316
|
+
var who = c.name || c.workerId;
|
|
317
|
+
var note =
|
|
318
|
+
c.kind === 'excluded'
|
|
319
|
+
? c.reason
|
|
320
|
+
: (c.conflicts || []).length
|
|
321
|
+
? (c.conflicts || []).length + ' already booked in this window'
|
|
322
|
+
: 'free';
|
|
323
|
+
var li = document.createElement('li');
|
|
324
|
+
li.className = 'pl-candidate';
|
|
325
|
+
li.setAttribute('data-kind', c.kind);
|
|
326
|
+
li.textContent = who + ' · ' + note;
|
|
327
|
+
list.appendChild(li);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
/* What the worker recorded, as counts. The contents themselves are files,
|
|
331
|
+
and the portal's own file exchange already carries them. */
|
|
332
|
+
document.getElementById('pl-record').textContent = [
|
|
333
|
+
'notes ' + (v.notes || 0),
|
|
334
|
+
'media ' + (v.media || 0),
|
|
335
|
+
'parts ' + (v.parts || 0),
|
|
336
|
+
'checklist ' + (v.checklistDone || 0) + '/' + (v.checklistTotal || 0),
|
|
337
|
+
'signatures ' + (v.signatures || 0),
|
|
338
|
+
].join(' · ');
|
|
339
|
+
|
|
340
|
+
document.getElementById('pl-allocate').hidden = false;
|
|
341
|
+
}
|
|
342
|
+
|
|
172
343
|
function render(data) {
|
|
173
344
|
var meta = data.meta;
|
|
174
345
|
/* UTC when the account published no config. Never the browser's: a page
|
|
@@ -225,12 +396,35 @@
|
|
|
225
396
|
'data-off',
|
|
226
397
|
w.workingHours && w.workingHours[HOUR_KEYS[i]] ? 'false' : 'true',
|
|
227
398
|
);
|
|
399
|
+
/* Every cell is a drop target, including a day the worker does not
|
|
400
|
+
work: the office books into one deliberately, and a cell that
|
|
401
|
+
refused the drop would make that decision for them. A row with no
|
|
402
|
+
configured worker is not a target, because there is no ownerId to
|
|
403
|
+
re-assign to. */
|
|
404
|
+
if (w.workerId) {
|
|
405
|
+
c.addEventListener('dragover', function (e) {
|
|
406
|
+
if (!dragging) return;
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
c.setAttribute('data-over', 'true');
|
|
409
|
+
});
|
|
410
|
+
c.addEventListener('dragleave', function () {
|
|
411
|
+
c.removeAttribute('data-over');
|
|
412
|
+
});
|
|
413
|
+
c.addEventListener('drop', function (e) {
|
|
414
|
+
e.preventDefault();
|
|
415
|
+
c.removeAttribute('data-over');
|
|
416
|
+
if (!dragging) return;
|
|
417
|
+
var v = dragging;
|
|
418
|
+
dragging = null;
|
|
419
|
+
move(v, meta, zone, w.ownerId, k);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
228
422
|
(byOwner[w.ownerId] || [])
|
|
229
423
|
.filter(function (v) {
|
|
230
424
|
return dayOf(v.startDate, zone) === k;
|
|
231
425
|
})
|
|
232
426
|
.forEach(function (v) {
|
|
233
|
-
c.appendChild(visitButton(v, meta, zone));
|
|
427
|
+
c.appendChild(visitButton(v, data, meta, zone));
|
|
234
428
|
});
|
|
235
429
|
gridEl.appendChild(c);
|
|
236
430
|
});
|
|
@@ -316,6 +510,10 @@
|
|
|
316
510
|
});
|
|
317
511
|
});
|
|
318
512
|
|
|
513
|
+
document.getElementById('pl-allocate-close').addEventListener('click', function () {
|
|
514
|
+
document.getElementById('pl-allocate').hidden = true;
|
|
515
|
+
});
|
|
516
|
+
|
|
319
517
|
document.getElementById('pl-prev').addEventListener('click', function () {
|
|
320
518
|
anchor.setUTCDate(anchor.getUTCDate() - 7);
|
|
321
519
|
if (latest) render(latest);
|
|
@@ -406,7 +406,7 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
|
|
|
406
406
|
|
|
407
407
|
**Reconcile by meaning on write (`profile-update`).** The existence check is two-tier. First an exact `(category, key)` match — the classic fast path, unchanged. When that misses, a semantic-neighbour lookup runs over the `preference_embedding` vector index, scoped to the same `accountId` and `scope:'admin'`, non-trashed. If the nearest neighbour is at or above `SAME_SUBJECT_COSINE` (0.85), the write lands on that node through the existing reinforce/update/contradict curve instead of minting a duplicate; the matched neighbours are returned in the tool result so the agent can classify future writes against real prior state. The confidence constants are unchanged — only the join key widens, so a rephrasing under a new `key` no longer forks the rule and a correction demotes the prior node rather than adding a contradictory one.
|
|
408
408
|
|
|
409
|
-
**Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context
|
|
409
|
+
**Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules (account owner)` block above `## Context`, whose header and first line name the owner as the subject of the rules so a third-person rule about the owner is not read as a statement about the sender. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
|
|
410
410
|
|
|
411
411
|
**Trust a scheduled turn's provenance, never an inbound's claim (channel-agent grounding).** A cron-dispatched agent turn reaches the same channel builders as a real inbound, so its own authorisation text would otherwise land inside `## Context` — the zone `REFRAME_INSTRUCTION` demotes to untrusted — and a security-cautious agent would distrust its own automation (the self-cancel loop). The dispatcher therefore stamps a `ScheduleProvenance` marker (owning account, event, owner userId, dispatch time) that the channel builders render as a `## Scheduled automation (platform-verified)` preamble **above** `## Context` (and above the rules block); the whatsapp wrap tag names the door `scheduler`. Both channel-server paths carry the marker — the single-target loop and the unified multi-target door reader (via the same `doorInboundPayload` seam that carries `standingRules`); the scheduler only dispatches to the whatsapp/telegram door, so a webchat frame never bears one. Because the marker is dispatcher-stamped and never derived from payload text, an inbound message cannot forge it — placement above `## Context` alone is not the trust signal, the dispatcher provenance is. The grounding binds to it in two places: one recipient-neutral clause added byte-identical to the three reframe instructions (a marker-bearing turn is authorised standing automation; a reconfiguration or re-enable is not intrusion), and the admin `IDENTITY.md` "Untrusted input" rule that the agent never states a platform-level authenticity or impersonation verdict it was not given (an unsourced "this is not from you" is its own inference, flagged, never asserted as the platform's). A sender has exactly one session: every inbound, real or scheduled, resolves to the deterministic `adminSessionIdFor(effectiveAccount, destination)`. An earlier change gave each firing its own salted seat so distrust could not accrete across firings, but the inbound hub keys on `senderId` and replaces its subscriber outright, so a seat's channel server attached under the sender's own key and swallowed the sender's REAL messages until it detached — splitting one conversation across two sessions that each denied what the other held. The seat is removed; the authoritative `agentPrompt`, this provenance marker, and the injected standing rules ground each firing without one. Self-cancel attribution is disarmed while that model beds in: the guard used to compare the cancelling turn's session id to a dispatcher stamp, which discriminated only because each firing ran its own seat, so with one session per sender it would report an autonomous loop on every operator cancel. It now always reports false — under-counting a real loop rather than inventing one — until the discriminator is rebuilt from the turn's own dispatched eventId. See `.docs/scheduling-agent-dispatch.md § Operator-provenance marker`, `§ Session model — one session per sender`, and `§ Self-cancel attribution — disarmed`.
|
|
412
412
|
|
|
@@ -46,6 +46,7 @@ export declare function parseSpawnRole(body: {
|
|
|
46
46
|
} | null;
|
|
47
47
|
export declare const SESSION_ID_PATTERN: RegExp;
|
|
48
48
|
export declare const RC_SPAWN_URL_WAIT_DEFAULT_MS = 60000;
|
|
49
|
+
export declare const LOG_FOLLOW_KEEPALIVE_DEFAULT_MS = 30000;
|
|
49
50
|
export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'> {
|
|
50
51
|
/** Admin operator's userId, sourced from users.json at boot (same as
|
|
51
52
|
* RcDaemonDeps.userId). Passed to buildRcChildEnv so the scheduling MCP
|
|
@@ -85,6 +86,10 @@ export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'>
|
|
|
85
86
|
* omits this and gets RC_SPAWN_URL_WAIT_DEFAULT_MS (60s); tests inject a
|
|
86
87
|
* short value to exercise the bind-timeout response without a 60s wait. */
|
|
87
88
|
rcSpawnUrlWaitMs?: number;
|
|
89
|
+
/** Task 2557 — idle gap after which `/log?follow=1` writes a bare newline.
|
|
90
|
+
* Production omits this and gets LOG_FOLLOW_KEEPALIVE_DEFAULT_MS (30s);
|
|
91
|
+
* tests inject a short value to exercise the keepalive without a 30s wait. */
|
|
92
|
+
logFollowKeepaliveMs?: number;
|
|
88
93
|
/** Task 1749 — accessor for the standing PTY census's most recent snapshot,
|
|
89
94
|
* wired from `createPtyCensus(...).latest`. Optional so the pre-1749 test
|
|
90
95
|
* deps factories keep working; production index.ts always injects it.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAqB3B,OAAO,KAAK,EAAU,SAAS,EAAW,MAAM,YAAY,CAAA;AAM5D,OAAO,EAkBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAqBzB,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAoBlD,OAAO,KAAK,EAAE,SAAS,EAAyB,MAAM,iBAAiB,CAAA;AACvE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAA;AAEzE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAE3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAA;AAQlE;;;;;;;;;;;mFAWmF;AACnF,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GACvB,OAAO,CAAC,SAAS,EAAE,OAAO,GAAG,QAAQ,CAAC,GAAG,IAAI,CAE/C;AAED;;;;;;;;;;2EAU2E;AAC3E,wBAAgB,cAAc,CAC5B,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3C,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,GAAG,IAAI,CAOlD;AAsFD,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAS1D,eAAO,MAAM,4BAA4B,QAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
|
|
@@ -207,6 +207,17 @@ export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
|
207
207
|
// Injectable via `HttpDeps.rcSpawnUrlWaitMs` so the timeout path is
|
|
208
208
|
// unit-testable without a real 60s wait.
|
|
209
209
|
export const RC_SPAWN_URL_WAIT_DEFAULT_MS = 60_000;
|
|
210
|
+
// Task 2557 — how long a follow stream may go without writing a byte before it
|
|
211
|
+
// emits a bare newline. The stream writes only when the JSONL grows, and Node's
|
|
212
|
+
// global fetch (undici) kills a response body after 300s with no bytes, so a
|
|
213
|
+
// conversation between messages killed its own reader: 129 of 129
|
|
214
|
+
// log-follow-close lines on laptop 192.168.88.10 read reason=aborted, shortest
|
|
215
|
+
// lifetime 313440ms, and no follower in the whole log history outlived a quiet
|
|
216
|
+
// patch. 30s costs one byte and leaves a tenfold margin against a slow tick.
|
|
217
|
+
// A genuinely stalled manager still trips undici's bound, because a stalled
|
|
218
|
+
// manager writes no keepalive either. Injectable via
|
|
219
|
+
// `HttpDeps.logFollowKeepaliveMs` so the path is testable without a 30s wait.
|
|
220
|
+
export const LOG_FOLLOW_KEEPALIVE_DEFAULT_MS = 30_000;
|
|
210
221
|
const LIST_TAIL_BYTES = 64 * 1024;
|
|
211
222
|
function timed(logger, method, path, status, ms) {
|
|
212
223
|
logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
|
|
@@ -2534,6 +2545,10 @@ export function buildHttpApp(deps) {
|
|
|
2534
2545
|
let position = 0;
|
|
2535
2546
|
let linesStreamed = 0;
|
|
2536
2547
|
let closeReason = 'end';
|
|
2548
|
+
// Task 2557 — when this stream last put a byte on the wire. Every real
|
|
2549
|
+
// write stamps it; the poll loop below sends a keepalive once the gap
|
|
2550
|
+
// reaches the threshold, so an idle session never looks dead to undici.
|
|
2551
|
+
let lastWriteAt = Date.now();
|
|
2537
2552
|
const countNewlines = (buf) => {
|
|
2538
2553
|
let n = 0;
|
|
2539
2554
|
for (let i = 0; i < buf.length; i++)
|
|
@@ -2548,6 +2563,7 @@ export function buildHttpApp(deps) {
|
|
|
2548
2563
|
const buf = chunk;
|
|
2549
2564
|
linesStreamed += countNewlines(buf);
|
|
2550
2565
|
await out.write(buf);
|
|
2566
|
+
lastWriteAt = Date.now();
|
|
2551
2567
|
}
|
|
2552
2568
|
position = initialSize;
|
|
2553
2569
|
}
|
|
@@ -2587,12 +2603,25 @@ export function buildHttpApp(deps) {
|
|
|
2587
2603
|
const buf = chunk;
|
|
2588
2604
|
linesStreamed += countNewlines(buf);
|
|
2589
2605
|
void out.write(buf);
|
|
2606
|
+
lastWriteAt = Date.now();
|
|
2590
2607
|
}
|
|
2591
2608
|
});
|
|
2592
2609
|
};
|
|
2593
2610
|
watchFile(jsonlPath, { interval: 250 }, listener);
|
|
2611
|
+
const keepaliveMs = deps.logFollowKeepaliveMs ?? LOG_FOLLOW_KEEPALIVE_DEFAULT_MS;
|
|
2594
2612
|
while (active) {
|
|
2595
2613
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
2614
|
+
if (!active)
|
|
2615
|
+
break;
|
|
2616
|
+
if (Date.now() - lastWriteAt < keepaliveMs)
|
|
2617
|
+
continue;
|
|
2618
|
+
// Task 2557 — a bare newline, not a file line: it is excluded from
|
|
2619
|
+
// `linesStreamed` because that counter is what this stream carried from
|
|
2620
|
+
// the JSONL, and the emitter drops it at its own `if (!line) continue`.
|
|
2621
|
+
// Its only job is to be a byte, so undici's bodyTimeout never elapses
|
|
2622
|
+
// on a conversation that is merely quiet.
|
|
2623
|
+
await out.write(Buffer.from('\n'));
|
|
2624
|
+
lastWriteAt = Date.now();
|
|
2596
2625
|
}
|
|
2597
2626
|
deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
|
|
2598
2627
|
});
|