@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.3

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.
Files changed (102) hide show
  1. package/README.md +55 -0
  2. package/package.json +7 -4
  3. package/src/clients/hub-bridge.mjs +99 -0
  4. package/src/clients/hub.mjs +324 -0
  5. package/src/commands/pulse/cutover.js +63 -0
  6. package/src/commands/pulse/doctor.js +1 -0
  7. package/src/commands/pulse/import.js +62 -0
  8. package/src/commands/pulse/log-write.js +26 -0
  9. package/src/commands/pulse/parity.js +20 -0
  10. package/src/commands/pulse/shadow.js +19 -0
  11. package/src/daemon/main.js +20 -12
  12. package/src/hub/core/auth.mjs +80 -0
  13. package/src/hub/core/changes.mjs +38 -0
  14. package/src/hub/core/conversations.mjs +121 -0
  15. package/src/hub/core/db.mjs +263 -0
  16. package/src/hub/core/edgeIds.mjs +9 -0
  17. package/src/hub/core/hub.mjs +20 -0
  18. package/src/hub/core/ids.mjs +39 -0
  19. package/src/hub/core/leases.mjs +55 -0
  20. package/src/hub/core/messaging.mjs +322 -0
  21. package/src/hub/core/registry.mjs +281 -0
  22. package/src/hub/core/ticks.mjs +58 -0
  23. package/src/hub/core/wake.mjs +125 -0
  24. package/src/hub/home.mjs +16 -0
  25. package/src/hub/hooks/hub-bridge.mjs +167 -0
  26. package/src/hub/keepawake/agent.mjs +96 -0
  27. package/src/hub/lib/brief.mjs +133 -0
  28. package/src/hub/lib/channels.mjs +268 -0
  29. package/src/hub/lib/chat.mjs +163 -0
  30. package/src/hub/lib/closure.mjs +92 -0
  31. package/src/hub/lib/collect-worker.mjs +20 -0
  32. package/src/hub/lib/collect.mjs +342 -0
  33. package/src/hub/lib/deliveries.mjs +222 -0
  34. package/src/hub/lib/git.mjs +93 -0
  35. package/src/hub/lib/me.mjs +123 -0
  36. package/src/hub/lib/personas.mjs +143 -0
  37. package/src/hub/lib/status.mjs +147 -0
  38. package/src/hub/lib/store.mjs +106 -0
  39. package/src/hub/lib/titles.mjs +29 -0
  40. package/src/hub/lib/transcript-feed.mjs +72 -0
  41. package/src/hub/lib/transcripts.mjs +364 -0
  42. package/src/hub/lib/writing.mjs +18 -0
  43. package/src/hub/projections/deliveries.mjs +31 -0
  44. package/src/hub/projections/graph.mjs +149 -0
  45. package/src/hub/runners/drivers.mjs +162 -0
  46. package/src/hub/workers/collector.mjs +128 -0
  47. package/src/hub/workers/manager.mjs +366 -0
  48. package/src/hub/workers/runner.mjs +145 -0
  49. package/src/lib/application.js +82 -34
  50. package/src/lib/channelScanner.js +99 -0
  51. package/src/lib/clientInstaller.js +111 -0
  52. package/src/lib/cloudFirstCommands.js +293 -0
  53. package/src/lib/contract.sha256 +1 -1
  54. package/src/lib/cutover.js +411 -0
  55. package/src/lib/cutoverRequest.js +485 -0
  56. package/src/lib/cutoverWiring.js +140 -0
  57. package/src/lib/daemonLock.js +2 -0
  58. package/src/lib/daemonLoop.js +5 -2
  59. package/src/lib/daemonProcess.js +57 -6
  60. package/src/lib/daemonRuntime.js +8 -0
  61. package/src/lib/doctorWarnings.js +53 -0
  62. package/src/lib/errors.js +14 -0
  63. package/src/lib/hubDbReader.js +132 -0
  64. package/src/lib/hubMapping.js +108 -0
  65. package/src/lib/hubScan.js +611 -0
  66. package/src/lib/hubSnapshot.js +64 -0
  67. package/src/lib/mirrorContract.js +1 -1
  68. package/src/lib/mirrorService.js +8 -1
  69. package/src/lib/mirrorSource.js +13 -38
  70. package/src/lib/nodeFloor.js +19 -0
  71. package/src/lib/outboxFlusher.js +75 -0
  72. package/src/lib/pairingLifecycle.js +1 -1
  73. package/src/lib/reconcile.js +316 -0
  74. package/src/lib/reconcileView.js +129 -0
  75. package/src/lib/runnerStore.js +418 -0
  76. package/src/lib/serviceManager.js +64 -1
  77. package/src/lib/shadowService.js +435 -0
  78. package/src/lib/sqlite.js +28 -0
  79. package/src/lib/tokenStore.js +43 -4
  80. package/src/lib/transport.js +53 -5
  81. package/src/lib/transportContract.js +3 -0
  82. package/src/lib/uploadRedactor.js +102 -0
  83. package/src/runner/applier.js +297 -0
  84. package/src/runner/bridge.js +289 -0
  85. package/src/runner/busIdentity.js +499 -0
  86. package/src/runner/capture.js +103 -0
  87. package/src/runner/captureMapping.js +21 -0
  88. package/src/runner/channelDiscipline.js +425 -0
  89. package/src/runner/deliveryStates.js +220 -0
  90. package/src/runner/facade.js +876 -0
  91. package/src/runner/hubCapture.js +160 -0
  92. package/src/runner/lease.js +46 -0
  93. package/src/runner/main.js +35 -0
  94. package/src/runner/render.js +91 -0
  95. package/src/runner/runnerChild.js +57 -0
  96. package/src/runner/runnerHub.js +104 -0
  97. package/src/runner/runnerProcess.js +124 -0
  98. package/src/runner/runnerSchema.js +51 -0
  99. package/src/runner/runnerService.js +149 -0
  100. package/src/runner/spool.js +86 -0
  101. package/src/runner/spoolInto.js +22 -0
  102. package/src/runner/statusPage.js +30 -0
@@ -0,0 +1,102 @@
1
+ import path from 'node:path';
2
+ import { redactSecretSubtree, secretKeyReplacement } from './mirrorContract.js';
3
+ import { createPathRedactor } from './pathRedaction.js';
4
+ import { redactSecrets } from './redaction.js';
5
+
6
+ const SEEN_MAX = 100_000;
7
+ // The line net can fire again on text a first pass redacted (a marker earlier on the line); a stable redaction repeats
8
+ // the passes until nothing changes, so the cloud's own pass over it changes nothing either.
9
+ const STABLE_PASSES = 4;
10
+
11
+ /**
12
+ * What leaves this machine for Pulse passes through here, for the mirror and the Pulse log alike: the contract's
13
+ * secret passes first, then path redaction, on every string and key, so a secret inside a path is never cut down to
14
+ * a last segment that still holds it, and nothing is projected, truncated or hashed before both have run. Repository
15
+ * roots are learned from the deliveries the hub reports; the redacted form of a value depends on those roots, the
16
+ * workspace, the home folder and the held secrets, and on nothing else.
17
+ */
18
+ export class UploadRedactor {
19
+ constructor({ workspace, home, approvedRoots = [] }) {
20
+ this.workspace = workspace;
21
+ this.home = home;
22
+ this.repoRoots = new Map(approvedRoots.map((root) => [root, path.basename(root)]));
23
+ this.worktreeRoots = new Map();
24
+ }
25
+
26
+ learnRepos(deliveries) {
27
+ for (const stream of deliveries.flatMap((delivery) => delivery.streams || [])) {
28
+ const git = stream.git || {};
29
+ if (typeof git.mainRepo === 'string' && path.isAbsolute(git.mainRepo)) this.repoRoots.set(git.mainRepo, path.basename(git.mainRepo));
30
+ for (const extra of [git.worktreePath, git.channelCheckout]) {
31
+ if (typeof extra === 'string' && path.isAbsolute(extra) && !this.repoRoots.has(extra)) this.worktreeRoots.set(extra, path.basename(extra));
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The learned roots, to persist: a worktree whose delivery is gone must keep redacting as it did, or a rescan of the
38
+ * same text gives another body than the one already sent (CUT-02).
39
+ */
40
+ roots() {
41
+ return { repo: [...this.repoRoots], worktree: [...this.worktreeRoots] };
42
+ }
43
+
44
+ /** Roots persisted by an earlier scan, added to what this one learns. */
45
+ seed(roots) {
46
+ for (const [root, name] of Array.isArray(roots?.repo) ? roots.repo : []) {
47
+ if (typeof root === 'string' && path.isAbsolute(root) && typeof name === 'string') this.repoRoots.set(root, name);
48
+ }
49
+ for (const [root, name] of Array.isArray(roots?.worktree) ? roots.worktree : []) {
50
+ if (typeof root === 'string' && path.isAbsolute(root) && typeof name === 'string' && !this.repoRoots.has(root)) this.worktreeRoots.set(root, name);
51
+ }
52
+ }
53
+
54
+ /** Everything the redacted form depends on besides the input, as one string (for memo keys and digests). */
55
+ basis(secrets) {
56
+ return JSON.stringify([[...this.repoRoots], [...this.worktreeRoots], this.workspace, this.home, secrets]);
57
+ }
58
+
59
+ /** `stable` (the Pulse log) repeats the secret passes until a pass changes nothing; the mirror keeps one pass. */
60
+ redactor(secrets, { stable = false } = {}) {
61
+ const repos = [...this.repoRoots, ...this.worktreeRoots].map(([root, name]) => ({ root, name }));
62
+ const paths = createPathRedactor({ repos, workspace: this.workspace, home: this.home });
63
+ const seen = new Map();
64
+ let redactions = 0;
65
+ const redactString = (input) => {
66
+ const original = String(input ?? '');
67
+ let done = seen.get(original);
68
+ if (done === undefined) {
69
+ let secret = redactSecrets(original, { secrets });
70
+ let count = secret.redactions;
71
+ for (let pass = 1; stable && pass < STABLE_PASSES; pass += 1) {
72
+ const again = redactSecrets(secret.text, { secrets });
73
+ if (again.text === secret.text) break;
74
+ count += again.redactions;
75
+ secret = again;
76
+ }
77
+ done = { text: paths.redactString(secret.text), redactions: count };
78
+ if (seen.size >= SEEN_MAX) seen.clear();
79
+ seen.set(original, done);
80
+ }
81
+ redactions += done.redactions;
82
+ return done.text;
83
+ };
84
+ const entry = (key, item) => {
85
+ const replaced = secretKeyReplacement(key, item);
86
+ if (replaced === undefined || replaced === null) return redactSecretSubtree(key, item, redactString);
87
+ redactions += 1;
88
+ return replaced;
89
+ };
90
+ const redactValue = (value, { keep = [] } = {}) => paths.redactValue(value, { keep, redactString, entry });
91
+ return {
92
+ redactString,
93
+ redactValue,
94
+ /** Secret replacements made since the last call (memoised strings count each time they are used). */
95
+ takeRedactions: () => {
96
+ const count = redactions;
97
+ redactions = 0;
98
+ return count;
99
+ },
100
+ };
101
+ }
102
+ }
@@ -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
+ }