@tinycloud/courier 0.6.0-beta.29 → 0.6.0-beta.30
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 +63 -14
- package/dist/commands.js +6 -1
- package/dist/commands.js.map +1 -1
- package/dist/intake/intake.js +7 -1
- package/dist/intake/intake.js.map +1 -1
- package/dist/interim-owner.js +2 -0
- package/dist/interim-owner.js.map +1 -1
- package/dist/interim-runtime.d.ts +9 -3
- package/dist/interim-runtime.js +39 -9
- package/dist/interim-runtime.js.map +1 -1
- package/dist/keys.d.ts +15 -0
- package/dist/keys.js +26 -0
- package/dist/keys.js.map +1 -1
- package/dist/node.d.ts +4 -1
- package/dist/node.js +33 -17
- package/dist/node.js.map +1 -1
- package/dist/pause.d.ts +3 -3
- package/dist/pause.js +32 -12
- package/dist/pause.js.map +1 -1
- package/dist/state.js +2 -0
- package/dist/state.js.map +1 -1
- package/dist/thread.d.ts +29 -1
- package/dist/thread.js +120 -5
- package/dist/thread.js.map +1 -1
- package/dist/watch-cmd.js +10 -3
- package/dist/watch-cmd.js.map +1 -1
- package/dist/watch.d.ts +72 -10
- package/dist/watch.js +353 -45
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
package/dist/watch.js
CHANGED
|
@@ -2,7 +2,7 @@ import { isAuthorizationRefusal } from './errors.js';
|
|
|
2
2
|
import { defer } from './defer.js';
|
|
3
3
|
import { decodeTime, ulid } from 'ulid';
|
|
4
4
|
import { errInfo, CourierError } from './errors.js';
|
|
5
|
-
import { k, PREFIX } from './keys.js';
|
|
5
|
+
import { k, PREFIX, stateKeys } from './keys.js';
|
|
6
6
|
import { isRecord, messageView, oneLine, taskView } from './a2a.js';
|
|
7
7
|
import { commitReceipts, selectUndelivered } from './inbox.js';
|
|
8
8
|
import { del, getJson, listAll, putCreate, putMatch, putOverwrite } from './store.js';
|
|
@@ -18,6 +18,13 @@ const WAKEN_MEMORY = 500;
|
|
|
18
18
|
const WAKE_RETRY_MS = 60_000;
|
|
19
19
|
/** Delay between starting a re-opened subscription and its catch-up list, so the stream is live first. */
|
|
20
20
|
const RECONNECT_CATCH_UP_MS = 2000;
|
|
21
|
+
/** Spec §8: while every hook subscription is healthy, the full catch-up listing runs this often (otherwise every `pollMs`). */
|
|
22
|
+
const HEALTHY_RECONCILE_MS = 300_000;
|
|
23
|
+
/** The scheduled renewal and authority check (TC-2917): idle revocation detection and sibling cleanup take up to this. */
|
|
24
|
+
const AUTHORITY_CHECK_MS = 300_000;
|
|
25
|
+
/** A same-agent state conflict: first standby window, doubling to the cap. */
|
|
26
|
+
const CONFLICT_STANDBY_MS = 300_000;
|
|
27
|
+
const CONFLICT_STANDBY_MAX_MS = 1_800_000;
|
|
21
28
|
/** Spec §7: an approval expires after 24 hours; an older one found at startup wakes nobody. */
|
|
22
29
|
const APPROVAL_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
23
30
|
/** Adapter runs are capped at 30 min in total (`--wake-timeout`, one deadline across all its calls). This profile's own claim is respected 35 min unless its holder is provably gone; another profile's or machine's, 45. */
|
|
@@ -104,7 +111,7 @@ export class Watcher {
|
|
|
104
111
|
retryAt = new Map();
|
|
105
112
|
waking = new Set();
|
|
106
113
|
approvalsRunning = false;
|
|
107
|
-
/** Thread keys currently paused:
|
|
114
|
+
/** Thread keys currently paused: this agent's pause markers plus those this watcher just wrote. */
|
|
108
115
|
pausedKeys = new Set();
|
|
109
116
|
/** Serialises everything that reads or writes pause markers, so a stale listing cannot drop a pause just written. */
|
|
110
117
|
pausedLock = Promise.resolve();
|
|
@@ -121,13 +128,25 @@ export class Watcher {
|
|
|
121
128
|
/** Set once `run` is told to stop: nothing new is woken after that. */
|
|
122
129
|
stopping = false;
|
|
123
130
|
onFatal;
|
|
124
|
-
/** Etag of
|
|
131
|
+
/** Etag of this agent's watch.json as this watcher last saw it: null = absent, undefined = unknown (fetch before writing). */
|
|
125
132
|
stateEtag;
|
|
126
133
|
taskCache = new Map();
|
|
127
134
|
metaMirrored = new Set();
|
|
128
135
|
syncing = new Map();
|
|
136
|
+
/** Peers whose last sync did not finish: a cycle between full catch-ups lists only these again. */
|
|
137
|
+
dirtyPeers = new Set();
|
|
138
|
+
approvalsDirty = false;
|
|
139
|
+
/** Peers whose next sync also reads their renewal marker. */
|
|
140
|
+
renewalDue = new Set();
|
|
141
|
+
/** Peers whose delegation changed (a marker hook event): their next sync checks authority before anything else. */
|
|
142
|
+
authorityDue = new Set();
|
|
143
|
+
/** Open hook streams by subscription id; absent while one is failed, ended or not yet opened. */
|
|
144
|
+
hookStreams = new Map();
|
|
145
|
+
/** Message keys each peer's receipts already list; receipts only grow, so a listing within this set has nothing new. */
|
|
146
|
+
receipted = new Map();
|
|
129
147
|
stateQueue = Promise.resolve();
|
|
130
148
|
now;
|
|
149
|
+
keys;
|
|
131
150
|
constructor(deps, opts = WATCH_DEFAULTS) {
|
|
132
151
|
this.deps = deps;
|
|
133
152
|
this.opts = opts;
|
|
@@ -135,10 +154,48 @@ export class Watcher {
|
|
|
135
154
|
this.profileId = deps.holder?.profileId ?? this.runId;
|
|
136
155
|
this.pid = deps.holder?.pid ?? process.pid;
|
|
137
156
|
this.isAlive = deps.holder?.isAlive ?? pidAlive;
|
|
157
|
+
this.keys = stateKeys(deps.agent);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Before TC-2917 every agent of the owner shared `me/state/`. On an agent's first start its own keys get a copy of
|
|
161
|
+
* what concerns its own configured peers (eligible now or not): pause markers first, then watch.json, whose existence
|
|
162
|
+
* marks the move done. Every write is create-only, so a crash or a second process repeats it harmlessly; the shared
|
|
163
|
+
* keys are left in place.
|
|
164
|
+
*/
|
|
165
|
+
async migrateLegacyState() {
|
|
166
|
+
const { selfKv, log } = this.deps;
|
|
167
|
+
if (!this.deps.agent || await getJson(selfKv, this.keys.watch))
|
|
168
|
+
return;
|
|
169
|
+
const mine = new Set(this.deps.peerHandles?.() ?? this.deps.loadPeers().map(peer => peer.handle));
|
|
170
|
+
const ownKey = (key) => mine.has(key.split('/')[0]);
|
|
171
|
+
let markers = 0;
|
|
172
|
+
for (const key of await listAll(selfKv, k.pausedPrefix)) {
|
|
173
|
+
const match = /\/paused\/([^/]+)\/([^/]+)\.json$/.exec(key);
|
|
174
|
+
if (!match || !SAFE_ID.test(match[1]) || !SAFE_ID.test(match[2]) || !mine.has(match[1]))
|
|
175
|
+
continue;
|
|
176
|
+
const marker = await getJson(selfKv, key);
|
|
177
|
+
if (!marker)
|
|
178
|
+
continue;
|
|
179
|
+
await this.mirrorCreate(this.keys.paused(match[1], match[2]), marker.value);
|
|
180
|
+
markers++;
|
|
181
|
+
}
|
|
182
|
+
const legacy = (await getJson(selfKv, k.watchState))?.value;
|
|
183
|
+
const state = isRecord(legacy) && legacy.v === 1 ? { ...emptyWatchState(), ...legacy } : emptyWatchState();
|
|
184
|
+
const byThread = (record) => Object.fromEntries(Object.entries(record ?? {}).filter(([key]) => ownKey(key)));
|
|
185
|
+
const byPeer = (record) => Object.fromEntries(Object.entries(record ?? {}).filter(([peer]) => mine.has(peer)));
|
|
186
|
+
const copy = {
|
|
187
|
+
...state,
|
|
188
|
+
pending: byThread(state.pending), wakes: byThread(state.wakes), wakeRetries: byThread(state.wakeRetries),
|
|
189
|
+
approvalRetries: byThread(state.approvalRetries), held: byThread(state.held), threadNotices: byThread(state.threadNotices),
|
|
190
|
+
paused: (state.paused ?? []).filter(ownKey), peerNotices: byPeer(state.peerNotices), noticeLog: byPeer(state.noticeLog),
|
|
191
|
+
outbox: (state.outbox ?? []).filter(item => mine.has(item.kind === 'inform' ? item.peer : item.notice.peer)),
|
|
192
|
+
};
|
|
193
|
+
await this.mirrorCreate(this.keys.watch, copy);
|
|
194
|
+
log('state-migrated', { pending: Object.keys(copy.pending).length, paused: markers });
|
|
138
195
|
}
|
|
139
196
|
// ---------- state ----------
|
|
140
197
|
async loadState() {
|
|
141
|
-
const got = await getJson(this.deps.selfKv,
|
|
198
|
+
const got = await getJson(this.deps.selfKv, this.keys.watch);
|
|
142
199
|
const value = got?.value;
|
|
143
200
|
this.stateEtag = got?.etag ?? null;
|
|
144
201
|
this.state = isRecord(value) && value.v === 1
|
|
@@ -211,7 +268,7 @@ export class Watcher {
|
|
|
211
268
|
}
|
|
212
269
|
/**
|
|
213
270
|
* Serialised: callers mutate `this.state` inside `fn`, then it is written with if-match against the etag this
|
|
214
|
-
* watcher last saw. A second `watch`
|
|
271
|
+
* watcher last saw. A second `watch` for the same agent therefore fails with `state-conflict` instead of
|
|
215
272
|
* silently overwriting (last writer wins) the other's pending wakes.
|
|
216
273
|
*/
|
|
217
274
|
mutate(fn) {
|
|
@@ -219,16 +276,16 @@ export class Watcher {
|
|
|
219
276
|
const result = fn();
|
|
220
277
|
const { selfKv } = this.deps;
|
|
221
278
|
if (this.stateEtag === undefined)
|
|
222
|
-
this.stateEtag = (await getJson(selfKv,
|
|
279
|
+
this.stateEtag = (await getJson(selfKv, this.keys.watch))?.etag ?? null;
|
|
223
280
|
try {
|
|
224
281
|
const written = this.stateEtag
|
|
225
|
-
? await putMatch(selfKv,
|
|
226
|
-
: await putCreate(selfKv,
|
|
282
|
+
? await putMatch(selfKv, this.keys.watch, this.state, this.stateEtag)
|
|
283
|
+
: await putCreate(selfKv, this.keys.watch, this.state);
|
|
227
284
|
this.stateEtag = written ?? undefined;
|
|
228
285
|
}
|
|
229
286
|
catch (error) {
|
|
230
287
|
if (error instanceof CourierError && error.code === 'conflict') {
|
|
231
|
-
throw this.stateConflict('
|
|
288
|
+
throw this.stateConflict('watch.json was changed by another process: a second `watch` is running for this Courier agent.');
|
|
232
289
|
}
|
|
233
290
|
throw error;
|
|
234
291
|
}
|
|
@@ -257,7 +314,7 @@ export class Watcher {
|
|
|
257
314
|
*/
|
|
258
315
|
async claimWake(peer, threadId, firstId) {
|
|
259
316
|
const { selfKv, log } = this.deps;
|
|
260
|
-
const key =
|
|
317
|
+
const key = this.keys.wakeClaim(peer, threadId, firstId);
|
|
261
318
|
const claim = { v: 1, profile: this.profileId, run: this.runId, pid: this.pid, at: new Date(this.now()).toISOString() };
|
|
262
319
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
263
320
|
try {
|
|
@@ -310,7 +367,7 @@ export class Watcher {
|
|
|
310
367
|
}
|
|
311
368
|
/** Drops this run's own claim (best effort): a failed wake must not keep the thread from being woken. */
|
|
312
369
|
async releaseClaim(peer, threadId, firstId) {
|
|
313
|
-
const key =
|
|
370
|
+
const key = this.keys.wakeClaim(peer, threadId, firstId);
|
|
314
371
|
try {
|
|
315
372
|
const existing = await getJson(this.deps.selfKv, key);
|
|
316
373
|
if (existing?.value.run === this.runId)
|
|
@@ -323,7 +380,7 @@ export class Watcher {
|
|
|
323
380
|
/** Claim markers are only needed while a double wake is possible; drop those older than two days. */
|
|
324
381
|
async pruneClaims() {
|
|
325
382
|
try {
|
|
326
|
-
for (const key of await listAll(this.deps.selfKv,
|
|
383
|
+
for (const key of await listAll(this.deps.selfKv, this.keys.wakeClaimsPrefix)) {
|
|
327
384
|
const id = /\/([^/]+)\.json$/.exec(key)?.[1];
|
|
328
385
|
const sentAt = id ? Date.parse(sentAtOf(id)) : NaN;
|
|
329
386
|
if (Number.isFinite(sentAt) && this.now() - sentAt > 2 * DAY_MS)
|
|
@@ -335,27 +392,51 @@ export class Watcher {
|
|
|
335
392
|
}
|
|
336
393
|
}
|
|
337
394
|
// ---------- catch-up and delivery ----------
|
|
338
|
-
/**
|
|
339
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Serialises syncs per peer; a request that arrives mid-sync reruns once after it. `pausedListed`: the caller has
|
|
397
|
+
* just listed the pause markers for this cycle, so the sync shares that listing instead of making its own.
|
|
398
|
+
*/
|
|
399
|
+
async sync(peer, via, pausedListed = false) {
|
|
340
400
|
const running = this.syncing.get(peer.handle);
|
|
341
401
|
if (running) {
|
|
342
402
|
running.again = true;
|
|
403
|
+
running.listPaused ||= !pausedListed;
|
|
343
404
|
return;
|
|
344
405
|
}
|
|
345
|
-
const entry = { again: false, via };
|
|
406
|
+
const entry = { again: false, via, listPaused: !pausedListed };
|
|
346
407
|
this.syncing.set(peer.handle, entry);
|
|
347
408
|
try {
|
|
348
409
|
do {
|
|
349
410
|
entry.again = false;
|
|
411
|
+
const listPaused = entry.listPaused;
|
|
412
|
+
entry.listPaused = false;
|
|
413
|
+
const renew = this.renewalDue.has(peer.handle);
|
|
414
|
+
this.dirtyPeers.delete(peer.handle);
|
|
350
415
|
try {
|
|
351
|
-
await this.syncPeer(peer, entry.via);
|
|
416
|
+
await this.syncPeer(peer, entry.via, listPaused, renew);
|
|
352
417
|
}
|
|
353
418
|
catch (error) {
|
|
354
|
-
if (isAuthorizationRefusal(error)) {
|
|
419
|
+
if (!isAuthorizationRefusal(error)) {
|
|
420
|
+
this.dirtyPeers.add(peer.handle);
|
|
421
|
+
this.deps.log('sync-error', { peer: peer.handle, ...errInfo(error) });
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
let renewed;
|
|
425
|
+
try {
|
|
426
|
+
renewed = await this.afterRefusal(peer, renew);
|
|
427
|
+
}
|
|
428
|
+
catch (failure) {
|
|
429
|
+
// Neither renewed nor refused for certain: retried by the next cycle, nothing recorded.
|
|
430
|
+
this.dirtyPeers.add(peer.handle);
|
|
431
|
+
this.deps.log('sync-error', { peer: peer.handle, ...errInfo(failure) });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!renewed) {
|
|
355
435
|
await this.deps.onAuthorityRefusal?.(`peer:${peer.handle}`);
|
|
356
436
|
return;
|
|
357
437
|
}
|
|
358
|
-
|
|
438
|
+
peer = renewed;
|
|
439
|
+
entry.again = true;
|
|
359
440
|
}
|
|
360
441
|
} while (entry.again);
|
|
361
442
|
}
|
|
@@ -363,25 +444,89 @@ export class Watcher {
|
|
|
363
444
|
this.syncing.delete(peer.handle);
|
|
364
445
|
}
|
|
365
446
|
}
|
|
366
|
-
|
|
447
|
+
/**
|
|
448
|
+
* A refusal may mean the peer moved to a new grant or revoked ours. Re-read its renewal marker (unless this sync just
|
|
449
|
+
* did) and check its authority at once. Returns the peer to retry only under a different grant, never the refused one.
|
|
450
|
+
*/
|
|
451
|
+
async afterRefusal(peer, markerRead) {
|
|
452
|
+
const refused = peer.inbound?.cid;
|
|
453
|
+
try {
|
|
454
|
+
if (!markerRead) {
|
|
455
|
+
await this.deps.kvFor(peer, true);
|
|
456
|
+
this.renewalDue.delete(peer.handle);
|
|
457
|
+
}
|
|
458
|
+
await this.deps.checkAuthority?.(peer.handle);
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
if (isAuthorizationRefusal(error))
|
|
462
|
+
return undefined;
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
const current = this.deps.loadPeers().find(candidate => candidate.handle === peer.handle);
|
|
466
|
+
return current?.inbound && current.inbound.cid !== refused ? current : undefined;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Acting for a peer (importing what it sent, waking for it, informing it) takes a fresh verdict from the node, never
|
|
470
|
+
* a cached one: the 300 s schedule only bounds how soon an idle revocation is noticed. False once the peer is gone.
|
|
471
|
+
*/
|
|
472
|
+
async authorized(peer) {
|
|
473
|
+
const handle = typeof peer === 'string' ? peer : peer.handle;
|
|
474
|
+
if (!this.deps.checkAuthority)
|
|
475
|
+
return true; // a library caller without an authority source
|
|
476
|
+
await this.deps.checkAuthority(handle);
|
|
477
|
+
return this.deps.loadPeers().some(current => current.handle === handle);
|
|
478
|
+
}
|
|
479
|
+
/** The scheduled renewal and authority check for every peer; each peer's next sync also reads its renewal marker. */
|
|
480
|
+
async checkAuthority() {
|
|
481
|
+
await this.deps.checkAuthority?.();
|
|
482
|
+
for (const peer of this.deps.loadPeers())
|
|
483
|
+
this.renewalDue.add(peer.handle);
|
|
484
|
+
}
|
|
485
|
+
async syncPeer(peer, via, listPaused, renew) {
|
|
367
486
|
if (peer.status !== 'paired' || !peer.inbound || !peer.theirHandleForMe)
|
|
368
487
|
return;
|
|
369
488
|
const { selfKv, log } = this.deps;
|
|
370
489
|
const mailbox = peer.theirHandleForMe;
|
|
371
|
-
const peerKv = await this.deps.kvFor(peer);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
490
|
+
const peerKv = await this.deps.kvFor(peer, renew);
|
|
491
|
+
if (renew)
|
|
492
|
+
this.renewalDue.delete(peer.handle);
|
|
493
|
+
let allowed;
|
|
494
|
+
if (this.authorityDue.delete(peer.handle)) {
|
|
495
|
+
// The marker changed: check now, even with nothing to import; the verdict also serves this sync's imports.
|
|
496
|
+
try {
|
|
497
|
+
allowed = Promise.resolve(await this.authorized(peer));
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
this.authorityDue.add(peer.handle);
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const authorized = () => allowed ??= this.authorized(peer);
|
|
505
|
+
// Pause markers are read with every listing (a cycle lists them once for all its peers just before), so a
|
|
506
|
+
// `courier resume` or `courier pause` takes effect on the very next message instead of at the next poll (a
|
|
507
|
+
// message delivered while the watcher still thought the thread was paused would never wake anyone).
|
|
375
508
|
const prefix = peer.sessionId ? `${k.threadRoot(mailbox, peer.sessionId)}/` : `${PREFIX}/to/${mailbox}/threads/`;
|
|
376
|
-
const [keys] = await Promise.all([listAll(peerKv, prefix), this.syncPaused()
|
|
377
|
-
|
|
378
|
-
|
|
509
|
+
const [keys] = await Promise.all([listAll(peerKv, prefix), listPaused ? this.syncPaused() : undefined]);
|
|
510
|
+
// Receipts are read only when the listing holds a message they were not already known to cover.
|
|
511
|
+
const seen = this.receipted.get(peer.handle) ?? new Set();
|
|
512
|
+
const unseen = selectUndelivered(keys, { v: 1, threads: {} }).filter(item => !seen.has(`${item.threadId}/${item.messageId}`));
|
|
513
|
+
let receipts = { v: 1, threads: {} };
|
|
514
|
+
if (unseen.length) {
|
|
515
|
+
receipts = (await getJson(selfKv, k.receipts(peer.handle)))?.value ?? receipts;
|
|
516
|
+
receipts.threads ??= {};
|
|
517
|
+
for (const [threadId, thread] of Object.entries(receipts.threads))
|
|
518
|
+
for (const id of thread.delivered ?? [])
|
|
519
|
+
seen.add(`${threadId}/${id}`);
|
|
520
|
+
this.receipted.set(peer.handle, seen);
|
|
521
|
+
}
|
|
379
522
|
const delivered = [];
|
|
380
|
-
for (const selected of
|
|
523
|
+
for (const selected of unseen.filter(item => !seen.has(`${item.threadId}/${item.messageId}`))) {
|
|
381
524
|
if (!SAFE_ID.test(selected.threadId) || !SAFE_ID.test(selected.messageId)) {
|
|
382
525
|
log('ignored', { peer: peer.handle, reason: 'unsafe-id' });
|
|
383
526
|
continue;
|
|
384
527
|
}
|
|
528
|
+
if (!(await authorized()))
|
|
529
|
+
return;
|
|
385
530
|
try {
|
|
386
531
|
const got = await getJson(peerKv, selected.key);
|
|
387
532
|
if (!got)
|
|
@@ -395,11 +540,12 @@ export class Watcher {
|
|
|
395
540
|
}
|
|
396
541
|
catch (error) {
|
|
397
542
|
// Not delivered, so no receipt: this message is retried on the next sync; the others go ahead.
|
|
543
|
+
this.dirtyPeers.add(peer.handle);
|
|
398
544
|
log('sync-error', { peer: peer.handle, messageId: selected.messageId, ...errInfo(error) });
|
|
399
545
|
}
|
|
400
546
|
}
|
|
401
547
|
if (!delivered.length) {
|
|
402
|
-
await this.mirrorThreads(peer, peerKv, keys);
|
|
548
|
+
await this.mirrorThreads(peer, peerKv, keys, authorized);
|
|
403
549
|
return;
|
|
404
550
|
}
|
|
405
551
|
const subjects = new Map();
|
|
@@ -417,6 +563,10 @@ export class Watcher {
|
|
|
417
563
|
sentAt, latencyMs: sentAt ? at - Date.parse(sentAt) : null,
|
|
418
564
|
});
|
|
419
565
|
}
|
|
566
|
+
// Only our local listing establishes a known thread, and only a peer's pause notice needs one.
|
|
567
|
+
if (delivered.some(item => item.intent === 'inform' && (item.notice === 'thread-paused' || item.notice === 'thread-resumed'))) {
|
|
568
|
+
await this.syncKnownThreads(peer);
|
|
569
|
+
}
|
|
420
570
|
await this.mutate(() => {
|
|
421
571
|
if (this.deps.notifyOnly) {
|
|
422
572
|
this.syncNoticePeers();
|
|
@@ -468,7 +618,9 @@ export class Watcher {
|
|
|
468
618
|
this.fillOutbox();
|
|
469
619
|
});
|
|
470
620
|
await commitReceipts(selfKv, new Map([[peer.handle, delivered.map(({ threadId, messageId }) => ({ threadId, messageId }))]]));
|
|
471
|
-
|
|
621
|
+
for (const item of delivered)
|
|
622
|
+
seen.add(`${item.threadId}/${item.messageId}`);
|
|
623
|
+
await this.mirrorThreads(peer, peerKv, keys, authorized);
|
|
472
624
|
}
|
|
473
625
|
/** Only locally authored meta/messages/tasks, or a local wake/pause witness, establish a known thread. */
|
|
474
626
|
async syncKnownThreads(peer) {
|
|
@@ -503,17 +655,18 @@ export class Watcher {
|
|
|
503
655
|
* meta.json is immutable (create-only); tasks are mutable, so copy the latest until they are terminal. Runs after
|
|
504
656
|
* message delivery and each item is isolated: a bad or unwritable peer task never blocks receipts or wakes.
|
|
505
657
|
*/
|
|
506
|
-
async mirrorThreads(peer, peerKv, keys) {
|
|
658
|
+
async mirrorThreads(peer, peerKv, keys, authorized) {
|
|
507
659
|
for (const key of keys) {
|
|
508
660
|
try {
|
|
509
|
-
await this.mirrorThreadItem(peer, peerKv, key);
|
|
661
|
+
await this.mirrorThreadItem(peer, peerKv, key, authorized);
|
|
510
662
|
}
|
|
511
663
|
catch (error) {
|
|
664
|
+
this.dirtyPeers.add(peer.handle);
|
|
512
665
|
this.deps.log('mirror-error', { peer: peer.handle, ...errInfo(error) });
|
|
513
666
|
}
|
|
514
667
|
}
|
|
515
668
|
}
|
|
516
|
-
async mirrorThreadItem(peer, peerKv, key) {
|
|
669
|
+
async mirrorThreadItem(peer, peerKv, key, authorized) {
|
|
517
670
|
const meta = /\/threads\/([^/]+)\/meta\.json$/.exec(key);
|
|
518
671
|
if (meta) {
|
|
519
672
|
if (!SAFE_ID.test(meta[1]))
|
|
@@ -523,6 +676,8 @@ export class Watcher {
|
|
|
523
676
|
const got = await getJson(peerKv, key);
|
|
524
677
|
if (!got)
|
|
525
678
|
return;
|
|
679
|
+
if (!(await authorized()))
|
|
680
|
+
return;
|
|
526
681
|
await this.mirrorCreate(k.mirrorMeta(peer.handle, meta[1]), got.value);
|
|
527
682
|
this.metaMirrored.add(key);
|
|
528
683
|
return;
|
|
@@ -538,6 +693,8 @@ export class Watcher {
|
|
|
538
693
|
return;
|
|
539
694
|
if (cached && got.etag && cached.etag === got.etag)
|
|
540
695
|
return;
|
|
696
|
+
if (!(await authorized()))
|
|
697
|
+
return;
|
|
541
698
|
await putOverwrite(this.deps.selfKv, k.mirrorTask(peer.handle, task[1], task[2]), got.value);
|
|
542
699
|
const state = taskView(got.value).state;
|
|
543
700
|
this.taskCache.set(key, { etag: got.etag, terminal: Boolean(state && TERMINAL_TASK_STATES.has(state)) });
|
|
@@ -555,10 +712,12 @@ export class Watcher {
|
|
|
555
712
|
try {
|
|
556
713
|
do {
|
|
557
714
|
this.approvalsAgain = false;
|
|
715
|
+
this.approvalsDirty = false;
|
|
558
716
|
try {
|
|
559
717
|
await this.syncApprovalsOnce(via);
|
|
560
718
|
}
|
|
561
719
|
catch (error) {
|
|
720
|
+
this.approvalsDirty = true;
|
|
562
721
|
this.deps.log('sync-error', { source: 'approvals', ...errInfo(error) });
|
|
563
722
|
}
|
|
564
723
|
} while (this.approvalsAgain);
|
|
@@ -590,6 +749,7 @@ export class Watcher {
|
|
|
590
749
|
}
|
|
591
750
|
catch (error) {
|
|
592
751
|
// Unread (transient failure): left unseen so the next sync retries it.
|
|
752
|
+
this.approvalsDirty = true;
|
|
593
753
|
log('sync-error', { source: 'approvals', approvalId: id, ...errInfo(error) });
|
|
594
754
|
}
|
|
595
755
|
}
|
|
@@ -733,6 +893,27 @@ export class Watcher {
|
|
|
733
893
|
this.retryAt.set(key, at + CLAIM_SKIP_MS);
|
|
734
894
|
return;
|
|
735
895
|
}
|
|
896
|
+
// Acting for the peer: a fresh verdict from the node, never a cached one. A removed or refused peer's wake is dropped.
|
|
897
|
+
let allowed;
|
|
898
|
+
try {
|
|
899
|
+
allowed = await this.authorized(pending.peer);
|
|
900
|
+
}
|
|
901
|
+
catch (error) {
|
|
902
|
+
await this.releaseClaim(pending.peer, pending.threadId, claimId);
|
|
903
|
+
this.retryAt.set(key, at + WAKE_RETRY_MS);
|
|
904
|
+
this.deps.log('wake-deferred', { peer: pending.peer, threadId: pending.threadId, reason: 'authority-unverified', ...errInfo(error) });
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (!allowed) {
|
|
908
|
+
await this.releaseClaim(pending.peer, pending.threadId, claimId);
|
|
909
|
+
await this.mutate(() => {
|
|
910
|
+
delete this.state.pending[key];
|
|
911
|
+
delete this.state.wakeRetries[key];
|
|
912
|
+
delete this.state.approvalRetries[key];
|
|
913
|
+
});
|
|
914
|
+
this.deps.log('wake-dropped', { peer: pending.peer, threadId: pending.threadId, messageIds: woken, approvalIds: approvedIds, reason: 'peer-not-authorized' });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
736
917
|
try {
|
|
737
918
|
// The attempt is counted against the budget BEFORE the adapter runs, and whether it then succeeds, fails or
|
|
738
919
|
// times out: an adapter that always throws must not be retried forever. (A crash mid-wake counts too.)
|
|
@@ -803,7 +984,7 @@ export class Watcher {
|
|
|
803
984
|
const at = new Date(this.now()).toISOString();
|
|
804
985
|
const pauseId = await nextNoticeId(this.deps.selfKv, pending.peer, pending.threadId, this.now());
|
|
805
986
|
const wrote = await this.exclusive(async () => {
|
|
806
|
-
const created = await writePauseMarker(this.deps.selfKv, { v: 1, peer: pending.peer, threadId: pending.threadId, by: 'budget', at, reason, informId: pauseId });
|
|
987
|
+
const created = await writePauseMarker(this.deps.selfKv, { v: 1, peer: pending.peer, threadId: pending.threadId, by: 'budget', at, reason, informId: pauseId }, this.keys.paused(pending.peer, pending.threadId));
|
|
807
988
|
this.pausedKeys.add(key);
|
|
808
989
|
return created;
|
|
809
990
|
});
|
|
@@ -1052,13 +1233,13 @@ export class Watcher {
|
|
|
1052
1233
|
this.state.outbox = this.state.outbox.filter(o => o.id !== item.id);
|
|
1053
1234
|
}
|
|
1054
1235
|
/**
|
|
1055
|
-
*
|
|
1236
|
+
* This agent's pause markers are the truth. A marker that disappeared means the human resumed: the budget starts
|
|
1056
1237
|
* over and what arrived meanwhile wakes the agent once. Serialised, so overlapping calls never apply a stale listing.
|
|
1057
1238
|
*/
|
|
1058
1239
|
syncPaused() {
|
|
1059
1240
|
return this.exclusive(async () => {
|
|
1060
1241
|
try {
|
|
1061
|
-
const keys = await listAll(this.deps.selfKv,
|
|
1242
|
+
const keys = await listAll(this.deps.selfKv, this.keys.pausedPrefix);
|
|
1062
1243
|
const listed = new Set();
|
|
1063
1244
|
for (const key of keys) {
|
|
1064
1245
|
const match = /\/paused\/([^/]+)\/([^/]+)\.json$/.exec(key);
|
|
@@ -1146,8 +1327,29 @@ export class Watcher {
|
|
|
1146
1327
|
this.outboxBusy.add(item.id);
|
|
1147
1328
|
runs.push((async () => {
|
|
1148
1329
|
try {
|
|
1149
|
-
if (item.kind === 'inform')
|
|
1330
|
+
if (item.kind === 'inform') {
|
|
1331
|
+
// Writing to a peer is acting for it: a fresh verdict. A removed or refused peer's inform is dropped; a
|
|
1332
|
+
// check that could not give a verdict defers it (fails closed) without counting as a failed attempt.
|
|
1333
|
+
let allowed;
|
|
1334
|
+
try {
|
|
1335
|
+
allowed = await this.authorized(item.peer);
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
this.deps.log('inform-deferred', { id: item.id, peer: item.peer, threadId: item.threadId, reason: 'authority-unverified', ...errInfo(error) });
|
|
1339
|
+
await this.mutate(() => {
|
|
1340
|
+
const stored = this.state.outbox.find(o => o.id === item.id);
|
|
1341
|
+
if (stored)
|
|
1342
|
+
stored.nextAttemptAt = this.now() + WAKE_RETRY_MS;
|
|
1343
|
+
});
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (!allowed) {
|
|
1347
|
+
this.deps.log('inform-dropped', { id: item.id, peer: item.peer, threadId: item.threadId, reason: 'peer-not-authorized' });
|
|
1348
|
+
await this.mutate(() => this.finishOutbox(item));
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1150
1351
|
await this.deps.postInform?.(item.peer, item.threadId, item.text, item.pauseId);
|
|
1352
|
+
}
|
|
1151
1353
|
else {
|
|
1152
1354
|
const { notice } = item;
|
|
1153
1355
|
let reserved = false;
|
|
@@ -1233,9 +1435,12 @@ export class Watcher {
|
|
|
1233
1435
|
await Promise.all(runs);
|
|
1234
1436
|
}
|
|
1235
1437
|
// ---------- run loop ----------
|
|
1438
|
+
/** One full catch-up: every peer and approvals, under one pause-marker listing. */
|
|
1236
1439
|
async pollOnce(via, preflight = true) {
|
|
1237
|
-
if (preflight)
|
|
1440
|
+
if (preflight) {
|
|
1238
1441
|
await this.deps.beforePoll?.();
|
|
1442
|
+
await this.checkAuthority();
|
|
1443
|
+
}
|
|
1239
1444
|
if (this.deps.notifyOnly) {
|
|
1240
1445
|
const paired = this.deps.loadPeers().filter(peer => peer.status === 'paired' && HANDLE.test(peer.handle)).map(peer => peer.handle);
|
|
1241
1446
|
if (paired.some(peer => !Object.hasOwn(this.state.peerNotices, peer))
|
|
@@ -1248,18 +1453,82 @@ export class Watcher {
|
|
|
1248
1453
|
}
|
|
1249
1454
|
await this.syncPaused();
|
|
1250
1455
|
await this.pruneState();
|
|
1251
|
-
await Promise.all(this.deps.loadPeers().map((peer) => this.sync(peer, via)));
|
|
1456
|
+
await Promise.all(this.deps.loadPeers().map((peer) => this.sync(peer, via, true)));
|
|
1252
1457
|
await this.syncApprovals(via);
|
|
1253
1458
|
}
|
|
1459
|
+
/** Between full catch-ups, a cycle lists again only what is dirty: a peer or approvals whose last sync did not finish. */
|
|
1460
|
+
async syncDirty() {
|
|
1461
|
+
const peers = this.deps.loadPeers().filter(peer => this.dirtyPeers.has(peer.handle));
|
|
1462
|
+
for (const handle of this.dirtyPeers)
|
|
1463
|
+
if (!peers.some(peer => peer.handle === handle))
|
|
1464
|
+
this.dirtyPeers.delete(handle);
|
|
1465
|
+
if (peers.length) {
|
|
1466
|
+
await this.syncPaused();
|
|
1467
|
+
await Promise.all(peers.map(peer => this.sync(peer, 'poll', true)));
|
|
1468
|
+
}
|
|
1469
|
+
if (this.approvalsDirty)
|
|
1470
|
+
await this.syncApprovals('poll');
|
|
1471
|
+
}
|
|
1472
|
+
/** Healthy: every subscription has an open stream that delivered an event or has lasted one ticket without failing. */
|
|
1473
|
+
hooksHealthy(ids) {
|
|
1474
|
+
const at = this.now();
|
|
1475
|
+
for (const id of ids) {
|
|
1476
|
+
const stream = this.hookStreams.get(id);
|
|
1477
|
+
if (!stream || !(stream.proven || at - stream.openedAt >= this.opts.ticketSeconds * 1000))
|
|
1478
|
+
return false;
|
|
1479
|
+
}
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1254
1482
|
/**
|
|
1255
|
-
* Runs until `signal` aborts. With `once`, does one catch-up and wakes everything immediately
|
|
1256
|
-
* `state-conflict` (after stopping everything) if another watcher is writing this
|
|
1483
|
+
* Runs until `signal` aborts. With `once`, does one catch-up and wakes everything immediately, and throws
|
|
1484
|
+
* `state-conflict` (after stopping everything) if another watcher is writing this agent's state.
|
|
1485
|
+
*
|
|
1486
|
+
* Without `once` (a service, which systemd restarts whatever it exits with), a conflict never ends the process: the
|
|
1487
|
+
* watcher stops subscribing, waking and writing, and stands by. It reads watch.json once per window (5 minutes,
|
|
1488
|
+
* doubling to 30) and takes over, with one catch-up, only after a whole window in which the other writer wrote nothing.
|
|
1489
|
+
* The other watcher is the same agent's, so its messages stay watched meanwhile.
|
|
1257
1490
|
*/
|
|
1258
1491
|
async run(external, options = {}) {
|
|
1492
|
+
if (options.once)
|
|
1493
|
+
return this.session(external, options);
|
|
1494
|
+
const { log, selfKv } = this.deps;
|
|
1495
|
+
let windowMs = CONFLICT_STANDBY_MS;
|
|
1496
|
+
for (;;) {
|
|
1497
|
+
const started = this.now();
|
|
1498
|
+
try {
|
|
1499
|
+
return await this.session(external, options);
|
|
1500
|
+
}
|
|
1501
|
+
catch (error) {
|
|
1502
|
+
if (!(error instanceof CourierError && error.code === 'state-conflict') || external.aborted)
|
|
1503
|
+
throw error;
|
|
1504
|
+
}
|
|
1505
|
+
if (this.now() - started >= CONFLICT_STANDBY_MAX_MS)
|
|
1506
|
+
windowMs = CONFLICT_STANDBY_MS;
|
|
1507
|
+
log('standby', {
|
|
1508
|
+
reason: 'state-conflict', retryInMs: windowMs,
|
|
1509
|
+
message: `Another \`watch\` is writing this Courier agent's watch.json. This watcher has stopped waking and checks again in ${Math.round(windowMs / 60_000)} min; stop one of the two.`,
|
|
1510
|
+
});
|
|
1511
|
+
let seen = (await getJson(selfKv, this.keys.watch).catch(() => undefined))?.etag;
|
|
1512
|
+
for (;;) {
|
|
1513
|
+
await sleep(windowMs, external);
|
|
1514
|
+
if (external.aborted)
|
|
1515
|
+
return;
|
|
1516
|
+
windowMs = Math.min(windowMs * 2, CONFLICT_STANDBY_MAX_MS);
|
|
1517
|
+
const etag = (await getJson(selfKv, this.keys.watch).catch(() => undefined))?.etag;
|
|
1518
|
+
if (etag === seen)
|
|
1519
|
+
break;
|
|
1520
|
+
seen = etag;
|
|
1521
|
+
}
|
|
1522
|
+
log('standby-over', { reason: 'other-writer-quiet' });
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
async session(external, options) {
|
|
1259
1526
|
const { log } = this.deps;
|
|
1260
1527
|
const stop = new AbortController();
|
|
1261
1528
|
const signal = stop.signal;
|
|
1262
1529
|
const onExternalAbort = () => stop.abort();
|
|
1530
|
+
this.fatalError = undefined;
|
|
1531
|
+
this.stopping = false;
|
|
1263
1532
|
signal.addEventListener('abort', () => { this.stopping = true; }, { once: true });
|
|
1264
1533
|
external.addEventListener('abort', onExternalAbort, { once: true });
|
|
1265
1534
|
if (external.aborted)
|
|
@@ -1269,6 +1538,9 @@ export class Watcher {
|
|
|
1269
1538
|
let ticker;
|
|
1270
1539
|
try {
|
|
1271
1540
|
await this.deps.beforePoll?.();
|
|
1541
|
+
await this.checkAuthority();
|
|
1542
|
+
let authorityAt = this.now();
|
|
1543
|
+
await this.migrateLegacyState();
|
|
1272
1544
|
await this.loadState();
|
|
1273
1545
|
log('start', { debounceMs: this.opts.debounceMs, pollMs: this.opts.pollMs, ticketSeconds: this.opts.ticketSeconds, once: Boolean(options.once) });
|
|
1274
1546
|
await this.pruneClaims();
|
|
@@ -1282,7 +1554,15 @@ export class Watcher {
|
|
|
1282
1554
|
desired.set(`peer:${peer.handle}:${peer.inbound.cid}`, {
|
|
1283
1555
|
sub: { space: peer.spaceId, service: 'kv', pathPrefix: peer.sessionId ? k.threadRoot(peer.theirHandleForMe, peer.sessionId) : `${PREFIX}/to/${peer.theirHandleForMe}` },
|
|
1284
1556
|
hooks: () => this.deps.peerHooks(peer),
|
|
1285
|
-
onEvent: () =>
|
|
1557
|
+
onEvent: (event) => {
|
|
1558
|
+
// A peer renewing or replacing its delegation to us writes this marker (a path-less event may be it):
|
|
1559
|
+
// read the marker and check the peer's authority now, not at the next scheduled check.
|
|
1560
|
+
if (event && (event.path === undefined || event.path.endsWith('/pairing/delegation.json'))) {
|
|
1561
|
+
this.renewalDue.add(peer.handle);
|
|
1562
|
+
this.authorityDue.add(peer.handle);
|
|
1563
|
+
}
|
|
1564
|
+
void this.sync(peer, 'hook');
|
|
1565
|
+
},
|
|
1286
1566
|
});
|
|
1287
1567
|
}
|
|
1288
1568
|
desired.set('approvals', {
|
|
@@ -1294,6 +1574,7 @@ export class Watcher {
|
|
|
1294
1574
|
if (!desired.has(id)) {
|
|
1295
1575
|
controller.abort();
|
|
1296
1576
|
supervisors.delete(id);
|
|
1577
|
+
this.hookStreams.delete(id);
|
|
1297
1578
|
}
|
|
1298
1579
|
}
|
|
1299
1580
|
for (const [id, source] of desired) {
|
|
@@ -1312,16 +1593,31 @@ export class Watcher {
|
|
|
1312
1593
|
// Open the subscriptions first, then catch up: a write between the two is seen by one or the other.
|
|
1313
1594
|
reconcile();
|
|
1314
1595
|
await sleep(RECONNECT_CATCH_UP_MS, signal);
|
|
1596
|
+
let fullAt = this.now();
|
|
1315
1597
|
if (!signal.aborted)
|
|
1316
1598
|
await this.pollOnce('startup', false);
|
|
1317
1599
|
ticker = setInterval(() => void this.tick().catch((e) => log('wake-error', errInfo(e))), 1000);
|
|
1318
1600
|
while (!signal.aborted) {
|
|
1319
|
-
|
|
1601
|
+
// Authority housekeeping and dirty peers keep a ≤300 s cadence however long `--poll` is.
|
|
1602
|
+
await sleep(Math.min(this.opts.pollMs, AUTHORITY_CHECK_MS), signal);
|
|
1320
1603
|
if (signal.aborted)
|
|
1321
1604
|
break;
|
|
1322
1605
|
await this.deps.beforePoll?.();
|
|
1606
|
+
if (this.now() - authorityAt >= AUTHORITY_CHECK_MS) {
|
|
1607
|
+
authorityAt = this.now();
|
|
1608
|
+
await this.checkAuthority();
|
|
1609
|
+
}
|
|
1323
1610
|
reconcile();
|
|
1324
|
-
|
|
1611
|
+
// Healthy hooks deliver at once, so the full listing is only the fallback for an event they missed: every
|
|
1612
|
+
// `--poll` while a subscription is unhealthy, otherwise every 5 minutes (or `--poll`, if longer).
|
|
1613
|
+
const fullEvery = this.hooksHealthy(supervisors.keys()) ? Math.max(this.opts.pollMs, HEALTHY_RECONCILE_MS) : this.opts.pollMs;
|
|
1614
|
+
if (this.now() - fullAt >= fullEvery) {
|
|
1615
|
+
fullAt = this.now();
|
|
1616
|
+
await this.pollOnce('poll', false);
|
|
1617
|
+
}
|
|
1618
|
+
else {
|
|
1619
|
+
await this.syncDirty();
|
|
1620
|
+
}
|
|
1325
1621
|
if (this.now() - this.lastClaimPrune > 3600_000) {
|
|
1326
1622
|
this.lastClaimPrune = this.now();
|
|
1327
1623
|
await this.pruneClaims();
|
|
@@ -1345,7 +1641,10 @@ export class Watcher {
|
|
|
1345
1641
|
if (this.fatalError)
|
|
1346
1642
|
throw this.fatalError;
|
|
1347
1643
|
}
|
|
1348
|
-
/**
|
|
1644
|
+
/**
|
|
1645
|
+
* One hook subscription. Authorization refusals stop this subscription; transient failures back off. A failed or
|
|
1646
|
+
* ended stream catches up at once, and the watcher lists everything every `pollMs` until a new stream is healthy.
|
|
1647
|
+
*/
|
|
1349
1648
|
async supervise(id, sub, hooks, onEvent, signal) {
|
|
1350
1649
|
let failures = 0;
|
|
1351
1650
|
let opened = false;
|
|
@@ -1360,21 +1659,30 @@ export class Watcher {
|
|
|
1360
1659
|
onEvent(); });
|
|
1361
1660
|
opened = true;
|
|
1362
1661
|
const service = await hooks();
|
|
1662
|
+
const stream = { openedAt: this.now(), proven: false };
|
|
1663
|
+
this.hookStreams.set(id, stream);
|
|
1363
1664
|
for await (const event of service.subscribe([sub], { ttlSeconds: this.opts.ticketSeconds, signal })) {
|
|
1364
1665
|
failures = 0;
|
|
1666
|
+
stream.proven = true;
|
|
1365
1667
|
this.deps.log('hook-event', { source: id.split(':').slice(0, 2).join(':'), path: event.path ?? null });
|
|
1366
1668
|
onEvent(event);
|
|
1367
1669
|
}
|
|
1368
|
-
|
|
1670
|
+
this.hookStreams.delete(id);
|
|
1671
|
+
if (!signal.aborted) {
|
|
1672
|
+
onEvent();
|
|
1369
1673
|
await sleep(5_000, signal);
|
|
1674
|
+
}
|
|
1370
1675
|
}
|
|
1371
1676
|
catch (error) {
|
|
1677
|
+
this.hookStreams.delete(id);
|
|
1372
1678
|
if (isAuthorizationRefusal(error)) {
|
|
1373
1679
|
await this.deps.onAuthorityRefusal?.(id);
|
|
1374
1680
|
return;
|
|
1375
1681
|
}
|
|
1376
1682
|
failures++;
|
|
1377
1683
|
this.deps.log('hook-error', { source: id.split(':').slice(0, 2).join(':'), ...errInfo(error) });
|
|
1684
|
+
if (!signal.aborted)
|
|
1685
|
+
onEvent();
|
|
1378
1686
|
await sleep(Math.min(300_000, 15_000 * 2 ** Math.min(failures - 1, 5)), signal);
|
|
1379
1687
|
}
|
|
1380
1688
|
}
|