@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
@@ -1,4 +1,6 @@
1
1
  import path from 'node:path';
2
+ import { CUTOVER_PROTOCOL, CutoverService } from './cutoverRequest.js';
3
+ import { createCutover } from './cutoverWiring.js';
2
4
  import { createProductionDependencies } from './daemonComposition.js';
3
5
  import { DaemonLock } from './daemonLock.js';
4
6
  import { createHeartbeat, MIRROR_UPDATE_STEP, TERMINAL_STATES } from './daemonLoop.js';
@@ -17,6 +19,8 @@ import {
17
19
  Watchdog,
18
20
  watchSource,
19
21
  } from './selfRestart.js';
22
+ import { ShadowService } from './shadowService.js';
23
+ import { RunnerService } from '../runner/runnerService.js';
20
24
  import { readOwnerOnlyJson } from './storage.js';
21
25
 
22
26
  // The longest a healthy cycle goes between two steps: a transport retry backoff (capped at 60 s) or a held claim (35 s).
@@ -26,6 +30,8 @@ const CYCLE_INTERVAL_MS = 60_000;
26
30
  const MIRROR_INTERVAL_MS = 60_000;
27
31
  const STALL_FACTOR = 3;
28
32
  const MIRROR_LOOPS = ['mirror', 'mirror windows'];
33
+ // The shadow feed's calls run on their own timer; they never count as progress of the job cycle.
34
+ const SHADOW_PATHS = ['/commands', '/cloud-first', '/parity'];
29
35
  /** The failure code of a job a restart had to abort: a drain that ran out of time, or a stalled loop. */
30
36
  export const RESTART_ABORT_CODE = 'HUB_DAEMON_RESTARTING';
31
37
  const DEFAULT_TIMING = Object.freeze({
@@ -56,7 +62,8 @@ const inside = (child, parent) => {
56
62
  * - start_failed: it could not start while it was holding restarts back.
57
63
  * A code or request restart drains (no new claim; running jobs finish and settle) for up to drainMs; a stall aborts at
58
64
  * once. Every trigger goes through one check that runs each tickMs. While the daemon has restarted too often it holds
59
- * the restart (health says restart_loop) until the window clears. SIGTERM always wins and exits 0.
65
+ * the restart (health says restart_loop) until the window clears, and while it runs a cutover request (CUT-03) it holds
66
+ * it until the cutover ends, whose own deadlines bound it. SIGTERM always wins and exits 0.
60
67
  */
61
68
  export async function runDaemon({
62
69
  directory,
@@ -68,6 +75,10 @@ export async function runDaemon({
68
75
  signals = process,
69
76
  createDependencies = createProductionDependencies,
70
77
  createMirror = (options) => new MirrorController(options),
78
+ createShadow = (options) => new ShadowService(options),
79
+ createRunner = (options) => new RunnerService(options),
80
+ createCutoverService = (options) => new CutoverService(options),
81
+ cutoverFactory = createCutover,
71
82
  dependencyOptions = {},
72
83
  runtimeOptions = {},
73
84
  lockOptions = {},
@@ -79,7 +90,7 @@ export async function runDaemon({
79
90
  const lock = new DaemonLock({ directory, ...lockOptions });
80
91
  await lock.acquire();
81
92
  // The first cycle (token exchange, reconnect, claim) can outlast `pulse start`'s wait; say we are up before it.
82
- await lock.heartbeat({ state: 'starting' });
93
+ await lock.heartbeat({ state: 'starting', cutoverProtocol: CUTOVER_PROTOCOL });
83
94
  let startingHealthAt = Date.now();
84
95
  const guard = new RestartGuard({ directory, now });
85
96
  await guard.recordStart();
@@ -91,6 +102,13 @@ export async function runDaemon({
91
102
  let failure = null;
92
103
  let heartbeat = null;
93
104
  let runtime = null;
105
+ let shadow = null;
106
+ let runnerService = null;
107
+ let cutoverService = null;
108
+ let cutoverDeferralLogged = false;
109
+ // Every read of config.json is numbered, so the runner never applies one older than it already has (CUT-03).
110
+ let configGeneration = 0;
111
+ let heartbeatGeneration = 0;
94
112
  let stopServices = async () => {};
95
113
  let timer = null;
96
114
  let watcher = null;
@@ -199,6 +217,14 @@ export async function runDaemon({
199
217
  const requestedAt = await consumeRestartRequest(directory);
200
218
  if (requestedAt !== null && requestedAt < bootedAt) log('restart_request_satisfied', { requestedAt: new Date(requestedAt).toISOString() });
201
219
  else if (requestedAt !== null) pending ??= { reason: 'request' };
220
+ if (pending && cutoverService?.busy()) {
221
+ if (!cutoverDeferralLogged) {
222
+ cutoverDeferralLogged = true;
223
+ log('restart_deferred', { reason: pending.reason, cutover: cutoverService.health()?.requestId ?? null });
224
+ }
225
+ return;
226
+ }
227
+ cutoverDeferralLogged = false;
202
228
  if (pending) restart(pending.reason);
203
229
  } catch (error) {
204
230
  log('restart_check_failed', { code: error.code || 'HUB_RESTART_CHECK_FAILED' });
@@ -209,9 +235,15 @@ export async function runDaemon({
209
235
 
210
236
  const extraHealth = () => {
211
237
  const loop = degraded();
238
+ const cutover = cutoverService?.health();
212
239
  return {
213
240
  ...(loop ? { degraded: loop } : {}),
214
241
  ...(restarting ? { restarting: { ...restarting, draining: runtime?.activeJobIds() ?? [] } } : {}),
242
+ ...(pending && cutoverService?.busy() ? { restartDeferred: { reason: pending.reason, untilCutoverEnds: cutover?.requestId ?? null } } : {}),
243
+ ...(shadow ? { shadow: shadow.health() } : {}),
244
+ ...(runnerService ? { runner: runnerService.health() } : {}),
245
+ cutoverProtocol: CUTOVER_PROTOCOL,
246
+ ...(cutover ? { cutover } : {}),
215
247
  };
216
248
  };
217
249
 
@@ -277,7 +309,7 @@ export async function runDaemon({
277
309
  onTransportAttempt: ({ path: requestPath, phase }) => {
278
310
  const call = `${phase} ${requestPath}`;
279
311
  if (requestPath === MIRROR_PATH) mirrorProgress(call);
280
- else watchdog.progress('cycle', { call });
312
+ else if (!SHADOW_PATHS.some((suffix) => requestPath.split('?')[0].endsWith(suffix))) watchdog.progress('cycle', { call });
281
313
  },
282
314
  ...dependencyOptions,
283
315
  });
@@ -312,18 +344,37 @@ export async function runDaemon({
312
344
  },
313
345
  },
314
346
  });
347
+ shadow = createShadow({ directory, transportFactory: production.transportFactory, tokenStore: production.tokenStore, log: mirrorLog });
348
+ runnerService = createRunner({ directory, transportFactory: production.transportFactory, log });
315
349
  heartbeat = createHeartbeat({
316
- readConfig: () => readOwnerOnlyJson(configFile),
350
+ readConfig: () => {
351
+ heartbeatGeneration = ++configGeneration;
352
+ return readOwnerOnlyJson(configFile);
353
+ },
317
354
  runtime,
318
355
  mirror,
356
+ shadow,
357
+ runner: { update: (config) => runnerService.update(config, { generation: heartbeatGeneration }) },
319
358
  lock,
320
359
  ledger,
321
360
  ownerStore,
322
361
  progress: cycleProgress,
323
362
  extraHealth,
324
363
  });
364
+ // A cutover request runs here, beside the heartbeat; its facade step starts this daemon's runner at once.
365
+ const serve = async () => {
366
+ const generation = ++configGeneration;
367
+ runnerService.update(await readOwnerOnlyJson(configFile), { generation });
368
+ };
369
+ cutoverService = createCutoverService({
370
+ directory,
371
+ log,
372
+ canTake: () => !exiting && !restarting && !pending,
373
+ createCutover: ({ log: stepLog }) => cutoverFactory({ configDir: directory, log: stepLog, serve }),
374
+ });
375
+ cutoverService.start();
325
376
  stopServices = async ({ abortCode }) => {
326
- const results = await Promise.allSettled([mirror.stop(), runtime.shutdown({ abortCode })]);
377
+ const results = await Promise.allSettled([cutoverService.stop(), mirror.stop(), shadow.stop(), runnerService.stop(), runtime.shutdown({ abortCode })]);
327
378
  const failed = results.find((result) => result.status === 'rejected');
328
379
  if (failed) throw failed.reason;
329
380
  };
@@ -353,7 +404,7 @@ export async function runDaemon({
353
404
  // A cycle that ends after shutdown released the lock has no health left to report.
354
405
  if (exiting) return;
355
406
  await Promise.resolve()
356
- .then(() => lock.heartbeat({ state: 'error', code: error.code || 'HUB_DAEMON_LOOP_FAILED' }))
407
+ .then(() => lock.heartbeat({ state: 'error', code: error.code || 'HUB_DAEMON_LOOP_FAILED', ...extraHealth() }))
357
408
  .catch(() => {});
358
409
  wait = t.errorRetryMs;
359
410
  } finally {
@@ -49,6 +49,8 @@ const SETTLE_GIVE_UP_MS = 30_000;
49
49
  // 20 s Retry-After. An open session is reused, so these two set the steady call rate.
50
50
  const HEARTBEAT_INTERVAL_MS = 15_000;
51
51
  const CLAIM_SPACING_MS = 10_000;
52
+ // With claims off a cycle is only the heartbeat, so it runs at the heartbeat's own pace.
53
+ const CLAIMS_OFF_WAIT_MS = HEARTBEAT_INTERVAL_MS;
52
54
  // A cloud that refuses this connector's contract version is asked again this late, or at the next daemon start.
53
55
  export const CONTRACT_RETRY_MS = 60 * 60 * 1_000;
54
56
 
@@ -610,6 +612,12 @@ export class DaemonRuntime {
610
612
  await this.#flush(transport);
611
613
  if (this.stopping) return { state: 'stopping', processed: 0 };
612
614
  if (this.draining) return { state: 'draining', processed: 0 };
615
+ // `jobs.claim: false` keeps the session, heartbeat and mirror but takes no work, so a test connector beside the
616
+ // live hub can never run a hub.* job into it.
617
+ if (this.config.jobs?.claim === false) {
618
+ this.state = 'running';
619
+ return { state: this.state, processed: 0, claims: 'off', retryAfterMs: CLAIMS_OFF_WAIT_MS };
620
+ }
613
621
  this.progress('claim');
614
622
  const claimedAt = this.now();
615
623
  const claimed = await transport.poll({
@@ -0,0 +1,53 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFileBounded } from './subprocess.js';
5
+
6
+ /**
7
+ * `pulse doctor` warnings (Pulse 1.1.1): facts about this machine worth knowing that never make it unhealthy. Each
8
+ * probe answers `{ ok: true, warn, message, ... }`, or `{ ok: true, skipped }` when there is nothing to look at.
9
+ */
10
+
11
+ /** In use at or above this share of max_connections, the local Postgres is close to refusing new connections. */
12
+ export const POSTGRES_HEADROOM_WARN = 0.8;
13
+ /** The hook events the Pulse hook needs beyond postToolUse, stop and afterAgentResponse: failed Shell calls bind aliases, the rest say whether a session is in a tool call or gone. */
14
+ export const HUB_HOOK_EVENTS = ['postToolUseFailure', 'preToolUse', 'sessionEnd'];
15
+ const QUERY = "SELECT count(*), current_setting('max_connections') FROM pg_stat_activity";
16
+
17
+ /** Connections in use against max_connections on the local Postgres (PULSE_PG_URL, else localhost), through psql. */
18
+ export async function postgresHeadroom({ url = process.env.PULSE_PG_URL || 'postgres://localhost/postgres', run = execFileBounded } = {}) {
19
+ let stdout;
20
+ try {
21
+ ({ stdout } = await run('psql', [url, '-X', '-A', '-t', '-F', ',', '-c', QUERY], { timeout: 5_000 }));
22
+ } catch (error) {
23
+ return { ok: true, skipped: error && error.code === 'ENOENT' ? 'psql is not installed' : 'no local Postgres answered' };
24
+ }
25
+ const [used, max] = String(stdout).trim().split(',').map(Number);
26
+ if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return { ok: true, skipped: 'unreadable answer from psql' };
27
+ const warn = used / max >= POSTGRES_HEADROOM_WARN;
28
+ return {
29
+ ok: true, used, max, warn,
30
+ message: `Local Postgres: ${used} of ${max} connections in use${warn ? `; past ${Math.round(POSTGRES_HEADROOM_WARN * 100)}%, new connections may soon be refused` : ''}.`,
31
+ };
32
+ }
33
+
34
+ /** Which of HUB_HOOK_EVENTS ~/.cursor/hooks.json sends to the hub hook (hub-bridge.mjs). Read-only. */
35
+ export async function hubHooks({ file = path.join(os.homedir(), '.cursor', 'hooks.json') } = {}) {
36
+ let hooks;
37
+ try {
38
+ hooks = JSON.parse(await readFile(file, 'utf8')).hooks || {};
39
+ } catch {
40
+ return { ok: true, skipped: 'no readable ~/.cursor/hooks.json' };
41
+ }
42
+ const wired = Object.entries(hooks)
43
+ .filter(([, entries]) => Array.isArray(entries) && entries.some((e) => e && typeof e.command === 'string' && e.command.includes('hub-bridge.mjs')))
44
+ .map(([event]) => event);
45
+ if (!wired.length) return { ok: true, skipped: 'the hub hook is not in ~/.cursor/hooks.json' };
46
+ const missing = HUB_HOOK_EVENTS.filter((event) => !wired.includes(event));
47
+ return {
48
+ ok: true, wired, missing, warn: missing.length > 0,
49
+ message: missing.length
50
+ ? `~/.cursor/hooks.json does not send ${missing.join(', ')} to the hub hook: a bus register chained to a failing command does not bind, and a session in a long tool call can look gone. Add them with the same command as postToolUse.`
51
+ : 'The hub hook gets every event it uses.',
52
+ };
53
+ }
package/src/lib/errors.js CHANGED
@@ -8,6 +8,20 @@ export class HubError extends Error {
8
8
  }
9
9
  }
10
10
 
11
+ /** Pulse 1.1.2 (SCOPE-02): what fixes a refusal no retry can fix: a sign-in missing scopes, or a machine grant missing a capability. */
12
+ export const SCOPE_FIX = 'agentpulse pulse login';
13
+ export const GRANT_FIX = 'agentpulse pulse log-write on';
14
+
15
+ /** A refusal for scopes or grant as health shows it (`{ code, missingScopes, fix }`), or null for any other error. */
16
+ export function scopeBlock(error) {
17
+ const code = error?.code;
18
+ if (code === 'HUB_SCOPE_DENIED' || code === 'HUB_SCOPE_MISSING') {
19
+ return { code, missingScopes: Array.isArray(error.details?.missingScopes) ? error.details.missingScopes : [], fix: SCOPE_FIX };
20
+ }
21
+ if (code === 'capability_denied') return { code, missingScopes: [], fix: GRANT_FIX };
22
+ return null;
23
+ }
24
+
11
25
  export function assertNever(value, label = 'value') {
12
26
  throw new HubError('HUB_INTERNAL_UNHANDLED_VARIANT', `Unhandled ${label}: ${String(value)}`);
13
27
  }
@@ -0,0 +1,132 @@
1
+ import path from 'node:path';
2
+ import { openDatabase } from './sqlite.js';
3
+
4
+ const parseJson = (text, fallback) => {
5
+ if (text === null || text === undefined || text === '') return fallback;
6
+ try {
7
+ return JSON.parse(text);
8
+ } catch {
9
+ return fallback;
10
+ }
11
+ };
12
+
13
+ /** An actor row in the local registry's shape (agents-hub core/registry.mjs rowActor at 37a0b2f, without `title`). */
14
+ export function rowActor(r) {
15
+ return {
16
+ id: r.id,
17
+ node_type: r.node_type,
18
+ role: r.role,
19
+ parent_id: r.parent_id,
20
+ delivery_id: r.delivery_id,
21
+ stream_key: r.stream_key,
22
+ cursor_title: r.cursor_title,
23
+ prompt_title: r.prompt_title,
24
+ persona_name: r.persona_name,
25
+ persona_first: r.persona_first,
26
+ persona_username: r.persona_username,
27
+ persona_seed: r.persona_seed,
28
+ persona: parseJson(r.persona_json, null),
29
+ source: r.source,
30
+ topics: parseJson(r.topics, []),
31
+ started_at: r.started_at,
32
+ last_at: r.last_at,
33
+ tokens_est: r.tokens_est,
34
+ msgs_in: r.msgs_in,
35
+ msgs_out: r.msgs_out,
36
+ repo: r.repo,
37
+ channel: r.channel,
38
+ synthetic: !!r.synthetic,
39
+ extra: parseJson(r.extra, null),
40
+ };
41
+ }
42
+
43
+ const plain = (row) => ({ ...row });
44
+
45
+ /**
46
+ * Read-only access to the local hub's `state/hub.db`. Each `read(fn)` opens the file read-only (no `immutable`, no
47
+ * pragma that writes), runs every query of one pass inside a single short read transaction so recipients and their
48
+ * messages come from the same snapshot, and closes the handle, so the hub's writers and WAL checkpoints never wait on
49
+ * a reader across an await. `fn` must be synchronous.
50
+ */
51
+ export class HubDbReader {
52
+ constructor({ hubDir, file = path.join(hubDir, 'state', 'hub.db'), busyMs = 2_000 }) {
53
+ this.file = file;
54
+ this.busyMs = busyMs;
55
+ }
56
+
57
+ read(fn) {
58
+ const db = openDatabase(this.file, { readOnly: true });
59
+ try {
60
+ db.prepare(`PRAGMA busy_timeout = ${Number(this.busyMs)}`).run();
61
+ db.prepare('BEGIN').run();
62
+ try {
63
+ return fn(new HubSnapshot(db));
64
+ } finally {
65
+ db.prepare('COMMIT').run();
66
+ }
67
+ } finally {
68
+ db.close();
69
+ }
70
+ }
71
+ }
72
+
73
+ /** The queries of one read transaction. */
74
+ export class HubSnapshot {
75
+ constructor(db) {
76
+ this.db = db;
77
+ }
78
+
79
+ /** Every actor in table order (the order the local registry lists them). */
80
+ actors() {
81
+ return this.db.prepare('SELECT * FROM actors ORDER BY rowid').all().map(rowActor);
82
+ }
83
+
84
+ presence() {
85
+ return this.db.prepare('SELECT actor_id, last_seen, last_event FROM presence ORDER BY rowid').all().map(plain);
86
+ }
87
+
88
+ /** Messages created at or after `sinceMs`, in (created_at, rowid) order: rowid breaks ties the way the hub inserted them. */
89
+ messagesSince(sinceMs) {
90
+ return this.db.prepare('SELECT rowid AS _rowid, * FROM messages WHERE created_at >= ? ORDER BY created_at, rowid').all(sinceMs)
91
+ .map((row) => ({ ...row, meta: parseJson(row.meta, {}) }));
92
+ }
93
+
94
+ messageIdsSince(sinceMs) {
95
+ return new Set(this.db.prepare('SELECT id FROM messages WHERE created_at >= ?').all(sinceMs).map((row) => row.id));
96
+ }
97
+
98
+ messageExists(id) {
99
+ return Boolean(this.db.prepare('SELECT 1 AS found FROM messages WHERE id = ?').get(id));
100
+ }
101
+
102
+ /** Recipient rows of these messages, in table order. */
103
+ recipients(messageIds) {
104
+ const out = new Map();
105
+ const statement = this.db.prepare(
106
+ 'SELECT message_id, recipient, state, attempts, delivered_at, delivered_via, acked_at FROM deliveries WHERE message_id = ? ORDER BY rowid',
107
+ );
108
+ for (const id of messageIds) out.set(id, statement.all(id).map(plain));
109
+ return out;
110
+ }
111
+
112
+ /** One recipient row with its message's expiry, or null when either is gone. */
113
+ receipt(messageId, recipient) {
114
+ const row = this.db.prepare(
115
+ `SELECT d.message_id, d.recipient, d.state, d.attempts, d.delivered_at, d.delivered_via, d.acked_at, m.expires_at, m.topic
116
+ FROM deliveries d JOIN messages m ON m.id = d.message_id
117
+ WHERE d.message_id = ? AND d.recipient = ?`,
118
+ ).get(messageId, recipient);
119
+ return row ? plain(row) : null;
120
+ }
121
+
122
+ eventsAfter(id) {
123
+ return this.db.prepare('SELECT id, at, kind, actor, payload FROM events WHERE id > ? ORDER BY id').all(id)
124
+ .map((row) => ({ ...row, payload: parseJson(row.payload, {}) }));
125
+ }
126
+
127
+ /** Facts of these kinds with their payload text as stored (parsed by the caller only when it changed). */
128
+ facts(kinds) {
129
+ const statement = this.db.prepare('SELECT kind, key, payload, updated_at FROM facts WHERE kind = ? ORDER BY key');
130
+ return kinds.flatMap((kind) => statement.all(kind).map(plain));
131
+ }
132
+ }
@@ -0,0 +1,108 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { pathRejected } from './mirrorContract.js';
3
+ import { canonicalJson } from './planContract.js';
4
+
5
+ /**
6
+ * The local hub's rows as Pulse commands (design §11 table, cf-phase2 PLAN v2), as pure functions: the import, the
7
+ * shadow feed and anything that captures the same rows later (Phase 3) produce the same keys and payloads. Redaction
8
+ * is not done here: callers pass rows through the UploadRedactor, and content keys are taken from redacted payloads.
9
+ */
10
+ const KEY_PART = /^[A-Za-z0-9._:/@-]{1,200}$/;
11
+ const PERSONA_FIELDS = ['name', 'first', 'username', 'seed', 'personality'];
12
+
13
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
14
+ export const without = (object, ...keys) => Object.fromEntries(Object.entries(object).filter(([key]) => !keys.includes(key)));
15
+
16
+ /** `<label>:<part>` when the part is key-safe, else `<label>:h:<sha40>`, so a key never carries a space, path or secret. */
17
+ export function keyPart(label, part) {
18
+ return KEY_PART.test(String(part)) && !pathRejected(String(part)) ? `${label}:${part}` : `${label}:h:${sha(String(part)).slice(0, 40)}`;
19
+ }
20
+
21
+ /** The content key of a state entity: the redacted payload without its `at`, digested. */
22
+ export function contentSha(redactedPayload) {
23
+ return sha(canonicalJson(without(redactedPayload, 'at'))).slice(0, 12);
24
+ }
25
+
26
+ export function actorPayload(actor) {
27
+ return { id: actor.id, actor, at: Math.floor(Number(actor.last_at) || 0) };
28
+ }
29
+
30
+ export function messagePayload(message, recipients) {
31
+ return {
32
+ id: message.id, from: message.sender, fromName: message.from_name ?? null, topic: message.topic, type: message.type,
33
+ body: message.body, to: [...new Set(recipients.map((row) => row.recipient))], needsAck: Boolean(message.needs_reply),
34
+ expiresAt: Number(message.expires_at), replyTo: message.reply_to ?? null, reaction: message.reaction ?? null,
35
+ meta: message.meta ?? {}, at: Number(message.created_at),
36
+ };
37
+ }
38
+
39
+ /** The receipts a recipient row's current state implies (`leased` is `queued`: leases stay on the machine). */
40
+ export function receiptEvents(row, topic, expiresAt) {
41
+ const base = { messageId: row.message_id, recipient: row.recipient };
42
+ const events = [];
43
+ const state = row.state === 'leased' ? 'queued' : row.state;
44
+ if (row.delivered_at !== null && row.delivered_at !== undefined) {
45
+ events.push({ type: 'receipt.delivered', key: `rcpt:${row.message_id}:${row.recipient}:delivered`, payload: { ...base, at: Number(row.delivered_at), ...(row.delivered_via ? { via: row.delivered_via } : {}) } });
46
+ }
47
+ if (state === 'acked') {
48
+ events.push({ type: 'receipt.acked', key: `rcpt:${row.message_id}:${row.recipient}:acked`, payload: { ...base, at: Number(row.acked_at ?? row.delivered_at ?? row.queued_at ?? expiresAt) } });
49
+ }
50
+ if (state === 'expired') {
51
+ events.push({ type: 'receipt.expired', key: `rcpt:${row.message_id}:${row.recipient}:expired`, payload: { ...base, at: Number(expiresAt) } });
52
+ }
53
+ return { state, events: events.map((event) => ({ ...event, group: `bus:${topic}` })) };
54
+ }
55
+
56
+ export function eventPayload(event) {
57
+ return { kind: event.kind, actor: event.actor ?? null, data: event.payload ?? {}, at: Number(event.at) };
58
+ }
59
+
60
+ export function personaPayload(id, persona) {
61
+ const value = Object.fromEntries(PERSONA_FIELDS.filter((field) => persona?.[field] !== undefined && persona?.[field] !== null).map((field) => [field, persona[field]]));
62
+ return { actorId: id, persona: value };
63
+ }
64
+
65
+ /**
66
+ * The commands of one fact row, each with its entity and key prefix (content keyed): a delivery travels without its
67
+ * streams, which go as stream facts of their own so a large delivery fits a command.
68
+ */
69
+ export function factDescriptors(row, fact) {
70
+ const entity = `fact:${row.kind}:${row.key}`;
71
+ const prefix = keyPart(`fact:${row.kind}`, row.key);
72
+ const at = Number.isFinite(fact.at) ? fact.at : Number(row.updated_at);
73
+ const one = (type, payload) => ({ type, entity, prefix, group: entity, payload });
74
+ switch (row.kind) {
75
+ case 'delivery': {
76
+ const streams = Array.isArray(fact.streams) ? fact.streams : [];
77
+ const delivery = { ...without(fact, 'streams'), streamKeys: streams.map((stream) => stream.key) };
78
+ return [
79
+ one('delivery.updated', { id: row.key, delivery, at: Number(row.updated_at) }),
80
+ ...streams.map((stream) => ({
81
+ type: 'stream.updated', entity: `fact:stream:${stream.key}`, prefix: keyPart('fact:stream', stream.key), group: `fact:stream:${stream.key}`,
82
+ payload: { key: stream.key, deliveryId: row.key, stream: without(stream, 'deliveryId'), at: Number(row.updated_at) },
83
+ })),
84
+ ];
85
+ }
86
+ case 'agent-row':
87
+ return [one('actor.row_updated', { id: row.key, row: fact, at: Number(row.updated_at) })];
88
+ case 'request':
89
+ return [one('request.created', { id: row.key, request: fact, at })];
90
+ case 'accepted':
91
+ return [one('delivery.accepted', { id: row.key, at })];
92
+ case 'settings':
93
+ return [one('settings.changed', { key: row.key, value: fact, at: Number(row.updated_at) })];
94
+ case 'status_override':
95
+ return [one('status.overridden', { key: row.key, override: fact, at })];
96
+ default:
97
+ throw new TypeError(`hub mapping: fact kind ${row.kind} has no command`);
98
+ }
99
+ }
100
+
101
+ /** A task-channel post: its key from its own block's digest and occurrence, never from its inferred time. */
102
+ export function postKey(channelKey, digestId) {
103
+ return `post:${channelKey}:${digestId}`;
104
+ }
105
+
106
+ export function postPayload(channel, post, body, truncated) {
107
+ return { channel, author: post.role, role: post.role, type: post.type, body, at: post.at, ...(truncated ? { truncated } : {}) };
108
+ }