@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,435 @@
|
|
|
1
|
+
import { appendFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { HubError, scopeBlock } from './errors.js';
|
|
5
|
+
import { HubDbReader } from './hubDbReader.js';
|
|
6
|
+
import { HubScan } from './hubScan.js';
|
|
7
|
+
import { SNAPSHOT_DIR, takeSnapshot } from './hubSnapshot.js';
|
|
8
|
+
import { discoverLocalHub, LocalHubReader } from './localHub.js';
|
|
9
|
+
import { OutboxFlusher } from './outboxFlusher.js';
|
|
10
|
+
import { pairedStatus } from './pairedConfig.js';
|
|
11
|
+
import {
|
|
12
|
+
channelOnDisk, cloudOnlyChannels, compareReconcile, driftTexts, itemId, localReconcile, redactionCandidates, resolvePostKeys, sourceCounts,
|
|
13
|
+
} from './reconcile.js';
|
|
14
|
+
import { REDACTION_ROOTS_META, RunnerStore } from './runnerStore.js';
|
|
15
|
+
import { UploadRedactor } from './uploadRedactor.js';
|
|
16
|
+
|
|
17
|
+
export const IMPORT_REQUEST_FILE = 'import.request';
|
|
18
|
+
export const PARITY_REQUEST_FILE = 'parity.request';
|
|
19
|
+
export const PARITY_LOG_FILE = 'parity.log';
|
|
20
|
+
export const RECONCILE_REQUEST_FILE = 'reconcile.request';
|
|
21
|
+
export const RECONCILE_REPORT_FILE = 'reconcile-last.json';
|
|
22
|
+
export const HUB_CHANNEL_DIRS = Object.freeze(['AgentPulseApps', 'Google_Cloud']);
|
|
23
|
+
const TICK_MS = 10_000;
|
|
24
|
+
const FLAG_FRESH_MS = 60_000;
|
|
25
|
+
const BLOCKED_BASE_MS = 60_000;
|
|
26
|
+
const BLOCKED_CAP_MS = 15 * 60_000;
|
|
27
|
+
const PARITY_EVERY_MS = 15 * 60_000;
|
|
28
|
+
const BATCHES_PER_TICK = 30;
|
|
29
|
+
// A redaction proof tries an item's own candidates one by one only up to this many; past it the item stays drift.
|
|
30
|
+
const REDACTION_TRIES = 6;
|
|
31
|
+
const SENT_KEEP_MS = 30 * 86_400_000;
|
|
32
|
+
const REFUSALS = new Set(['cloud_first_off', 'capability_denied', 'HUB_SCOPE_DENIED', 'HUB_LOGIN_REQUIRED']);
|
|
33
|
+
|
|
34
|
+
export function shadowEnabled(config) {
|
|
35
|
+
return config?.cloudFirst?.shadow === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The service runs for an import (and its reconcile) without the shadow; it tails only with `shadow` on. */
|
|
39
|
+
export function importEnabled(config) {
|
|
40
|
+
return shadowEnabled(config) || config?.cloudFirst?.import === true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The hub's own channel roots (agents-hub server/http.mjs CHANNEL_ROOTS), unless the config names others. */
|
|
44
|
+
export function channelRoots(config, hubDir) {
|
|
45
|
+
const configured = config?.cloudFirst?.channelRoots;
|
|
46
|
+
if (Array.isArray(configured) && configured.length && configured.every((root) => typeof root === 'string' && path.isAbsolute(root))) return configured;
|
|
47
|
+
return HUB_CHANNEL_DIRS.map((dir) => path.join(path.resolve(hubDir, '..'), dir));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readRequest(file) {
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(await readFile(file, 'utf8'));
|
|
53
|
+
return Number.isFinite(value?.requestedAt) ? value : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The import and shadow feed (cloud-first Phase 2, ADR-4). An import is asked for with import.request in the config
|
|
61
|
+
* directory and runs here, on the daemon's own connector session: it snapshots the hub's sources (hubSnapshot.js) and
|
|
62
|
+
* sends the snapshot to the Pulse log through runner.db. A reconcile (reconcile.request) rescans that snapshot into an
|
|
63
|
+
* empty store and compares it with the cloud's reconcile view. Both run with `cloudFirst.import` or `cloudFirst.shadow`
|
|
64
|
+
* on and the team's cloud-first flag on in Pulse. Only with `shadow` on does it then tail the live hub (after an
|
|
65
|
+
* import has fully drained, `imported_at`, so an import can never land an older value after a newer one), fetching a
|
|
66
|
+
* parity report every 15 minutes (or on parity.request) into parity.log. The shadow is for cutover only.
|
|
67
|
+
*/
|
|
68
|
+
export class ShadowService {
|
|
69
|
+
constructor({
|
|
70
|
+
directory, transportFactory, tokenStore = null, discover = discoverLocalHub, openStore = (dir) => RunnerStore.open(dir),
|
|
71
|
+
home = os.homedir(), now = Date.now, tickMs = TICK_MS, log = () => {}, random = Math.random,
|
|
72
|
+
}) {
|
|
73
|
+
this.directory = directory;
|
|
74
|
+
this.transportFactory = transportFactory;
|
|
75
|
+
this.tokenStore = tokenStore;
|
|
76
|
+
this.discover = discover;
|
|
77
|
+
this.openStore = openStore;
|
|
78
|
+
this.home = home;
|
|
79
|
+
this.now = now;
|
|
80
|
+
this.tickMs = tickMs;
|
|
81
|
+
this.log = log;
|
|
82
|
+
this.random = random;
|
|
83
|
+
this.config = null;
|
|
84
|
+
this.store = null;
|
|
85
|
+
this.timer = null;
|
|
86
|
+
this.running = null;
|
|
87
|
+
this.flag = null;
|
|
88
|
+
this.blocked = null;
|
|
89
|
+
this.refusals = 0;
|
|
90
|
+
this.lastParityAt = -Infinity;
|
|
91
|
+
this.state = { state: 'off' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The heartbeat's config; starts, stops or keeps the loop. Never waits on a pass. */
|
|
95
|
+
update(config) {
|
|
96
|
+
this.config = config;
|
|
97
|
+
const on = importEnabled(config) && pairedStatus(config).ok;
|
|
98
|
+
if (on && !this.timer && !this.running) this.#schedule(0);
|
|
99
|
+
if (!on && (this.timer || this.running)) void this.stop();
|
|
100
|
+
if (!on) this.state = { state: 'off' };
|
|
101
|
+
return this.state;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async stop() {
|
|
105
|
+
clearTimeout(this.timer);
|
|
106
|
+
this.timer = null;
|
|
107
|
+
await this.running;
|
|
108
|
+
this.store?.close();
|
|
109
|
+
this.store = null;
|
|
110
|
+
this.state = { state: 'off' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#schedule(waitMs) {
|
|
114
|
+
clearTimeout(this.timer);
|
|
115
|
+
this.timer = setTimeout(() => {
|
|
116
|
+
this.timer = null;
|
|
117
|
+
this.running = this.tick().finally(() => {
|
|
118
|
+
this.running = null;
|
|
119
|
+
if (importEnabled(this.config) && pairedStatus(this.config).ok) this.#schedule(this.#nextWait());
|
|
120
|
+
});
|
|
121
|
+
}, waitMs);
|
|
122
|
+
this.timer.unref?.();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#nextWait() {
|
|
126
|
+
return this.blocked ? Math.max(0, this.blocked.retryAt - this.now()) : this.tickMs;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One pass: flag, import (or reconcile) or shadow scan, flush, presence, parity. Never rejects. */
|
|
130
|
+
async tick() {
|
|
131
|
+
try {
|
|
132
|
+
const config = this.config;
|
|
133
|
+
this.store ??= await this.openStore(this.directory);
|
|
134
|
+
const request = await readRequest(path.join(this.directory, IMPORT_REQUEST_FILE));
|
|
135
|
+
const reconcile = await readRequest(path.join(this.directory, RECONCILE_REQUEST_FILE));
|
|
136
|
+
const imported = this.store.meta('imported_at');
|
|
137
|
+
let job = this.store.meta('import');
|
|
138
|
+
const asked = (request && job?.requestedAt !== request.requestedAt) || job?.phase === 'sending' || reconcile;
|
|
139
|
+
if (!asked && !shadowEnabled(config)) {
|
|
140
|
+
this.state = imported ? { state: 'imported', importedAt: imported } : { state: 'waiting_for_import' };
|
|
141
|
+
await this.#persistState();
|
|
142
|
+
return this.state;
|
|
143
|
+
}
|
|
144
|
+
const transport = await this.transportFactory(config);
|
|
145
|
+
// Blocked for scopes: the retry reads the stored sign-in again, so a `pulse login` since is what it sends.
|
|
146
|
+
if (this.blocked?.scope) transport.auth?.invalidate?.();
|
|
147
|
+
if (!(await this.#flagOn(transport))) return this.state;
|
|
148
|
+
const hub = await this.discover({ config });
|
|
149
|
+
this.hubToken ??= await new LocalHubReader({ dir: hub.dir, port: hub.port }).tokenValue().catch(() => null);
|
|
150
|
+
const flusher = new OutboxFlusher({ store: this.store, transport, random: this.random });
|
|
151
|
+
if (request && job?.requestedAt !== request.requestedAt) {
|
|
152
|
+
this.state = { state: 'importing', phase: 'scanning', requestedAt: request.requestedAt };
|
|
153
|
+
await this.#persistState();
|
|
154
|
+
const snapshot = request.reuseSnapshot && job?.snapshot
|
|
155
|
+
? job.snapshot
|
|
156
|
+
: await takeSnapshot({ hubDir: hub.dir, roots: channelRoots(config, hub.dir), into: path.join(this.directory, SNAPSHOT_DIR), now: this.now });
|
|
157
|
+
const before = this.store.counts();
|
|
158
|
+
const scan = this.#scanner({ store: this.store, hub, config, transport, snapshot });
|
|
159
|
+
const report = await scan.run({ mode: 'import' });
|
|
160
|
+
this.#keepRoots(scan);
|
|
161
|
+
job = {
|
|
162
|
+
requestedAt: request.requestedAt, phase: 'sending', startedAt: this.now(), scan: summary(report), before, presence: report.presence.length,
|
|
163
|
+
snapshot: snapshotRef(snapshot), reusedSnapshot: Boolean(request.reuseSnapshot && job?.snapshot),
|
|
164
|
+
};
|
|
165
|
+
this.store.setMeta('import', job);
|
|
166
|
+
await this.#beats(transport, report.presence);
|
|
167
|
+
}
|
|
168
|
+
if (job?.phase === 'sending') {
|
|
169
|
+
const sent = await flusher.drain({ maxBatches: BATCHES_PER_TICK });
|
|
170
|
+
if (this.store.pendingCount() === 0) {
|
|
171
|
+
job = { ...job, phase: 'done', finishedAt: this.now(), after: this.store.counts() };
|
|
172
|
+
this.store.setMeta('import', job);
|
|
173
|
+
if (!imported) this.store.setMeta('imported_at', job.finishedAt);
|
|
174
|
+
await rm(path.join(this.directory, IMPORT_REQUEST_FILE), { force: true });
|
|
175
|
+
}
|
|
176
|
+
this.state = { state: 'importing', phase: job.phase, requestedAt: job.requestedAt, pending: this.store.pendingCount(), lastBatch: sent };
|
|
177
|
+
} else if (reconcile) {
|
|
178
|
+
await this.#reconcile({ hub, config, transport, job, request: reconcile });
|
|
179
|
+
} else if (this.store.meta('imported_at')) {
|
|
180
|
+
const scan = this.#scanner({ store: this.store, hub, config, transport });
|
|
181
|
+
const report = await scan.run({ mode: 'shadow' });
|
|
182
|
+
this.#keepRoots(scan);
|
|
183
|
+
await this.#beats(transport, report.presence);
|
|
184
|
+
const sent = await flusher.drain({ maxBatches: BATCHES_PER_TICK });
|
|
185
|
+
this.store.pruneSent(this.now() - SENT_KEEP_MS);
|
|
186
|
+
this.state = { state: 'running', lastPassAt: this.now(), planned: report.totals.planned, pending: this.store.pendingCount(), lastBatch: sent };
|
|
187
|
+
await this.#parity(transport);
|
|
188
|
+
} else {
|
|
189
|
+
this.state = { state: 'waiting_for_import' };
|
|
190
|
+
}
|
|
191
|
+
this.blocked = null;
|
|
192
|
+
this.refusals = 0;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.#fail(error);
|
|
195
|
+
}
|
|
196
|
+
await this.#persistState();
|
|
197
|
+
return this.state;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async #flagOn(transport) {
|
|
201
|
+
if (!this.flag || this.now() - this.flag.at >= FLAG_FRESH_MS) {
|
|
202
|
+
const answer = await transport.cloudFirst();
|
|
203
|
+
this.flag = { enabled: answer?.enabled === true, at: this.now() };
|
|
204
|
+
}
|
|
205
|
+
if (this.flag.enabled) return true;
|
|
206
|
+
throw new HubError('cloud_first_off', 'Pulse cloud-first is off for this team');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A scanner over the live hub (the shadow tail) or over a snapshot of it (an import, a reconcile). Either way the
|
|
211
|
+
* redaction is the live workspace's, so a snapshot scans to the very commands the live sources would. It starts from
|
|
212
|
+
* every repo and worktree root kept in the live store (learned before, or proven by a reconcile), so a worktree whose
|
|
213
|
+
* delivery is gone still redacts as it did when its text was sent. `seed` adds candidate roots to a scratch scan.
|
|
214
|
+
*/
|
|
215
|
+
#scanner({ store, hub, config, transport, snapshot = null, seed = null }) {
|
|
216
|
+
const key = `${hub.dir}\0${snapshot?.dir ?? ''}\0${store === this.store}`;
|
|
217
|
+
let scan = this.scanners?.key === key ? this.scanners.scan : null;
|
|
218
|
+
if (!scan) {
|
|
219
|
+
const approvedRoots = (config.localRoots || []).map((root) => root.canonicalRoot).filter(Boolean).sort();
|
|
220
|
+
const sourceDir = snapshot?.snapshotHubDir ?? hub.dir;
|
|
221
|
+
scan = new HubScan({
|
|
222
|
+
store,
|
|
223
|
+
reader: new HubDbReader({ hubDir: sourceDir }),
|
|
224
|
+
hubDir: sourceDir,
|
|
225
|
+
roots: snapshot?.snapshotRoots ?? channelRoots(config, hub.dir),
|
|
226
|
+
redaction: new UploadRedactor({ workspace: path.dirname(hub.dir), home: this.home, approvedRoots }),
|
|
227
|
+
secrets: () => [this.hubToken, ...(transport.auth?.heldSecrets?.() ?? [])],
|
|
228
|
+
now: this.now,
|
|
229
|
+
});
|
|
230
|
+
if (store === this.store) this.scanners = { key, scan };
|
|
231
|
+
}
|
|
232
|
+
scan.redaction.seed(this.store.meta(REDACTION_ROOTS_META));
|
|
233
|
+
if (seed) scan.redaction.seed(seed);
|
|
234
|
+
return scan;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The roots a pass over the live store learned join the kept ones (written only when they grew). */
|
|
238
|
+
#keepRoots(scan) {
|
|
239
|
+
scan.redaction.seed(this.store.meta(REDACTION_ROOTS_META));
|
|
240
|
+
const roots = scan.redaction.roots();
|
|
241
|
+
if (JSON.stringify(roots) !== JSON.stringify(this.store.meta(REDACTION_ROOTS_META))) this.store.setMeta(REDACTION_ROOTS_META, roots);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The snapshot rescanned into an empty scratch store, as the reconcile's local side: posts keyed as first sent.
|
|
246
|
+
* `seed` adds redaction roots and `only` limits it to a few channels and conversations (a redaction proof).
|
|
247
|
+
*/
|
|
248
|
+
async #rescan({ hub, config, transport, job, seed = null, only = null }) {
|
|
249
|
+
const scratch = await mkdtemp(path.join(this.directory, 'reconcile-'));
|
|
250
|
+
try {
|
|
251
|
+
const store = await this.openStore(scratch);
|
|
252
|
+
try {
|
|
253
|
+
const report = await this.#scanner({ store, hub, config, transport, snapshot: job.snapshot, seed }).run({ mode: 'import', only });
|
|
254
|
+
const commands = resolvePostKeys(store.planned(), (channel, sha) => this.store.ingestedKey(channel, sha));
|
|
255
|
+
return { report, local: localReconcile({ commands, dead: store.deadKeys() }) };
|
|
256
|
+
} finally {
|
|
257
|
+
store.close();
|
|
258
|
+
}
|
|
259
|
+
} finally {
|
|
260
|
+
await rm(scratch, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* CUT-02 (the 12:44:58 DECISION): which drifted items Pulse holds in a more-redacted form of the same text. The
|
|
266
|
+
* candidates are the deleted directories each item's own text names (`redactionCandidates`). An item is proven only
|
|
267
|
+
* when a rescan of it with candidates added gives exactly the cloud's sha: all candidates first, then the item's
|
|
268
|
+
* own, then each of those alone. Returns the proven items' ids with the roots each match took: an item proven by
|
|
269
|
+
* all candidates keeps only its own if they alone match again, else all of them.
|
|
270
|
+
*/
|
|
271
|
+
async #proveRedaction({ hub, config, transport, job, drift, cloud }) {
|
|
272
|
+
const texts = await driftTexts(job.snapshot, drift);
|
|
273
|
+
const workspace = path.dirname(hub.dir);
|
|
274
|
+
const own = new Map();
|
|
275
|
+
for (const entry of drift) own.set(itemId(entry.family, entry.item), await redactionCandidates({ texts: texts.get(itemId(entry.family, entry.item)) ?? [], workspace }));
|
|
276
|
+
const matching = async (entries, roots) => {
|
|
277
|
+
if (!roots.length) return [];
|
|
278
|
+
const only = { channels: new Set(), topics: new Set() };
|
|
279
|
+
for (const entry of entries) (entry.family === 'posts.byChannel' ? only.channels : only.topics).add(entry.item);
|
|
280
|
+
const { local } = await this.#rescan({ hub, config, transport, job, seed: { worktree: roots.map((root) => [root, path.basename(root)]) }, only });
|
|
281
|
+
return entries.filter((entry) => {
|
|
282
|
+
const [view, theirs] = entry.family === 'posts.byChannel'
|
|
283
|
+
? [local.view.posts.byChannel, cloud.view.posts.byChannel] : [local.view.messages.byTopic, cloud.view.messages.byTopic];
|
|
284
|
+
return Boolean(view[entry.item]) && view[entry.item].sha === theirs[entry.item]?.sha;
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
const proven = new Map();
|
|
288
|
+
const all = [...new Set([...own.values()].flat())].sort();
|
|
289
|
+
for (const entry of await matching(drift, all)) {
|
|
290
|
+
const mine = own.get(itemId(entry.family, entry.item));
|
|
291
|
+
const alone = mine.length && mine.length < all.length ? (await matching([entry], mine)).length > 0 : mine.length === all.length;
|
|
292
|
+
proven.set(itemId(entry.family, entry.item), alone ? mine : all);
|
|
293
|
+
}
|
|
294
|
+
for (const entry of drift) {
|
|
295
|
+
const mine = own.get(itemId(entry.family, entry.item));
|
|
296
|
+
if (proven.has(itemId(entry.family, entry.item)) || mine.length > REDACTION_TRIES) continue;
|
|
297
|
+
for (const roots of [mine, ...(mine.length > 1 ? mine.map((root) => [root]) : [])]) {
|
|
298
|
+
if ((await matching([entry], roots)).length) {
|
|
299
|
+
proven.set(itemId(entry.family, entry.item), roots);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return proven;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* ADR-4: rescan the last import's snapshot into an empty scratch store (a fresh read of the sources with the same
|
|
309
|
+
* scanner, redactor and keys), count the snapshot's rows without the scanner, ask the cloud for its view of this
|
|
310
|
+
* machine's log, and record the comparison in runner.db and reconcile-last.json. Each post takes the key it was
|
|
311
|
+
* first sent under, each cloud-only channel is looked for on disk, and drift is checked for redaction (CUT-02); the
|
|
312
|
+
* roots a redaction proof took are kept, so later scans redact as Pulse holds it. Nothing is sent.
|
|
313
|
+
*/
|
|
314
|
+
async #reconcile({ hub, config, transport, job, request }) {
|
|
315
|
+
this.state = { state: 'reconciling', requestedAt: request.requestedAt };
|
|
316
|
+
await this.#persistState();
|
|
317
|
+
const requestFile = path.join(this.directory, RECONCILE_REQUEST_FILE);
|
|
318
|
+
let result;
|
|
319
|
+
if (!job?.snapshot || job.phase !== 'done') {
|
|
320
|
+
result = { status: 'no_import', message: 'Run `agentpulse pulse import` to completion first; the reconcile rescans its snapshot.' };
|
|
321
|
+
} else {
|
|
322
|
+
const { report, local } = await this.#rescan({ hub, config, transport, job });
|
|
323
|
+
const counts = await sourceCounts({ hubDir: job.snapshot.snapshotHubDir, roots: job.snapshot.snapshotRoots });
|
|
324
|
+
const cloud = await transport.parity({ view: 'reconcile' });
|
|
325
|
+
const roots = channelRoots(config, hub.dir);
|
|
326
|
+
const onDisk = {};
|
|
327
|
+
for (const channel of cloudOnlyChannels(local, cloud)) onDisk[channel] = await channelOnDisk(channel, roots);
|
|
328
|
+
let compared = compareReconcile({ local, cloud, counts, report, sent: this.store, onDisk });
|
|
329
|
+
if (compared.drift.length) {
|
|
330
|
+
const redacted = await this.#proveRedaction({ hub, config, transport, job, drift: compared.drift, cloud });
|
|
331
|
+
compared = compareReconcile({ local, cloud, counts, report, sent: this.store, onDisk, redacted });
|
|
332
|
+
const matched = [...new Set([...redacted.values()].flat())];
|
|
333
|
+
if (matched.length) {
|
|
334
|
+
const kept = new UploadRedactor({ workspace: path.dirname(hub.dir), home: this.home });
|
|
335
|
+
kept.seed(this.store.meta(REDACTION_ROOTS_META));
|
|
336
|
+
kept.seed({ worktree: matched.map((root) => [root, path.basename(root)]) });
|
|
337
|
+
this.store.setMeta(REDACTION_ROOTS_META, kept.roots());
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const sent = this.store.counts();
|
|
341
|
+
result = {
|
|
342
|
+
status: compared.status,
|
|
343
|
+
snapshotTakenAt: job.snapshot.takenAt,
|
|
344
|
+
head: cloud.head,
|
|
345
|
+
compared: cloud.compared,
|
|
346
|
+
otherWriters: cloud.otherWriters,
|
|
347
|
+
sources: compared.sources,
|
|
348
|
+
imported: Object.fromEntries(Object.entries(sent).map(([source, row]) => [source, { written: row.written, duplicate: row.duplicate, setAside: row.dead }])),
|
|
349
|
+
families: familyTable(local.view, cloud.view),
|
|
350
|
+
classes: compared.classes,
|
|
351
|
+
retired: compared.retired,
|
|
352
|
+
redacted: compared.redacted,
|
|
353
|
+
withheld: local.withheld,
|
|
354
|
+
cut: local.cut,
|
|
355
|
+
mismatches: compared.mismatches,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const recorded = { ...result, at: this.now(), requestedAt: request.requestedAt };
|
|
359
|
+
this.store.setMeta('reconcile_last', recorded);
|
|
360
|
+
await writeFile(path.join(this.directory, RECONCILE_REPORT_FILE), `${JSON.stringify(recorded, null, 1)}\n`, { mode: 0o600 });
|
|
361
|
+
await rm(requestFile, { force: true });
|
|
362
|
+
this.state = { state: 'reconciled', status: result.status, at: recorded.at };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Presence beats ride straight to the cloud (they are not events and are coalesced there); sent ones are recorded. */
|
|
366
|
+
async #beats(transport, beats) {
|
|
367
|
+
for (let start = 0; start < beats.length; start += 100) {
|
|
368
|
+
const chunk = beats.slice(start, start + 100);
|
|
369
|
+
const answer = await transport.commands({ commands: chunk });
|
|
370
|
+
chunk.forEach((beat, index) => {
|
|
371
|
+
if (!answer?.results?.[index]?.error) this.store.setPresenceSent(beat.payload.actorId, beat.payload.at);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async #parity(transport) {
|
|
377
|
+
const requestFile = path.join(this.directory, PARITY_REQUEST_FILE);
|
|
378
|
+
const request = await readRequest(requestFile);
|
|
379
|
+
if (!request && this.now() - this.lastParityAt < PARITY_EVERY_MS) return;
|
|
380
|
+
this.lastParityAt = this.now();
|
|
381
|
+
const hours = Number(request?.hours) || 6;
|
|
382
|
+
const report = await transport.parity({ hours });
|
|
383
|
+
const line = { at: this.now(), hours, status: report.status, percent: report.percent, totals: report.totals, families: report.families };
|
|
384
|
+
this.store.setMeta('parity_last', { ...line, diffs: (report.diffs || []).slice(0, 50), requestedAt: request?.requestedAt ?? null });
|
|
385
|
+
await appendFile(path.join(this.directory, PARITY_LOG_FILE), `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
|
386
|
+
if (request) await rm(requestFile, { force: true });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#fail(error) {
|
|
390
|
+
const code = error.code || 'HUB_SHADOW_FAILED';
|
|
391
|
+
if (REFUSALS.has(code)) {
|
|
392
|
+
const waitMs = Math.min(BLOCKED_CAP_MS, BLOCKED_BASE_MS * 2 ** this.refusals);
|
|
393
|
+
this.refusals += 1;
|
|
394
|
+
const scope = scopeBlock(error);
|
|
395
|
+
this.blocked = { code, retryAt: this.now() + waitMs, scope: Boolean(scope) };
|
|
396
|
+
if (code === 'cloud_first_off') this.flag = null;
|
|
397
|
+
this.state = { state: 'blocked', code, retryAt: new Date(this.blocked.retryAt).toISOString(), ...(scope ? { missingScopes: scope.missingScopes, fix: scope.fix } : {}) };
|
|
398
|
+
} else {
|
|
399
|
+
this.state = { state: 'error', code };
|
|
400
|
+
}
|
|
401
|
+
this.log('shadow_failed', { code });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async #persistState() {
|
|
405
|
+
try {
|
|
406
|
+
this.store?.setMeta('shadow_state', { ...this.state, at: this.now() });
|
|
407
|
+
} catch {
|
|
408
|
+
// A store that cannot take the state still runs; status reads the last one written.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** What health shows: the state with the backlog and set-aside counts. */
|
|
413
|
+
health() {
|
|
414
|
+
if (!this.store) return this.state;
|
|
415
|
+
const dead = Object.values(this.store.counts()).reduce((sum, source) => sum + Object.values(source.dead).reduce((n, v) => n + v, 0), 0);
|
|
416
|
+
return { ...this.state, pending: this.store.pendingCount(), setAside: dead };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function summary(report) {
|
|
421
|
+
return { totals: report.totals, sources: report.sources };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function snapshotRef(snapshot) {
|
|
425
|
+
return { dir: snapshot.dir, takenAt: snapshot.takenAt, files: snapshot.files, snapshotHubDir: snapshot.snapshotHubDir, snapshotRoots: snapshot.snapshotRoots };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Per family: the local count and hash beside the cloud's. */
|
|
429
|
+
function familyTable(local, cloud = {}) {
|
|
430
|
+
const row = (a = {}, b = {}) => ({ local: a.count ?? null, cloud: b.count ?? null, equal: a.count === b.count && a.sha === b.sha });
|
|
431
|
+
const out = {};
|
|
432
|
+
for (const family of ['channels', 'posts', 'messages', 'actors', 'agentRows', 'personas', 'statusOverrides']) out[family] = row(local[family], cloud[family]);
|
|
433
|
+
for (const kind of Object.keys(local.facts)) out[`facts.${kind}`] = row(local.facts[kind], cloud.facts?.[kind]);
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { HubError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* node:sqlite's DatabaseSync, looked up when a store is opened rather than imported, so the daemon and the mirror
|
|
5
|
+
* still load on a Node without it (before 22.13); only what needs runner.db or hub.db refuses there.
|
|
6
|
+
*/
|
|
7
|
+
export function openDatabase(file, options = {}, getBuiltin = process.getBuiltinModule?.bind(process)) {
|
|
8
|
+
const sqlite = getBuiltin?.('node:sqlite');
|
|
9
|
+
if (!sqlite?.DatabaseSync) {
|
|
10
|
+
throw new HubError('HUB_NODE_TOO_OLD', `The Pulse shadow feed and import need Node 22.13 or later; this is Node ${process.versions.node}`);
|
|
11
|
+
}
|
|
12
|
+
return new sqlite.DatabaseSync(file, options);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A consistent copy of a live SQLite file (WAL included) at `to`, read-only on `from`: the online backup API where
|
|
17
|
+
* this Node has it (22.16 and later), else `VACUUM INTO`, which gives the same transactionally consistent copy.
|
|
18
|
+
*/
|
|
19
|
+
export async function backupDatabase(from, to, getBuiltin = process.getBuiltinModule?.bind(process)) {
|
|
20
|
+
const sqlite = getBuiltin?.('node:sqlite');
|
|
21
|
+
const source = openDatabase(from, { readOnly: true }, getBuiltin);
|
|
22
|
+
try {
|
|
23
|
+
if (typeof sqlite.backup === 'function') await sqlite.backup(source, to);
|
|
24
|
+
else source.prepare('VACUUM INTO ?').run(to);
|
|
25
|
+
} finally {
|
|
26
|
+
source.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/lib/tokenStore.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { rm } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { HubError } from './errors.js';
|
|
@@ -5,7 +6,10 @@ import { acquireOwnerLock } from './ownerLock.js';
|
|
|
5
6
|
import { ProcessIdentityProvider } from './processOwnership.js';
|
|
6
7
|
import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
/** The one name every config used before sign-ins were named per config dir; a config without `secretRef` still uses it. */
|
|
10
|
+
export const LEGACY_SECRET_REFERENCE = 'oauth-session';
|
|
11
|
+
/** This config dir's own sign-in item, so a second config (another XDG_CONFIG_HOME or profile) never overwrites it. */
|
|
12
|
+
export const sessionSecretReference = (configDir) => `oauth-session-${createHash('sha256').update(path.resolve(configDir)).digest('hex').slice(0, 16)}`;
|
|
9
13
|
const METADATA_FILE = 'oauth-session.json';
|
|
10
14
|
const LOCK_FILE = 'oauth-refresh.lock';
|
|
11
15
|
const inProcessLocks = new Map();
|
|
@@ -13,16 +17,23 @@ const inProcessLocks = new Map();
|
|
|
13
17
|
export class TokenStore {
|
|
14
18
|
constructor({ secretStore, configDir, lockTimeoutMs = 10_000, identityProvider = new ProcessIdentityProvider() }) {
|
|
15
19
|
this.secretStore = secretStore;
|
|
20
|
+
this.ownReference = sessionSecretReference(configDir);
|
|
16
21
|
this.metadataFile = path.join(configDir, METADATA_FILE);
|
|
17
22
|
this.lockFile = path.join(configDir, LOCK_FILE);
|
|
18
23
|
this.lockTimeoutMs = lockTimeoutMs;
|
|
19
24
|
this.identityProvider = identityProvider;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
/** The item a config's sign-in lives in: its recorded one, the legacy one for a config signed in before, else its own. */
|
|
28
|
+
#reference(metadata) {
|
|
29
|
+
if (!metadata) return this.ownReference;
|
|
30
|
+
return metadata.secretRef || LEGACY_SECRET_REFERENCE;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
async load() {
|
|
23
34
|
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
24
35
|
if (!metadata) return null;
|
|
25
|
-
const secret = await this.secretStore.get(
|
|
36
|
+
const secret = await this.secretStore.get(this.#reference(metadata));
|
|
26
37
|
if (!secret) return null;
|
|
27
38
|
let tokens;
|
|
28
39
|
try {
|
|
@@ -52,11 +63,13 @@ export class TokenStore {
|
|
|
52
63
|
if (typeof session?.accessToken !== 'string' || !session.userId || !session.teamId || !session.platformUrl) {
|
|
53
64
|
throw new HubError('HUB_TOKEN_STORE_INVALID', 'Refusing to store an incomplete Pulse sign-in');
|
|
54
65
|
}
|
|
55
|
-
|
|
66
|
+
const reference = this.#reference(await readOwnerOnlyJson(this.metadataFile));
|
|
67
|
+
await this.secretStore.set(reference, JSON.stringify({
|
|
56
68
|
accessToken: session.accessToken,
|
|
57
69
|
refreshToken: session.refreshToken ?? null,
|
|
58
70
|
}));
|
|
59
71
|
await writeOwnerOnlyJson(this.metadataFile, {
|
|
72
|
+
...(reference === LEGACY_SECRET_REFERENCE ? {} : { secretRef: reference }),
|
|
60
73
|
platformUrl: session.platformUrl,
|
|
61
74
|
userId: session.userId,
|
|
62
75
|
teamId: session.teamId,
|
|
@@ -66,10 +79,36 @@ export class TokenStore {
|
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
async clear() {
|
|
69
|
-
await this.secretStore.delete(
|
|
82
|
+
await this.secretStore.delete(this.#reference(await readOwnerOnlyJson(this.metadataFile)));
|
|
70
83
|
await rm(this.metadataFile, { force: true });
|
|
71
84
|
}
|
|
72
85
|
|
|
86
|
+
/** What `migrateSecret` would do, read-only: `{ from, to }` for a legacy sign-in, else null. */
|
|
87
|
+
async secretPlan() {
|
|
88
|
+
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
89
|
+
return metadata && !metadata.secretRef ? { from: LEGACY_SECRET_REFERENCE, to: this.ownReference } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Move a legacy sign-in to this config's own item (at cutover): copy, read it back, record it, and only then remove
|
|
94
|
+
* the legacy item. Runs under the refresh lock, so a refresh can't write the legacy item in between.
|
|
95
|
+
*/
|
|
96
|
+
async migrateSecret() {
|
|
97
|
+
return this.withRefreshLock(async () => {
|
|
98
|
+
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
99
|
+
if (!metadata || metadata.secretRef) return { moved: false, reference: metadata?.secretRef ?? null };
|
|
100
|
+
const secret = await this.secretStore.get(LEGACY_SECRET_REFERENCE);
|
|
101
|
+
if (!secret) return { moved: false, reference: null };
|
|
102
|
+
await this.secretStore.set(this.ownReference, secret);
|
|
103
|
+
if ((await this.secretStore.get(this.ownReference)) !== secret) {
|
|
104
|
+
throw new HubError('HUB_TOKEN_MIGRATION_FAILED', 'The sign-in did not read back from its new Keychain item; the old item is kept');
|
|
105
|
+
}
|
|
106
|
+
await writeOwnerOnlyJson(this.metadataFile, { secretRef: this.ownReference, ...metadata });
|
|
107
|
+
await this.secretStore.delete(LEGACY_SECRET_REFERENCE);
|
|
108
|
+
return { moved: true, from: LEGACY_SECRET_REFERENCE, to: this.ownReference };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
73
112
|
async withRefreshLock(action) {
|
|
74
113
|
const previous = inProcessLocks.get(this.lockFile) || Promise.resolve();
|
|
75
114
|
let release;
|
package/src/lib/transport.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
2
|
import { fullJitterDelay } from './backoff.js';
|
|
3
3
|
import { loginRequired } from './cloudAuth.js';
|
|
4
|
-
import { HubError } from './errors.js';
|
|
4
|
+
import { HubError, SCOPE_FIX } from './errors.js';
|
|
5
5
|
import { cleanOrigin, fetchJson } from './http.js';
|
|
6
6
|
import { CONTRACT_HEADER, CONTRACT_VERSION, MIRROR_LIMITS, MIRROR_PATH, parseAccepts } from './mirrorContract.js';
|
|
7
7
|
import { PriorityLane } from './priorityLane.js';
|
|
@@ -22,6 +22,9 @@ const MIRROR_TIMEOUT_MS = 30_000;
|
|
|
22
22
|
const MIRROR_GZIP_ABOVE_BYTES = 8 * 1024;
|
|
23
23
|
// The cloud holds a claim up to waitSeconds before answering; the request waits that long plus this much.
|
|
24
24
|
const CLAIM_TIMEOUT_MARGIN_MS = 10_000;
|
|
25
|
+
// The cloud's PULSE_LIMITS: a command batch holds at most 100 commands in 512 KB.
|
|
26
|
+
const PULSE_MAX_BATCH = 100;
|
|
27
|
+
const PULSE_BODY_BYTES = 512 * 1024;
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* A claimed job with its integers as numbers. The cloud returns agent_hub_jobs rows as node-pg serialises them, so the
|
|
@@ -58,7 +61,7 @@ export function contractUnsupported(accepts, message) {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
function requestBytes(payload, maxBodyBytes) {
|
|
61
|
-
if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > maxBodyBytes) {
|
|
64
|
+
if (payload !== undefined && Buffer.byteLength(JSON.stringify(payload), 'utf8') > maxBodyBytes) {
|
|
62
65
|
throw new HubError('HUB_TRANSPORT_PAYLOAD_TOO_LARGE', `Request exceeds ${maxBodyBytes} bytes`);
|
|
63
66
|
}
|
|
64
67
|
}
|
|
@@ -106,7 +109,7 @@ export class ConnectorTransport {
|
|
|
106
109
|
* reading credentials (a token refresh or dev-org mint), the JSON and wire bytes, and the cloud's Server-Timing of
|
|
107
110
|
* the attempt that answered.
|
|
108
111
|
*/
|
|
109
|
-
async #request(path, payload, { retry = false, maxBodyBytes, timeoutMs = REQUEST_TIMEOUT_MS, gzipAboveBytes, trace = {} }) {
|
|
112
|
+
async #request(path, payload, { retry = false, maxBodyBytes, timeoutMs = REQUEST_TIMEOUT_MS, gzipAboveBytes, trace = {}, method = 'POST' }) {
|
|
110
113
|
requestBytes(payload, maxBodyBytes);
|
|
111
114
|
trace.attempts = 0;
|
|
112
115
|
trace.statuses = [];
|
|
@@ -132,6 +135,7 @@ export class ConnectorTransport {
|
|
|
132
135
|
response = await fetchJson({
|
|
133
136
|
fetch: this.fetch,
|
|
134
137
|
url: `${this.baseUrl}${path}`,
|
|
138
|
+
method,
|
|
135
139
|
headers: { ...credentials, ...this.#identityHeaders() },
|
|
136
140
|
body: payload,
|
|
137
141
|
maxBytes: CONNECTOR_RESPONSE_MAX_BYTES,
|
|
@@ -163,8 +167,11 @@ export class ConnectorTransport {
|
|
|
163
167
|
continue;
|
|
164
168
|
}
|
|
165
169
|
if (response.status === 403 && response.body.error === 'FORBIDDEN') {
|
|
166
|
-
|
|
167
|
-
|
|
170
|
+
const missingScopes = (Array.isArray(response.body.required) ? response.body.required : []).filter((s) => typeof s === 'string').slice(0, 10).map((s) => s.slice(0, 80));
|
|
171
|
+
throw new HubError('HUB_SCOPE_DENIED', missingScopes.length
|
|
172
|
+
? `The Pulse sign-in lacks ${missingScopes.join(', ')}; run \`${SCOPE_FIX}\``
|
|
173
|
+
: 'Your Pulse tier does not grant the connector scopes for this operation', {
|
|
174
|
+
details: { status: 403, missingScopes, fix: SCOPE_FIX },
|
|
168
175
|
});
|
|
169
176
|
}
|
|
170
177
|
if (retry && (response.status === 429 || response.status >= 500)) {
|
|
@@ -252,6 +259,47 @@ export class ConnectorTransport {
|
|
|
252
259
|
}), { path: '/events' });
|
|
253
260
|
}
|
|
254
261
|
|
|
262
|
+
/**
|
|
263
|
+
* One batch of Pulse log commands (`[{ type, idempotencyKey, payload, group? }]`, at most 100), answered per item.
|
|
264
|
+
* It waits behind heartbeats like a mirror batch. 429 and 5xx are retried with Retry-After; a refusal of the whole
|
|
265
|
+
* batch (the team's cloud-first flag is off, a missing capability) is thrown with the cloud's code.
|
|
266
|
+
*/
|
|
267
|
+
commands(payload) {
|
|
268
|
+
if (!Array.isArray(payload?.commands) || payload.commands.length < 1 || payload.commands.length > PULSE_MAX_BATCH) {
|
|
269
|
+
throw new HubError('HUB_COMMAND_BATCH_INVALID', `A command batch holds 1 to ${PULSE_MAX_BATCH} commands`);
|
|
270
|
+
}
|
|
271
|
+
return this.lane.background(() => this.#request(`${CONNECTOR_API_BASE}/commands`, payload, {
|
|
272
|
+
retry: true,
|
|
273
|
+
maxBodyBytes: PULSE_BODY_BYTES,
|
|
274
|
+
timeoutMs: MIRROR_TIMEOUT_MS,
|
|
275
|
+
gzipAboveBytes: MIRROR_GZIP_ABOVE_BYTES,
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One page of this machine's Pulse log after `since` (design §5.4), held up to `wait` seconds when nothing is new:
|
|
281
|
+
* `{ events, next, head, floor, presence, presenceNext }`. `presence` is the last answer's `presenceNext`, an opaque
|
|
282
|
+
* keyset, so beats already sent never come back and a held wait holds. Behind the retention floor the cloud answers 410.
|
|
283
|
+
*/
|
|
284
|
+
sync({ since, wait = 20, presence = null } = {}) {
|
|
285
|
+
const query = new URLSearchParams({ since: String(since), wait: String(wait), ...(presence ? { presence } : {}) });
|
|
286
|
+
return this.#request(`${CONNECTOR_API_BASE}/sync?${query}`, undefined, {
|
|
287
|
+
retry: true, maxBodyBytes: 0, method: 'GET', timeoutMs: (Number(wait) + 10) * 1000,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Whether this team has Pulse cloud-first turned on: `{ enabled, since }`. */
|
|
292
|
+
cloudFirst() {
|
|
293
|
+
return this.#request(`${CONNECTOR_API_BASE}/cloud-first`, undefined, { retry: true, maxBodyBytes: 0, method: 'GET' });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The parity report of this machine's mirror against the Pulse log, for the last `hours`; `view: 'reconcile'` asks for
|
|
297
|
+
* the import reconciliation view of this machine's log instead (ADR-4). */
|
|
298
|
+
parity({ hours = 6, view = null } = {}) {
|
|
299
|
+
const query = new URLSearchParams(view ? { view } : { hours: String(hours) });
|
|
300
|
+
return this.#request(`${CONNECTOR_API_BASE}/parity?${query}`, undefined, { retry: true, maxBodyBytes: 0, method: 'GET', timeoutMs: MIRROR_TIMEOUT_MS });
|
|
301
|
+
}
|
|
302
|
+
|
|
255
303
|
/** The heartbeat, session, renew, ack and events calls in flight at any time since `since`, for a pass record. */
|
|
256
304
|
priorityCalls({ since } = {}) {
|
|
257
305
|
return this.lane.calls({ since });
|
|
@@ -17,6 +17,9 @@ export const CONNECTOR_SCOPES = Object.freeze([
|
|
|
17
17
|
'agent-hub:connector:renew',
|
|
18
18
|
'agent-hub:connector:event',
|
|
19
19
|
'agent-hub:connector:mirror',
|
|
20
|
+
// The Pulse log (cloud-first Phase 2): the shadow feed writes commands and reads the cloud-first flag and parity.
|
|
21
|
+
'agent-hub:connector:sync',
|
|
22
|
+
'agent-hub:connector:commands',
|
|
20
23
|
]);
|
|
21
24
|
|
|
22
25
|
export const CONNECTOR_AUTH_CONTRACT = Object.freeze({
|