@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.6
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/package.json +3 -2
- package/src/hub/core/db.mjs +40 -5
- package/src/hub/core/wake.mjs +5 -3
- package/src/hub/workers/collector.mjs +59 -19
- package/src/hub/workers/manager.mjs +44 -6
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/daemonRuntime.js +25 -7
- package/src/lib/hubDbReader.js +6 -0
- package/src/lib/hubScan.js +1 -0
- package/src/lib/ledger.js +21 -12
- package/src/lib/lifecycle.js +3 -1
- package/src/lib/mirrorSource.js +19 -6
- package/src/lib/runnerStore.js +61 -49
- package/src/runner/bridge.js +8 -3
- package/src/runner/busIdentity.js +11 -1
- package/src/runner/capture.js +83 -20
- package/src/runner/channelDiscipline.js +48 -14
- package/src/runner/channelFiles.js +39 -0
- package/src/runner/channelWalk.js +42 -0
- package/src/runner/collectWorker.js +92 -0
- package/src/runner/deliveryStates.js +22 -9
- package/src/runner/directoryCache.js +34 -0
- package/src/runner/facade.js +149 -87
- package/src/runner/fileReads.js +64 -0
- package/src/runner/fileReadsWorker.js +61 -0
- package/src/runner/hubCapture.js +105 -56
- package/src/runner/loopMonitor.js +89 -0
- package/src/runner/runnerHub.js +3 -1
- package/src/runner/runnerProcess.js +58 -16
- package/src/runner/runnerService.js +18 -2
- package/src/runner/serial.js +5 -0
- package/src/runner/spool.js +46 -11
- package/src/runner/transcriptFeed.js +88 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentpulselabs/cli-plugin-pulse",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "AgentPulse CLI plugin for installing and operating the Pulse local connector (replaces the deprecated build-discipline Pulse plugin).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "node --import ./test/support/register-loader.mjs --test \"test/*.test.js\" \"test/hub/*.test.js\"",
|
|
23
23
|
"contract:check": "node scripts/sync-contract.mjs --local",
|
|
24
|
-
"check": "node --check src/commands/pulse/install.js && node --check src/commands/pulse/pair.js && node --check src/commands/pulse/login.js && node --check src/commands/pulse/start.js && node --check src/commands/pulse/status.js && node --check src/commands/pulse/doctor.js && node --check src/commands/pulse/stop.js && node --check src/commands/pulse/restart.js && node --check src/lib/selfRestart.js && node --check src/lib/subprocess.js && node --check src/lib/daemonProcess.js && node --check src/commands/pulse/mirror.js && node --check src/commands/pulse/hub-write.js && node --check src/lib/localHubWriter.js && node --check src/lib/hubActions.js && node --check src/lib/localHub.js && node --check src/lib/pathRedaction.js && node --check src/lib/rulePacks.js && node --check src/lib/mirrorContract.js && node --check src/lib/graphWindows.js && node --check src/lib/mirrorSource.js && node --check src/lib/mirrorSync.js && node --check src/lib/mirrorService.js && node --check src/lib/mirrorDaemon.js && node --check src/lib/daemonComposition.js && node --check src/lib/daemonRuntime.js && node --check src/lib/daemonLoop.js && node --check src/daemon/main.js && node --check src/lib/sqlite.js && node --check src/lib/nodeFloor.js && node --check src/lib/runnerStore.js && node --check src/lib/hubDbReader.js && node --check src/lib/channelScanner.js && node --check src/lib/uploadRedactor.js && node --check src/lib/hubScan.js && node --check src/lib/outboxFlusher.js && node --check src/lib/hubSnapshot.js && node --check src/lib/reconcileView.js && node --check src/lib/reconcile.js && node --check src/lib/shadowService.js && node --check src/lib/cutover.js && node --check src/lib/cutoverWiring.js && node --check src/lib/cutoverRequest.js && node --check src/commands/pulse/cutover.js && node --check src/lib/cloudFirstCommands.js && node --check src/commands/pulse/import.js && node --check src/commands/pulse/shadow.js && node --check src/commands/pulse/parity.js && node --check src/runner/main.js && node --check src/runner/runnerProcess.js && node --check src/runner/facade.js && node --check src/runner/bridge.js && node --check src/runner/runnerChild.js && node --check src/runner/applier.js && node --check src/runner/capture.js && node --check src/runner/hubCapture.js && node --check src/clients/hub.mjs && node --check src/clients/hub-bridge.mjs && node --check src/runner/busIdentity.js && node --check src/runner/deliveryStates.js && node --check src/lib/doctorWarnings.js && node --check src/hub/lib/closure.mjs && node --check src/runner/channelDiscipline.js",
|
|
24
|
+
"check": "node --check src/commands/pulse/install.js && node --check src/commands/pulse/pair.js && node --check src/commands/pulse/login.js && node --check src/commands/pulse/start.js && node --check src/commands/pulse/status.js && node --check src/commands/pulse/doctor.js && node --check src/commands/pulse/stop.js && node --check src/commands/pulse/restart.js && node --check src/lib/selfRestart.js && node --check src/lib/subprocess.js && node --check src/lib/daemonProcess.js && node --check src/commands/pulse/mirror.js && node --check src/commands/pulse/hub-write.js && node --check src/lib/localHubWriter.js && node --check src/lib/hubActions.js && node --check src/lib/localHub.js && node --check src/lib/pathRedaction.js && node --check src/lib/rulePacks.js && node --check src/lib/mirrorContract.js && node --check src/lib/graphWindows.js && node --check src/lib/mirrorSource.js && node --check src/lib/mirrorSync.js && node --check src/lib/mirrorService.js && node --check src/lib/mirrorDaemon.js && node --check src/lib/daemonComposition.js && node --check src/lib/daemonRuntime.js && node --check src/lib/daemonLoop.js && node --check src/daemon/main.js && node --check src/lib/sqlite.js && node --check src/lib/nodeFloor.js && node --check src/lib/runnerStore.js && node --check src/lib/hubDbReader.js && node --check src/lib/channelScanner.js && node --check src/lib/uploadRedactor.js && node --check src/lib/hubScan.js && node --check src/lib/outboxFlusher.js && node --check src/lib/hubSnapshot.js && node --check src/lib/reconcileView.js && node --check src/lib/reconcile.js && node --check src/lib/shadowService.js && node --check src/lib/cutover.js && node --check src/lib/cutoverWiring.js && node --check src/lib/cutoverRequest.js && node --check src/commands/pulse/cutover.js && node --check src/lib/cloudFirstCommands.js && node --check src/commands/pulse/import.js && node --check src/commands/pulse/shadow.js && node --check src/commands/pulse/parity.js && node --check src/runner/main.js && node --check src/runner/runnerProcess.js && node --check src/runner/facade.js && node --check src/runner/bridge.js && node --check src/runner/runnerChild.js && node --check src/runner/applier.js && node --check src/runner/capture.js && node --check src/runner/loopMonitor.js && node --check src/runner/channelWalk.js && node --check src/runner/serial.js && node --check src/runner/directoryCache.js && node --check src/runner/channelFiles.js && node --check src/runner/fileReads.js && node --check src/runner/fileReadsWorker.js && node --check src/runner/transcriptFeed.js && node --check src/runner/collectWorker.js && node --check src/runner/hubCapture.js && node --check src/clients/hub.mjs && node --check src/clients/hub-bridge.mjs && node --check src/runner/busIdentity.js && node --check src/runner/deliveryStates.js && node --check src/lib/doctorWarnings.js && node --check src/hub/lib/closure.mjs && node --check src/runner/channelDiscipline.js",
|
|
25
25
|
"test:t2": "node --import ./test/support/register-loader.mjs --test --test-timeout=120000 \"test/t2/*.test.js\"",
|
|
26
|
+
"test:perf": "node --import ./test/support/register-loader.mjs --test --test-concurrency=1 --test-timeout=900000 \"test/perf/*.test.js\"",
|
|
26
27
|
"test:t3": "node --import ./test/support/register-loader.mjs --test --test-concurrency=1 --test-timeout=240000 \"test/t3/*.test.js\""
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
package/src/hub/core/db.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { isBusy } from './ticks.mjs';
|
|
6
7
|
|
|
7
8
|
export const SCHEMA_VERSION = 1;
|
|
8
9
|
|
|
@@ -231,27 +232,61 @@ export function withTx(db, fn, label = null) {
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
const pause = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
235
|
+
/** How long a busy chunk of writeInChunks waits before each new try; the wait blocks its thread, the collect worker's. */
|
|
236
|
+
const BUSY_PAUSES_MS = [250, 500, 1000, 2000];
|
|
234
237
|
|
|
235
238
|
/**
|
|
236
239
|
* Runs `steps` (functions) in as many short write transactions as it takes for none to hold the lock past `budgetMs`,
|
|
237
240
|
* pausing `pauseMs` between them so writers on other connections get in. Inside an open transaction it simply joins it.
|
|
238
|
-
*
|
|
241
|
+
* A chunk that finds the database busy past busy_timeout is rolled back and run again from its first step, after
|
|
242
|
+
* `busyPausesMs`, so another writer's long transaction delays a pass instead of failing it. Returns how many
|
|
243
|
+
* transactions it used.
|
|
239
244
|
*/
|
|
240
|
-
export function writeInChunks(db, steps, { budgetMs = 50, pauseMs = 15, label = 'chunked write', onPause = null } = {}) {
|
|
245
|
+
export function writeInChunks(db, steps, { budgetMs = 50, pauseMs = 15, label = 'chunked write', onPause = null, busyPausesMs = BUSY_PAUSES_MS } = {}) {
|
|
241
246
|
if (TX.get(db)) { for (const step of steps) step(); return 0; }
|
|
242
247
|
let i = 0;
|
|
243
248
|
let chunks = 0;
|
|
249
|
+
let busy = 0;
|
|
244
250
|
while (i < steps.length) {
|
|
245
251
|
if (chunks) { pause(pauseMs); if (onPause) onPause(); }
|
|
246
252
|
const started = performance.now();
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
253
|
+
const first = i;
|
|
254
|
+
try {
|
|
255
|
+
withTx(db, () => {
|
|
256
|
+
do steps[i++](); while (i < steps.length && performance.now() - started < budgetMs);
|
|
257
|
+
}, `${label} (chunk ${chunks + 1})`);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if (!isBusy(e) || busy >= busyPausesMs.length) throw e;
|
|
260
|
+
txTiming.log(`${label}: database busy, chunk ${chunks + 1} again in ${busyPausesMs[busy]} ms`);
|
|
261
|
+
pause(busyPausesMs[busy++]);
|
|
262
|
+
i = first;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
busy = 0;
|
|
250
266
|
chunks++;
|
|
251
267
|
}
|
|
252
268
|
return chunks;
|
|
253
269
|
}
|
|
254
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Pulse runner-stalls round 2: when discovery last saved a pass. A pass rewrites only the rows that moved, so a row's
|
|
273
|
+
* updated_at no longer says when a pass last confirmed it; this does. Kept out of the facts, where capture and the
|
|
274
|
+
* change feed would see it on every pass.
|
|
275
|
+
*/
|
|
276
|
+
export function recordDiscoveryPass(db, at = Date.now()) {
|
|
277
|
+
runSql(db, 'CREATE TABLE IF NOT EXISTS discovery_pass (id INTEGER PRIMARY KEY CHECK (id = 1), at INTEGER NOT NULL)');
|
|
278
|
+
db.prepare('INSERT INTO discovery_pass (id, at) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET at = excluded.at').run(at);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** When discovery last saved a pass (recordDiscoveryPass), or 0 before the first. */
|
|
282
|
+
export function lastDiscoveryPass(db) {
|
|
283
|
+
try {
|
|
284
|
+
return Number(db.prepare('SELECT at FROM discovery_pass WHERE id = 1').get()?.at) || 0;
|
|
285
|
+
} catch {
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
255
290
|
export function jsonCol(v) {
|
|
256
291
|
if (v == null) return null;
|
|
257
292
|
return typeof v === 'string' ? v : JSON.stringify(v);
|
package/src/hub/core/wake.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// and again after WAKE_AGAIN_MS if your message is still waiting.
|
|
5
5
|
import { ADDRESS, managerChats } from './ids.mjs';
|
|
6
6
|
import { edgeId } from './edgeIds.mjs';
|
|
7
|
+
import { lastDiscoveryPass } from './db.mjs';
|
|
7
8
|
|
|
8
9
|
export const WAKE_AGAIN_MS = 15 * 60e3;
|
|
9
10
|
/** No hook event for this long: the subagent is not running (long commands can be silent for minutes). */
|
|
@@ -59,8 +60,9 @@ const nudgeText = (every) => `Scheduled check-in from the hub (every ${every} mi
|
|
|
59
60
|
/**
|
|
60
61
|
* Agents finished by what the Board shows: a stream whose final review passed, or a done agent row or delivery. Rows
|
|
61
62
|
* and deliveries are only as fresh as the last collect, so one older than the agent's last sign of life does not count.
|
|
63
|
+
* A row is as fresh as the last saved pass (`passAt`), which rewrites only the rows that moved.
|
|
62
64
|
*/
|
|
63
|
-
function doneIds(registry, actors, seen) {
|
|
65
|
+
function doneIds(registry, actors, seen, passAt = 0) {
|
|
64
66
|
const rows = new Map(registry.facts('agent-row').map((r) => [r.key, r]));
|
|
65
67
|
const deliveries = new Map(registry.facts('delivery').map((d) => [d.key, d]));
|
|
66
68
|
const done = new Set();
|
|
@@ -69,7 +71,7 @@ function doneIds(registry, actors, seen) {
|
|
|
69
71
|
const key = a.stream_key || (row && row.streamKey);
|
|
70
72
|
const stream = key && registry.fact('stream', key);
|
|
71
73
|
const d = a.node_type === 'orchestrator' && deliveries.get(a.id);
|
|
72
|
-
const current = (f) => f && f.status === 'done' && f.updated_at >= (seen.get(a.id) || 0);
|
|
74
|
+
const current = (f) => f && f.status === 'done' && Math.max(f.updated_at, passAt) >= (seen.get(a.id) || 0);
|
|
73
75
|
if ((stream && stream.done) || current(row) || current(d)) done.add(a.id);
|
|
74
76
|
}
|
|
75
77
|
return done;
|
|
@@ -91,7 +93,7 @@ export function scheduledNudge(hub, { everyMin, now = Date.now(), skip = null, r
|
|
|
91
93
|
for (const chat of managerChats(registry.fact('manager', 'config'))) exclude.add(chat);
|
|
92
94
|
if (skip) for (const id of skip(last)) exclude.add(id);
|
|
93
95
|
if (released) for (const id of released()) pending.delete(id);
|
|
94
|
-
const targets = nudgeTargets({ actors, seen, done: doneIds(registry, actors, seen), pending, exclude, now });
|
|
96
|
+
const targets = nudgeTargets({ actors, seen, done: doneIds(registry, actors, seen, lastDiscoveryPass(db)), pending, exclude, now });
|
|
95
97
|
const dir = targets.length ? registry.directory(now) : [];
|
|
96
98
|
const sent = [];
|
|
97
99
|
for (const t of targets) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Discovery worker: scans transcripts and channels, writes inferred actors and facts. Does not serve GET.
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { DERIVED_REQUEST_PREFIX, collect } from '../lib/collect.mjs';
|
|
5
|
-
import { writeInChunks } from '../core/db.mjs';
|
|
5
|
+
import { recordDiscoveryPass, writeInChunks } from '../core/db.mjs';
|
|
6
6
|
import { nodeTypeOf } from '../core/ids.mjs';
|
|
7
7
|
import { projectGraph } from '../projections/graph.mjs';
|
|
8
8
|
|
|
@@ -16,6 +16,15 @@ export const DISCOVERY_REUSE_MS = 30e3;
|
|
|
16
16
|
export const CANONICAL_HOURS = 168;
|
|
17
17
|
|
|
18
18
|
const lastWritten = new WeakMap();
|
|
19
|
+
/**
|
|
20
|
+
* Pulse runner-stalls: per db, every row the last saved pass wrote, as hashes of what it wrote and of the row it left.
|
|
21
|
+
* A row whose input and current row both still match is not written again, so a pass where little moved takes the
|
|
22
|
+
* write lock, and gives capture journal rows, only for what did.
|
|
23
|
+
*/
|
|
24
|
+
const lastRows = new WeakMap();
|
|
25
|
+
const rowHash = (text) => crypto.createHash('sha1').update(String(text)).digest('base64');
|
|
26
|
+
/** The message ids conversations.importTranscriptTurn and forgetOpening use for a session's opening turn. */
|
|
27
|
+
const openingIds = (id) => { const base = `t-${`${id}-open`.slice(0, 40)}`; return [`${base}-p`, `${base}-r`]; };
|
|
19
28
|
|
|
20
29
|
/** What a pass would write, minus what moves on every pass by itself (generatedAt, the manager node's window). */
|
|
21
30
|
function discoverySignature(registry, graph) {
|
|
@@ -26,7 +35,7 @@ function discoverySignature(registry, graph) {
|
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
|
-
* Saves a discovery pass. Returns false, writing
|
|
38
|
+
* Saves a discovery pass. Returns false, writing no rows (only the pass time), when it matches the last pass saved through this db. A fact
|
|
30
39
|
* kind written here must be listed in DISCOVERY_FACTS (core/changes.mjs) or each pass wakes the change feed.
|
|
31
40
|
*
|
|
32
41
|
* A full pass is ~1.5k actor and ~1.1k fact writes (0.3-0.4 s on the live hub.db), so it goes in short transactions
|
|
@@ -36,18 +45,39 @@ function discoverySignature(registry, graph) {
|
|
|
36
45
|
export function persistDiscovery(hub, graph, chunks = {}) {
|
|
37
46
|
const { registry, conversations, db } = hub;
|
|
38
47
|
const signature = discoverySignature(registry, graph);
|
|
39
|
-
if (lastWritten.get(db) === signature)
|
|
48
|
+
if (lastWritten.get(db) === signature) {
|
|
49
|
+
// Nothing moved, but this pass still confirmed every row (core/wake.mjs doneIds reads when).
|
|
50
|
+
writeInChunks(db, [() => recordDiscoveryPass(db)], { label: 'discovery pass', ...chunks });
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
40
53
|
const now = graph.generatedAt || Date.now();
|
|
54
|
+
const rows = lastRows.get(db) || new Map();
|
|
55
|
+
const kept = new Map();
|
|
41
56
|
const steps = [];
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
/** A keyed step is skipped while `input` and the row `read` returns are what the last saved pass left. */
|
|
58
|
+
const step = (fn, key = null, input = null, read = null) => {
|
|
59
|
+
if (key === null) { steps.push(fn); return; }
|
|
60
|
+
const want = rowHash(JSON.stringify(input));
|
|
61
|
+
const was = rows.get(key);
|
|
62
|
+
if (was && was.input === want && was.row === rowHash(read())) { kept.set(key, was); return; }
|
|
63
|
+
steps.push(() => { fn(); kept.set(key, { input: want, row: rowHash(read()) }); });
|
|
64
|
+
};
|
|
65
|
+
const actorRow = db.prepare('SELECT * FROM actors WHERE id = ?');
|
|
66
|
+
const factRow = db.prepare('SELECT payload FROM facts WHERE kind = ? AND key = ?');
|
|
67
|
+
const factKeys = db.prepare("SELECT kind, key FROM facts WHERE kind IN ('delivery', 'stream', 'agent-row') ORDER BY kind, key");
|
|
68
|
+
const restRows = db.prepare("SELECT kind, key, payload FROM facts WHERE kind IN ('request', 'totals') ORDER BY kind, key");
|
|
69
|
+
const openingRows = db.prepare('SELECT m.id, d.recipient FROM messages m LEFT JOIN deliveries d ON d.message_id = m.id WHERE m.id IN (?, ?) ORDER BY m.id');
|
|
70
|
+
const readActor = (id) => () => JSON.stringify(actorRow.get(id) ?? null);
|
|
71
|
+
const readFact = (kind, key) => () => factRow.get(kind, key)?.payload ?? '';
|
|
72
|
+
const readOpening = (id) => () => JSON.stringify(openingRows.all(...openingIds(id)));
|
|
73
|
+
for (const n of graph.nodes || []) {
|
|
44
74
|
const parentId = n.parent || n.launchedBy || null;
|
|
45
75
|
const synthetic = !!n.synthetic;
|
|
46
76
|
const nodeType = n.node_type || nodeTypeOf({
|
|
47
77
|
id: n.id, parentId, synthetic, role: n.role,
|
|
48
78
|
});
|
|
49
79
|
const persona = n.persona || {};
|
|
50
|
-
|
|
80
|
+
const patch = {
|
|
51
81
|
id: n.id,
|
|
52
82
|
node_type: nodeType,
|
|
53
83
|
role: n.role || (nodeType === 'orchestrator' ? 'ARCHITECT' : null),
|
|
@@ -65,22 +95,29 @@ export function persistDiscovery(hub, graph, chunks = {}) {
|
|
|
65
95
|
started_at: n.startedAt, last_at: n.lastAt, tokens_est: n.tokensEst,
|
|
66
96
|
msgs_in: n.msgsIn, msgs_out: n.msgsOut, repo: n.repo || n.project, channel: n.channel,
|
|
67
97
|
synthetic, extra: { streams: n.streams, status: n.status, kind: n.kind },
|
|
68
|
-
}
|
|
98
|
+
};
|
|
99
|
+
step(() => registry.upsertInferred(patch), `actor:${n.id}`, patch, readActor(n.id));
|
|
69
100
|
// Presence is owned by the hook heartbeat. Discovery only records last_at on the actor.
|
|
70
|
-
}
|
|
101
|
+
}
|
|
71
102
|
const deliveries = graph.deliveries || [];
|
|
72
103
|
const accepted = new Map(registry.facts('accepted').map((a) => [a.key, a.at || a.updated_at]));
|
|
73
|
-
|
|
74
|
-
for (const d of deliveries)
|
|
75
|
-
for (const
|
|
104
|
+
const setFact = (kind, key, payload) => step(() => registry.setFact(kind, key, payload), `fact:${kind}:${key}`, payload, readFact(kind, key));
|
|
105
|
+
for (const d of deliveries) setFact('delivery', d.id, accepted.has(d.id) ? { ...d, acceptedAt: accepted.get(d.id) } : d);
|
|
106
|
+
for (const d of deliveries) for (const s of d.streams || []) setFact('stream', s.key, { ...s, deliveryId: d.id });
|
|
107
|
+
for (const a of graph.agents || []) setFact('agent-row', a.id, a);
|
|
108
|
+
const keys = {
|
|
109
|
+
delivery: deliveries.map((d) => d.id),
|
|
110
|
+
stream: deliveries.flatMap((d) => (d.streams || []).map((s) => s.key)),
|
|
111
|
+
'agent-row': (graph.agents || []).map((a) => a.id),
|
|
112
|
+
};
|
|
76
113
|
step(() => {
|
|
77
|
-
registry.keepFacts('delivery',
|
|
78
|
-
registry.keepFacts('stream',
|
|
79
|
-
registry.keepFacts('agent-row',
|
|
80
|
-
});
|
|
81
|
-
for (const n of graph.nodes || []) if (n.kind === 'agent') step(() => conversations.forgetOpening(n.id));
|
|
114
|
+
registry.keepFacts('delivery', keys.delivery);
|
|
115
|
+
registry.keepFacts('stream', keys.stream);
|
|
116
|
+
registry.keepFacts('agent-row', keys['agent-row']);
|
|
117
|
+
}, 'keep', keys, () => JSON.stringify(factKeys.all()));
|
|
118
|
+
for (const n of graph.nodes || []) if (n.kind === 'agent') step(() => conversations.forgetOpening(n.id), `forget:${n.id}`, null, readOpening(n.id));
|
|
82
119
|
const requests = (graph.requests || []).map((r) => [r.id || `r-${r.at}`, r]);
|
|
83
|
-
for (const [key, r] of requests)
|
|
120
|
+
for (const [key, r] of requests) setFact('request', key, r);
|
|
84
121
|
step(() => {
|
|
85
122
|
// Only post-derived requests are discovery's to drop; filed ones (hub.mjs request) live as facts alone.
|
|
86
123
|
const current = new Set(requests.map(([key]) => key));
|
|
@@ -90,18 +127,21 @@ export function persistDiscovery(hub, graph, chunks = {}) {
|
|
|
90
127
|
// The projection counts each window's totals from this one basis; per-window facts older passes wrote are dropped.
|
|
91
128
|
if (graph.totalsBasis) registry.setFact('totals', 'basis', graph.totalsBasis);
|
|
92
129
|
registry.keepFacts('totals', graph.totalsBasis ? ['basis'] : []);
|
|
93
|
-
});
|
|
130
|
+
}, 'requests', [[...requests.map(([key]) => key)].sort(), graph.totalsBasis ?? null], () => JSON.stringify(restRows.all()));
|
|
94
131
|
for (const n of graph.nodes || []) {
|
|
95
132
|
if (n.kind !== 'session' || n.parent || n.launchedBy) continue;
|
|
96
133
|
if (n.promptTitle || n.title) {
|
|
134
|
+
// A transcript turn is inserted once and never updated, so a pass's own clock is no part of its input.
|
|
97
135
|
step(() => conversations.importTranscriptTurn({
|
|
98
136
|
sessionId: n.id, turnId: `${n.id}-open`, at: n.startedAt || now,
|
|
99
137
|
prompt: n.promptTitle || n.title, reply: null,
|
|
100
|
-
}));
|
|
138
|
+
}), `opening:${n.id}`, [n.startedAt || null, n.promptTitle || n.title], readOpening(n.id));
|
|
101
139
|
}
|
|
102
140
|
}
|
|
141
|
+
step(() => recordDiscoveryPass(db));
|
|
103
142
|
writeInChunks(db, steps, { label: 'discovery', ...chunks });
|
|
104
143
|
lastWritten.set(db, signature);
|
|
144
|
+
lastRows.set(db, kept);
|
|
105
145
|
return true;
|
|
106
146
|
}
|
|
107
147
|
|
|
@@ -20,6 +20,10 @@ const MAX_OUTPUT = 2 * 1024 * 1024;
|
|
|
20
20
|
/** Answers took 0.7-9.8 min in one chat resumed for two days; a fresh, small chat answers well inside this. */
|
|
21
21
|
export const RUN_TIMEOUT_MS = 5 * 60e3;
|
|
22
22
|
const MAX_ATTEMPTS = 3;
|
|
23
|
+
/** Claims also count a run's own lapsed claim taken back after a stall; past this many, a message no run finishes is given up. */
|
|
24
|
+
const MAX_CLAIMS = 10;
|
|
25
|
+
/** A run that ends while the lease is away (a stalled runner lapses it for a moment) waits this long for it to come back. */
|
|
26
|
+
export const HELD_WAIT_MS = 60e3;
|
|
23
27
|
/** Runs cut off by restarts do not use up attempts; past this many restarts a message is given up, not looped on. */
|
|
24
28
|
export const MAX_RESTARTS = 3;
|
|
25
29
|
/** Runs at once, one chat each. One, as before: a second question waits one run at most, FYIs none. */
|
|
@@ -129,10 +133,19 @@ ${voice || ''}`.trim();
|
|
|
129
133
|
const topicOf = (sender) => (sender === 'user' ? 'user' : `agent/${sender}`);
|
|
130
134
|
const errorText = (error) => String(error && error.message ? error.message : error);
|
|
131
135
|
|
|
136
|
+
/** Why a cursor-agent run failed (exit, signal, stderr), never its command line: that holds the whole prompt. */
|
|
137
|
+
function runError(error) {
|
|
138
|
+
if (!error || !String(error.message || '').startsWith('Command failed:')) return errorText(error);
|
|
139
|
+
const how = error.killed ? `timed out (${error.signal})` : error.code != null ? `exit ${error.code}` : `signal ${error.signal}`;
|
|
140
|
+
const said = String(error.stderr || error.stdout || '').trim().split('\n').slice(-3).join(' ').slice(0, 300);
|
|
141
|
+
return `cursor-agent ${how}${said ? `: ${said}` : ''}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
132
144
|
export function createManager({
|
|
133
145
|
hub, state, run = runFile, onChange = () => {}, parallel = PARALLEL, now = Date.now, log = console.error, ackRetryMs = RETRY_MS,
|
|
134
146
|
held = () => true,
|
|
135
147
|
fyi = null,
|
|
148
|
+
heldWaitMs = HELD_WAIT_MS,
|
|
136
149
|
}) {
|
|
137
150
|
const configFile = path.join(state, 'manager.json');
|
|
138
151
|
const statusFile = path.join(state, 'manager-status.json');
|
|
@@ -143,6 +156,8 @@ export function createManager({
|
|
|
143
156
|
const { messaging, registry, leases } = hub;
|
|
144
157
|
/** message id → { message, leaseToken, slot, startedAt, abort } */
|
|
145
158
|
const inflight = new Map();
|
|
159
|
+
/** message id → { count, error } of its runs that failed; claims alone are no attempts. */
|
|
160
|
+
const failures = new Map();
|
|
146
161
|
const runs = new Set();
|
|
147
162
|
let lastHeartbeat = 0;
|
|
148
163
|
let timer = null;
|
|
@@ -222,6 +237,15 @@ export function createManager({
|
|
|
222
237
|
}, 'manager answer'), { delaysMs: ackRetryMs, label: `manager answer to ${m.id}`, log });
|
|
223
238
|
}
|
|
224
239
|
|
|
240
|
+
/** Only the lease holder settles; a stall that lapsed the lease here gets it back moments later. */
|
|
241
|
+
async function heldAgain(signal) {
|
|
242
|
+
for (let waited = 0; !held(); waited += 250) {
|
|
243
|
+
if (signal.aborted || waited >= heldWaitMs) return false;
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
245
|
+
}
|
|
246
|
+
return !signal.aborted;
|
|
247
|
+
}
|
|
248
|
+
|
|
225
249
|
async function answer(f) {
|
|
226
250
|
const { message: m, slot, abort } = f;
|
|
227
251
|
const chat = await chatFor(slot, abort.signal);
|
|
@@ -236,6 +260,10 @@ export function createManager({
|
|
|
236
260
|
if (envelope && envelope.is_error) throw new Error(`cursor-agent failed: ${String(textOf(envelope) || 'error').slice(0, 300)}`);
|
|
237
261
|
const actions = parseManagerActions(stdout);
|
|
238
262
|
if (!actions.reply) throw new Error('manager returned no reply');
|
|
263
|
+
if (!(await heldAgain(abort.signal))) {
|
|
264
|
+
if (!abort.signal.aborted) log(`manager answer to ${m.id} held back: the manager lease is not held here`);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
239
267
|
await settle(m, actions);
|
|
240
268
|
recordTurn(slot, chat.chatId, m, prompt, actions.reply);
|
|
241
269
|
if (registry.fact('manager-restart', m.id)) registry.dropFact('manager-restart', m.id);
|
|
@@ -252,11 +280,16 @@ export function createManager({
|
|
|
252
280
|
const done = answer(f).then((replied) => ({ replied }), (error) => ({ error })).then(({ replied, error }) => {
|
|
253
281
|
inflight.delete(m.id);
|
|
254
282
|
runs.delete(done);
|
|
255
|
-
if (f.abort.signal.aborted) return;
|
|
256
|
-
|
|
283
|
+
if (f.abort.signal.aborted) { log(`manager run for ${m.id} was stopped before it finished; it is not counted as a try`); return; }
|
|
284
|
+
const why = error ? runError(error).slice(0, 500) : null;
|
|
285
|
+
if (error) {
|
|
286
|
+
const failed = { count: ((failures.get(m.id) || {}).count || 0) + 1, error: why };
|
|
287
|
+
failures.set(m.id, failed);
|
|
288
|
+
log(`manager failed for ${m.id} (try ${failed.count} of ${MAX_ATTEMPTS}): ${why}`);
|
|
289
|
+
} else if (replied) failures.delete(m.id);
|
|
257
290
|
status({
|
|
258
291
|
...(replied || {}), state: error ? 'error' : inflight.size ? 'working' : 'online', messageId: error ? m.id : null,
|
|
259
|
-
error: error ?
|
|
292
|
+
error: why, inflight: inflightView(), ...(error ? { lastFailure: { id: m.id, from: m.from, error: why, at: now() } } : {}),
|
|
260
293
|
});
|
|
261
294
|
}).catch((e) => log(`manager status after ${m.id} failed: ${errorText(e)}`));
|
|
262
295
|
runs.add(done);
|
|
@@ -315,9 +348,14 @@ export function createManager({
|
|
|
315
348
|
const own = inflight.get(message.id);
|
|
316
349
|
if (own) { own.leaseToken = message.leaseToken; continue; }
|
|
317
350
|
if (isFyi(message)) { if (!(fyi && fyi(message))) messaging.ack('manager', message.id, {}, registry.directory()); continue; }
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
351
|
+
const failed = failures.get(message.id);
|
|
352
|
+
if ((failed && failed.count >= MAX_ATTEMPTS) || message.attempts > MAX_CLAIMS) {
|
|
353
|
+
const tries = failed ? failed.count : message.attempts - 1;
|
|
354
|
+
const last = failed ? failed.error : `none of its ${tries} runs finished (each was cut off by a restart or a lost lease)`;
|
|
355
|
+
messaging.ack('manager', message.id, { reply: `I could not answer this after ${tries} tries, so I stopped retrying. Last error: ${last}`, fromName: 'Manager' }, registry.directory());
|
|
356
|
+
failures.delete(message.id);
|
|
357
|
+
log(`manager gave up on ${message.id} after ${tries} tries: ${last}`);
|
|
358
|
+
status({ gaveUp: { id: message.id, from: message.from, tries, error: last, at: t } });
|
|
321
359
|
continue;
|
|
322
360
|
}
|
|
323
361
|
dispatch(message);
|
package/src/lib/contract.sha256
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
c9db92b38d0c978d4fa18d8a731fadbf3013a19659b524d615583ad099a8fafc connector-contract.json
|
|
2
2
|
7b7a59c08797c100b46436123dfcb09b517730357fa90cd0890da9b80308faf3 mirror-contract.json
|
package/src/lib/daemonRuntime.js
CHANGED
|
@@ -152,6 +152,7 @@ export class DaemonRuntime {
|
|
|
152
152
|
attemptNo: attempt.attemptNo,
|
|
153
153
|
fenceToken: attempt.fenceToken,
|
|
154
154
|
events,
|
|
155
|
+
itemRefusals: true,
|
|
155
156
|
});
|
|
156
157
|
} catch (error) {
|
|
157
158
|
if (machineWideError(error)) throw error;
|
|
@@ -164,15 +165,32 @@ export class DaemonRuntime {
|
|
|
164
165
|
this.log('events_discarded', { ...trace, discarded });
|
|
165
166
|
continue;
|
|
166
167
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
await this.#settleUpload(attempt, events, response);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Apply the cloud's answer to one uploaded batch. A stored event comes back with its `cursor` (`cloudCursor` is read
|
|
174
|
+
* when that is the only one) and leaves the outbox. A refused one ({ refused }) is dropped on its own, so the rest of
|
|
175
|
+
* the batch is kept. An event the answer leaves out stays pending and is sent again.
|
|
176
|
+
*/
|
|
177
|
+
async #settleUpload(attempt, events, response) {
|
|
178
|
+
if (!Array.isArray(response?.events)) return;
|
|
179
|
+
const sent = new Set(events.map((event) => event.sourceEventId));
|
|
180
|
+
const accepted = [];
|
|
181
|
+
const refused = new Map();
|
|
182
|
+
for (const item of response.events) {
|
|
183
|
+
if (!sent.has(item?.sourceEventId)) continue;
|
|
184
|
+
if (item.refused) refused.set(item.sourceEventId, item.refused.code || 'HUB_EVENT_REFUSED');
|
|
185
|
+
else if (Number.isSafeInteger(item.cursor ?? item.cloudCursor)) {
|
|
186
|
+
accepted.push({ sourceEventId: item.sourceEventId, cursor: item.cursor ?? item.cloudCursor });
|
|
174
187
|
}
|
|
175
188
|
}
|
|
189
|
+
if (accepted.length) await this.ledger.acknowledgeEvents(accepted);
|
|
190
|
+
if (!refused.size) return;
|
|
191
|
+
const codes = [...new Set(refused.values())].sort().join(',');
|
|
192
|
+
const { discarded } = await this.ledger.discardEvents(attempt.jobId, codes, { sourceEventIds: [...refused.keys()] });
|
|
193
|
+
this.log('events_refused', { jobId: attempt.jobId, type: attempt.jobType, attemptNo: attempt.attemptNo, code: codes, discarded });
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
#compose(grant) {
|
package/src/lib/hubDbReader.js
CHANGED
|
@@ -129,4 +129,10 @@ export class HubSnapshot {
|
|
|
129
129
|
const statement = this.db.prepare('SELECT kind, key, payload, updated_at FROM facts WHERE kind = ? ORDER BY key');
|
|
130
130
|
return kinds.flatMap((kind) => statement.all(kind).map(plain));
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
/** The `{ kind, key }` of the same facts in the same order, without their payloads. */
|
|
134
|
+
factKeys(kinds) {
|
|
135
|
+
const statement = this.db.prepare('SELECT kind, key FROM facts WHERE kind = ? ORDER BY key');
|
|
136
|
+
return kinds.flatMap((kind) => statement.all(kind).map(plain));
|
|
137
|
+
}
|
|
132
138
|
}
|
package/src/lib/hubScan.js
CHANGED
|
@@ -17,6 +17,7 @@ export const SKIPPED_FACT_KINDS = Object.freeze({
|
|
|
17
17
|
run: 'machine_runtime_state',
|
|
18
18
|
wake: 'machine_runtime_state',
|
|
19
19
|
manager: 'machine_runtime_state',
|
|
20
|
+
'manager-restart': 'machine_runtime_state',
|
|
20
21
|
totals: 'phase1_deviation_totals',
|
|
21
22
|
});
|
|
22
23
|
export const REMOVABLE_KINDS = Object.freeze(['delivery', 'stream', 'agent-row', 'request', 'status_override']);
|
package/src/lib/ledger.js
CHANGED
|
@@ -226,6 +226,7 @@ export class DurableLedger {
|
|
|
226
226
|
return clone(this.data.outbox.slice(0, Math.max(0, Math.min(100, limit))));
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/** Events the cloud stored, as { sourceEventId, cursor } with the cloud cursor it gave each, the wire's names. */
|
|
229
230
|
async acknowledgeEvents(acknowledgements) {
|
|
230
231
|
return this.#mutate((state) => {
|
|
231
232
|
if (!Array.isArray(acknowledgements) || acknowledgements.length > 100) {
|
|
@@ -237,15 +238,15 @@ export class DurableLedger {
|
|
|
237
238
|
if (
|
|
238
239
|
!pendingIds.has(item.sourceEventId) ||
|
|
239
240
|
seen.has(item.sourceEventId) ||
|
|
240
|
-
!Number.isSafeInteger(item.
|
|
241
|
-
item.
|
|
241
|
+
!Number.isSafeInteger(item.cursor) ||
|
|
242
|
+
item.cursor < 1
|
|
242
243
|
) {
|
|
243
244
|
throw new HubError('HUB_EVENT_ACK_INVALID', 'Event acknowledgement does not match the sent batch');
|
|
244
245
|
}
|
|
245
246
|
seen.add(item.sourceEventId);
|
|
246
247
|
}
|
|
247
248
|
const accepted = new Map(
|
|
248
|
-
acknowledgements.map((item) => [item.sourceEventId, Number(item.
|
|
249
|
+
acknowledgements.map((item) => [item.sourceEventId, Number(item.cursor)])
|
|
249
250
|
);
|
|
250
251
|
for (const event of state.outbox) {
|
|
251
252
|
if (!accepted.has(event.sourceEventId) || !TERMINAL_STATES.has(event.terminalState)) continue;
|
|
@@ -465,19 +466,27 @@ export class DurableLedger {
|
|
|
465
466
|
}
|
|
466
467
|
|
|
467
468
|
/**
|
|
468
|
-
* Drop a job's pending events the cloud refused for good (its fence is gone or the batch is invalid)
|
|
469
|
-
*
|
|
469
|
+
* Drop a job's pending events the cloud refused for good: all of them (its fence is gone or the batch is invalid),
|
|
470
|
+
* or only sourceEventIds (the cloud refused those items and stored the rest). They never count toward
|
|
471
|
+
* acceptedLocalCursor. A terminal attempt has nothing left to upload once its terminal event is gone.
|
|
470
472
|
*/
|
|
471
|
-
async discardEvents(jobId, code) {
|
|
473
|
+
async discardEvents(jobId, code, { sourceEventIds = null } = {}) {
|
|
474
|
+
const only = sourceEventIds ? new Set(sourceEventIds) : null;
|
|
472
475
|
return this.#mutate((state) => {
|
|
473
|
-
const
|
|
474
|
-
|
|
476
|
+
const dropped = (event) => event.jobId === jobId && (!only || only.has(event.sourceEventId));
|
|
477
|
+
const gone = state.outbox.filter(dropped);
|
|
478
|
+
state.outbox = state.outbox.filter((event) => !dropped(event));
|
|
475
479
|
const attempt = state.attempts[jobId];
|
|
476
|
-
if (attempt &&
|
|
477
|
-
attempt.eventsDiscarded = {
|
|
478
|
-
|
|
480
|
+
if (attempt && gone.length) {
|
|
481
|
+
attempt.eventsDiscarded = {
|
|
482
|
+
count: (only ? attempt.eventsDiscarded?.count || 0 : 0) + gone.length,
|
|
483
|
+
code,
|
|
484
|
+
at: new Date().toISOString(),
|
|
485
|
+
};
|
|
486
|
+
const terminalGone = !only || gone.some((event) => event.terminalState === attempt.state);
|
|
487
|
+
if (TERMINAL_STATES.has(attempt.state) && terminalGone) attempt.terminalUploaded = true;
|
|
479
488
|
}
|
|
480
|
-
return { discarded };
|
|
489
|
+
return { discarded: gone.length };
|
|
481
490
|
});
|
|
482
491
|
}
|
|
483
492
|
|
package/src/lib/lifecycle.js
CHANGED
|
@@ -3,10 +3,12 @@ import { HubError } from './errors.js';
|
|
|
3
3
|
import { validateJobResult } from './intents.js';
|
|
4
4
|
import { redactText } from './redaction.js';
|
|
5
5
|
|
|
6
|
+
// Hex, so the id always starts with a letter or digit: Pulse clouds before 1.1.3 refuse a sourceEventId starting with
|
|
7
|
+
// '-' or '_', which a base64url digest does about 3% of the time.
|
|
6
8
|
function eventId(tuple, type) {
|
|
7
9
|
return createHash('sha256')
|
|
8
10
|
.update(`${tuple.jobId}\0${tuple.attemptNo}\0${tuple.fenceToken}\0${type}`)
|
|
9
|
-
.digest('
|
|
11
|
+
.digest('hex');
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export class AttemptCoordinator {
|
package/src/lib/mirrorSource.js
CHANGED
|
@@ -150,6 +150,7 @@ export class MirrorSource {
|
|
|
150
150
|
this.redaction = new UploadRedactor({ workspace, home, approvedRoots });
|
|
151
151
|
this.windowCache = new Map();
|
|
152
152
|
this.rowMemo = new RowMemo();
|
|
153
|
+
this.itemMemo = new RowMemo();
|
|
153
154
|
this.briefs = new Map();
|
|
154
155
|
this.taskKeys = null;
|
|
155
156
|
}
|
|
@@ -388,12 +389,18 @@ export class MirrorSource {
|
|
|
388
389
|
checkpoint();
|
|
389
390
|
const secrets = [await this.reader.tokenValue().catch(() => null), ...this.heldSecrets()].filter(Boolean);
|
|
390
391
|
const redactor = this.redaction.redactor(secrets);
|
|
392
|
+
const basis = this.redaction.basis(secrets);
|
|
391
393
|
const items = new Map();
|
|
394
|
+
// An item's redacted, checked form depends on its kind and data and on the redaction basis, so one that did not
|
|
395
|
+
// move since the last pass is not redacted and checked again.
|
|
396
|
+
this.itemMemo.begin(basis);
|
|
392
397
|
for (const entry of raw) {
|
|
393
398
|
if (typeof entry.externalId !== 'string' || !entry.externalId) continue;
|
|
394
399
|
const externalId = externalIdFor(entry.externalId);
|
|
395
|
-
const
|
|
396
|
-
|
|
400
|
+
const result = this.itemMemo.take(`${entry.kind}\0${JSON.stringify(entry.data)}`, () => {
|
|
401
|
+
const redacted = redactor.redactValue(entry.data, { keep: entry.kind === 'rule_pack' ? ['file'] : [] });
|
|
402
|
+
return sanitizeItem(entry.kind, entry.kind === 'brief' ? markTruncated(redacted) : redacted, { secrets });
|
|
403
|
+
});
|
|
397
404
|
if (result.error) {
|
|
398
405
|
withheld.push({ kind: entry.kind, externalId, error: result.error });
|
|
399
406
|
continue;
|
|
@@ -405,19 +412,25 @@ export class MirrorSource {
|
|
|
405
412
|
const graphDropped = {};
|
|
406
413
|
if (windows.length === WINDOW_HOURS.length) {
|
|
407
414
|
// A row's redacted, checked form depends on the roots the redactor knows and the secrets, and on nothing else.
|
|
408
|
-
this.rowMemo.begin(
|
|
415
|
+
this.rowMemo.begin(basis);
|
|
409
416
|
const options = { redact: (value) => redactor.redactValue(value), secrets, memo: this.rowMemo };
|
|
410
|
-
const
|
|
417
|
+
const perWindow = windows.map(({ hours, body, fresh }) => {
|
|
411
418
|
if (!fresh) return this.windowCache.get(hours).rows;
|
|
412
419
|
const rows = windowRows(hours, body, options);
|
|
413
420
|
this.windowCache.set(hours, { body, rows });
|
|
414
421
|
return rows;
|
|
415
|
-
})
|
|
422
|
+
});
|
|
423
|
+
// Each checked row's canonical text, so an item is keyed by its windows and that text, not stringified again.
|
|
424
|
+
const textOf = new Map(perWindow.flatMap(({ rows }) => rows.map(({ row, text }) => [row, text])));
|
|
425
|
+
const built = mergeWindows(perWindow);
|
|
416
426
|
Object.assign(graphDropped, built.dropped);
|
|
417
427
|
const updatedAt = firstTime(Math.max(...windows.map(({ body }) => Number(body.generatedAt) || 0)), fallback);
|
|
418
428
|
for (const row of built.items) {
|
|
419
429
|
const externalId = externalIdFor(row.externalId);
|
|
420
|
-
const
|
|
430
|
+
const { section, windows: held } = row.data;
|
|
431
|
+
const text = textOf.get(row.data[section]);
|
|
432
|
+
const key = text === undefined ? JSON.stringify(row.data) : `${section}\0${held.join(',')}\0${text}`;
|
|
433
|
+
const result = this.rowMemo.take(`item\0${key}`, () => sanitizeItem('graph_window', row.data, { secrets }));
|
|
421
434
|
if (result.error) {
|
|
422
435
|
withheld.push({ kind: 'graph_window', externalId, error: result.error });
|
|
423
436
|
continue;
|