@agentpulselabs/cli-plugin-pulse 1.0.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 +46 -0
- package/package.json +7 -4
- 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 +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +19 -12
- 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/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +16 -2
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -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/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +61 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The import reconciliation view (cloud-first ADR-4): one machine's imported items as families of identities, each a
|
|
5
|
+
* count and a hash, so the connector's fresh scan and the Pulse log can be compared item for item. The connector keeps
|
|
6
|
+
* a byte-identical copy (agentpulse-cli-plugin-pulse src/lib/reconcileView.js, checked by its tests) and builds its
|
|
7
|
+
* side with it.
|
|
8
|
+
*/
|
|
9
|
+
export const RECONCILE_TYPES = Object.freeze([
|
|
10
|
+
'channel.created', 'post.created', 'message.published', 'message.retracted', 'actor.registered', 'actor.row_updated',
|
|
11
|
+
'persona.updated', 'delivery.updated', 'stream.updated', 'request.created', 'delivery.accepted', 'settings.changed',
|
|
12
|
+
'audit.recorded', 'status.overridden', 'fact.removed',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const FACT_KIND = Object.freeze({
|
|
16
|
+
'delivery.updated': 'delivery', 'stream.updated': 'stream', 'request.created': 'request', 'delivery.accepted': 'accepted',
|
|
17
|
+
'settings.changed': 'settings', 'audit.recorded': 'event',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** A count and a hash over the keys, in the order given (posts) or sorted (every other family). */
|
|
21
|
+
export function digest(keys) {
|
|
22
|
+
return { count: keys.length, sha: createHash('sha256').update(keys.join('\n'), 'utf8').digest('hex').slice(0, 16) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sorted = (set) => [...set].sort();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Entries `{ type, key, channel, id, topic, factKey, kind, actorId, digest }` in log order (the connector's in send
|
|
29
|
+
* order). A post or message hashes as `<key> <sha256 of its body>`, so a changed body is a mismatch too. Returns the
|
|
30
|
+
* serializable view and the identity sets behind it.
|
|
31
|
+
*/
|
|
32
|
+
export function reconcileFamilies(entries) {
|
|
33
|
+
const channels = new Set();
|
|
34
|
+
const posts = new Map();
|
|
35
|
+
const messages = new Map();
|
|
36
|
+
const retracted = new Set();
|
|
37
|
+
const actors = new Set();
|
|
38
|
+
const agentRows = new Set();
|
|
39
|
+
const personas = new Set();
|
|
40
|
+
const statusOverrides = new Set();
|
|
41
|
+
const facts = new Map(Object.values(FACT_KIND).map((kind) => [kind, new Set()]));
|
|
42
|
+
const removed = [];
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
switch (entry.type) {
|
|
45
|
+
case 'channel.created':
|
|
46
|
+
channels.add(entry.factKey);
|
|
47
|
+
break;
|
|
48
|
+
case 'post.created':
|
|
49
|
+
channels.add(entry.channel);
|
|
50
|
+
if (!posts.has(entry.channel)) posts.set(entry.channel, []);
|
|
51
|
+
posts.get(entry.channel).push({ key: entry.key, digest: entry.digest });
|
|
52
|
+
break;
|
|
53
|
+
case 'message.published':
|
|
54
|
+
messages.set(entry.id, { topic: entry.topic, digest: entry.digest });
|
|
55
|
+
break;
|
|
56
|
+
case 'message.retracted':
|
|
57
|
+
retracted.add(entry.id);
|
|
58
|
+
break;
|
|
59
|
+
case 'actor.registered':
|
|
60
|
+
actors.add(entry.id);
|
|
61
|
+
break;
|
|
62
|
+
case 'actor.row_updated':
|
|
63
|
+
agentRows.add(entry.id);
|
|
64
|
+
break;
|
|
65
|
+
case 'persona.updated':
|
|
66
|
+
personas.add(entry.actorId);
|
|
67
|
+
break;
|
|
68
|
+
case 'delivery.updated':
|
|
69
|
+
case 'request.created':
|
|
70
|
+
case 'delivery.accepted':
|
|
71
|
+
facts.get(FACT_KIND[entry.type]).add(entry.id);
|
|
72
|
+
break;
|
|
73
|
+
case 'stream.updated':
|
|
74
|
+
case 'settings.changed':
|
|
75
|
+
facts.get(FACT_KIND[entry.type]).add(entry.factKey);
|
|
76
|
+
break;
|
|
77
|
+
case 'audit.recorded':
|
|
78
|
+
facts.get('event').add(entry.key);
|
|
79
|
+
break;
|
|
80
|
+
case 'status.overridden':
|
|
81
|
+
statusOverrides.add(entry.factKey);
|
|
82
|
+
break;
|
|
83
|
+
case 'fact.removed':
|
|
84
|
+
removed.push(entry);
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
throw new Error(`reconcile: unhandled type ${entry.type}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const id of retracted) messages.delete(id);
|
|
91
|
+
for (const entry of removed) {
|
|
92
|
+
if (entry.kind === 'agent-row') agentRows.delete(entry.factKey);
|
|
93
|
+
else if (entry.kind === 'status_override') statusOverrides.delete(entry.factKey);
|
|
94
|
+
else facts.get(entry.kind)?.delete(entry.factKey);
|
|
95
|
+
}
|
|
96
|
+
const byTopic = new Map();
|
|
97
|
+
for (const [id, { topic, digest: body }] of messages) {
|
|
98
|
+
if (!byTopic.has(topic)) byTopic.set(topic, []);
|
|
99
|
+
byTopic.get(topic).push(`${id} ${body}`);
|
|
100
|
+
}
|
|
101
|
+
const line = (post) => `${post.key} ${post.digest}`;
|
|
102
|
+
const allPosts = [...posts.values()].flat().map(line);
|
|
103
|
+
return {
|
|
104
|
+
sets: { channels, posts, messages, actors, agentRows, personas, statusOverrides, facts },
|
|
105
|
+
view: {
|
|
106
|
+
channels: digest(sorted(channels)),
|
|
107
|
+
posts: { ...digest(allPosts), byChannel: Object.fromEntries([...posts].sort(([a], [b]) => (a < b ? -1 : 1)).map(([key, list]) => [key, digest(list.map(line))])) },
|
|
108
|
+
messages: { ...digest([...messages].map(([id, { digest: body }]) => `${id} ${body}`).sort()), byTopic: Object.fromEntries([...byTopic].sort(([a], [b]) => (a < b ? -1 : 1)).map(([topic, ids]) => [topic, digest(ids.sort())])) },
|
|
109
|
+
actors: digest(sorted(actors)),
|
|
110
|
+
agentRows: digest(sorted(agentRows)),
|
|
111
|
+
personas: digest(sorted(personas)),
|
|
112
|
+
statusOverrides: digest(sorted(statusOverrides)),
|
|
113
|
+
facts: Object.fromEntries([...facts].map(([kind, keys]) => [kind, digest(sorted(keys))])),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A log row (or a connector command) as a reconcile entry; `digest` is the sha256 hex of a post or message body. */
|
|
119
|
+
export function reconcileEntry(type, key, payload = {}, digest = null) {
|
|
120
|
+
return {
|
|
121
|
+
type, key, channel: payload.channel ?? null, id: payload.id ?? null, topic: payload.topic ?? null,
|
|
122
|
+
factKey: payload.key ?? null, kind: payload.kind ?? null, actorId: payload.actorId ?? null, digest,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The sha256 hex of a body, as the state tables store it (agent_hub_posts.body_digest). */
|
|
127
|
+
export function bodyDigest(body) {
|
|
128
|
+
return createHash('sha256').update(String(body ?? ''), 'utf8').digest('hex');
|
|
129
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { chmod } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { HubError } from './errors.js';
|
|
4
|
+
import { openDatabase } from './sqlite.js';
|
|
5
|
+
import { ensureOwnerOnlyDirectory } from './storage.js';
|
|
6
|
+
|
|
7
|
+
export const RUNNER_DB_FILE = 'runner.db';
|
|
8
|
+
/** The meta key of the cutover backup the hub tables were seeded from: `{ file, sha256, at, counts, rolledBackAt? }`. */
|
|
9
|
+
export const SEED_META = 'seeded_from';
|
|
10
|
+
/** The meta key of the repo and worktree roots the upload redactor learned, `{ repo, worktree }`: a union that never shrinks. */
|
|
11
|
+
export const REDACTION_ROOTS_META = 'redaction_roots';
|
|
12
|
+
// §6.2: the pending backlog is capped; acknowledged keys live in `sent` and do not count against it.
|
|
13
|
+
export const OUTBOX_MAX_PENDING = 50_000;
|
|
14
|
+
|
|
15
|
+
const SCHEMA = [
|
|
16
|
+
'CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
|
17
|
+
'CREATE TABLE IF NOT EXISTS cursor (source TEXT PRIMARY KEY, position TEXT NOT NULL)',
|
|
18
|
+
`CREATE TABLE IF NOT EXISTS outbox (
|
|
19
|
+
ord INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
key TEXT NOT NULL UNIQUE,
|
|
21
|
+
grp TEXT NOT NULL,
|
|
22
|
+
type TEXT NOT NULL,
|
|
23
|
+
payload TEXT NOT NULL,
|
|
24
|
+
source TEXT NOT NULL,
|
|
25
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
next_at INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
last_error TEXT,
|
|
28
|
+
enqueued_at INTEGER NOT NULL)`,
|
|
29
|
+
'CREATE INDEX IF NOT EXISTS outbox_grp ON outbox (grp, ord)',
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS sent (
|
|
31
|
+
key TEXT PRIMARY KEY, source TEXT NOT NULL, type TEXT NOT NULL, seq TEXT, duplicate INTEGER NOT NULL, at INTEGER NOT NULL)`,
|
|
32
|
+
'CREATE INDEX IF NOT EXISTS sent_at ON sent (at)',
|
|
33
|
+
`CREATE TABLE IF NOT EXISTS dead (
|
|
34
|
+
key TEXT PRIMARY KEY, grp TEXT NOT NULL, type TEXT NOT NULL, payload TEXT NOT NULL, source TEXT NOT NULL,
|
|
35
|
+
code TEXT NOT NULL, message TEXT, at INTEGER NOT NULL)`,
|
|
36
|
+
`CREATE TABLE IF NOT EXISTS version (
|
|
37
|
+
entity TEXT PRIMARY KEY, sha TEXT NOT NULL, raw_sha TEXT, static_sha TEXT, key TEXT NOT NULL, at INTEGER NOT NULL)`,
|
|
38
|
+
'CREATE TABLE IF NOT EXISTS version_seen (entity TEXT NOT NULL, sha TEXT NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (entity, sha))',
|
|
39
|
+
`CREATE TABLE IF NOT EXISTS receipt_state (
|
|
40
|
+
message_id TEXT NOT NULL, recipient TEXT NOT NULL, state TEXT NOT NULL, PRIMARY KEY (message_id, recipient))`,
|
|
41
|
+
'CREATE TABLE IF NOT EXISTS recent_message (id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, grp TEXT NOT NULL)',
|
|
42
|
+
`CREATE TABLE IF NOT EXISTS ingested (
|
|
43
|
+
channel TEXT NOT NULL, ordinal INTEGER NOT NULL, sha TEXT NOT NULL, post_key TEXT NOT NULL, at INTEGER NOT NULL,
|
|
44
|
+
PRIMARY KEY (channel, ordinal))`,
|
|
45
|
+
'CREATE TABLE IF NOT EXISTS presence_sent (actor_id TEXT PRIMARY KEY, last_seen INTEGER NOT NULL, sent_at INTEGER NOT NULL)',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const TERMINAL_RECEIPT = new Set(['acked', 'expired']);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The runner's local store (design §6.2), `runner.db` in the connector's config directory: per-source scan cursors,
|
|
52
|
+
* the durable outbox of commands waiting for the cloud, the keys the cloud acknowledged, dead letters, and what the
|
|
53
|
+
* scanners last saw of each mutable entity. node:sqlite with WAL and synchronous=FULL; owner-only.
|
|
54
|
+
*/
|
|
55
|
+
export class RunnerStore {
|
|
56
|
+
static async open(directory, { now = Date.now, maxPending = OUTBOX_MAX_PENDING } = {}) {
|
|
57
|
+
await ensureOwnerOnlyDirectory(directory);
|
|
58
|
+
const file = path.join(directory, RUNNER_DB_FILE);
|
|
59
|
+
const db = openDatabase(file);
|
|
60
|
+
const run = (sql) => db.prepare(sql).run();
|
|
61
|
+
run('PRAGMA journal_mode = WAL');
|
|
62
|
+
run('PRAGMA synchronous = FULL');
|
|
63
|
+
run('PRAGMA busy_timeout = 5000');
|
|
64
|
+
for (const statement of SCHEMA) run(statement);
|
|
65
|
+
await chmod(file, 0o600);
|
|
66
|
+
return new RunnerStore({ db, file, now, maxPending });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The store as the daemon left it, for status reads; null when there is none. Writes through it fail. */
|
|
70
|
+
static openReadOnly(directory, { now = Date.now } = {}) {
|
|
71
|
+
const file = path.join(directory, RUNNER_DB_FILE);
|
|
72
|
+
let db;
|
|
73
|
+
try {
|
|
74
|
+
db = openDatabase(file, { readOnly: true });
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
db.prepare('PRAGMA busy_timeout = 5000').run();
|
|
79
|
+
return new RunnerStore({ db, file, now, maxPending: OUTBOX_MAX_PENDING });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A consistent copy of the store in `target` (a file that must not exist), taken without blocking its writer. */
|
|
83
|
+
static copy(directory, target) {
|
|
84
|
+
const source = RunnerStore.openReadOnly(directory);
|
|
85
|
+
if (!source) return false;
|
|
86
|
+
try {
|
|
87
|
+
source.db.prepare('VACUUM INTO ?').run(target);
|
|
88
|
+
return true;
|
|
89
|
+
} finally {
|
|
90
|
+
source.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
constructor({ db, file, now, maxPending }) {
|
|
95
|
+
this.db = db;
|
|
96
|
+
this.file = file;
|
|
97
|
+
this.now = now;
|
|
98
|
+
this.maxPending = maxPending;
|
|
99
|
+
this.depth = 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close() {
|
|
103
|
+
this.db.close();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run `fn` in one immediate transaction: everything it enqueues and every cursor it moves commit together. Inside a
|
|
108
|
+
* transaction already open on this connection (the runner's capture) it joins that one.
|
|
109
|
+
*/
|
|
110
|
+
transaction(fn) {
|
|
111
|
+
if (this.depth > 0 || this.db.isTransaction) return fn();
|
|
112
|
+
this.db.prepare('BEGIN IMMEDIATE').run();
|
|
113
|
+
this.depth += 1;
|
|
114
|
+
try {
|
|
115
|
+
const out = fn();
|
|
116
|
+
this.db.prepare('COMMIT').run();
|
|
117
|
+
return out;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
this.db.prepare('ROLLBACK').run();
|
|
120
|
+
throw error;
|
|
121
|
+
} finally {
|
|
122
|
+
this.depth -= 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
meta(key) {
|
|
127
|
+
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
|
|
128
|
+
return row ? JSON.parse(row.value) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setMeta(key, value) {
|
|
132
|
+
this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value')
|
|
133
|
+
.run(key, JSON.stringify(value));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
cursor(source) {
|
|
137
|
+
const row = this.db.prepare('SELECT position FROM cursor WHERE source = ?').get(source);
|
|
138
|
+
return row ? JSON.parse(row.position) : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
setCursor(source, position) {
|
|
142
|
+
this.db.prepare('INSERT INTO cursor (source, position) VALUES (?, ?) ON CONFLICT (source) DO UPDATE SET position = excluded.position')
|
|
143
|
+
.run(source, JSON.stringify(position));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether a key was ever enqueued: pending, acknowledged or dead. */
|
|
147
|
+
known(key) {
|
|
148
|
+
return Boolean(this.db.prepare(
|
|
149
|
+
'SELECT 1 AS found FROM outbox WHERE key = ? UNION ALL SELECT 1 FROM sent WHERE key = ? UNION ALL SELECT 1 FROM dead WHERE key = ? LIMIT 1',
|
|
150
|
+
).get(key, key, key));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pendingCount() {
|
|
154
|
+
return Number(this.db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Queue commands (`{ key, group, type, payload, source }`), skipping any key already known. Returns how many were new.
|
|
159
|
+
* Past the pending cap it refuses the whole call with pulse_backlog, so the caller's cursor does not move either.
|
|
160
|
+
*/
|
|
161
|
+
enqueue(entries) {
|
|
162
|
+
return this.transaction(() => {
|
|
163
|
+
const insert = this.db.prepare(
|
|
164
|
+
`INSERT INTO outbox (key, grp, type, payload, source, enqueued_at)
|
|
165
|
+
SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sent WHERE key = ?) AND NOT EXISTS (SELECT 1 FROM dead WHERE key = ?)
|
|
166
|
+
ON CONFLICT (key) DO NOTHING`,
|
|
167
|
+
);
|
|
168
|
+
let added = 0;
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
added += Number(insert.run(entry.key, entry.group, entry.type, JSON.stringify(entry.payload), entry.source, this.now(), entry.key, entry.key).changes);
|
|
171
|
+
}
|
|
172
|
+
if (added && this.pendingCount() > this.maxPending) {
|
|
173
|
+
throw new HubError('pulse_backlog', `More than ${this.maxPending} commands are waiting for Pulse`);
|
|
174
|
+
}
|
|
175
|
+
return added;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The next batch to send: the oldest due rows in enqueue order, at most `max` rows and `maxBytes` of payload. A row
|
|
181
|
+
* behind an earlier row of its own group that is not due (backing off) waits for it, so each group keeps its order;
|
|
182
|
+
* other groups go on.
|
|
183
|
+
*/
|
|
184
|
+
nextBatch({ max = 100, maxBytes = 480 * 1024 } = {}) {
|
|
185
|
+
const now = this.now();
|
|
186
|
+
const blocked = new Set();
|
|
187
|
+
const batch = [];
|
|
188
|
+
const corrupt = [];
|
|
189
|
+
let bytes = 0;
|
|
190
|
+
for (const row of this.db.prepare('SELECT ord, key, grp, type, payload, source, attempts, next_at FROM outbox ORDER BY ord').iterate()) {
|
|
191
|
+
if (blocked.has(row.grp)) continue;
|
|
192
|
+
if (Number(row.next_at) > now) {
|
|
193
|
+
blocked.add(row.grp);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const size = Buffer.byteLength(row.payload, 'utf8') + row.key.length + row.type.length + 64;
|
|
197
|
+
if (batch.length && bytes + size > maxBytes) break;
|
|
198
|
+
let payload;
|
|
199
|
+
try {
|
|
200
|
+
payload = JSON.parse(row.payload);
|
|
201
|
+
} catch {
|
|
202
|
+
corrupt.push(row.key);
|
|
203
|
+
blocked.add(row.grp);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
batch.push({ key: row.key, group: row.grp, type: row.type, payload, source: row.source, attempts: Number(row.attempts) });
|
|
207
|
+
bytes += size;
|
|
208
|
+
if (batch.length >= max) break;
|
|
209
|
+
}
|
|
210
|
+
for (const key of corrupt) this.dead(key, { code: 'corrupt_row', message: 'The stored payload could not be read' });
|
|
211
|
+
return batch;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The cloud stored (or already had) this key. */
|
|
215
|
+
acked(key, { seq = null, duplicate = false } = {}) {
|
|
216
|
+
this.transaction(() => {
|
|
217
|
+
const row = this.db.prepare('SELECT source, type FROM outbox WHERE key = ?').get(key);
|
|
218
|
+
if (!row) return;
|
|
219
|
+
this.db.prepare('INSERT INTO sent (key, source, type, seq, duplicate, at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key) DO NOTHING')
|
|
220
|
+
.run(key, row.source, row.type, seq === null ? null : String(seq), duplicate ? 1 : 0, this.now());
|
|
221
|
+
this.db.prepare('DELETE FROM outbox WHERE key = ?').run(key);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A retryable refusal: the row stays first in its group and is due again after `delayMs`. */
|
|
226
|
+
retry(key, { code, delayMs }) {
|
|
227
|
+
this.db.prepare('UPDATE outbox SET attempts = attempts + 1, next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Held behind an earlier command of its group: due again after `delayMs`, without counting an attempt. */
|
|
231
|
+
hold(key, { code, delayMs }) {
|
|
232
|
+
this.db.prepare('UPDATE outbox SET next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A refusal that repeating cannot fix: the row leaves the outbox with its reason. */
|
|
236
|
+
dead(key, { code, message = null }) {
|
|
237
|
+
this.transaction(() => {
|
|
238
|
+
const row = this.db.prepare('SELECT grp, type, payload, source FROM outbox WHERE key = ?').get(key);
|
|
239
|
+
if (!row) return;
|
|
240
|
+
this.db.prepare(
|
|
241
|
+
`INSERT INTO dead (key, grp, type, payload, source, code, message, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
242
|
+
ON CONFLICT (key) DO UPDATE SET code = excluded.code, message = excluded.message, at = excluded.at`,
|
|
243
|
+
).run(key, row.grp, row.type, row.payload, row.source, code, message, this.now());
|
|
244
|
+
this.db.prepare('DELETE FROM outbox WHERE key = ?').run(key);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A command refused before it was queued (it never reaches the cloud), recorded with its reason. */
|
|
249
|
+
refuse(entry, { code, message = null }) {
|
|
250
|
+
this.db.prepare(
|
|
251
|
+
`INSERT INTO dead (key, grp, type, payload, source, code, message, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
252
|
+
ON CONFLICT (key) DO NOTHING`,
|
|
253
|
+
).run(entry.key, entry.group, entry.type, JSON.stringify(entry.payload ?? null), entry.source, code, message, this.now());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The code a key was refused or dead-lettered with, or null. */
|
|
257
|
+
deadCode(key) {
|
|
258
|
+
return this.db.prepare('SELECT code FROM dead WHERE key = ?').get(key)?.code ?? null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Every queued command in queue order, payload parsed (a reconcile reads a scratch store's whole scan this way). */
|
|
262
|
+
planned() {
|
|
263
|
+
return this.db.prepare('SELECT key, grp, type, payload, source FROM outbox ORDER BY ord').all()
|
|
264
|
+
.map((row) => ({ key: row.key, group: row.grp, type: row.type, payload: JSON.parse(row.payload), source: row.source }));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Every set-aside key with its code (all of them; deadRows is the newest few). */
|
|
268
|
+
deadKeys() {
|
|
269
|
+
return this.db.prepare('SELECT key, type, source, code FROM dead ORDER BY key').all().map((row) => ({ ...row }));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
deadRows({ limit = 20 } = {}) {
|
|
273
|
+
return this.db.prepare('SELECT key, type, source, code, message, at FROM dead ORDER BY at DESC LIMIT ?').all(limit).map((row) => ({ ...row }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Counts per source: pending, sent (written or duplicate) and dead (by code). */
|
|
277
|
+
counts() {
|
|
278
|
+
const out = {};
|
|
279
|
+
const at = (source) => (out[source] ||= { pending: 0, written: 0, duplicate: 0, dead: {} });
|
|
280
|
+
for (const row of this.db.prepare('SELECT source, COUNT(*) AS n FROM outbox GROUP BY source').all()) at(row.source).pending = Number(row.n);
|
|
281
|
+
for (const row of this.db.prepare('SELECT source, duplicate, COUNT(*) AS n FROM sent GROUP BY source, duplicate').all()) {
|
|
282
|
+
at(row.source)[row.duplicate ? 'duplicate' : 'written'] = Number(row.n);
|
|
283
|
+
}
|
|
284
|
+
for (const row of this.db.prepare('SELECT source, code, COUNT(*) AS n FROM dead GROUP BY source, code').all()) at(row.source).dead[row.code] = Number(row.n);
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
version(entity) {
|
|
289
|
+
const row = this.db.prepare('SELECT sha, raw_sha, static_sha, key, at FROM version WHERE entity = ?').get(entity);
|
|
290
|
+
return row ? { sha: row.sha, rawSha: row.raw_sha, staticSha: row.static_sha, key: row.key, at: Number(row.at) } : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The key for this content of an entity: `<prefix><sep><sha>` the first time this content is sent, and
|
|
295
|
+
* `<prefix><sep><sha>.<k>` when it returns after another version (A→B→A), so the return is a new event that applies.
|
|
296
|
+
*/
|
|
297
|
+
versionKey(entity, prefix, sha, separator = ':') {
|
|
298
|
+
const seen = this.db.prepare('SELECT count FROM version_seen WHERE entity = ? AND sha = ?').get(entity, sha);
|
|
299
|
+
const count = seen ? Number(seen.count) : 0;
|
|
300
|
+
return count === 0 ? `${prefix}${separator}${sha}` : `${prefix}${separator}${sha}.${count}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Record that this content of an entity was queued under `key`. */
|
|
304
|
+
setVersion(entity, { sha, rawSha = null, staticSha = null, key }) {
|
|
305
|
+
const last = this.version(entity);
|
|
306
|
+
if (last?.sha !== sha) {
|
|
307
|
+
this.db.prepare(
|
|
308
|
+
`INSERT INTO version_seen (entity, sha, count) VALUES (?, ?, 1)
|
|
309
|
+
ON CONFLICT (entity, sha) DO UPDATE SET count = count + 1`,
|
|
310
|
+
).run(entity, sha);
|
|
311
|
+
}
|
|
312
|
+
this.db.prepare(
|
|
313
|
+
`INSERT INTO version (entity, sha, raw_sha, static_sha, key, at) VALUES (?, ?, ?, ?, ?, ?)
|
|
314
|
+
ON CONFLICT (entity) DO UPDATE SET sha = excluded.sha, raw_sha = excluded.raw_sha, static_sha = excluded.static_sha,
|
|
315
|
+
key = excluded.key, at = excluded.at`,
|
|
316
|
+
).run(entity, sha, rawSha, staticSha, key, this.now());
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The raw form last read, without changing what was sent (an unchanged row skips redaction next time). */
|
|
320
|
+
touchVersion(entity, rawSha) {
|
|
321
|
+
this.db.prepare('UPDATE version SET raw_sha = ? WHERE entity = ?').run(rawSha, entity);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
dropVersion(entity) {
|
|
325
|
+
this.db.prepare('DELETE FROM version WHERE entity = ?').run(entity);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
entities(prefix) {
|
|
329
|
+
return this.db.prepare("SELECT entity FROM version WHERE entity >= ? AND entity < ? || char(1114111)").all(prefix, prefix).map((row) => row.entity);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The receipt state last queued for each recipient row still able to change (not acked or expired). */
|
|
333
|
+
openReceipts() {
|
|
334
|
+
return this.db.prepare("SELECT message_id, recipient, state FROM receipt_state WHERE state NOT IN ('acked', 'expired')").all()
|
|
335
|
+
.map((row) => ({ messageId: row.message_id, recipient: row.recipient, state: row.state }));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
receiptState(messageId, recipient) {
|
|
339
|
+
return this.db.prepare('SELECT state FROM receipt_state WHERE message_id = ? AND recipient = ?').get(messageId, recipient)?.state ?? null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setReceiptState(messageId, recipient, state) {
|
|
343
|
+
this.db.prepare(
|
|
344
|
+
`INSERT INTO receipt_state (message_id, recipient, state) VALUES (?, ?, ?)
|
|
345
|
+
ON CONFLICT (message_id, recipient) DO UPDATE SET state = excluded.state`,
|
|
346
|
+
).run(messageId, recipient, state);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
dropReceipts(messageId) {
|
|
350
|
+
this.db.prepare('DELETE FROM receipt_state WHERE message_id = ?').run(messageId);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
rememberMessage(id, createdAt, group) {
|
|
354
|
+
this.db.prepare('INSERT INTO recent_message (id, created_at, grp) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING').run(id, createdAt, group);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
recentMessages(sinceMs) {
|
|
358
|
+
return this.db.prepare('SELECT id, created_at, grp FROM recent_message WHERE created_at >= ? ORDER BY created_at').all(sinceMs)
|
|
359
|
+
.map((row) => ({ id: row.id, createdAt: Number(row.created_at), group: row.grp }));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
forgetMessage(id) {
|
|
363
|
+
this.db.prepare('DELETE FROM recent_message WHERE id = ?').run(id);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
pruneRecentMessages(beforeMs) {
|
|
367
|
+
this.db.prepare('DELETE FROM recent_message WHERE created_at < ?').run(beforeMs);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
ingested(channel) {
|
|
371
|
+
return this.db.prepare('SELECT ordinal, sha, post_key, at FROM ingested WHERE channel = ? ORDER BY ordinal').all(channel)
|
|
372
|
+
.map((row) => ({ ordinal: Number(row.ordinal), sha: row.sha, postKey: row.post_key, at: Number(row.at) }));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The key a channel's block (its digest and occurrence) was first sent under, or null. */
|
|
376
|
+
ingestedKey(channel, sha) {
|
|
377
|
+
return this.db.prepare('SELECT post_key FROM ingested WHERE channel = ? AND sha = ?').get(channel, sha)?.post_key ?? null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
setIngested(channel, { ordinal, sha, postKey, at }) {
|
|
381
|
+
this.db.prepare(
|
|
382
|
+
`INSERT INTO ingested (channel, ordinal, sha, post_key, at) VALUES (?, ?, ?, ?, ?)
|
|
383
|
+
ON CONFLICT (channel, ordinal) DO UPDATE SET sha = excluded.sha, post_key = excluded.post_key, at = excluded.at`,
|
|
384
|
+
).run(channel, ordinal, sha, postKey, at);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** What one channel file holds now, by position, replacing what was recorded before. */
|
|
388
|
+
replaceIngested(channel, rows) {
|
|
389
|
+
this.transaction(() => {
|
|
390
|
+
this.db.prepare('DELETE FROM ingested WHERE channel = ?').run(channel);
|
|
391
|
+
for (const row of rows) this.setIngested(channel, row);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
presenceSent(actorId) {
|
|
396
|
+
const row = this.db.prepare('SELECT last_seen, sent_at FROM presence_sent WHERE actor_id = ?').get(actorId);
|
|
397
|
+
return row ? { lastSeen: Number(row.last_seen), sentAt: Number(row.sent_at) } : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
setPresenceSent(actorId, lastSeen) {
|
|
401
|
+
this.db.prepare(
|
|
402
|
+
`INSERT INTO presence_sent (actor_id, last_seen, sent_at) VALUES (?, ?, ?)
|
|
403
|
+
ON CONFLICT (actor_id) DO UPDATE SET last_seen = excluded.last_seen, sent_at = excluded.sent_at`,
|
|
404
|
+
).run(actorId, lastSeen, this.now());
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Whether the cloud acknowledged `key` as a command of `type`. */
|
|
408
|
+
sentAs(key, type) {
|
|
409
|
+
return Boolean(this.db.prepare('SELECT 1 AS found FROM sent WHERE key = ? AND type = ?').get(key, type));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Acknowledged keys older than `beforeMs` go; the cloud's dedupe is the backstop for anything resent after that. */
|
|
413
|
+
pruneSent(beforeMs) {
|
|
414
|
+
return Number(this.db.prepare('DELETE FROM sent WHERE at < ?').run(beforeMs).changes);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export { TERMINAL_RECEIPT };
|
|
@@ -127,6 +127,7 @@ export class ServiceManager {
|
|
|
127
127
|
),
|
|
128
128
|
execute = execFile,
|
|
129
129
|
wait = delay,
|
|
130
|
+
launchAgentsDir = path.join(home, 'Library', 'LaunchAgents'),
|
|
130
131
|
}) {
|
|
131
132
|
this.platform = platform;
|
|
132
133
|
this.home = home;
|
|
@@ -137,6 +138,7 @@ export class ServiceManager {
|
|
|
137
138
|
this.logDir = logDir;
|
|
138
139
|
this.execute = execute;
|
|
139
140
|
this.wait = wait;
|
|
141
|
+
this.launchAgentsDir = launchAgentsDir;
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
serviceFile() {
|
|
@@ -342,6 +344,65 @@ export class ServiceManager {
|
|
|
342
344
|
return { state: 'stopped', pid: null, serviceFile: this.serviceFile() };
|
|
343
345
|
}
|
|
344
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Another LaunchAgent of this machine, by label (the old local hub, its keep-awake helper), for cutover and
|
|
349
|
+
* rollback. macOS only; every call goes through the injected executor.
|
|
350
|
+
*/
|
|
351
|
+
#requireLaunchd(label) {
|
|
352
|
+
if (this.platform !== 'darwin') throw new HubError('HUB_UNSUPPORTED_PLATFORM', `${label} is a macOS LaunchAgent`);
|
|
353
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(label)) throw new HubError('HUB_SERVICE_LABEL_INVALID', `Bad LaunchAgent label: ${label}`);
|
|
354
|
+
return `gui/${this.uid}/${label}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
plistFor(label) {
|
|
358
|
+
this.#requireLaunchd(label);
|
|
359
|
+
return path.join(this.launchAgentsDir, `${label}.plist`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** `{ loaded, pid, plist }` for a LaunchAgent label. */
|
|
363
|
+
async labelState(label) {
|
|
364
|
+
const target = this.#requireLaunchd(label);
|
|
365
|
+
const plist = this.plistFor(label);
|
|
366
|
+
try {
|
|
367
|
+
const result = await this.execute('/bin/launchctl', ['print', target]);
|
|
368
|
+
return { loaded: true, pid: Number(result.stdout.match(/\bpid\s*=\s*(\d+)/)?.[1]) || null, plist, installed: await exists(plist) };
|
|
369
|
+
} catch {
|
|
370
|
+
return { loaded: false, pid: null, plist, installed: await exists(plist) };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Unload a LaunchAgent and keep it from loading again at login (`disable`), until `restoreLabel`. */
|
|
375
|
+
async retireLabel(label) {
|
|
376
|
+
const target = this.#requireLaunchd(label);
|
|
377
|
+
try {
|
|
378
|
+
await this.execute('/bin/launchctl', ['bootout', target]);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (!ABSENT_SERVICE.test(`${error.message || ''}\n${error.stderr || ''}`)) {
|
|
381
|
+
throw new HubError('HUB_SERVICE_STOP_FAILED', `launchd could not unload ${label}`, { cause: error });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
await this.execute('/bin/launchctl', ['disable', target]);
|
|
385
|
+
return this.labelState(label);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Undo `retireLabel`: enable the label again and load its plist. */
|
|
389
|
+
async restoreLabel(label) {
|
|
390
|
+
const target = this.#requireLaunchd(label);
|
|
391
|
+
await this.execute('/bin/launchctl', ['enable', target]);
|
|
392
|
+
let lastError = null;
|
|
393
|
+
for (let attempt = 0; attempt < START_ATTEMPTS; attempt += 1) {
|
|
394
|
+
if (attempt > 0) await this.wait(START_RETRY_MS * attempt);
|
|
395
|
+
if ((await this.labelState(label)).loaded) return this.labelState(label);
|
|
396
|
+
try {
|
|
397
|
+
await this.execute('/bin/launchctl', ['bootstrap', `gui/${this.uid}`, this.plistFor(label)]);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
lastError = error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if ((await this.labelState(label)).loaded) return this.labelState(label);
|
|
403
|
+
throw new HubError('HUB_SERVICE_START_FAILED', `launchd could not load ${label}`, { cause: lastError });
|
|
404
|
+
}
|
|
405
|
+
|
|
345
406
|
async health({ freshMs = 30_000, after = 0 } = {}) {
|
|
346
407
|
const health = await readOwnerOnlyJson(path.join(this.configDir, 'daemon-health.json'));
|
|
347
408
|
const heartbeat = Date.parse(health?.heartbeatAt);
|