@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.1.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/README.md +115 -32
- package/package.json +14 -19
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +43 -0
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { acceptDelivery } from '../hub/lib/store.mjs';
|
|
2
|
+
import { applyRemotePresence } from './busIdentity.js';
|
|
3
|
+
|
|
4
|
+
/** How far a recipient row has got; a cloud receipt moves a local row only forward. `leased` stays on the machine. */
|
|
5
|
+
const RANK = { queued: 0, leased: 0, delivered: 1, acked: 2, expired: 3 };
|
|
6
|
+
const RECEIPT_STATE = { 'receipt.delivered': 'delivered', 'receipt.acked': 'acked', 'receipt.expired': 'expired' };
|
|
7
|
+
|
|
8
|
+
/** Cloud types this machine keeps no local copy of; recorded as seen, never quarantined. */
|
|
9
|
+
const NOT_LOCAL = new Set([
|
|
10
|
+
'project.created', 'project.updated', 'project.removed', 'project.member_added', 'project.role_changed', 'project.member_removed',
|
|
11
|
+
'rules.pack_enabled', 'rules.pack_disabled', 'rules.rule_toggled', 'team.flag_set', 'persona.updated', 'channel.created',
|
|
12
|
+
'post.edited', 'post.removed', 'decision.recorded',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const json = (value) => (value === undefined || value === null ? null : typeof value === 'string' ? value : JSON.stringify(value));
|
|
16
|
+
|
|
17
|
+
/** The ordering group a local change to this event's entity is queued under (Phase 2's grp), or null. */
|
|
18
|
+
function entityGroup(event) {
|
|
19
|
+
const p = event.payload || {};
|
|
20
|
+
switch (event.type) {
|
|
21
|
+
case 'actor.registered':
|
|
22
|
+
case 'actor.updated':
|
|
23
|
+
return `actor:${p.id}`;
|
|
24
|
+
case 'actor.row_updated':
|
|
25
|
+
return `fact:agent-row:${p.id}`;
|
|
26
|
+
case 'delivery.updated':
|
|
27
|
+
return `fact:delivery:${p.id}`;
|
|
28
|
+
case 'stream.updated':
|
|
29
|
+
return `fact:stream:${p.key}`;
|
|
30
|
+
case 'request.created':
|
|
31
|
+
return `fact:request:${p.id}`;
|
|
32
|
+
case 'delivery.accepted':
|
|
33
|
+
return `fact:accepted:${p.id}`;
|
|
34
|
+
case 'status.overridden':
|
|
35
|
+
return `fact:status_override:${p.key}`;
|
|
36
|
+
case 'settings.changed':
|
|
37
|
+
return `fact:settings:${p.key}`;
|
|
38
|
+
default:
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Applies `/connector/sync` pages to runner.db (design §4.5, §6.3): one transaction per page, each event in its own
|
|
45
|
+
* savepoint, with capture off so nothing applied here is sent back, and the cursor advanced in the same transaction.
|
|
46
|
+
*
|
|
47
|
+
* - An event is skipped as this machine's own when its key is already known (sent, pending or dead here), or when it
|
|
48
|
+
* came from this machine at or below the cutover seq, whose content the seed already holds (N3).
|
|
49
|
+
* - A change to an entity whose own newer version is still in the outbox is deferred until that version is acked,
|
|
50
|
+
* then applied only if it is later (M1).
|
|
51
|
+
* - An event that cannot be applied goes to pulse_quarantine with its reason; the cursor still moves past it (I1).
|
|
52
|
+
*/
|
|
53
|
+
export function createApplier({ runner, machineId, cutoverSeq = () => 0, now = Date.now, log = () => {}, checkpoint = () => {} }) {
|
|
54
|
+
/**
|
|
55
|
+
* Every machine id this hub's content was sent up under: today's, and those the import and the shadow ran as before a
|
|
56
|
+
* re-pair (`origin_machines`, recorded at cutover). Up to the cutover seq, their events are already in the seed.
|
|
57
|
+
*/
|
|
58
|
+
const ownMachines = () => new Set([machineId, ...(runner.store.meta('origin_machines') || [])].filter(Boolean));
|
|
59
|
+
const { hub, store, capture, mapping } = runner;
|
|
60
|
+
const { db } = hub;
|
|
61
|
+
const q = {
|
|
62
|
+
seen: db.prepare('SELECT 1 AS found FROM pulse_events WHERE seq = ? OR id = ?'),
|
|
63
|
+
record: db.prepare('INSERT OR IGNORE INTO pulse_events (seq, id, type, key, origin, outcome, applied_at) VALUES (?, ?, ?, ?, ?, ?, ?)'),
|
|
64
|
+
quarantine: db.prepare('INSERT OR IGNORE INTO pulse_quarantine (seq, id, type, reason, payload, at) VALUES (?, ?, ?, ?, ?, ?)'),
|
|
65
|
+
defer: db.prepare('INSERT OR IGNORE INTO pulse_deferred (seq, grp, event, at) VALUES (?, ?, ?, ?)'),
|
|
66
|
+
deferred: db.prepare('SELECT seq, grp, event FROM pulse_deferred ORDER BY seq'),
|
|
67
|
+
undefer: db.prepare('DELETE FROM pulse_deferred WHERE seq = ?'),
|
|
68
|
+
pending: db.prepare('SELECT 1 AS found FROM outbox WHERE grp = ? LIMIT 1'),
|
|
69
|
+
ackedSeq: db.prepare("SELECT MAX(CAST(s.seq AS INTEGER)) AS seq FROM sent s JOIN version v ON v.key = s.key WHERE v.entity = ?"),
|
|
70
|
+
sent: db.prepare("INSERT INTO sent (key, source, type, seq, duplicate, at) VALUES (?, 'sync', ?, ?, 0, ?) ON CONFLICT (key) DO NOTHING"),
|
|
71
|
+
cursor: db.prepare('UPDATE pulse_sync SET applied_seq = ?, head = ?, floor = ?, last_ok_at = ?, last_error = NULL WHERE one = 1'),
|
|
72
|
+
message: db.prepare(`INSERT OR IGNORE INTO messages (id, sender, topic, type, body, needs_reply, expires_at, reply_to, reaction, from_name, created_at, meta)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
74
|
+
delivery: db.prepare("INSERT OR IGNORE INTO deliveries (message_id, recipient, state, queued_at, attempts) VALUES (?, ?, 'queued', ?, 0)"),
|
|
75
|
+
receipt: db.prepare('SELECT state FROM deliveries WHERE message_id = ? AND recipient = ?'),
|
|
76
|
+
messageExists: db.prepare('SELECT 1 AS found FROM messages WHERE id = ?'),
|
|
77
|
+
fact: db.prepare(`INSERT INTO facts (kind, key, payload, updated_at) VALUES (?, ?, ?, ?)
|
|
78
|
+
ON CONFLICT (kind, key) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`),
|
|
79
|
+
factOf: db.prepare('SELECT payload FROM facts WHERE kind = ? AND key = ?'),
|
|
80
|
+
dropFact: db.prepare('DELETE FROM facts WHERE kind = ? AND key = ?'),
|
|
81
|
+
event: db.prepare('INSERT INTO events (at, kind, actor, payload) VALUES (?, ?, ?, ?)'),
|
|
82
|
+
};
|
|
83
|
+
const touched = [];
|
|
84
|
+
const adopt = (change) => touched.push(change);
|
|
85
|
+
|
|
86
|
+
function setFact(kind, key, payload, at) {
|
|
87
|
+
q.fact.run(kind, key, json(payload), Number(at) || now());
|
|
88
|
+
adopt({ tbl: 'facts', k1: kind, k2: key });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function receipt(event) {
|
|
92
|
+
const p = event.payload;
|
|
93
|
+
const target = RECEIPT_STATE[event.type];
|
|
94
|
+
const current = q.receipt.get(p.messageId, p.recipient);
|
|
95
|
+
if (!current) {
|
|
96
|
+
if (!q.messageExists.get(p.messageId)) throw Object.assign(new Error(`receipt for a message this machine never had: ${p.messageId}`), { reason: 'message_missing' });
|
|
97
|
+
q.delivery.run(p.messageId, p.recipient, Number(p.at) || now());
|
|
98
|
+
} else if (RANK[current.state] >= RANK[target] || current.state === 'expired') {
|
|
99
|
+
return 'not_forward';
|
|
100
|
+
}
|
|
101
|
+
const at = Number(p.at) || now();
|
|
102
|
+
if (target === 'delivered') {
|
|
103
|
+
db.prepare(`UPDATE deliveries SET state = 'delivered', delivered_at = COALESCE(delivered_at, ?), delivered_via = COALESCE(?, delivered_via),
|
|
104
|
+
lease_token = NULL, lease_until = NULL WHERE message_id = ? AND recipient = ?`).run(at, p.via ?? null, p.messageId, p.recipient);
|
|
105
|
+
} else if (target === 'acked') {
|
|
106
|
+
db.prepare(`UPDATE deliveries SET state = 'acked', acked_at = ?, delivered_at = COALESCE(delivered_at, ?), awaiting_reply = 0,
|
|
107
|
+
lease_token = NULL, lease_until = NULL WHERE message_id = ? AND recipient = ?`).run(at, at, p.messageId, p.recipient);
|
|
108
|
+
} else {
|
|
109
|
+
db.prepare("UPDATE deliveries SET state = 'expired', lease_token = NULL, lease_until = NULL WHERE message_id = ? AND recipient = ?").run(p.messageId, p.recipient);
|
|
110
|
+
}
|
|
111
|
+
adopt({ tbl: 'deliveries', k1: p.messageId, k2: p.recipient });
|
|
112
|
+
return 'applied';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function upsertActor(actor) {
|
|
116
|
+
db.prepare(`INSERT INTO actors (id, node_type, role, parent_id, delivery_id, stream_key, cursor_title, prompt_title, persona_name,
|
|
117
|
+
persona_first, persona_username, persona_seed, persona_json, source, topics, started_at, last_at, tokens_est, msgs_in, msgs_out,
|
|
118
|
+
repo, channel, synthetic, extra)
|
|
119
|
+
VALUES (@id, @node_type, @role, @parent_id, @delivery_id, @stream_key, @cursor_title, @prompt_title, @persona_name, @persona_first,
|
|
120
|
+
@persona_username, @persona_seed, @persona_json, @source, @topics, @started_at, @last_at, @tokens_est, @msgs_in, @msgs_out, @repo,
|
|
121
|
+
@channel, @synthetic, @extra)
|
|
122
|
+
ON CONFLICT (id) DO UPDATE SET node_type = excluded.node_type, role = excluded.role, parent_id = excluded.parent_id,
|
|
123
|
+
delivery_id = excluded.delivery_id, stream_key = excluded.stream_key, cursor_title = excluded.cursor_title,
|
|
124
|
+
prompt_title = excluded.prompt_title, persona_name = excluded.persona_name, persona_first = excluded.persona_first,
|
|
125
|
+
persona_username = excluded.persona_username, persona_seed = excluded.persona_seed, persona_json = excluded.persona_json,
|
|
126
|
+
source = excluded.source, topics = excluded.topics, started_at = excluded.started_at,
|
|
127
|
+
last_at = MAX(COALESCE(actors.last_at, 0), COALESCE(excluded.last_at, 0)), tokens_est = excluded.tokens_est,
|
|
128
|
+
msgs_in = excluded.msgs_in, msgs_out = excluded.msgs_out, repo = excluded.repo, channel = excluded.channel,
|
|
129
|
+
synthetic = excluded.synthetic, extra = excluded.extra`).run({
|
|
130
|
+
id: actor.id, node_type: actor.node_type, role: actor.role ?? null, parent_id: actor.parent_id ?? null, delivery_id: actor.delivery_id ?? null,
|
|
131
|
+
stream_key: actor.stream_key ?? null, cursor_title: actor.cursor_title ?? null, prompt_title: actor.prompt_title ?? null,
|
|
132
|
+
persona_name: actor.persona_name ?? null, persona_first: actor.persona_first ?? null, persona_username: actor.persona_username ?? null,
|
|
133
|
+
persona_seed: actor.persona_seed ?? null, persona_json: json(actor.persona), source: actor.source || 'registered',
|
|
134
|
+
topics: json(actor.topics ?? []), started_at: actor.started_at ?? null, last_at: actor.last_at ?? null, tokens_est: actor.tokens_est ?? null,
|
|
135
|
+
msgs_in: actor.msgs_in ?? 0, msgs_out: actor.msgs_out ?? 0, repo: actor.repo ?? null, channel: actor.channel ?? null,
|
|
136
|
+
synthetic: actor.synthetic ? 1 : 0, extra: json(actor.extra),
|
|
137
|
+
});
|
|
138
|
+
adopt({ tbl: 'actors', k1: actor.id });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The delivery as the local collector stores it: its streams carried inside it, from their own stream facts. */
|
|
142
|
+
function deliveryFact(p) {
|
|
143
|
+
const prior = JSON.parse(q.factOf.get('delivery', p.id)?.payload || 'null');
|
|
144
|
+
const { streamKeys = [], ...delivery } = p.delivery || {};
|
|
145
|
+
const byKey = new Map((prior?.streams || []).map((s) => [s.key, s]));
|
|
146
|
+
const streams = streamKeys.map((key) => {
|
|
147
|
+
const own = q.factOf.get('stream', key);
|
|
148
|
+
return own ? { ...JSON.parse(own.payload), key } : byKey.get(key) || { key };
|
|
149
|
+
});
|
|
150
|
+
return { ...delivery, streams };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function applyOne(event) {
|
|
154
|
+
const p = event.payload || {};
|
|
155
|
+
switch (event.type) {
|
|
156
|
+
case 'message.published': {
|
|
157
|
+
q.message.run(p.id, p.from, p.topic, p.type, p.body, p.needsAck ? 1 : 0, Number(p.expiresAt), p.replyTo ?? null, p.reaction ?? null,
|
|
158
|
+
p.fromName ?? null, Number(p.at) || now(), json(p.meta ?? {}));
|
|
159
|
+
for (const to of p.to || []) q.delivery.run(p.id, to, Number(p.at) || now());
|
|
160
|
+
return 'applied';
|
|
161
|
+
}
|
|
162
|
+
case 'receipt.delivered':
|
|
163
|
+
case 'receipt.acked':
|
|
164
|
+
case 'receipt.expired':
|
|
165
|
+
return receipt(event);
|
|
166
|
+
case 'message.retracted':
|
|
167
|
+
db.prepare('DELETE FROM deliveries WHERE message_id = ?').run(p.id);
|
|
168
|
+
db.prepare('DELETE FROM messages WHERE id = ?').run(p.id);
|
|
169
|
+
return 'applied';
|
|
170
|
+
case 'actor.registered':
|
|
171
|
+
case 'actor.updated':
|
|
172
|
+
upsertActor({ ...(p.actor || {}), id: p.id });
|
|
173
|
+
return 'applied';
|
|
174
|
+
case 'actor.retired':
|
|
175
|
+
return 'not_local';
|
|
176
|
+
case 'actor.row_updated':
|
|
177
|
+
setFact('agent-row', p.id, p.row, p.at);
|
|
178
|
+
return 'applied';
|
|
179
|
+
case 'delivery.updated':
|
|
180
|
+
case 'delivery.created':
|
|
181
|
+
setFact('delivery', p.id, deliveryFact(p), p.at);
|
|
182
|
+
return 'applied';
|
|
183
|
+
case 'stream.updated': {
|
|
184
|
+
setFact('stream', p.key, p.stream, p.at);
|
|
185
|
+
const owner = JSON.parse(q.factOf.get('delivery', p.deliveryId)?.payload || 'null');
|
|
186
|
+
if (owner) setFact('delivery', p.deliveryId, { ...owner, streams: (owner.streams || []).map((s) => (s.key === p.key ? { ...p.stream, key: p.key } : s)) }, p.at);
|
|
187
|
+
return 'applied';
|
|
188
|
+
}
|
|
189
|
+
case 'request.created':
|
|
190
|
+
setFact('request', p.id, p.request, p.at);
|
|
191
|
+
return 'applied';
|
|
192
|
+
case 'delivery.accepted':
|
|
193
|
+
setFact('accepted', p.id, { at: Number(p.at) || now() }, p.at);
|
|
194
|
+
acceptDelivery(p.id);
|
|
195
|
+
return 'applied';
|
|
196
|
+
case 'settings.changed':
|
|
197
|
+
setFact('settings', p.key, p.value, p.at);
|
|
198
|
+
return 'applied';
|
|
199
|
+
case 'status.overridden':
|
|
200
|
+
setFact('status_override', p.key, p.override, p.at);
|
|
201
|
+
return 'applied';
|
|
202
|
+
case 'fact.removed':
|
|
203
|
+
q.dropFact.run(p.kind, p.key);
|
|
204
|
+
return 'applied';
|
|
205
|
+
case 'audit.recorded':
|
|
206
|
+
q.event.run(Number(p.at) || now(), p.kind, p.actor ?? null, json(p.data ?? {}));
|
|
207
|
+
return 'applied';
|
|
208
|
+
case 'post.created':
|
|
209
|
+
// Appended to channel.md by the channel bridge after this page commits (render.js), once.
|
|
210
|
+
return 'applied';
|
|
211
|
+
default:
|
|
212
|
+
if (NOT_LOCAL.has(event.type)) return 'not_local';
|
|
213
|
+
throw Object.assign(new Error(`no local meaning for ${event.type}`), { reason: 'unknown_type' });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const ownEvent = (event, own) => store.known(event.key) || (own.has(event.machineId) && Number(event.seq) <= Number(cutoverSeq()));
|
|
218
|
+
|
|
219
|
+
function step(event, { retry = false, own = ownMachines() } = {}) {
|
|
220
|
+
if (!retry && q.seen.get(event.seq, event.id)) return 'replayed';
|
|
221
|
+
if (ownEvent(event, own)) return 'own';
|
|
222
|
+
const group = entityGroup(event);
|
|
223
|
+
if (group && q.pending.get(group)) {
|
|
224
|
+
q.defer.run(event.seq, group, JSON.stringify(event), now());
|
|
225
|
+
return 'deferred';
|
|
226
|
+
}
|
|
227
|
+
if (retry && group) {
|
|
228
|
+
const entity = group;
|
|
229
|
+
const acked = Number(q.ackedSeq.get(entity)?.seq || 0);
|
|
230
|
+
if (acked > Number(event.seq)) return 'superseded';
|
|
231
|
+
}
|
|
232
|
+
db.prepare('SAVEPOINT pulse_event').run();
|
|
233
|
+
try {
|
|
234
|
+
const outcome = applyOne(event);
|
|
235
|
+
db.prepare('RELEASE pulse_event').run();
|
|
236
|
+
return outcome;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
db.prepare('ROLLBACK TO pulse_event').run();
|
|
239
|
+
db.prepare('RELEASE pulse_event').run();
|
|
240
|
+
touched.length = 0;
|
|
241
|
+
q.quarantine.run(event.seq, event.id ?? null, event.type ?? null, String(error.reason || error.message).slice(0, 300), JSON.stringify(event.payload ?? null).slice(0, 32_000), now());
|
|
242
|
+
return 'quarantined';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function settle(event, outcome) {
|
|
247
|
+
q.record.run(event.seq, event.id || `seq:${event.seq}`, event.type || 'unknown', event.key || '', event.machineId ?? null, outcome, now());
|
|
248
|
+
if (outcome === 'applied' && event.key) q.sent.run(event.key, event.type, String(event.seq), now());
|
|
249
|
+
for (const change of touched.splice(0)) mapping.adopt?.(change, db);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
/** One page, in one transaction. Returns counts and the cursor it moved to. */
|
|
254
|
+
apply(page) {
|
|
255
|
+
const counts = { applied: 0, skipped: 0, deferred: 0, quarantined: 0, notLocal: 0, posts: [] };
|
|
256
|
+
const own = ownMachines();
|
|
257
|
+
capture.applying(() => {
|
|
258
|
+
for (const event of page.events || []) {
|
|
259
|
+
const outcome = step(event, { own });
|
|
260
|
+
if (outcome === 'replayed') continue;
|
|
261
|
+
if (outcome === 'applied') counts.applied += 1;
|
|
262
|
+
else if (outcome === 'own' || outcome === 'not_forward') counts.skipped += 1;
|
|
263
|
+
else if (outcome === 'deferred') counts.deferred += 1;
|
|
264
|
+
else if (outcome === 'quarantined') counts.quarantined += 1;
|
|
265
|
+
else if (outcome === 'not_local') counts.notLocal += 1;
|
|
266
|
+
if (outcome !== 'deferred') settle(event, outcome);
|
|
267
|
+
else q.record.run(event.seq, event.id || `seq:${event.seq}`, event.type, event.key || '', event.machineId ?? null, 'deferred', now());
|
|
268
|
+
if (outcome === 'applied' && event.type === 'post.created') counts.posts.push(event);
|
|
269
|
+
checkpoint('after-event');
|
|
270
|
+
}
|
|
271
|
+
q.cursor.run(Number(page.next) || 0, Number(page.head) || 0, Number(page.floor) || 0, now());
|
|
272
|
+
// Other machines' beats: whose aliases and sessions are live there (Pulse 1.1.1 F1, bus identity).
|
|
273
|
+
counts.presence = applyRemotePresence(db, page.presence, machineId);
|
|
274
|
+
});
|
|
275
|
+
return { ...counts, next: Number(page.next) || 0 };
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
/** Deferred foreign changes whose entity has nothing pending any more: applied now if they are the later write. */
|
|
279
|
+
retryDeferred() {
|
|
280
|
+
const counts = { applied: 0, superseded: 0, waiting: 0 };
|
|
281
|
+
capture.applying(() => {
|
|
282
|
+
for (const row of q.deferred.all()) {
|
|
283
|
+
if (q.pending.get(row.grp)) { counts.waiting += 1; continue; }
|
|
284
|
+
const event = JSON.parse(row.event);
|
|
285
|
+
const outcome = step(event, { retry: true });
|
|
286
|
+
if (outcome === 'deferred') { counts.waiting += 1; continue; }
|
|
287
|
+
q.undefer.run(row.seq);
|
|
288
|
+
db.prepare('UPDATE pulse_events SET outcome = ? WHERE seq = ?').run(outcome, event.seq);
|
|
289
|
+
if (outcome === 'applied') { counts.applied += 1; settle(event, outcome); }
|
|
290
|
+
if (outcome === 'superseded') counts.superseded += 1;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
if (counts.applied) log('deferred_applied', counts);
|
|
294
|
+
return counts;
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { fullJitterDelay } from '../lib/backoff.js';
|
|
3
|
+
import { scopeBlock } from '../lib/errors.js';
|
|
4
|
+
import { OUTBOX_MAX_PENDING } from '../lib/runnerStore.js';
|
|
5
|
+
import { OutboxFlusher } from '../lib/outboxFlusher.js';
|
|
6
|
+
|
|
7
|
+
const ONLINE_MS = 35_000;
|
|
8
|
+
const OFFLINE_MS = 60_000;
|
|
9
|
+
const LAG_EVENTS = 50;
|
|
10
|
+
const WAKE_GAP_MS = 30_000;
|
|
11
|
+
const FLUSH_EVERY_MS = 1_000;
|
|
12
|
+
const HEALTH_EVERY_MS = 5_000;
|
|
13
|
+
const BATCHES_PER_FLUSH = 30;
|
|
14
|
+
/** The Phase 2 scanner's file sources (channel.md, personas.json, state/chat) and presence, as the shadow ran them. */
|
|
15
|
+
export const FILE_SOURCES = Object.freeze(['personas', 'chat', 'channels', 'presence']);
|
|
16
|
+
const FILE_SCAN_MS = 10_000;
|
|
17
|
+
/** The manager lease (Q9): renewed well inside its ttl; this machine stops counting on it `LEASE_MARGIN_MS` early. */
|
|
18
|
+
export const LEASE_TTL_MS = 30_000;
|
|
19
|
+
const LEASE_RENEW_MS = 10_000;
|
|
20
|
+
const LEASE_MARGIN_MS = 5_000;
|
|
21
|
+
/** runner.db meta key of the cloud's presence keyset cursor (S3), kept beside the event cursor and sent on every sync. */
|
|
22
|
+
const PRESENCE_CURSOR = 'presence_cursor';
|
|
23
|
+
/** This many sync failures in a row (no page applied in between) and health says so, ahead of anything milder. */
|
|
24
|
+
export const SYNC_FAILING_AFTER = 3;
|
|
25
|
+
/** SCOPE-02: while blocked for a scope or grant, the sync is retried this often with the sign-in read again, so a re-login clears it. */
|
|
26
|
+
export const BLOCK_RETRY_MS = 60_000;
|
|
27
|
+
const ERROR_CODES = new Set(['HUB_LOGIN_REQUIRED', 'HUB_SCOPE_DENIED', 'capability_denied', 'cloud_first_off', 'HUB_MACHINE_REVOKED']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The daemon's half of the runner (design §6.3): it holds the cloud credentials, so it long-polls `/connector/sync`
|
|
31
|
+
* and hands each page to the runner process to apply, and it flushes runner.db's outbox to `/connector/commands`.
|
|
32
|
+
* Outbound only. The cursor it asks from is the one the runner committed with the page it applied (I2), read back
|
|
33
|
+
* from runner.db, so a crash anywhere re-asks for the same page. Health (§9.4) is measured here and sent to the
|
|
34
|
+
* runner for the facade, `pulse status` and the cloud.
|
|
35
|
+
*/
|
|
36
|
+
export class RunnerBridge {
|
|
37
|
+
constructor({
|
|
38
|
+
runner, transport, store, machineId = null, now = Date.now, monotonic = () => performance.now(), random = Math.random, waitSeconds = 20, log = () => {},
|
|
39
|
+
lease = { ttlMs: LEASE_TTL_MS, renewMs: LEASE_RENEW_MS, marginMs: LEASE_MARGIN_MS }, fileScan = null, fileScanMs = FILE_SCAN_MS, blockRetryMs = BLOCK_RETRY_MS,
|
|
40
|
+
flusher = new OutboxFlusher({ store, transport, random }),
|
|
41
|
+
}) {
|
|
42
|
+
this.runner = runner;
|
|
43
|
+
this.transport = transport;
|
|
44
|
+
this.store = store;
|
|
45
|
+
this.now = now;
|
|
46
|
+
this.monotonic = monotonic;
|
|
47
|
+
this.random = random;
|
|
48
|
+
this.waitSeconds = waitSeconds;
|
|
49
|
+
this.log = log;
|
|
50
|
+
this.flusher = flusher;
|
|
51
|
+
this.machineId = machineId;
|
|
52
|
+
this.leaseConfig = lease;
|
|
53
|
+
this.leaseDeadline = 0;
|
|
54
|
+
this.fileScan = fileScan;
|
|
55
|
+
this.fileScanMs = fileScanMs;
|
|
56
|
+
this.blockRetryMs = blockRetryMs;
|
|
57
|
+
this.running = false;
|
|
58
|
+
this.kicked = null;
|
|
59
|
+
this.state = { lastSyncAt: null, lastContactAt: null, failures: 0, syncFailure: null, error: null, resync: null, head: 0, sleptAt: null, flushError: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
start() {
|
|
63
|
+
if (this.running) return;
|
|
64
|
+
this.running = true;
|
|
65
|
+
this.runner.on('wrote', () => this.kick());
|
|
66
|
+
this.syncing = this.#syncLoop();
|
|
67
|
+
this.flushing = this.#flushLoop();
|
|
68
|
+
if (this.leaseConfig) this.leasing = this.#leaseLoop();
|
|
69
|
+
if (this.fileScan) this.scanning = this.#fileLoop();
|
|
70
|
+
this.healthTimer = setInterval(() => this.#pushHealth(), HEALTH_EVERY_MS);
|
|
71
|
+
this.healthTimer.unref?.();
|
|
72
|
+
this.#pushHealth();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async stop() {
|
|
76
|
+
this.running = false;
|
|
77
|
+
clearInterval(this.healthTimer);
|
|
78
|
+
this.kick();
|
|
79
|
+
this.leaseWake?.();
|
|
80
|
+
this.fileWake?.();
|
|
81
|
+
await Promise.allSettled([this.syncing, this.flushing, this.leasing, this.scanning]);
|
|
82
|
+
if (this.leaseConfig && this.leaseDeadline > this.monotonic()) {
|
|
83
|
+
await this.#claimLease({ release: true }).catch(() => {});
|
|
84
|
+
this.runner.send({ type: 'lease', held: false });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A local write landed: flush now instead of at the next tick. */
|
|
89
|
+
kick() {
|
|
90
|
+
const wake = this.kicked;
|
|
91
|
+
this.kicked = null;
|
|
92
|
+
wake?.();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
applied() {
|
|
96
|
+
return Number(this.store.db.prepare('SELECT applied_seq FROM pulse_sync WHERE one = 1').get()?.applied_seq || 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async #syncLoop() {
|
|
100
|
+
let lastWall = this.now();
|
|
101
|
+
let lastMono = this.monotonic();
|
|
102
|
+
let lagging = true;
|
|
103
|
+
while (this.running) {
|
|
104
|
+
const wallGap = this.now() - lastWall;
|
|
105
|
+
const monoGap = this.monotonic() - lastMono;
|
|
106
|
+
if (wallGap - monoGap > WAKE_GAP_MS) {
|
|
107
|
+
this.state.sleptAt = this.now();
|
|
108
|
+
this.log('runner_woke', { asleepMs: Math.round(wallGap - monoGap) });
|
|
109
|
+
lagging = true;
|
|
110
|
+
}
|
|
111
|
+
lastWall = this.now();
|
|
112
|
+
lastMono = this.monotonic();
|
|
113
|
+
const blocked = this.state.error?.block ? this.state.error : null;
|
|
114
|
+
if (this.state.resync || (this.state.error && !blocked)) { await sleep(5_000); continue; }
|
|
115
|
+
if (blocked) {
|
|
116
|
+
const left = blocked.at + this.blockRetryMs - this.now();
|
|
117
|
+
if (left > 0) { await sleep(Math.min(5_000, left)); continue; }
|
|
118
|
+
this.transport.auth?.invalidate?.();
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const since = this.applied();
|
|
122
|
+
const presence = this.store.meta?.(PRESENCE_CURSOR) ?? null;
|
|
123
|
+
const page = await this.transport.sync({ since, wait: lagging ? 0 : this.waitSeconds, ...(presence ? { presence } : {}) });
|
|
124
|
+
this.state.lastContactAt = this.now();
|
|
125
|
+
const result = await this.runner.request({ type: 'apply', page });
|
|
126
|
+
this.state.lastSyncAt = this.now();
|
|
127
|
+
if (blocked) {
|
|
128
|
+
this.state.error = null;
|
|
129
|
+
this.log('sync_unblocked', { code: blocked.code });
|
|
130
|
+
this.#pushHealth();
|
|
131
|
+
}
|
|
132
|
+
if (typeof page.presenceNext === 'string' && page.presenceNext && page.presenceNext !== presence) this.store.setMeta?.(PRESENCE_CURSOR, page.presenceNext);
|
|
133
|
+
this.state.failures = 0;
|
|
134
|
+
this.state.syncFailure = null;
|
|
135
|
+
this.state.head = Number(page.head) || 0;
|
|
136
|
+
lagging = Number(page.head) > Number(result.next);
|
|
137
|
+
if (result.quarantined) this.log('sync_quarantined', { count: result.quarantined, next: result.next });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!this.running) break;
|
|
140
|
+
if (error.code === 'runner_exited') { await sleep(1_000); continue; }
|
|
141
|
+
if (error.status === 410 || error.code === 'resync_required') {
|
|
142
|
+
this.state.resync = { at: this.now(), floor: error.details?.floor ?? null };
|
|
143
|
+
this.log('sync_resync_required', { since: this.applied() });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// A cursor the cloud refuses (after a downgrade, say) would fail every sync: drop it and start presence over.
|
|
147
|
+
if (error.code === 'invalid_presence' && this.store.meta?.(PRESENCE_CURSOR)) {
|
|
148
|
+
this.store.setMeta?.(PRESENCE_CURSOR, null);
|
|
149
|
+
this.log('presence_cursor_reset', { code: error.code });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (ERROR_CODES.has(error.code)) {
|
|
153
|
+
this.state.error = { code: error.code, at: this.now(), block: scopeBlock(error) };
|
|
154
|
+
this.log('sync_refused', { code: error.code });
|
|
155
|
+
this.#pushHealth();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
this.state.failures += 1;
|
|
159
|
+
this.state.syncFailure = { code: error.code || 'error', at: this.now() };
|
|
160
|
+
this.log('sync_failed', { code: error.code || 'error', failures: this.state.failures });
|
|
161
|
+
await sleep(Math.max(1_000, fullJitterDelay(this.state.failures, { capMs: 30_000, random: this.random })));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async #flushLoop() {
|
|
167
|
+
let failures = 0;
|
|
168
|
+
while (this.running) {
|
|
169
|
+
try {
|
|
170
|
+
const tally = await this.flusher.drain({ maxBatches: BATCHES_PER_FLUSH });
|
|
171
|
+
this.state.lastContactAt = tally.batches ? this.now() : this.state.lastContactAt;
|
|
172
|
+
this.state.flushError = null;
|
|
173
|
+
failures = 0;
|
|
174
|
+
if (tally.written + tally.duplicate > 0) await this.runner.request({ type: 'retryDeferred' }).catch(() => {});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
failures += 1;
|
|
177
|
+
this.state.flushError = { code: error.code || 'error', at: this.now() };
|
|
178
|
+
if (ERROR_CODES.has(error.code)) this.state.error = { code: error.code, at: this.now(), block: scopeBlock(error) };
|
|
179
|
+
await sleep(Math.max(1_000, fullJitterDelay(failures, { capMs: 30_000, random: this.random })));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
await Promise.race([sleep(FLUSH_EVERY_MS), new Promise((resolve) => { this.kicked = resolve; })]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* After cutover the hub.db sources are captured in the runner; the file sources stay with the Phase 2 scanner, now
|
|
188
|
+
* reading runner.db: channel.md posts agents append, personas, project chat, and presence beats (not log events).
|
|
189
|
+
*/
|
|
190
|
+
async #fileLoop() {
|
|
191
|
+
while (this.running) {
|
|
192
|
+
try {
|
|
193
|
+
const report = await this.fileScan.run({ mode: 'shadow', sources: FILE_SOURCES });
|
|
194
|
+
for (let start = 0; start < report.presence.length; start += 100) {
|
|
195
|
+
const chunk = report.presence.slice(start, start + 100);
|
|
196
|
+
const answer = await this.transport.commands({ commands: chunk });
|
|
197
|
+
chunk.forEach((beat, index) => {
|
|
198
|
+
if (!answer?.results?.[index]?.error) this.store.setPresenceSent(beat.payload.actorId, beat.payload.at);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (report.totals.planned) this.kick();
|
|
202
|
+
} catch (error) {
|
|
203
|
+
this.log('file_scan_failed', { code: error.code || 'error', message: String(error.message).slice(0, 200) });
|
|
204
|
+
}
|
|
205
|
+
await Promise.race([sleep(this.fileScanMs), new Promise((resolve) => { this.fileWake = resolve; })]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One lease call through /commands (no seq). Resolves with the lease, or throws. */
|
|
210
|
+
async #claimLease({ release = false } = {}) {
|
|
211
|
+
const payload = release ? { role: 'manager', release: true } : { role: 'manager', ttlMs: this.leaseConfig.ttlMs };
|
|
212
|
+
const answer = await this.transport.commands({ commands: [{ type: 'runner.lease', idempotencyKey: `lease:${this.machineId || 'runner'}:${this.now()}`, payload }] });
|
|
213
|
+
const result = answer?.results?.[0];
|
|
214
|
+
if (!result || result.error) throw Object.assign(new Error(result?.error?.message || 'no lease result'), { code: result?.error?.code || 'lease_failed' });
|
|
215
|
+
return result.lease;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Claims or renews the manager lease every renewMs. The deadline is the monotonic time the winning request was sent,
|
|
220
|
+
* plus the ttl, minus a margin: never the cloud's `until` against this machine's clock (H4). A failed renew keeps the
|
|
221
|
+
* deadline it had; when it passes, the runner is told at once and stops its manager before the cloud lets anyone else in.
|
|
222
|
+
*/
|
|
223
|
+
async #leaseLoop() {
|
|
224
|
+
const { ttlMs, renewMs, marginMs } = this.leaseConfig;
|
|
225
|
+
let lapse = null;
|
|
226
|
+
const report = (lease) => {
|
|
227
|
+
const remainingMs = Math.max(0, this.leaseDeadline - this.monotonic());
|
|
228
|
+
this.runner.send({ type: 'lease', held: remainingMs > 0, remainingMs, holder: lease?.holder ?? null, fence: lease?.fence ?? 0 });
|
|
229
|
+
};
|
|
230
|
+
while (this.running) {
|
|
231
|
+
const sentAt = this.monotonic();
|
|
232
|
+
try {
|
|
233
|
+
const lease = await this.#claimLease();
|
|
234
|
+
this.lease = lease;
|
|
235
|
+
this.leaseDeadline = lease.here ? sentAt + ttlMs - marginMs : 0;
|
|
236
|
+
report(lease);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
this.log('lease_failed', { code: error.code || 'error' });
|
|
239
|
+
report(this.lease);
|
|
240
|
+
}
|
|
241
|
+
clearTimeout(lapse);
|
|
242
|
+
const remaining = this.leaseDeadline - this.monotonic();
|
|
243
|
+
if (remaining > 0) {
|
|
244
|
+
lapse = setTimeout(() => report(this.lease), remaining);
|
|
245
|
+
lapse.unref?.();
|
|
246
|
+
}
|
|
247
|
+
await Promise.race([sleep(renewMs), new Promise((resolve) => { this.leaseWake = resolve; })]);
|
|
248
|
+
}
|
|
249
|
+
clearTimeout(lapse);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The measured state (§9.4): never "running" alone. */
|
|
253
|
+
health() {
|
|
254
|
+
const now = this.now();
|
|
255
|
+
const pending = this.store.pendingCount();
|
|
256
|
+
const dead = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM dead').get().n);
|
|
257
|
+
const quarantined = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM pulse_quarantine').get()?.n || 0);
|
|
258
|
+
const applied = this.applied();
|
|
259
|
+
const lag = Math.max(0, this.state.head - applied);
|
|
260
|
+
const outbox = { pending, dead, capacity: OUTBOX_MAX_PENDING };
|
|
261
|
+
const base = { lastSyncAt: this.state.lastSyncAt, applied, head: this.state.head, lag, outbox, quarantined,
|
|
262
|
+
staleSeconds: this.state.lastSyncAt ? (now - this.state.lastSyncAt) / 1000 : null };
|
|
263
|
+
const { block } = this.state.error || {};
|
|
264
|
+
if (block) {
|
|
265
|
+
const missing = block.missingScopes.length ? `missing ${block.missingScopes.join(', ')}` : 'missing a scope or capability';
|
|
266
|
+
return { ...base, state: 'blocked', reason: `${block.code}: ${missing}`, code: block.code, missingScopes: block.missingScopes, fix: block.fix };
|
|
267
|
+
}
|
|
268
|
+
if (this.state.error) return { ...base, state: 'error', reason: this.state.error.code, fix: this.state.error.code === 'HUB_LOGIN_REQUIRED' ? 'agentpulse login' : null };
|
|
269
|
+
if (this.state.resync) return { ...base, state: 'error', reason: 'resync_required' };
|
|
270
|
+
if (this.state.failures >= SYNC_FAILING_AFTER && this.state.syncFailure) {
|
|
271
|
+
const { code } = this.state.syncFailure;
|
|
272
|
+
return { ...base, state: 'degraded', reason: `sync failing (${code})`, code, failures: this.state.failures };
|
|
273
|
+
}
|
|
274
|
+
if (this.state.sleptAt && (!this.state.lastSyncAt || this.state.lastSyncAt < this.state.sleptAt)) return { ...base, state: 'sleeping', reason: 'woke from sleep, catching up' };
|
|
275
|
+
if (!this.state.lastContactAt || now - this.state.lastContactAt > OFFLINE_MS) {
|
|
276
|
+
return { ...base, state: 'offline', reason: this.state.lastContactAt ? 'no contact with Pulse' : 'not connected yet' };
|
|
277
|
+
}
|
|
278
|
+
if (lag > LAG_EVENTS || (pending > 0 && this.state.flushError)) return { ...base, state: 'catching_up', reason: `${lag} events and ${pending} changes to go` };
|
|
279
|
+
if (dead || quarantined || pending > 0.8 * OUTBOX_MAX_PENDING) {
|
|
280
|
+
return { ...base, state: 'degraded', reason: `${dead} change${dead === 1 ? '' : 's'} and ${quarantined} event${quarantined === 1 ? '' : 's'} set aside` };
|
|
281
|
+
}
|
|
282
|
+
if (!this.state.lastSyncAt || now - this.state.lastSyncAt > ONLINE_MS) return { ...base, state: 'catching_up', reason: 'waiting for a sync' };
|
|
283
|
+
return { ...base, state: 'online', reason: 'in sync' };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#pushHealth() {
|
|
287
|
+
try { this.runner.send({ type: 'health', health: this.health() }); } catch (error) { this.log('health_failed', { code: error.code || 'error' }); }
|
|
288
|
+
}
|
|
289
|
+
}
|