@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -32
- package/package.json +14 -19
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +43 -0
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
} from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { HubError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
export async function ensureOwnerOnlyDirectory(directory) {
|
|
15
|
+
const absolute = path.resolve(directory);
|
|
16
|
+
const root = path.parse(absolute).root;
|
|
17
|
+
const components = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
18
|
+
let current = root;
|
|
19
|
+
let ownerAnchorReached = false;
|
|
20
|
+
for (const component of components) {
|
|
21
|
+
current = path.join(current, component);
|
|
22
|
+
try {
|
|
23
|
+
const componentInfo = await lstat(current);
|
|
24
|
+
if (componentInfo.isSymbolicLink()) {
|
|
25
|
+
if (ownerAnchorReached || current === absolute) {
|
|
26
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Storage ancestor is a symlink: ${current}`);
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!componentInfo.isDirectory()) {
|
|
31
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Storage ancestor is not a directory: ${current}`);
|
|
32
|
+
}
|
|
33
|
+
if (typeof process.getuid === 'function' && componentInfo.uid === process.getuid()) {
|
|
34
|
+
ownerAnchorReached = true;
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ENOENT') throw error;
|
|
38
|
+
await mkdir(current, { mode: 0o700 });
|
|
39
|
+
ownerAnchorReached = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const info = await lstat(directory);
|
|
43
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
44
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Storage path is not a real directory: ${directory}`);
|
|
45
|
+
}
|
|
46
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid()) {
|
|
47
|
+
throw new HubError('HUB_STORAGE_NOT_OWNED', `Storage path is not owned by the current user: ${directory}`);
|
|
48
|
+
}
|
|
49
|
+
await chmod(directory, 0o700);
|
|
50
|
+
return directory;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function assertOwnerOnlyFile(file) {
|
|
54
|
+
const info = await lstat(file);
|
|
55
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
56
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Storage path is not a regular file: ${file}`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid()) {
|
|
59
|
+
throw new HubError('HUB_STORAGE_NOT_OWNED', `Storage file is not owned by the current user: ${file}`);
|
|
60
|
+
}
|
|
61
|
+
if ((info.mode & 0o077) !== 0) {
|
|
62
|
+
throw new HubError('HUB_STORAGE_PERMISSIONS', `Storage file must be owner-only: ${file}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function writeOwnerOnlyJson(file, value) {
|
|
67
|
+
return writeOwnerOnlyText(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function writeOwnerOnlyText(file, value) {
|
|
71
|
+
const directory = path.dirname(file);
|
|
72
|
+
await ensureOwnerOnlyDirectory(directory);
|
|
73
|
+
try {
|
|
74
|
+
const existing = await lstat(file);
|
|
75
|
+
if (existing.isSymbolicLink()) {
|
|
76
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Refusing to replace symlink: ${file}`);
|
|
77
|
+
}
|
|
78
|
+
if (!existing.isFile()) {
|
|
79
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Refusing to replace non-file storage: ${file}`);
|
|
80
|
+
}
|
|
81
|
+
if (typeof process.getuid === 'function' && existing.uid !== process.getuid()) {
|
|
82
|
+
throw new HubError('HUB_STORAGE_NOT_OWNED', `Storage file is not owned by the current user: ${file}`);
|
|
83
|
+
}
|
|
84
|
+
if ((existing.mode & 0o077) !== 0) {
|
|
85
|
+
throw new HubError('HUB_STORAGE_PERMISSIONS', `Storage file must already be owner-only: ${file}`);
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code !== 'ENOENT') throw error;
|
|
89
|
+
}
|
|
90
|
+
const temporary = path.join(
|
|
91
|
+
directory,
|
|
92
|
+
`.${path.basename(file)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
93
|
+
);
|
|
94
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
95
|
+
try {
|
|
96
|
+
await handle.writeFile(String(value), 'utf8');
|
|
97
|
+
await handle.sync();
|
|
98
|
+
} finally {
|
|
99
|
+
await handle.close();
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await rename(temporary, file);
|
|
103
|
+
await chmod(file, 0o600);
|
|
104
|
+
try {
|
|
105
|
+
const directoryHandle = await open(directory, 'r');
|
|
106
|
+
try {
|
|
107
|
+
await directoryHandle.sync();
|
|
108
|
+
} finally {
|
|
109
|
+
await directoryHandle.close();
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Some filesystems do not support directory fsync; the atomic rename still applies.
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await rm(temporary, { force: true });
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function readOwnerOnlyJson(file, fallback = null) {
|
|
121
|
+
try {
|
|
122
|
+
await assertOwnerOnlyFile(file);
|
|
123
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.code === 'ENOENT') return fallback;
|
|
126
|
+
if (error instanceof SyntaxError) {
|
|
127
|
+
throw new HubError('HUB_INVALID_CONFIG', `Invalid JSON in ${file}`, { cause: error });
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
export const SUBPROCESS_TIMEOUT_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFileCallback);
|
|
7
|
+
const bounded = (ms, fallback) => (Number.isFinite(ms) && ms > 0 ? ms : fallback);
|
|
8
|
+
|
|
9
|
+
/** execFile that always ends: past its timeout (never unbounded, not even with timeout: 0) the child is SIGKILLed. */
|
|
10
|
+
export function execFileBounded(bin, args, { defaultTimeoutMs = SUBPROCESS_TIMEOUT_MS, ...options } = {}) {
|
|
11
|
+
return execFileAsync(bin, args, { ...options, timeout: bounded(options.timeout, defaultTimeoutMs), killSignal: 'SIGKILL' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs bin with input on stdin and resolves { code, signal, stdout, stderr } when it exits. Past timeoutMs the child
|
|
16
|
+
* is SIGKILLed and the promise rejects with onTimeout(result), so a hung tool never holds its caller.
|
|
17
|
+
*/
|
|
18
|
+
export function spawnBounded(bin, args, {
|
|
19
|
+
input = '', env = process.env, timeoutMs = SUBPROCESS_TIMEOUT_MS, onTimeout = () => Object.assign(new Error(`${bin} timed out`), { code: 'ETIMEDOUT' }),
|
|
20
|
+
} = {}) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
23
|
+
const stdout = [];
|
|
24
|
+
const stderr = [];
|
|
25
|
+
let timedOut = false;
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
timedOut = true;
|
|
28
|
+
child.kill('SIGKILL');
|
|
29
|
+
}, bounded(timeoutMs, SUBPROCESS_TIMEOUT_MS));
|
|
30
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
31
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
32
|
+
child.on('error', (error) => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
reject(error);
|
|
35
|
+
});
|
|
36
|
+
child.on('close', (code, signal) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
const result = { code, signal, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') };
|
|
39
|
+
if (timedOut) reject(Object.assign(onTimeout(result), { result }));
|
|
40
|
+
else resolve(result);
|
|
41
|
+
});
|
|
42
|
+
child.stdin.on('error', () => {});
|
|
43
|
+
child.stdin.end(input);
|
|
44
|
+
});
|
|
45
|
+
}
|