agent-embassy 1.3.0 → 1.4.0

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CONTRIBUTING.md +25 -9
  3. package/README.md +39 -15
  4. package/README.zh-CN.md +34 -13
  5. package/SECURITY.md +13 -7
  6. package/dist/src/gateway/claude-helper-protocol.d.ts +1 -0
  7. package/dist/src/gateway/claude-helper-protocol.js +4 -2
  8. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  9. package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -0
  10. package/dist/src/gateway/claude-helper-supervisor.js +3 -0
  11. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  12. package/dist/src/gateway/cli-copy.d.ts +1 -1
  13. package/dist/src/gateway/cli-copy.en.d.ts +3 -2
  14. package/dist/src/gateway/cli-copy.en.js +3 -1
  15. package/dist/src/gateway/cli-copy.en.js.map +1 -1
  16. package/dist/src/gateway/cli-copy.js +1 -0
  17. package/dist/src/gateway/cli-copy.js.map +1 -1
  18. package/dist/src/gateway/cli-copy.zh-CN.d.ts +3 -2
  19. package/dist/src/gateway/cli-copy.zh-CN.js +3 -1
  20. package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
  21. package/dist/src/gateway/cli.d.ts +1 -1
  22. package/dist/src/gateway/cli.js +12 -1
  23. package/dist/src/gateway/cli.js.map +1 -1
  24. package/dist/src/gateway/codex-app-server.d.ts +1 -1
  25. package/dist/src/gateway/codex-app-server.js +32 -11
  26. package/dist/src/gateway/codex-app-server.js.map +1 -1
  27. package/dist/src/gateway/config.d.ts +1 -1
  28. package/dist/src/gateway/control.d.ts +1 -1
  29. package/dist/src/gateway/control.js +5 -3
  30. package/dist/src/gateway/control.js.map +1 -1
  31. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  32. package/dist/src/gateway/dashboard-copy.en.d.ts +15 -1
  33. package/dist/src/gateway/dashboard-copy.en.js +25 -11
  34. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  35. package/dist/src/gateway/dashboard-copy.js +15 -1
  36. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  37. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +15 -1
  38. package/dist/src/gateway/dashboard-copy.zh-CN.js +25 -11
  39. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  40. package/dist/src/gateway/dashboard-model.d.ts +2 -3
  41. package/dist/src/gateway/dashboard-model.js +118 -30
  42. package/dist/src/gateway/dashboard-model.js.map +1 -1
  43. package/dist/src/gateway/dashboard.js +22 -9
  44. package/dist/src/gateway/dashboard.js.map +1 -1
  45. package/dist/src/gateway/instance-lease.d.ts +2 -3
  46. package/dist/src/gateway/instance-lease.js +17 -140
  47. package/dist/src/gateway/instance-lease.js.map +1 -1
  48. package/dist/src/gateway/live-dashboard-app/app.js +45 -18
  49. package/dist/src/gateway/live-dashboard-http.js +1 -1
  50. package/dist/src/gateway/live-dashboard-http.js.map +1 -1
  51. package/dist/src/gateway/progress-watch-machine.d.ts +6 -5
  52. package/dist/src/gateway/progress-watch-machine.js +6 -6
  53. package/dist/src/gateway/progress-watch-machine.js.map +1 -1
  54. package/dist/src/gateway/provenance-envelope.d.ts +1 -0
  55. package/dist/src/gateway/provenance-envelope.js +10 -3
  56. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  57. package/dist/src/gateway/providers.d.ts +1 -0
  58. package/dist/src/gateway/providers.js +6 -0
  59. package/dist/src/gateway/providers.js.map +1 -1
  60. package/dist/src/gateway/service.d.ts +2 -0
  61. package/dist/src/gateway/service.js +25 -8
  62. package/dist/src/gateway/service.js.map +1 -1
  63. package/dist/src/gateway/store.d.ts +9 -21
  64. package/dist/src/gateway/store.js +284 -122
  65. package/dist/src/gateway/store.js.map +1 -1
  66. package/dist/src/gateway/types.d.ts +1 -2
  67. package/dist/src/gateway/types.js.map +1 -1
  68. package/docs/CONFIGURATION.md +102 -0
  69. package/docs/CONFIGURATION.zh-CN.md +72 -0
  70. package/docs/DASHBOARD.md +103 -0
  71. package/docs/DASHBOARD.zh-CN.md +47 -0
  72. package/docs/DELIVERY.md +84 -0
  73. package/docs/DELIVERY.zh-CN.md +53 -0
  74. package/docs/GATEWAY-ARCHITECTURE.md +177 -93
  75. package/package.json +8 -4
@@ -7,7 +7,7 @@ import { BridgeError } from "../errors.js";
7
7
  import { KeyedMutex } from "../mutex.js";
8
8
  import { isCodexRegistrationGeneration } from "./codex-registration-generation.js";
9
9
  import { compatibilityCacheKey, isCompatibilityAttestation, } from "./compatibility.js";
10
- import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_MAX_IDLE_MS, PROGRESS_WATCH_MIN_IDLE_MS, createProgressWatchMachine, progressWatchCapabilities, progressWatchJournalKinds, progressWatchPhases, transitionProgressWatch, } from "./progress-watch-machine.js";
10
+ import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY, PROGRESS_WATCH_MAX_IDLE_MS, PROGRESS_WATCH_MIN_IDLE_MS, createProgressWatchMachine, progressWatchCapabilities, progressWatchJournalKinds, progressWatchPhases, transitionProgressWatch, } from "./progress-watch-machine.js";
11
11
  import { CODEX_ENDPOINT_REFRESH_JOURNAL_CAPACITY, CODEX_ORPHAN_REMOVAL_JOURNAL_CAPACITY, compatibilityStates, connectorHealthStates, deliveryStates, gatewayProviders, messageDirections, projectGatewayPublicSnapshot, routeRegistrationModes, routeStates, } from "./types.js";
12
12
  const STATE_MARKER = ".agent-embassy-state";
13
13
  const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
@@ -271,7 +271,25 @@ function isPairRecord(value) {
271
271
  isIsoTimestamp(value.updatedAt) &&
272
272
  isRouteCounters(value.counters));
273
273
  }
274
- function isProgressWatchMachine(value) {
274
+ function progressWatchPairKey(watch) {
275
+ return [
276
+ `${watch.ownerAlias}\0${watch.ownerLease}`,
277
+ `${watch.workerAlias}\0${watch.workerLease}`,
278
+ ]
279
+ .sort()
280
+ .join("\0");
281
+ }
282
+ function progressWatchMatchesPair(watch, pair) {
283
+ return ((watch.ownerAlias === pair.claudeAlias &&
284
+ watch.ownerLease === pair.claudeOwnerLease &&
285
+ watch.workerAlias === pair.codexAlias &&
286
+ watch.workerLease === pair.codexOwnerLease) ||
287
+ (watch.ownerAlias === pair.codexAlias &&
288
+ watch.ownerLease === pair.codexOwnerLease &&
289
+ watch.workerAlias === pair.claudeAlias &&
290
+ watch.workerLease === pair.claudeOwnerLease));
291
+ }
292
+ function isLegacyProgressWatchMachine(value) {
275
293
  if (!isObject(value) ||
276
294
  !hasOnlyKeys(value, [
277
295
  "conversationId",
@@ -322,6 +340,10 @@ function isProgressWatchMachine(value) {
322
340
  (value.workerReportedCompleteAt === undefined ||
323
341
  isIsoTimestamp(value.workerReportedCompleteAt)));
324
342
  }
343
+ function isProgressWatchMachine(value) {
344
+ return (isLegacyProgressWatchMachine(value) &&
345
+ !Object.hasOwn(value, "workerReportedCompleteAt"));
346
+ }
325
347
  function isProgressWatchJournalEvent(value) {
326
348
  return (isObject(value) &&
327
349
  hasOnlyKeys(value, [
@@ -787,6 +809,94 @@ function migrateLegacyCompatibilityCertifications(value) {
787
809
  });
788
810
  return changed ? { ...value, compatibilityAttestations } : value;
789
811
  }
812
+ /**
813
+ * Before v1.4, separately tracked conversations could leave multiple active
814
+ * watches on one exact pair, and a worker completion could remain as a flag on
815
+ * an active watch. Settle both legacy shapes into the bounded journal. Losers
816
+ * are ordered by their stable original-array position, so a persisted result
817
+ * is idempotent when loaded again.
818
+ */
819
+ function migrateLegacyProgressWatchState(value, eventCapacity) {
820
+ if (!isObject(value) ||
821
+ value.schemaVersion !== 1 ||
822
+ !isIsoTimestamp(value.updatedAt) ||
823
+ !isNonNegativeInteger(value.watchSequence) ||
824
+ !Array.isArray(value.progressWatches) ||
825
+ value.progressWatches.length > PROGRESS_WATCH_HARD_CAPACITY ||
826
+ !value.progressWatches.every(isLegacyProgressWatchMachine) ||
827
+ !Array.isArray(value.progressWatchEvents) ||
828
+ !value.progressWatchEvents.every(isProgressWatchJournalEvent) ||
829
+ value.progressWatchEvents.length > eventCapacity ||
830
+ new Set(value.progressWatches.map((watch) => watch.conversationId)).size !== value.progressWatches.length) {
831
+ return value;
832
+ }
833
+ const progressWatches = value.progressWatches;
834
+ const existingEvents = value.progressWatchEvents;
835
+ const watchSequenceBefore = value.watchSequence;
836
+ if (existingEvents.some((event, index) => event.sequence > watchSequenceBefore ||
837
+ (index > 0 && event.sequence <= existingEvents[index - 1].sequence))) {
838
+ return value;
839
+ }
840
+ const winnerByPair = new Map();
841
+ for (const [index, watch] of progressWatches.entries()) {
842
+ const key = progressWatchPairKey(watch);
843
+ const winnerIndex = winnerByPair.get(key);
844
+ const winner = winnerIndex === undefined
845
+ ? undefined
846
+ : progressWatches[winnerIndex];
847
+ if (winner === undefined ||
848
+ Date.parse(watch.createdAt) >= Date.parse(winner.createdAt)) {
849
+ winnerByPair.set(key, index);
850
+ }
851
+ }
852
+ const winnerIndexes = new Set(winnerByPair.values());
853
+ const loserIndexes = progressWatches
854
+ .map((_, index) => index)
855
+ .filter((index) => !winnerIndexes.has(index));
856
+ const completedWinnerIndexes = [...winnerByPair.values()].filter((index) => progressWatches[index]?.workerReportedCompleteAt !== undefined);
857
+ const additions = loserIndexes.length + completedWinnerIndexes.length;
858
+ if (additions === 0)
859
+ return value;
860
+ if (watchSequenceBefore > Number.MAX_SAFE_INTEGER - additions) {
861
+ throw new BridgeError("PROGRESS_WATCH_SEQUENCE_EXHAUSTED", "The bounded progress-watch journal sequence is exhausted.");
862
+ }
863
+ const retained = new Set(winnerIndexes);
864
+ for (const index of completedWinnerIndexes)
865
+ retained.delete(index);
866
+ let watchSequence = watchSequenceBefore;
867
+ const progressWatchEvents = [...existingEvents];
868
+ const append = (watch, kind, timestamp) => {
869
+ watchSequence += 1;
870
+ progressWatchEvents.push({
871
+ sequence: watchSequence,
872
+ timestamp,
873
+ conversationId: watch.conversationId,
874
+ ownerAlias: watch.ownerAlias,
875
+ workerAlias: watch.workerAlias,
876
+ kind,
877
+ });
878
+ while (progressWatchEvents.length > eventCapacity) {
879
+ progressWatchEvents.shift();
880
+ }
881
+ };
882
+ for (const index of loserIndexes) {
883
+ const watch = progressWatches[index];
884
+ const winner = progressWatches[winnerByPair.get(progressWatchPairKey(watch))];
885
+ append(watch, "replaced", winner.createdAt);
886
+ }
887
+ for (const index of completedWinnerIndexes) {
888
+ const watch = progressWatches[index];
889
+ append(watch, "worker_reported_complete", watch.workerReportedCompleteAt);
890
+ }
891
+ return {
892
+ ...value,
893
+ watchSequence,
894
+ progressWatches: progressWatches
895
+ .filter((_, index) => retained.has(index))
896
+ .map(({ workerReportedCompleteAt: _legacy, ...watch }) => watch),
897
+ progressWatchEvents,
898
+ };
899
+ }
790
900
  function isPersistedState(value) {
791
901
  if (!isObject(value) ||
792
902
  !hasOnlyKeys(value, [
@@ -872,6 +982,9 @@ function isPersistedState(value) {
872
982
  if (watchConversationIds.size !== candidate.progressWatches.length) {
873
983
  return false;
874
984
  }
985
+ const watchPairKeys = new Set(candidate.progressWatches.map(progressWatchPairKey));
986
+ if (watchPairKeys.size !== candidate.progressWatches.length)
987
+ return false;
875
988
  const compatibilityKeys = candidate.compatibilityAttestations.map(compatibilityCacheKey);
876
989
  if (new Set(compatibilityKeys).size !== compatibilityKeys.length) {
877
990
  return false;
@@ -944,6 +1057,10 @@ function isPersistedState(value) {
944
1057
  };
945
1058
  const sequencesStrictlyIncrease = candidate.events.every((event, index) => index === 0 ||
946
1059
  event.sequence > (candidate.events[index - 1]?.sequence ?? 0));
1060
+ const progressWatchSequencesStrictlyIncrease = candidate.progressWatchEvents.every((event, index) => event.sequence <= candidate.watchSequence &&
1061
+ (index === 0 ||
1062
+ event.sequence >
1063
+ (candidate.progressWatchEvents[index - 1]?.sequence ?? 0)));
947
1064
  const endpointRefreshSequenceConsistent = candidate.codexEndpointRefreshEvents.length === 0
948
1065
  ? candidate.codexEndpointRefreshSequence === 0
949
1066
  : candidate.codexEndpointRefreshEvents.at(-1)?.sequence ===
@@ -974,6 +1091,7 @@ function isPersistedState(value) {
974
1091
  candidate.rateBuckets.length &&
975
1092
  candidate.accounting.queuedBytes === expectedQueuedBytes &&
976
1093
  sequencesStrictlyIncrease &&
1094
+ progressWatchSequencesStrictlyIncrease &&
977
1095
  candidate.events.every((event) => event.sequence <= candidate.eventSequence) &&
978
1096
  endpointRefreshSequenceConsistent &&
979
1097
  orphanRemovalSequenceConsistent &&
@@ -1510,6 +1628,8 @@ export class GatewayStore {
1510
1628
  // selected Claude routes and their independent consent edges survive.
1511
1629
  const retired = selectedClaudeRoutes.filter((route) => route !== retained && route === aliasOwner);
1512
1630
  const retiredAliases = new Set(retired.map((route) => route.alias));
1631
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => retiredAliases.has(watch.ownerAlias) ||
1632
+ retiredAliases.has(watch.workerAlias)).length);
1513
1633
  const settlementPlan = this.validateAffectedInFlightSettlements(state, retiredAliases, input.inFlightSettlements ?? []);
1514
1634
  const settlements = this.terminateAffectedMessages(state, retiredAliases, now, "ROUTE_UNREGISTERED", settlementPlan);
1515
1635
  this.settleProgressWatchesForAliases(state, retiredAliases, now);
@@ -1886,6 +2006,7 @@ export class GatewayStore {
1886
2006
  return await this.mutate(async (state, now) => {
1887
2007
  const route = this.requireOwnedRoute(state, alias, ownerLease);
1888
2008
  const aliases = new Set([route.alias]);
2009
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => aliases.has(watch.ownerAlias) || aliases.has(watch.workerAlias)).length);
1889
2010
  const settlementPlan = this.validateAffectedInFlightSettlements(state, aliases, inFlightSettlements);
1890
2011
  route.enabled = false;
1891
2012
  route.state = "disabled";
@@ -2341,65 +2462,34 @@ export class GatewayStore {
2341
2462
  .sort((left, right) => pairKey(left.claudeAlias, left.codexAlias).localeCompare(pairKey(right.claudeAlias, right.codexAlias)));
2342
2463
  });
2343
2464
  }
2344
- /** Open one durable owner-ended watch on an exact consent edge. */
2345
- async openProgressWatch(input) {
2465
+ async inspectProgressWatches() {
2466
+ return this.mutex.run("gateway", async () => this.requireState().progressWatches.map((watch) => ({ ...watch })));
2467
+ }
2468
+ /** Bind one restarted route-capability watch to its first exact owner send. */
2469
+ async bindProgressWatchConversation(input) {
2346
2470
  return this.mutate(async (state, now) => {
2347
- if (this.config.trackingEnabled === false) {
2348
- throw new BridgeError("PROGRESS_TRACKING_DISABLED", "Progress tracking is disabled by the controller configuration.");
2349
- }
2350
2471
  if (!CONVERSATION_ID_PATTERN.test(input.conversationId) ||
2351
2472
  !ALIAS_PATTERN.test(input.ownerAlias) ||
2352
2473
  !ALIAS_PATTERN.test(input.workerAlias) ||
2353
- input.ownerAlias === input.workerAlias ||
2354
- !Number.isSafeInteger(input.idleMs) ||
2355
- input.idleMs < PROGRESS_WATCH_MIN_IDLE_MS ||
2356
- input.idleMs > PROGRESS_WATCH_MAX_IDLE_MS) {
2357
- throw new BridgeError("INVALID_PROGRESS_WATCH", "The progress-watch request is malformed or outside its idle bound.");
2474
+ input.ownerAlias === input.workerAlias) {
2475
+ throw new BridgeError("INVALID_PROGRESS_WATCH", "The progress-watch conversation binding is malformed.");
2358
2476
  }
2359
- const existing = state.progressWatches.find((watch) => watch.conversationId === input.conversationId);
2360
- if (existing !== undefined) {
2361
- if (existing.ownerAlias !== input.ownerAlias ||
2362
- existing.workerAlias !== input.workerAlias) {
2363
- throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The conversation watch belongs to different exact endpoints.");
2364
- }
2365
- return { created: false, watch: { ...existing } };
2366
- }
2367
- if (state.progressWatches.length >=
2368
- (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
2369
- throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The bounded progress-watch inventory is full.", true);
2370
- }
2371
- const owner = this.requireAvailableRoute(state, input.ownerAlias);
2372
- const worker = this.requireAvailableRoute(state, input.workerAlias);
2373
- const pairAliases = pairAliasesForRoutes(owner, worker);
2374
- this.requireExactPair(state, owner.binding.provider === "claude" ? owner : worker, owner.binding.provider === "codex" ? owner : worker);
2375
- if (pairAliases.claudeAlias !==
2376
- (owner.binding.provider === "claude"
2377
- ? owner.alias
2378
- : worker.alias) ||
2379
- pairAliases.codexAlias !==
2380
- (owner.binding.provider === "codex" ? owner.alias : worker.alias)) {
2381
- throw new BridgeError("PROGRESS_WATCH_EDGE_MISMATCH", "The watch endpoints do not form one exact consent edge.");
2382
- }
2383
- const watch = createProgressWatchMachine({
2384
- conversationId: input.conversationId,
2385
- ownerAlias: owner.alias,
2386
- workerAlias: worker.alias,
2387
- ownerLease: owner.binding.ownerLease,
2388
- workerLease: worker.binding.ownerLease,
2389
- idleMs: input.idleMs,
2390
- at: now.getTime(),
2391
- });
2392
- state.progressWatches.push(watch);
2393
- this.appendProgressWatchEvent(state, watch, {
2394
- kind: "opened",
2395
- timestamp: now.toISOString(),
2396
- });
2397
- return { created: true, watch: { ...watch } };
2477
+ const routeWatch = state.progressWatches.find((watch) => watch.capability === "route" &&
2478
+ watch.ownerAlias === input.ownerAlias &&
2479
+ watch.workerAlias === input.workerAlias);
2480
+ if (routeWatch === undefined)
2481
+ return false;
2482
+ const pair = state.pairs.find((candidate) => pairMatchesMessage(candidate, {
2483
+ sourceAlias: input.ownerAlias,
2484
+ targetAlias: input.workerAlias,
2485
+ pair: true,
2486
+ }) && progressWatchMatchesPair(routeWatch, candidate));
2487
+ if (pair === undefined) {
2488
+ throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "A restarted watch can bind only through its exact durable pair.");
2489
+ }
2490
+ return this.bindRouteProgressWatchConversation(state, now, input.conversationId, input.ownerAlias, input.workerAlias, pair);
2398
2491
  });
2399
2492
  }
2400
- async inspectProgressWatches() {
2401
- return this.mutex.run("gateway", async () => this.requireState().progressWatches.map((watch) => ({ ...watch })));
2402
- }
2403
2493
  async inspectCompatibilityAttestation(surface, version) {
2404
2494
  return this.mutex.run("gateway", async () => {
2405
2495
  const attestation = this.requireState().compatibilityAttestations.find((candidate) => candidate.surface === surface && candidate.version === version);
@@ -2432,37 +2522,6 @@ export class GatewayStore {
2432
2522
  state.compatibilityAttestations.push(structuredClone(attestation));
2433
2523
  });
2434
2524
  }
2435
- async touchProgressWatch(input) {
2436
- return this.mutate(async (state, now) => {
2437
- const index = state.progressWatches.findIndex((watch) => watch.conversationId === input.conversationId);
2438
- const watch = state.progressWatches[index];
2439
- if (watch === undefined)
2440
- return false;
2441
- if (input.actorAlias !== watch.ownerAlias &&
2442
- input.actorAlias !== watch.workerAlias) {
2443
- throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The watch activity did not come from an endpoint on its exact edge.");
2444
- }
2445
- const workerReportedComplete = input.workerReportedComplete === true &&
2446
- input.actorAlias === watch.workerAlias;
2447
- const transition = transitionProgressWatch(watch, {
2448
- type: "activity",
2449
- at: now.getTime(),
2450
- ...(workerReportedComplete
2451
- ? { workerReportedComplete: true }
2452
- : {}),
2453
- });
2454
- if (transition.state === null)
2455
- return false;
2456
- state.progressWatches[index] = transition.state;
2457
- if (workerReportedComplete) {
2458
- this.appendProgressWatchEvent(state, transition.state, {
2459
- kind: "worker_reported_complete",
2460
- timestamp: now.toISOString(),
2461
- });
2462
- }
2463
- return true;
2464
- });
2465
- }
2466
2525
  async touchProgressWatchesForAlias(alias) {
2467
2526
  return this.mutate(async (state, now) => {
2468
2527
  if (!ALIAS_PATTERN.test(alias))
@@ -2507,6 +2566,7 @@ export class GatewayStore {
2507
2566
  throw new BridgeError("INVALID_PROGRESS_WATCH_SETTLEMENT", "Unresponsive settlement is owned only by the watch deadline machine.");
2508
2567
  }
2509
2568
  const transition = transitionProgressWatch(watch, event);
2569
+ this.requireProgressWatchJournalCapacity(state, 1);
2510
2570
  state.progressWatches.splice(index, 1);
2511
2571
  this.appendProgressWatchEvent(state, watch, {
2512
2572
  kind: input.outcome,
@@ -2518,6 +2578,12 @@ export class GatewayStore {
2518
2578
  /** Advance every due watch once under one durable store mutation. */
2519
2579
  async advanceDueProgressWatches() {
2520
2580
  return this.mutate(async (state, now) => {
2581
+ const terminalCount = state.progressWatches.filter((watch) => {
2582
+ if (Date.parse(watch.nextActionAt) > now.getTime())
2583
+ return false;
2584
+ return this.progressWatchDueTransition(state, watch, now).effects.some((effect) => effect.type === "settled");
2585
+ }).length;
2586
+ this.requireProgressWatchJournalCapacity(state, terminalCount);
2521
2587
  const actions = [];
2522
2588
  const retained = [];
2523
2589
  for (const watch of state.progressWatches) {
@@ -2620,6 +2686,7 @@ export class GatewayStore {
2620
2686
  return this.mutate(async (state, now) => {
2621
2687
  const { claude, codex } = this.requirePairRoutes(state, input, false);
2622
2688
  const pair = this.requireExactPair(state, claude, codex);
2689
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => progressWatchMatchesPair(watch, pair)).length);
2623
2690
  const plan = this.validateAffectedPairInFlightSettlements(state, pair, input.inFlightSettlements ?? []);
2624
2691
  const settlements = this.terminateAffectedPairMessages(state, pair, now, "PAIR_REMOVED", plan);
2625
2692
  this.settleProgressWatchesForPair(state, pair, now);
@@ -3081,7 +3148,6 @@ export class GatewayStore {
3081
3148
  nextActionAt: watch.nextActionAt,
3082
3149
  idleMs: watch.idleMs,
3083
3150
  nudgeCount: watch.nudgeCount,
3084
- workerReportedComplete: watch.workerReportedCompleteAt !== undefined,
3085
3151
  }))
3086
3152
  .sort((left, right) => `${left.nextActionAt}\0${left.ownerAlias}\0${left.workerAlias}`.localeCompare(`${right.nextActionAt}\0${right.ownerAlias}\0${right.workerAlias}`));
3087
3153
  const progressWatchEvents = state.progressWatchEvents
@@ -3315,6 +3381,9 @@ export class GatewayStore {
3315
3381
  }
3316
3382
  replaceCodexRouteForSuccession(state, route, identity, now, routeState, compatibility) {
3317
3383
  const oldAlias = route.alias;
3384
+ const affectedAliases = new Set([oldAlias, identity.alias]);
3385
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => affectedAliases.has(watch.ownerAlias) ||
3386
+ affectedAliases.has(watch.workerAlias)).length);
3318
3387
  route.alias = identity.alias;
3319
3388
  route.binding = { ...identity.binding };
3320
3389
  route.registrationMode = "explicit_opt_in";
@@ -3339,8 +3408,8 @@ export class GatewayStore {
3339
3408
  record.targetAlias !== identity.alias);
3340
3409
  state.rateBuckets = state.rateBuckets.filter((bucket) => bucket.sourceAlias !== oldAlias &&
3341
3410
  bucket.sourceAlias !== identity.alias);
3342
- this.settleProgressWatchesForAliases(state, new Set([oldAlias, identity.alias]), now);
3343
- removePairsForAliases(state, new Set([oldAlias, identity.alias]));
3411
+ this.settleProgressWatchesForAliases(state, affectedAliases, now);
3412
+ removePairsForAliases(state, affectedAliases);
3344
3413
  }
3345
3414
  assertAllowedIdentity(identity) {
3346
3415
  if (!isPrivateEndpointIdentity(identity)) {
@@ -3829,43 +3898,62 @@ export class GatewayStore {
3829
3898
  activity.openIdleMs !== undefined)) {
3830
3899
  throw new BridgeError("INVALID_PROGRESS_WATCH", "The message watch activity is malformed or contradictory.");
3831
3900
  }
3832
- const index = state.progressWatches.findIndex((watch) => watch.conversationId === activity.conversationId);
3901
+ const pair = sides.pair === true
3902
+ ? state.pairs.find((candidate) => pairMatchesMessage(candidate, sides))
3903
+ : undefined;
3904
+ let index = state.progressWatches.findIndex((watch) => watch.conversationId === activity.conversationId);
3905
+ if (index < 0 &&
3906
+ activity.openIdleMs === undefined &&
3907
+ activity.completionSignal !== true &&
3908
+ pair !== undefined) {
3909
+ const routeMatches = state.progressWatches
3910
+ .map((watch, watchIndex) => ({ watch, watchIndex }))
3911
+ .filter(({ watch }) => watch.capability === "route" &&
3912
+ watch.ownerAlias === sides.sourceAlias &&
3913
+ watch.workerAlias === sides.targetAlias &&
3914
+ progressWatchMatchesPair(watch, pair));
3915
+ if (routeMatches.length === 1) {
3916
+ this.bindRouteProgressWatchConversation(state, now, activity.conversationId, sides.sourceAlias, sides.targetAlias, pair);
3917
+ index = routeMatches[0].watchIndex;
3918
+ }
3919
+ }
3833
3920
  const existing = state.progressWatches[index];
3834
3921
  if (existing !== undefined) {
3835
- if (activity.actorAlias !== existing.ownerAlias &&
3836
- activity.actorAlias !== existing.workerAlias) {
3922
+ if (pair === undefined ||
3923
+ !progressWatchMatchesPair(existing, pair) ||
3924
+ !((sides.sourceAlias === existing.ownerAlias &&
3925
+ sides.targetAlias === existing.workerAlias) ||
3926
+ (sides.sourceAlias === existing.workerAlias &&
3927
+ sides.targetAlias === existing.ownerAlias))) {
3837
3928
  throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The watched conversation belongs to different exact endpoints.");
3838
3929
  }
3839
3930
  if (activity.openIdleMs !== undefined &&
3840
3931
  activity.actorAlias !== existing.ownerAlias) {
3841
3932
  throw new BridgeError("PROGRESS_WATCH_OWNER_REQUIRED", "Only the exact watch owner may reassert tracking options.");
3842
3933
  }
3843
- if (activity.completionSignal === true &&
3844
- activity.actorAlias === existing.ownerAlias) {
3934
+ if (activity.completionSignal === true) {
3935
+ const transition = transitionProgressWatch(existing, activity.actorAlias === existing.ownerAlias
3936
+ ? { type: "owner_done", at: now.getTime() }
3937
+ : { type: "worker_done", at: now.getTime() });
3938
+ if (transition.state !== null) {
3939
+ throw new BridgeError("INVALID_PROGRESS_WATCH_SETTLEMENT", "The completion signal did not settle its exact watch.");
3940
+ }
3941
+ this.requireProgressWatchJournalCapacity(state, 1);
3845
3942
  state.progressWatches.splice(index, 1);
3846
3943
  this.appendProgressWatchEvent(state, existing, {
3847
- kind: "done",
3944
+ kind: activity.actorAlias === existing.ownerAlias
3945
+ ? "done"
3946
+ : "worker_reported_complete",
3848
3947
  timestamp: now.toISOString(),
3849
3948
  });
3850
3949
  return;
3851
3950
  }
3852
- const workerReportedComplete = activity.completionSignal === true &&
3853
- activity.actorAlias === existing.workerAlias;
3854
3951
  const transition = transitionProgressWatch(existing, {
3855
3952
  type: "activity",
3856
3953
  at: now.getTime(),
3857
- ...(workerReportedComplete
3858
- ? { workerReportedComplete: true }
3859
- : {}),
3860
3954
  });
3861
3955
  if (transition.state !== null) {
3862
3956
  state.progressWatches[index] = transition.state;
3863
- if (workerReportedComplete) {
3864
- this.appendProgressWatchEvent(state, transition.state, {
3865
- kind: "worker_reported_complete",
3866
- timestamp: now.toISOString(),
3867
- });
3868
- }
3869
3957
  }
3870
3958
  return;
3871
3959
  }
@@ -3874,13 +3962,9 @@ export class GatewayStore {
3874
3962
  if (this.config.trackingEnabled === false) {
3875
3963
  throw new BridgeError("PROGRESS_TRACKING_DISABLED", "Progress tracking is disabled by the controller configuration.");
3876
3964
  }
3877
- if (sides.pair !== true) {
3965
+ if (pair === undefined) {
3878
3966
  throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "Progress tracking requires one exact paired consent edge.");
3879
3967
  }
3880
- if (state.progressWatches.length >=
3881
- (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
3882
- throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The bounded progress-watch inventory is full.", true);
3883
- }
3884
3968
  const owner = state.routes.find((route) => route.alias === sides.sourceAlias);
3885
3969
  const worker = state.routes.find((route) => route.alias === sides.targetAlias);
3886
3970
  if (owner === undefined || worker === undefined) {
@@ -3895,11 +3979,10 @@ export class GatewayStore {
3895
3979
  idleMs: activity.openIdleMs,
3896
3980
  at: now.getTime(),
3897
3981
  });
3898
- state.progressWatches.push(watch);
3899
- this.appendProgressWatchEvent(state, watch, {
3900
- kind: "opened",
3901
- timestamp: now.toISOString(),
3902
- });
3982
+ if (!progressWatchMatchesPair(watch, pair)) {
3983
+ throw new BridgeError("PROGRESS_WATCH_EDGE_MISMATCH", "The watch endpoints do not form one exact consent edge.");
3984
+ }
3985
+ this.installProgressWatch(state, watch, pair, now);
3903
3986
  }
3904
3987
  progressWatchDueTransition(state, watch, now) {
3905
3988
  const owner = state.routes.find((route) => route.alias === watch.ownerAlias &&
@@ -3943,6 +4026,7 @@ export class GatewayStore {
3943
4026
  effect.nudgeNumber === nudge.nudgeNumber)) {
3944
4027
  throw new BridgeError("PROGRESS_WATCH_NUDGE_NOT_DUE", "The requested progress-watch nudge is not currently due.", true);
3945
4028
  }
4029
+ this.requireProgressWatchJournalCapacity(state, 1);
3946
4030
  state.progressWatches[index] = transition.state;
3947
4031
  this.appendProgressWatchEvent(state, transition.state, {
3948
4032
  kind: "nudge",
@@ -3950,7 +4034,41 @@ export class GatewayStore {
3950
4034
  nudgeNumber: nudge.nudgeNumber,
3951
4035
  });
3952
4036
  }
4037
+ installProgressWatch(state, watch, pair, now) {
4038
+ const replacedIndex = state.progressWatches.findIndex((candidate) => progressWatchMatchesPair(candidate, pair));
4039
+ if (replacedIndex < 0 &&
4040
+ state.progressWatches.length >=
4041
+ (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
4042
+ throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The bounded progress-watch inventory is full.", true);
4043
+ }
4044
+ if (replacedIndex >= 0 &&
4045
+ state.progressWatches[replacedIndex].ownerAlias !== watch.ownerAlias) {
4046
+ throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the incumbent watch owner may replace this pair watch; ask that owner to untrack it first.");
4047
+ }
4048
+ this.requireProgressWatchJournalCapacity(state, replacedIndex < 0 ? 1 : 2);
4049
+ if (replacedIndex >= 0) {
4050
+ const replaced = state.progressWatches[replacedIndex];
4051
+ state.progressWatches.splice(replacedIndex, 1);
4052
+ this.appendProgressWatchEvent(state, replaced, {
4053
+ kind: "replaced",
4054
+ timestamp: now.toISOString(),
4055
+ });
4056
+ }
4057
+ state.progressWatches.push(watch);
4058
+ this.appendProgressWatchEvent(state, watch, {
4059
+ kind: "opened",
4060
+ timestamp: now.toISOString(),
4061
+ });
4062
+ }
4063
+ requireProgressWatchJournalCapacity(state, additions) {
4064
+ if (!Number.isSafeInteger(additions) ||
4065
+ additions < 0 ||
4066
+ state.watchSequence > Number.MAX_SAFE_INTEGER - additions) {
4067
+ throw new BridgeError("PROGRESS_WATCH_SEQUENCE_EXHAUSTED", "The bounded progress-watch journal sequence is exhausted.");
4068
+ }
4069
+ }
3953
4070
  appendProgressWatchEvent(state, watch, event) {
4071
+ this.requireProgressWatchJournalCapacity(state, 1);
3954
4072
  state.watchSequence += 1;
3955
4073
  state.progressWatchEvents.push({
3956
4074
  sequence: state.watchSequence,
@@ -3968,6 +4086,7 @@ export class GatewayStore {
3968
4086
  }
3969
4087
  }
3970
4088
  settleProgressWatchesForAliases(state, aliases, now) {
4089
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => aliases.has(watch.ownerAlias) || aliases.has(watch.workerAlias)).length);
3971
4090
  const retained = [];
3972
4091
  for (const watch of state.progressWatches) {
3973
4092
  if (!aliases.has(watch.ownerAlias) &&
@@ -3983,23 +4102,61 @@ export class GatewayStore {
3983
4102
  state.progressWatches = retained;
3984
4103
  }
3985
4104
  settleProgressWatchesForPair(state, pair, now) {
4105
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => progressWatchMatchesPair(watch, pair)).length);
3986
4106
  const retained = [];
3987
4107
  for (const watch of state.progressWatches) {
3988
- const matches = (watch.ownerAlias === pair.claudeAlias &&
3989
- watch.workerAlias === pair.codexAlias) ||
3990
- (watch.ownerAlias === pair.codexAlias &&
3991
- watch.workerAlias === pair.claudeAlias);
3992
- if (!matches) {
4108
+ if (!progressWatchMatchesPair(watch, pair)) {
3993
4109
  retained.push(watch);
3994
4110
  continue;
3995
4111
  }
3996
4112
  this.appendProgressWatchEvent(state, watch, {
3997
- kind: "endpoint_retired",
4113
+ kind: "pair_removed",
3998
4114
  timestamp: now.toISOString(),
3999
4115
  });
4000
4116
  }
4001
4117
  state.progressWatches = retained;
4002
4118
  }
4119
+ bindRouteProgressWatchConversation(state, now, conversationId, ownerAlias, workerAlias, pair) {
4120
+ const conversationOwner = state.progressWatches.find((watch) => watch.conversationId === conversationId);
4121
+ if (conversationOwner !== undefined &&
4122
+ (conversationOwner.capability !== "route" ||
4123
+ conversationOwner.ownerAlias !== ownerAlias ||
4124
+ conversationOwner.workerAlias !== workerAlias ||
4125
+ !progressWatchMatchesPair(conversationOwner, pair))) {
4126
+ throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The fresh conversation capability belongs to another exact watch.");
4127
+ }
4128
+ const matches = state.progressWatches
4129
+ .map((watch, index) => ({ watch, index }))
4130
+ .filter(({ watch }) => watch.capability === "route" &&
4131
+ watch.ownerAlias === ownerAlias &&
4132
+ watch.workerAlias === workerAlias &&
4133
+ progressWatchMatchesPair(watch, pair));
4134
+ if (matches.length === 0)
4135
+ return false;
4136
+ if (matches.length !== 1) {
4137
+ throw new BridgeError("CORRUPT_GATEWAY_STATE", "More than one route-capability watch claims the exact pair.");
4138
+ }
4139
+ const match = matches[0];
4140
+ this.requireProgressWatchJournalCapacity(state, 1);
4141
+ const restored = transitionProgressWatch(match.watch, {
4142
+ type: "restart",
4143
+ at: now.getTime(),
4144
+ conversationCapabilityRestored: true,
4145
+ });
4146
+ if (restored.state === null) {
4147
+ throw new BridgeError("INVALID_PROGRESS_WATCH_REBIND", "The exact progress watch could not restore conversation authority.");
4148
+ }
4149
+ const rebound = {
4150
+ ...restored.state,
4151
+ conversationId,
4152
+ };
4153
+ state.progressWatches[match.index] = rebound;
4154
+ this.appendProgressWatchEvent(state, rebound, {
4155
+ kind: "conversation_rebound",
4156
+ timestamp: now.toISOString(),
4157
+ });
4158
+ return true;
4159
+ }
4003
4160
  finishMetadata(state, metadata, deliveryState, now, safeErrorCode) {
4004
4161
  const counterKey = deliveryState;
4005
4162
  state.accounting[counterKey] += 1;
@@ -4165,6 +4322,7 @@ export class GatewayStore {
4165
4322
  const state = this.requireState();
4166
4323
  this.recoverCodexSuccessionAfterRestart(state, now);
4167
4324
  if (this.config.trackingEnabled === false) {
4325
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.length);
4168
4326
  for (const watch of state.progressWatches) {
4169
4327
  this.appendProgressWatchEvent(state, watch, {
4170
4328
  kind: "disabled",
@@ -4173,6 +4331,9 @@ export class GatewayStore {
4173
4331
  }
4174
4332
  state.progressWatches = [];
4175
4333
  }
4334
+ else {
4335
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => !watch.degradedNoticeSent).length);
4336
+ }
4176
4337
  for (let index = 0; index < state.progressWatches.length; index += 1) {
4177
4338
  const watch = state.progressWatches[index];
4178
4339
  if (watch === undefined)
@@ -4185,7 +4346,7 @@ export class GatewayStore {
4185
4346
  if (transition.state !== null) {
4186
4347
  state.progressWatches[index] = transition.state;
4187
4348
  }
4188
- if (transition.effects.some((effect) => effect.type === "notify_capability_degraded")) {
4349
+ if (transition.effects.some((effect) => effect.type === "record_capability_degraded")) {
4189
4350
  this.appendProgressWatchEvent(state, transition.state ?? watch, {
4190
4351
  kind: "capability_degraded",
4191
4352
  timestamp: now.toISOString(),
@@ -4502,6 +4663,7 @@ export class GatewayStore {
4502
4663
  parsed = migratePreCodexOrphanRemovalJournal(parsed);
4503
4664
  parsed = migrateLegacyHopCounts(parsed);
4504
4665
  parsed = migrateLegacyCompatibilityCertifications(parsed);
4666
+ parsed = migrateLegacyProgressWatchState(parsed, this.config.limits.eventCapacity);
4505
4667
  if (!isPersistedState(parsed)) {
4506
4668
  throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict schema validation.");
4507
4669
  }