@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,353 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { HubError } from './errors.js';
|
|
5
|
+
import { CONTRACT_VERSION, MIRROR_LIMITS } from './mirrorContract.js';
|
|
6
|
+
import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
|
|
7
|
+
|
|
8
|
+
const STATE_VERSION = 1;
|
|
9
|
+
const RECENT_PASSES = 20;
|
|
10
|
+
const MAX_PRIORITY_CALLS = 10;
|
|
11
|
+
const BATCH_HEADROOM_BYTES = 4 * 1024;
|
|
12
|
+
// Read whole on every pass that reads them at all, so a targeted pass may retire their missing items too.
|
|
13
|
+
const WHOLE_FAMILIES = ['graph_window', 'brief'];
|
|
14
|
+
export const RESEND_REQUEST_FILE = 'mirror-resend.request';
|
|
15
|
+
// Contract keys already acted on; a key seen before never re-sends again, so a rolling deploy cannot loop.
|
|
16
|
+
const MAX_CONTRACT_KEYS = 20;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Asks the daemon to send every rejected item again on its next pass, through a file it takes once, so this never
|
|
20
|
+
* writes the state file the daemon holds in memory. Returns how many items are rejected now.
|
|
21
|
+
*/
|
|
22
|
+
export async function requestResend(directory, { stateFile = path.join(directory, 'mirror-state.json') } = {}) {
|
|
23
|
+
const stored = await readOwnerOnlyJson(stateFile, null);
|
|
24
|
+
const rejected = Object.values(stored?.items || {}).filter((entry) => entry?.r).length;
|
|
25
|
+
await writeOwnerOnlyJson(path.join(directory, RESEND_REQUEST_FILE), { requestedAt: Date.now() });
|
|
26
|
+
return { rejected };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function takeResendRequest(directory) {
|
|
30
|
+
const file = path.join(directory, RESEND_REQUEST_FILE);
|
|
31
|
+
try {
|
|
32
|
+
await stat(file);
|
|
33
|
+
await unlink(file);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code === 'ENOENT') return false;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function canonical(value) {
|
|
42
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
43
|
+
if (value && typeof value === 'object') {
|
|
44
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function itemHash(data) {
|
|
50
|
+
return createHash('sha256').update(canonical(data)).digest('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function emptyState() {
|
|
54
|
+
return {
|
|
55
|
+
version: STATE_VERSION, items: {}, withheld: {}, graphDropped: {}, channelLastAt: {}, lastSyncAt: null, lastFullAt: null, lastCompleteAt: null, lastResult: null, lastPass: null, recentPasses: [], lastError: null, lastAbsent: {}, contractKeys: null,
|
|
56
|
+
redactedTotal: 0, redactedLastAt: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where one batch's time went. networkMs is what is left once the lane wait, reading credentials, the backoff and the
|
|
62
|
+
* server's own time are taken out: the bytes on the wire both ways, TLS and the platform in front of the app.
|
|
63
|
+
*/
|
|
64
|
+
function batchSplit(items, ms, trace) {
|
|
65
|
+
const laneWaitMs = trace.laneWaitMs ?? 0;
|
|
66
|
+
const credentialsMs = trace.credentialsMs ?? 0;
|
|
67
|
+
const backoffMs = trace.backoffMs ?? 0;
|
|
68
|
+
const serverMs = trace.server?.totalMs ?? null;
|
|
69
|
+
return {
|
|
70
|
+
items,
|
|
71
|
+
ms,
|
|
72
|
+
laneWaitMs,
|
|
73
|
+
laneCapped: trace.laneCapped ?? false,
|
|
74
|
+
heldBy: trace.heldBy ?? [],
|
|
75
|
+
credentialsMs,
|
|
76
|
+
attempts: trace.attempts ?? 1,
|
|
77
|
+
statuses: trace.statuses ?? [],
|
|
78
|
+
backoffMs,
|
|
79
|
+
bytes: trace.bytes ?? null,
|
|
80
|
+
sentBytes: trace.sentBytes ?? null,
|
|
81
|
+
serverMs,
|
|
82
|
+
dbMs: trace.server?.dbMs ?? null,
|
|
83
|
+
lockMs: trace.server?.lockMs ?? null,
|
|
84
|
+
networkMs: Math.max(0, ms - laneWaitMs - credentialsMs - backoffMs - (serverMs ?? 0)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function kindOf(key) {
|
|
89
|
+
return key.slice(0, key.indexOf('\0'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function familyOf(key) {
|
|
93
|
+
const kind = kindOf(key);
|
|
94
|
+
return (kind === 'channel' || kind === 'message') && key.startsWith('bus:', kind.length + 1) ? 'bus' : kind;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function chunkItems(items, { maxItems = MIRROR_LIMITS.maxItems, maxBytes = MIRROR_LIMITS.bodyBytes - BATCH_HEADROOM_BYTES } = {}) {
|
|
98
|
+
const batches = [];
|
|
99
|
+
let batch = [];
|
|
100
|
+
let bytes = 0;
|
|
101
|
+
for (const item of items) {
|
|
102
|
+
const size = Buffer.byteLength(JSON.stringify(item), 'utf8') + 1;
|
|
103
|
+
if (batch.length && (batch.length >= maxItems || bytes + size > maxBytes)) {
|
|
104
|
+
batches.push(batch);
|
|
105
|
+
batch = [];
|
|
106
|
+
bytes = 0;
|
|
107
|
+
}
|
|
108
|
+
batch.push(item);
|
|
109
|
+
bytes += size;
|
|
110
|
+
}
|
|
111
|
+
if (batch.length) batches.push(batch);
|
|
112
|
+
return batches;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class MirrorSync {
|
|
116
|
+
constructor({ source, transport, stateFile, now = Date.now, maxBatchItems = MIRROR_LIMITS.maxItems }) {
|
|
117
|
+
this.source = source;
|
|
118
|
+
this.transport = transport;
|
|
119
|
+
this.stateFile = stateFile;
|
|
120
|
+
this.now = now;
|
|
121
|
+
this.maxBatchItems = maxBatchItems;
|
|
122
|
+
this.state = null;
|
|
123
|
+
this.loading = null;
|
|
124
|
+
this.saving = Promise.resolve();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A windows pass and a change pass run at once (see MirrorService), so both share one loaded state, and saves are
|
|
128
|
+
// written one after another, each from the state as it is then, so an older snapshot never lands last.
|
|
129
|
+
#load() {
|
|
130
|
+
this.loading ??= readOwnerOnlyJson(this.stateFile, null).then((stored) => {
|
|
131
|
+
this.state = stored?.version === STATE_VERSION ? { ...emptyState(), ...stored } : emptyState();
|
|
132
|
+
return this.state;
|
|
133
|
+
}, (error) => {
|
|
134
|
+
this.loading = null;
|
|
135
|
+
throw error;
|
|
136
|
+
});
|
|
137
|
+
return this.loading;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#save() {
|
|
141
|
+
const write = this.saving.then(() => writeOwnerOnlyJson(this.stateFile, this.state));
|
|
142
|
+
this.saving = write.catch(() => {});
|
|
143
|
+
return write;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async status() {
|
|
147
|
+
const state = await this.#load();
|
|
148
|
+
const mirrored = {};
|
|
149
|
+
const dropped = {};
|
|
150
|
+
let rejected = 0;
|
|
151
|
+
const drop = (key, error) => {
|
|
152
|
+
const kind = kindOf(key);
|
|
153
|
+
dropped[kind] ||= {};
|
|
154
|
+
dropped[kind][error] = (dropped[kind][error] || 0) + 1;
|
|
155
|
+
};
|
|
156
|
+
for (const [key, entry] of Object.entries(state.items)) {
|
|
157
|
+
if (entry.r) {
|
|
158
|
+
drop(key, entry.r);
|
|
159
|
+
rejected += 1;
|
|
160
|
+
} else mirrored[kindOf(key)] = (mirrored[kindOf(key)] || 0) + 1;
|
|
161
|
+
}
|
|
162
|
+
for (const [key, error] of Object.entries(state.withheld)) drop(key, error);
|
|
163
|
+
return {
|
|
164
|
+
mirrored,
|
|
165
|
+
dropped,
|
|
166
|
+
rejected,
|
|
167
|
+
graphDropped: state.graphDropped,
|
|
168
|
+
lastSyncAt: state.lastSyncAt,
|
|
169
|
+
lastFullAt: state.lastFullAt,
|
|
170
|
+
lastCompleteAt: state.lastCompleteAt,
|
|
171
|
+
lastResult: state.lastResult,
|
|
172
|
+
lastPass: state.lastPass,
|
|
173
|
+
recentPasses: state.recentPasses,
|
|
174
|
+
lastError: state.lastError,
|
|
175
|
+
lastAbsent: state.lastAbsent,
|
|
176
|
+
redactedTotal: state.redactedTotal,
|
|
177
|
+
redactedLastAt: state.redactedLastAt,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#recordWithheld(state, collected, full) {
|
|
182
|
+
const current = new Map(collected.withheld.map((entry) => [`${entry.kind}\0${entry.externalId}`, entry.error]));
|
|
183
|
+
for (const key of collected.items.keys()) delete state.withheld[key];
|
|
184
|
+
if (full) {
|
|
185
|
+
for (const key of Object.keys(state.withheld)) {
|
|
186
|
+
if (!current.has(key) && collected.complete.has(familyOf(key))) delete state.withheld[key];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const [key, error] of current) {
|
|
190
|
+
if (error === 'content_withheld' && state.withheld[key] !== error) {
|
|
191
|
+
state.redactedTotal = (state.redactedTotal || 0) + 1;
|
|
192
|
+
state.redactedLastAt = this.now();
|
|
193
|
+
}
|
|
194
|
+
state.withheld[key] = error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async recordError(error, { retryAt } = {}) {
|
|
199
|
+
const state = await this.#load();
|
|
200
|
+
state.lastError = { code: error.code || 'HUB_MIRROR_FAILED', at: this.now(), ...(retryAt ? { retryAt } : {}) };
|
|
201
|
+
await this.#save();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#tombstoneKeys(state, collected, full) {
|
|
205
|
+
const whole = full ? collected.complete : new Set(WHOLE_FAMILIES.filter((family) => collected.complete.has(family)));
|
|
206
|
+
if (!whole.size) return [];
|
|
207
|
+
const keys = [];
|
|
208
|
+
const listed = full && collected.listedChannels ? new Set(collected.listedChannels) : null;
|
|
209
|
+
const refetched = new Set(collected.refetchedChannels || []);
|
|
210
|
+
for (const [key, entry] of Object.entries(state.items)) {
|
|
211
|
+
if (collected.items.has(key)) continue;
|
|
212
|
+
const family = familyOf(key);
|
|
213
|
+
if (whole.has(family)) {
|
|
214
|
+
keys.push(key);
|
|
215
|
+
} else if (family === 'message' && listed && (!listed.has(entry.c) || refetched.has(entry.c))) {
|
|
216
|
+
keys.push(key);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return keys;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A pass whose signal aborts (a change preempting a full pass) stops between reads or at the next batch boundary.
|
|
224
|
+
* The batches it uploaded stay recorded; the rest are sent by a later pass. It is no reconcile and no error.
|
|
225
|
+
*/
|
|
226
|
+
async #preempted(state, { full, sources, startedAt, timing }) {
|
|
227
|
+
const pass = { full, sources: full ? null : [...(sources || [])].sort(), preempted: true, ms: this.now() - startedAt, ...timing, priority: this.#priorityCalls(startedAt) };
|
|
228
|
+
state.recentPasses = [...(state.recentPasses || []), { at: this.now(), ...pass }].slice(-RECENT_PASSES);
|
|
229
|
+
await this.#save();
|
|
230
|
+
return new HubError('HUB_MIRROR_PREEMPTED', 'A change preempted this mirror pass');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A rejected item keeps its hash, so it is not sent again until it changes. It loses the hash, and the next pass that
|
|
235
|
+
* reads it sends it once, when this connector's contract or the cloud's accepted range is one this state has never
|
|
236
|
+
* seen (an upgrade on either side), or when `pulse mirror resend-rejected` left a request.
|
|
237
|
+
*/
|
|
238
|
+
async #releaseRejected(state) {
|
|
239
|
+
const key = `mirror/${CONTRACT_VERSION}|${this.transport?.contractAccepts ?? ''}`;
|
|
240
|
+
const seen = Array.isArray(state.contractKeys) ? state.contractKeys : [];
|
|
241
|
+
const fresh = !seen.includes(key);
|
|
242
|
+
if (fresh) state.contractKeys = [...seen, key].slice(-MAX_CONTRACT_KEYS);
|
|
243
|
+
const requested = await takeResendRequest(path.dirname(this.stateFile));
|
|
244
|
+
if (!fresh && !requested) return;
|
|
245
|
+
for (const [itemKey, entry] of Object.entries(state.items)) {
|
|
246
|
+
if (entry.r) state.items[itemKey] = { ...entry, h: null };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The heartbeat, session, renew, ack and events calls that overlapped a pass: what a batch's lane wait was behind. */
|
|
251
|
+
#priorityCalls(startedAt) {
|
|
252
|
+
return (this.transport.priorityCalls?.({ since: startedAt }) ?? []).slice(-MAX_PRIORITY_CALLS);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async run({ full = false, sources, signal } = {}) {
|
|
256
|
+
const state = await this.#load();
|
|
257
|
+
const startedAt = this.now();
|
|
258
|
+
await this.#releaseRejected(state);
|
|
259
|
+
let collected;
|
|
260
|
+
try {
|
|
261
|
+
collected = await this.source.collect({ full, sources, channelLastAt: new Map(Object.entries(state.channelLastAt)), ...(signal ? { signal } : {}) });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error.code === 'HUB_MIRROR_PREEMPTED') throw await this.#preempted(state, { full, sources, startedAt, timing: { collectMs: this.now() - startedAt } });
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
const now = this.now();
|
|
267
|
+
const timing = { collectMs: now - startedAt, uploadMs: 0, batches: 0, slowestBatchMs: 0, bytes: 0, sentBytes: 0, slowestBatch: null };
|
|
268
|
+
if (signal?.aborted) throw await this.#preempted(state, { full, sources, startedAt, timing });
|
|
269
|
+
const pending = new Map();
|
|
270
|
+
const outgoing = [];
|
|
271
|
+
for (const [key, item] of collected.items) {
|
|
272
|
+
const hash = itemHash(item.data);
|
|
273
|
+
const previous = state.items[key];
|
|
274
|
+
if (previous && previous.h === hash) continue;
|
|
275
|
+
const updatedAt = Math.max(item.updatedAt, previous?.u ?? 0);
|
|
276
|
+
const entry = { h: hash, u: updatedAt };
|
|
277
|
+
if (item.kind === 'message') entry.c = item.data.channelId;
|
|
278
|
+
pending.set(key, entry);
|
|
279
|
+
outgoing.push({ kind: item.kind, externalId: item.externalId, updatedAt, data: item.data });
|
|
280
|
+
}
|
|
281
|
+
for (const key of this.#tombstoneKeys(state, collected, full)) {
|
|
282
|
+
const externalId = key.slice(key.indexOf('\0') + 1);
|
|
283
|
+
pending.set(key, null);
|
|
284
|
+
outgoing.push({ kind: kindOf(key), externalId, updatedAt: Math.max(now, state.items[key].u + 1), deleted: true });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const result = {
|
|
288
|
+
pushed: 0, tombstoned: 0, unchanged: 0, ignored: 0, redacted: 0, rejected: [], withheld: collected.withheld.length, readErrors: collected.errors, briefsPending: collected.briefsPending === true,
|
|
289
|
+
};
|
|
290
|
+
try {
|
|
291
|
+
for (const batch of chunkItems(outgoing, { maxItems: this.maxBatchItems })) {
|
|
292
|
+
if (signal?.aborted) throw await this.#preempted(state, { full, sources, startedAt, timing });
|
|
293
|
+
const sentAt = this.now();
|
|
294
|
+
const trace = {};
|
|
295
|
+
const response = await this.transport.mirror({ items: batch }, { trace });
|
|
296
|
+
const batchMs = trace.ms ?? this.now() - sentAt;
|
|
297
|
+
timing.uploadMs += batchMs;
|
|
298
|
+
timing.batches += 1;
|
|
299
|
+
timing.bytes += trace.bytes || 0;
|
|
300
|
+
timing.sentBytes += trace.sentBytes || 0;
|
|
301
|
+
if (batchMs >= timing.slowestBatchMs) {
|
|
302
|
+
timing.slowestBatchMs = batchMs;
|
|
303
|
+
timing.slowestBatch = batchSplit(batch.length, batchMs, trace);
|
|
304
|
+
}
|
|
305
|
+
const rejected = new Map((response.rejected || []).map((entry) => [`${entry.kind}\0${entry.externalId}`, entry.error]));
|
|
306
|
+
for (const item of batch) {
|
|
307
|
+
const key = `${item.kind}\0${item.externalId}`;
|
|
308
|
+
const entry = pending.get(key);
|
|
309
|
+
if (entry === null) {
|
|
310
|
+
delete state.items[key];
|
|
311
|
+
result.tombstoned += 1;
|
|
312
|
+
} else if (rejected.has(key)) {
|
|
313
|
+
state.items[key] = { ...entry, r: rejected.get(key) };
|
|
314
|
+
result.rejected.push({ kind: item.kind, externalId: item.externalId, error: rejected.get(key) });
|
|
315
|
+
} else {
|
|
316
|
+
state.items[key] = entry;
|
|
317
|
+
result.pushed += 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
result.unchanged += response.unchanged || 0;
|
|
321
|
+
result.ignored += response.ignored || 0;
|
|
322
|
+
// The cloud counts only the items it wrote; a batch it wrote nothing of (a retry) adds nothing whatever it says.
|
|
323
|
+
const redacted = response.accepted > 0 ? response.redacted || 0 : 0;
|
|
324
|
+
result.redacted += redacted;
|
|
325
|
+
// Kept per batch, so a pass that fails later still counts what the cloud already redacted from this machine.
|
|
326
|
+
if (redacted > 0) {
|
|
327
|
+
state.redactedTotal = (state.redactedTotal || 0) + redacted;
|
|
328
|
+
state.redactedLastAt = this.now();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error.code === 'HUB_MIRROR_PREEMPTED') throw error;
|
|
333
|
+
state.lastError = { code: error.code || 'HUB_MIRROR_FAILED', at: now };
|
|
334
|
+
await this.#save();
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
this.#recordWithheld(state, collected, full);
|
|
338
|
+
state.graphDropped = Object.fromEntries(Object.entries({ ...state.graphDropped, ...collected.graphDropped }).filter(([, sections]) => Object.keys(sections).length));
|
|
339
|
+
if (full || sources?.has('channels')) state.channelLastAt = Object.fromEntries(collected.channelLastAt);
|
|
340
|
+
state.lastSyncAt = now;
|
|
341
|
+
if (full) state.lastFullAt = now;
|
|
342
|
+
if (full) {
|
|
343
|
+
state.lastAbsent = collected.absent || {};
|
|
344
|
+
if (!collected.errors.length) state.lastCompleteAt = now;
|
|
345
|
+
}
|
|
346
|
+
state.lastResult = { pushed: result.pushed, tombstoned: result.tombstoned, rejected: result.rejected.length, withheld: result.withheld, ignored: result.ignored, redacted: result.redacted };
|
|
347
|
+
state.lastPass = { full, sources: full ? null : [...(sources || [])].sort(), ms: this.now() - startedAt, ...timing, priority: this.#priorityCalls(startedAt) };
|
|
348
|
+
state.recentPasses = [...(state.recentPasses || []), { at: now, ...state.lastPass }].slice(-RECENT_PASSES);
|
|
349
|
+
state.lastError = collected.errors.length ? { code: collected.errors[0].code, at: now } : null;
|
|
350
|
+
await this.#save();
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { HubError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const NODE_FLOOR_MAJOR = 24;
|
|
4
|
+
|
|
5
|
+
/** Whether this Node can run Pulse; on Node 22 and older, fetch can lose an abort on a long-lived stream (NODE22-01). */
|
|
6
|
+
export function nodeFloor(version = process.versions.node) {
|
|
7
|
+
if (Number(version.split('.')[0]) >= NODE_FLOOR_MAJOR) return { ok: true, version };
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
code: 'HUB_NODE_TOO_OLD',
|
|
11
|
+
version,
|
|
12
|
+
message: `Pulse needs Node ${NODE_FLOOR_MAJOR} or newer; this is Node ${version}. Install Node ${NODE_FLOOR_MAJOR} (Active LTS), then run \`agentpulse pulse install\` and \`agentpulse pulse start\`.`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertNodeFloor(version) {
|
|
17
|
+
const floor = nodeFloor(version);
|
|
18
|
+
if (!floor.ok) throw new HubError(floor.code, floor.message);
|
|
19
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { HubError } from './errors.js';
|
|
4
|
+
import { cleanOrigin, decodeJwtClaims, fetchJson } from './http.js';
|
|
5
|
+
import { LOGIN_SCOPE, OAUTH_CLIENT_ID } from './transportContract.js';
|
|
6
|
+
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
|
+
const DONE_PAGE = '<!doctype html><meta charset="utf-8"><title>AgentPulse Pulse</title>'
|
|
9
|
+
+ '<p>Pulse connector sign-in finished. You can close this tab.</p>';
|
|
10
|
+
|
|
11
|
+
export function createPkce(random = randomBytes) {
|
|
12
|
+
const verifier = random(32).toString('base64url');
|
|
13
|
+
return {
|
|
14
|
+
verifier,
|
|
15
|
+
challenge: createHash('sha256').update(verifier, 'ascii').digest('base64url'),
|
|
16
|
+
method: 'S256',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildAuthorizeUrl({ platformUrl, redirectUri, state, challenge, scope = LOGIN_SCOPE }) {
|
|
21
|
+
return `${cleanOrigin(platformUrl, 'Platform URL')}/api/oauth/authorize?${new URLSearchParams({
|
|
22
|
+
response_type: 'code',
|
|
23
|
+
client_id: OAUTH_CLIENT_ID,
|
|
24
|
+
redirect_uri: redirectUri,
|
|
25
|
+
scope,
|
|
26
|
+
state,
|
|
27
|
+
code_challenge: challenge,
|
|
28
|
+
code_challenge_method: 'S256',
|
|
29
|
+
})}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function waitForAuthorizationCode({ state, timeoutMs = 300_000 }) {
|
|
33
|
+
return new Promise((listening, failedToListen) => {
|
|
34
|
+
let settle;
|
|
35
|
+
const code = new Promise((resolve, reject) => {
|
|
36
|
+
settle = { resolve, reject };
|
|
37
|
+
});
|
|
38
|
+
let timer;
|
|
39
|
+
const server = http.createServer((req, res) => {
|
|
40
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
41
|
+
if (url.pathname !== '/callback') {
|
|
42
|
+
res.writeHead(404).end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(DONE_PAGE);
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
server.close();
|
|
48
|
+
if (url.searchParams.get('error')) {
|
|
49
|
+
settle.reject(new HubError('HUB_OAUTH_DENIED', 'Sign-in was denied in the browser'));
|
|
50
|
+
} else if (url.searchParams.get('state') !== state) {
|
|
51
|
+
settle.reject(new HubError('HUB_OAUTH_STATE_MISMATCH', 'Sign-in callback state did not match; aborting'));
|
|
52
|
+
} else if (!url.searchParams.get('code')) {
|
|
53
|
+
settle.reject(new HubError('HUB_OAUTH_DENIED', 'Sign-in callback carried no authorization code'));
|
|
54
|
+
} else {
|
|
55
|
+
settle.resolve(url.searchParams.get('code'));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
server.on('error', failedToListen);
|
|
59
|
+
server.listen(0, '127.0.0.1', () => {
|
|
60
|
+
timer = setTimeout(() => {
|
|
61
|
+
server.close();
|
|
62
|
+
settle.reject(new HubError('HUB_OAUTH_TIMEOUT', 'Timed out waiting for browser sign-in (5 minutes)'));
|
|
63
|
+
}, timeoutMs);
|
|
64
|
+
listening({ redirectUri: `http://127.0.0.1:${server.address().port}/callback`, code });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function acceptUserToken(tokenResponse, { platformUrl, now = Date.now } = {}) {
|
|
70
|
+
const claims = decodeJwtClaims(tokenResponse?.access_token);
|
|
71
|
+
if (
|
|
72
|
+
claims?.type !== 'user_delegated' ||
|
|
73
|
+
!UUID.test(String(claims.sub || '')) ||
|
|
74
|
+
!UUID.test(String(claims.team_id || ''))
|
|
75
|
+
) {
|
|
76
|
+
throw new HubError(
|
|
77
|
+
'HUB_OAUTH_TOKEN_INVALID',
|
|
78
|
+
'The platform did not return a user-delegated token; run `agentpulse pulse login` again',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const expiresIn = Number(tokenResponse.expires_in);
|
|
82
|
+
return {
|
|
83
|
+
platformUrl: cleanOrigin(platformUrl, 'Platform URL'),
|
|
84
|
+
userId: claims.sub.toLowerCase(),
|
|
85
|
+
teamId: claims.team_id.toLowerCase(),
|
|
86
|
+
accessToken: tokenResponse.access_token,
|
|
87
|
+
refreshToken: typeof tokenResponse.refresh_token === 'string' ? tokenResponse.refresh_token : null,
|
|
88
|
+
expiresAt: new Date(now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) * 1000).toISOString(),
|
|
89
|
+
scopes: String(tokenResponse.scope || '').split(' ').filter(Boolean),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function loginWithBrowser({
|
|
94
|
+
platformUrl,
|
|
95
|
+
fetch = globalThis.fetch,
|
|
96
|
+
openBrowser,
|
|
97
|
+
onAuthUrl = () => {},
|
|
98
|
+
scope = LOGIN_SCOPE,
|
|
99
|
+
timeoutMs = 300_000,
|
|
100
|
+
now = Date.now,
|
|
101
|
+
random = randomBytes,
|
|
102
|
+
}) {
|
|
103
|
+
const origin = cleanOrigin(platformUrl, 'Platform URL');
|
|
104
|
+
const pkce = createPkce(random);
|
|
105
|
+
const state = random(16).toString('base64url');
|
|
106
|
+
const callback = await waitForAuthorizationCode({ state, timeoutMs });
|
|
107
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
108
|
+
platformUrl: origin,
|
|
109
|
+
redirectUri: callback.redirectUri,
|
|
110
|
+
state,
|
|
111
|
+
challenge: pkce.challenge,
|
|
112
|
+
scope,
|
|
113
|
+
});
|
|
114
|
+
onAuthUrl(authorizeUrl);
|
|
115
|
+
await openBrowser(authorizeUrl);
|
|
116
|
+
const code = await callback.code;
|
|
117
|
+
const exchanged = await fetchJson({
|
|
118
|
+
fetch,
|
|
119
|
+
url: `${origin}/api/oauth/token`,
|
|
120
|
+
body: {
|
|
121
|
+
grant_type: 'authorization_code',
|
|
122
|
+
client_id: OAUTH_CLIENT_ID,
|
|
123
|
+
code,
|
|
124
|
+
redirect_uri: callback.redirectUri,
|
|
125
|
+
code_verifier: pkce.verifier,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
if (!exchanged.ok) {
|
|
129
|
+
throw new HubError('HUB_OAUTH_EXCHANGE_FAILED', `Sign-in token exchange failed with HTTP ${exchanged.status}`);
|
|
130
|
+
}
|
|
131
|
+
return acceptUserToken(exchanged.body, { platformUrl: origin, now });
|
|
132
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { fullJitterDelay } from './backoff.js';
|
|
2
|
+
import { HubError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
// I3: a command gets at most this many retryable refusals before it is set aside, so it can block its group only so long.
|
|
5
|
+
export const MAX_ATTEMPTS = 6;
|
|
6
|
+
// The cloud holds a command behind an earlier refused command of its group; it goes again as soon as that one does.
|
|
7
|
+
const HELD_CODE = 'held_by_group';
|
|
8
|
+
const HELD_RETRY_MS = 1_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sends the runner store's outbox to Pulse, one batch at a time and in enqueue order (design §6.3 flush loop). Each
|
|
12
|
+
* command carries its ordering group, so the cloud holds a group's later commands behind one it refused for now, and
|
|
13
|
+
* the store holds them locally while it backs off. Per item: stored or duplicate → acked; retryable → backoff (then
|
|
14
|
+
* dead after MAX_ATTEMPTS); any other refusal → dead with its code. A refusal of the whole batch is thrown.
|
|
15
|
+
*/
|
|
16
|
+
export class OutboxFlusher {
|
|
17
|
+
constructor({ store, transport, random = Math.random, maxAttempts = MAX_ATTEMPTS }) {
|
|
18
|
+
this.store = store;
|
|
19
|
+
this.transport = transport;
|
|
20
|
+
this.random = random;
|
|
21
|
+
this.maxAttempts = maxAttempts;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One batch; resolves to its tally, or null when nothing is due. */
|
|
25
|
+
async flushOnce() {
|
|
26
|
+
const batch = this.store.nextBatch();
|
|
27
|
+
if (!batch.length) return null;
|
|
28
|
+
const response = await this.transport.commands({
|
|
29
|
+
commands: batch.map((row) => ({ type: row.type, idempotencyKey: row.key, payload: row.payload, group: row.group })),
|
|
30
|
+
});
|
|
31
|
+
const results = Array.isArray(response?.results) ? response.results : [];
|
|
32
|
+
const tally = { sent: batch.length, written: 0, duplicate: 0, retried: 0, held: 0, dead: 0 };
|
|
33
|
+
batch.forEach((row, index) => {
|
|
34
|
+
const result = results[index];
|
|
35
|
+
if (!result || result.key !== row.key) {
|
|
36
|
+
this.store.retry(row.key, { code: 'no_result', delayMs: fullJitterDelay(row.attempts, { random: this.random }) });
|
|
37
|
+
tally.retried += 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!result.error) {
|
|
41
|
+
this.store.acked(row.key, { seq: result.seq ?? null, duplicate: Boolean(result.duplicate) });
|
|
42
|
+
tally[result.duplicate ? 'duplicate' : 'written'] += 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const { code = 'refused', retryable = false, message = null } = result.error;
|
|
46
|
+
if (code === HELD_CODE) {
|
|
47
|
+
this.store.hold(row.key, { code, delayMs: HELD_RETRY_MS });
|
|
48
|
+
tally.held += 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (retryable && row.attempts + 1 < this.maxAttempts) {
|
|
52
|
+
this.store.retry(row.key, { code, delayMs: fullJitterDelay(row.attempts, { random: this.random }) });
|
|
53
|
+
tally.retried += 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.store.dead(row.key, { code: retryable ? `max_attempts:${code}` : code, message });
|
|
57
|
+
tally.dead += 1;
|
|
58
|
+
});
|
|
59
|
+
return tally;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Batches until the outbox has nothing due, `maxBatches` were sent or the signal fires; the summed tally. */
|
|
63
|
+
async drain({ maxBatches = Infinity, signal } = {}) {
|
|
64
|
+
const total = { batches: 0, sent: 0, written: 0, duplicate: 0, retried: 0, held: 0, dead: 0 };
|
|
65
|
+
while (total.batches < maxBatches) {
|
|
66
|
+
if (signal?.aborted) throw new HubError('HUB_SHADOW_STOPPED', 'The shadow feed stopped');
|
|
67
|
+
const tally = await this.flushOnce();
|
|
68
|
+
if (!tally) break;
|
|
69
|
+
total.batches += 1;
|
|
70
|
+
for (const key of Object.keys(tally)) total[key] += tally[key];
|
|
71
|
+
if (tally.written + tally.duplicate + tally.dead === 0) break;
|
|
72
|
+
}
|
|
73
|
+
return total;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { lstat, open, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
|
+
import { ensureOwnerOnlyDirectory } from './storage.js';
|
|
5
|
+
|
|
6
|
+
const WRITER_GRACE_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
async function readIfPresent(file) {
|
|
9
|
+
try {
|
|
10
|
+
return await readFile(file, 'utf8');
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code === 'ENOENT') return null;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function currentOwner(file) {
|
|
18
|
+
let info;
|
|
19
|
+
try {
|
|
20
|
+
info = await lstat(file);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === 'ENOENT') return { state: 'free' };
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
const text = await readIfPresent(file);
|
|
26
|
+
if (text === null) return { state: 'free' };
|
|
27
|
+
if (!text.trim()) {
|
|
28
|
+
return Date.now() - info.mtimeMs < WRITER_GRACE_MS ? { state: 'writing' } : { state: 'invalid', text };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const owner = JSON.parse(text);
|
|
32
|
+
return owner && typeof owner === 'object' && Number.isInteger(owner.pid)
|
|
33
|
+
? { state: 'owned', owner, text }
|
|
34
|
+
: { state: 'invalid', text };
|
|
35
|
+
} catch {
|
|
36
|
+
return { state: 'invalid', text };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function removeIfUnchanged(file, observedText) {
|
|
41
|
+
const takeover = `${file}.takeover`;
|
|
42
|
+
let handle;
|
|
43
|
+
try {
|
|
44
|
+
handle = await open(takeover, 'wx', 0o600);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code !== 'EEXIST') throw error;
|
|
47
|
+
const info = await lstat(takeover).catch(() => null);
|
|
48
|
+
if (info && Date.now() - info.mtimeMs > WRITER_GRACE_MS) await rm(takeover, { force: true });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await handle.close();
|
|
53
|
+
if ((await readIfPresent(file)) === observedText) await rm(file, { force: true });
|
|
54
|
+
} finally {
|
|
55
|
+
await rm(takeover, { force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function acquireOwnerLock({
|
|
60
|
+
file,
|
|
61
|
+
identity,
|
|
62
|
+
identityProvider,
|
|
63
|
+
timeoutMs = 0,
|
|
64
|
+
retryMs = 50,
|
|
65
|
+
heldError,
|
|
66
|
+
}) {
|
|
67
|
+
await ensureOwnerOnlyDirectory(path.dirname(file));
|
|
68
|
+
const serialized = `${JSON.stringify(identity)}\n`;
|
|
69
|
+
const deadline = Date.now() + timeoutMs;
|
|
70
|
+
for (;;) {
|
|
71
|
+
try {
|
|
72
|
+
const handle = await open(file, 'wx', 0o600);
|
|
73
|
+
try {
|
|
74
|
+
await handle.writeFile(serialized, 'utf8');
|
|
75
|
+
await handle.sync();
|
|
76
|
+
} finally {
|
|
77
|
+
await handle.close();
|
|
78
|
+
}
|
|
79
|
+
return async () => {
|
|
80
|
+
const text = await readFile(file, 'utf8').catch(() => null);
|
|
81
|
+
if (text === serialized) await rm(file, { force: true });
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code !== 'EEXIST') throw error;
|
|
85
|
+
}
|
|
86
|
+
const { state, owner, text } = await currentOwner(file);
|
|
87
|
+
const held = state === 'writing' || (state === 'owned' && await identityProvider.matches(owner));
|
|
88
|
+
if (!held) {
|
|
89
|
+
if (state !== 'free') {
|
|
90
|
+
await removeIfUnchanged(file, text);
|
|
91
|
+
await sleep(Math.min(retryMs, 10));
|
|
92
|
+
}
|
|
93
|
+
if (Date.now() >= deadline + WRITER_GRACE_MS) throw heldError(owner);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() >= deadline) throw heldError(owner);
|
|
97
|
+
await sleep(retryMs);
|
|
98
|
+
}
|
|
99
|
+
}
|