@creatornader/agent-bridge 0.3.1 → 0.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 (72) hide show
  1. package/CHANGELOG.md +150 -1
  2. package/README.md +239 -5
  3. package/ROADMAP.md +59 -7
  4. package/SECURITY.md +55 -5
  5. package/SKILL.md +103 -0
  6. package/dist/archive.d.ts +2 -2
  7. package/dist/archive.js +2 -2
  8. package/dist/{bridge-service-hX1ZQp4W.d.ts → bridge-service-YTjGr91A.d.ts} +1 -1
  9. package/dist/{bridge-store-DPVSCwfs.d.ts → bridge-store-Boaj-ZbO.d.ts} +2 -0
  10. package/dist/bridge.d.ts +17 -7
  11. package/dist/bridge.js +15 -7
  12. package/dist/{chunk-WAI2IYIQ.js → chunk-3YHVUPU2.js} +48 -5
  13. package/dist/chunk-3YHVUPU2.js.map +1 -0
  14. package/dist/{chunk-45BZGB77.js → chunk-6BTJE6D4.js} +168 -7
  15. package/dist/chunk-6BTJE6D4.js.map +1 -0
  16. package/dist/{chunk-5GMB3A77.js → chunk-DQOZJJZ2.js} +22 -10
  17. package/dist/chunk-DQOZJJZ2.js.map +1 -0
  18. package/dist/{chunk-NCO7RLBM.js → chunk-GTK4KOED.js} +108 -8
  19. package/dist/chunk-GTK4KOED.js.map +1 -0
  20. package/dist/{chunk-GADH244D.js → chunk-IHZFI7TH.js} +12 -4
  21. package/dist/chunk-IHZFI7TH.js.map +1 -0
  22. package/dist/{chunk-CEXBMOI5.js → chunk-LAHNQZDN.js} +2 -2
  23. package/dist/{chunk-UAHMP6GC.js → chunk-MCKKVNCE.js} +9 -6
  24. package/dist/chunk-MCKKVNCE.js.map +1 -0
  25. package/dist/{chunk-MBPZXTJA.js → chunk-PDIHDBP3.js} +173 -20
  26. package/dist/chunk-PDIHDBP3.js.map +1 -0
  27. package/dist/{chunk-Q6PPMXJC.js → chunk-SV3SVBYS.js} +377 -21
  28. package/dist/chunk-SV3SVBYS.js.map +1 -0
  29. package/dist/{chunk-SPSMZOCJ.js → chunk-SYGONYMM.js} +4 -4
  30. package/dist/{chunk-A6W4QALF.js → chunk-TC6A7GMF.js} +2 -2
  31. package/dist/cli.js +5865 -436
  32. package/dist/cli.js.map +1 -1
  33. package/dist/client-runtime.d.ts +4 -2
  34. package/dist/client-runtime.js +6 -6
  35. package/dist/contracts/generate.js +1 -1
  36. package/dist/dr.d.ts +9 -2
  37. package/dist/dr.js +6 -4
  38. package/dist/{gateway-CggQ8vTC.d.ts → gateway-BQHpV-MZ.d.ts} +27 -3
  39. package/dist/gateway-main.js +69 -5
  40. package/dist/gateway-main.js.map +1 -1
  41. package/dist/gateway.d.ts +3 -3
  42. package/dist/gateway.js +2 -2
  43. package/dist/index.js +7 -7
  44. package/dist/{postgres-bridge-store-Bfd3hRId.d.ts → postgres-bridge-store-WASumj26.d.ts} +1 -1
  45. package/dist/server.js +7 -7
  46. package/dist/sqlite-edge-store-Bc15DvpX.d.ts +148 -0
  47. package/dist/sqlite.d.ts +19 -80
  48. package/dist/sqlite.js +11 -3
  49. package/docs/architecture-v2.md +256 -10
  50. package/docs/decisions/0004-client-lifecycle-and-endpoint-migration.md +204 -0
  51. package/docs/deployment.md +205 -0
  52. package/docs/troubleshooting.md +23 -0
  53. package/llms.txt +67 -0
  54. package/openapi/agent-bridge-v2.openapi.json +1023 -0
  55. package/package.json +6 -2
  56. package/schemas/agent-bridge-v2.capabilities.json +24 -0
  57. package/schemas/agent-bridge-v2.mcp.json +6 -0
  58. package/schemas/agent-bridge-v2.schema.json +163 -0
  59. package/sql/migrations/017_gateway_authority_binding.sql +99 -0
  60. package/sql/migrations/018_endpoint_migration_challenges.sql +1028 -0
  61. package/sql/migrations/019_endpoint_migration_same_successor.sql +324 -0
  62. package/dist/chunk-45BZGB77.js.map +0 -1
  63. package/dist/chunk-5GMB3A77.js.map +0 -1
  64. package/dist/chunk-GADH244D.js.map +0 -1
  65. package/dist/chunk-MBPZXTJA.js.map +0 -1
  66. package/dist/chunk-NCO7RLBM.js.map +0 -1
  67. package/dist/chunk-Q6PPMXJC.js.map +0 -1
  68. package/dist/chunk-UAHMP6GC.js.map +0 -1
  69. package/dist/chunk-WAI2IYIQ.js.map +0 -1
  70. /package/dist/{chunk-CEXBMOI5.js.map → chunk-LAHNQZDN.js.map} +0 -0
  71. /package/dist/{chunk-SPSMZOCJ.js.map → chunk-SYGONYMM.js.map} +0 -0
  72. /package/dist/{chunk-A6W4QALF.js.map → chunk-TC6A7GMF.js.map} +0 -0
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS,
3
4
  assertEdgeUpgradeCandidate,
4
5
  installEdgeMarkers,
5
6
  retrySqliteBusy
6
- } from "./chunk-5GMB3A77.js";
7
+ } from "./chunk-DQOZJJZ2.js";
7
8
  import {
8
9
  preparePrivateSqliteLocation,
9
10
  securePrivatePath,
11
+ securePrivateSqliteSidecar,
10
12
  verifyPrivatePathAccess
11
- } from "./chunk-NCO7RLBM.js";
13
+ } from "./chunk-GTK4KOED.js";
12
14
  import {
13
15
  cursorScope,
14
16
  decodeCursor,
@@ -19,11 +21,16 @@ import {
19
21
  import { createHash, randomUUID } from "crypto";
20
22
  import { existsSync, lstatSync } from "fs";
21
23
  import { createRequire } from "module";
24
+ import { pathToFileURL } from "url";
22
25
  var require2 = createRequire(import.meta.url);
23
26
  function openDatabase(path) {
24
27
  const { DatabaseSync } = require2("node:sqlite");
25
28
  return new DatabaseSync(path);
26
29
  }
30
+ function openReadOnlyDatabase(path) {
31
+ const { DatabaseSync } = require2("node:sqlite");
32
+ return new DatabaseSync(`${pathToFileURL(path).toString()}?immutable=1`, { readOnly: true });
33
+ }
27
34
  var MAX_SEQUENCE = 9223372036854775807n;
28
35
  var schema = `
29
36
  PRAGMA journal_mode = WAL;
@@ -42,6 +49,21 @@ CREATE TABLE IF NOT EXISTS edge_scopes (
42
49
  last_attempt_at TEXT,
43
50
  cache_contract INTEGER NOT NULL DEFAULT 1
44
51
  );
52
+ CREATE TABLE IF NOT EXISTS edge_migration_gates (
53
+ scope_key TEXT PRIMARY KEY REFERENCES edge_scopes(scope_key),
54
+ state TEXT NOT NULL CHECK(state IN ('active', 'draining', 'retired')),
55
+ operation_id TEXT,
56
+ lease_token TEXT,
57
+ lease_expires_at TEXT,
58
+ updated_at TEXT NOT NULL,
59
+ CHECK(
60
+ (state='active' AND operation_id IS NULL AND lease_token IS NULL AND lease_expires_at IS NULL)
61
+ OR (state='draining' AND operation_id IS NOT NULL AND lease_token IS NOT NULL AND lease_expires_at IS NOT NULL)
62
+ OR (state='retired' AND operation_id IS NOT NULL AND lease_token IS NULL AND lease_expires_at IS NULL)
63
+ )
64
+ );
65
+ CREATE INDEX IF NOT EXISTS edge_migration_gates_state
66
+ ON edge_migration_gates(state, lease_expires_at);
45
67
  CREATE TABLE IF NOT EXISTS edge_outbox (
46
68
  position INTEGER PRIMARY KEY AUTOINCREMENT,
47
69
  scope_key TEXT NOT NULL REFERENCES edge_scopes(scope_key),
@@ -62,6 +84,15 @@ CREATE TABLE IF NOT EXISTS edge_outbox (
62
84
  );
63
85
  CREATE INDEX IF NOT EXISTS edge_outbox_due
64
86
  ON edge_outbox(scope_key, state, available_at, position);
87
+ CREATE TRIGGER IF NOT EXISTS edge_outbox_migration_gate_insert
88
+ BEFORE INSERT ON edge_outbox
89
+ WHEN NOT EXISTS (
90
+ SELECT 1 FROM edge_migration_gates
91
+ WHERE scope_key=NEW.scope_key AND state='active'
92
+ )
93
+ BEGIN
94
+ SELECT RAISE(ABORT, 'edge migration gate rejects outbox publication');
95
+ END;
65
96
  CREATE TABLE IF NOT EXISTS edge_inbox (
66
97
  scope_key TEXT NOT NULL REFERENCES edge_scopes(scope_key),
67
98
  message_id TEXT NOT NULL,
@@ -87,12 +118,81 @@ CREATE INDEX IF NOT EXISTS edge_inbox_thread
87
118
  CREATE INDEX IF NOT EXISTS edge_inbox_created
88
119
  ON edge_inbox(scope_key, created_at, sequence_key);
89
120
  `;
121
+ function inspectEdgeScopeReadOnly(path, scope, now = /* @__PURE__ */ new Date()) {
122
+ if (!existsSync(path)) {
123
+ return { exists: false, gate: { scopeKey: edgeScopeKey(scope), state: "active", operationId: void 0, leaseExpiresAt: void 0, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString() }, pending: 0, due: 0, scheduled: 0, leased: 0, blocked: 0, cached: 0 };
124
+ }
125
+ verifyPrivatePathAccess(path, "file");
126
+ const details = lstatSync(path);
127
+ if (!details.isFile() || details.isSymbolicLink()) throw new EdgeConflictError("edge database is not a private regular file");
128
+ if (existsSync(`${path}-wal`) || existsSync(`${path}-shm`)) {
129
+ throw new EdgeConflictError("read-only migration inspection refuses an edge with live WAL state");
130
+ }
131
+ const db = openReadOnlyDatabase(path);
132
+ try {
133
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('edge_scopes','edge_migration_gates','edge_outbox','edge_inbox') ORDER BY name").all();
134
+ if (tables.length === 0) {
135
+ const userTables = db.prepare("SELECT count(*) AS count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").get();
136
+ if (Number(userTables.count) !== 0) throw new EdgeConflictError("edge database is not an Agent Bridge edge store");
137
+ return { exists: true, gate: { scopeKey: edgeScopeKey(scope), state: "active", operationId: void 0, leaseExpiresAt: void 0, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString() }, pending: 0, due: 0, scheduled: 0, leased: 0, blocked: 0, cached: 0 };
138
+ }
139
+ if (tables.length !== 4) throw new EdgeConflictError("edge database schema is incomplete");
140
+ const key = edgeScopeKey(scope);
141
+ const scopeRow = db.prepare("SELECT 1 AS present FROM edge_scopes WHERE scope_key=?").get(key);
142
+ if (!scopeRow) {
143
+ return { exists: true, gate: { scopeKey: key, state: "active", operationId: void 0, leaseExpiresAt: void 0, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString() }, pending: 0, due: 0, scheduled: 0, leased: 0, blocked: 0, cached: 0 };
144
+ }
145
+ const gateRow = db.prepare("SELECT state,operation_id,lease_expires_at,updated_at FROM edge_migration_gates WHERE scope_key=?").get(key);
146
+ if (!gateRow || !["active", "draining", "retired"].includes(String(gateRow.state))) throw new EdgeConflictError("edge migration gate is missing or invalid");
147
+ const nowText = now.toISOString();
148
+ const counts = db.prepare(`SELECT
149
+ sum(CASE WHEN state='pending' THEN 1 ELSE 0 END) AS pending,
150
+ sum(CASE WHEN state='pending' AND available_at<=? AND (lease_expires_at IS NULL OR lease_expires_at<=?) THEN 1 ELSE 0 END) AS due,
151
+ sum(CASE WHEN state='pending' AND available_at>? AND (lease_expires_at IS NULL OR lease_expires_at<=?) THEN 1 ELSE 0 END) AS scheduled,
152
+ sum(CASE WHEN state='pending' AND lease_expires_at>? THEN 1 ELSE 0 END) AS leased,
153
+ sum(CASE WHEN state='blocked' THEN 1 ELSE 0 END) AS blocked
154
+ FROM edge_outbox WHERE scope_key=?`).get(nowText, nowText, nowText, nowText, nowText, key);
155
+ const cached = db.prepare("SELECT count(*) AS count FROM edge_inbox WHERE scope_key=?").get(key);
156
+ return {
157
+ exists: true,
158
+ gate: { scopeKey: key, state: gateRow.state, operationId: gateRow.operation_id ? String(gateRow.operation_id) : void 0, leaseExpiresAt: gateRow.lease_expires_at ? String(gateRow.lease_expires_at) : void 0, updatedAt: gateRow.updated_at ? String(gateRow.updated_at) : (/* @__PURE__ */ new Date(0)).toISOString() },
159
+ pending: Number(counts.pending ?? 0),
160
+ due: Number(counts.due ?? 0),
161
+ scheduled: Number(counts.scheduled ?? 0),
162
+ leased: Number(counts.leased ?? 0),
163
+ blocked: Number(counts.blocked ?? 0),
164
+ cached: Number(cached.count ?? 0)
165
+ };
166
+ } finally {
167
+ db.close();
168
+ }
169
+ }
90
170
  var EdgeConflictError = class extends Error {
91
171
  constructor() {
92
172
  super(...arguments);
93
173
  this.code = "edge_idempotency_conflict";
94
174
  }
95
175
  };
176
+ var EdgeMigrationDrainingError = class extends Error {
177
+ constructor(gate) {
178
+ super("edge scope is draining for a client migration");
179
+ this.gate = gate;
180
+ this.code = "client_migration_draining";
181
+ }
182
+ };
183
+ var EdgeMigrationRetiredError = class extends Error {
184
+ constructor(gate) {
185
+ super("edge scope was retired by a client migration");
186
+ this.gate = gate;
187
+ this.code = "client_migration_retired";
188
+ }
189
+ };
190
+ var EdgeMigrationLeaseError = class extends Error {
191
+ constructor() {
192
+ super("client migration worker no longer holds the edge drain lease");
193
+ this.code = "client_migration_lease_lost";
194
+ }
195
+ };
96
196
  function canonical(value) {
97
197
  if (value === void 0) return "null";
98
198
  if (value === null || typeof value !== "object") return JSON.stringify(value);
@@ -143,20 +243,26 @@ function parseDraft(value) {
143
243
  function parseMessage(value) {
144
244
  return JSON.parse(String(value));
145
245
  }
246
+ function safeOperationId(value) {
247
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
248
+ throw new Error("client migration operation ID is invalid");
249
+ }
250
+ return value.toLowerCase();
251
+ }
146
252
  var SQLiteEdgeStore = class {
147
253
  constructor(path, scope, busyTimeoutMs = 2e3) {
148
254
  this.path = path;
149
255
  this.scope = scope;
150
256
  this.busyTimeoutMs = busyTimeoutMs;
151
257
  this.initialized = false;
152
- const selected = preparePrivateSqliteLocation(path, true);
258
+ const selected = preparePrivateSqliteLocation(path, true, "defer");
153
259
  this.databasePath = selected;
154
260
  this.preexistingFiles = new Set(selected === ":memory:" ? [] : [selected, `${selected}-wal`, `${selected}-shm`].filter(existsSync));
155
261
  const before = selected === ":memory:" || !existsSync(selected) ? void 0 : lstatSync(selected);
156
262
  this.db = openDatabase(selected);
157
263
  try {
158
264
  this.db.exec(`PRAGMA busy_timeout = ${Math.max(1, Math.trunc(this.busyTimeoutMs))}`);
159
- this.restrictFiles();
265
+ this.restrictFiles(false);
160
266
  if (before) {
161
267
  const after = lstatSync(selected);
162
268
  if (after.dev !== before.dev || after.ino !== before.ino) throw new Error("SQLite edge database path identity changed while opening");
@@ -167,17 +273,59 @@ var SQLiteEdgeStore = class {
167
273
  }
168
274
  this.key = edgeScopeKey(scope);
169
275
  }
170
- restrictFiles() {
276
+ restrictFiles(includeSidecars = true) {
171
277
  if (this.databasePath === ":memory:") return;
172
- for (const path of [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`]) {
278
+ const paths = includeSidecars ? [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`] : [this.databasePath];
279
+ for (const path of paths) {
173
280
  if (!existsSync(path)) continue;
174
281
  if (path === this.databasePath && this.preexistingFiles.has(path)) verifyPrivatePathAccess(path, "file");
175
- else securePrivatePath(path, "file");
282
+ else if (path === this.databasePath) securePrivatePath(path, "file");
283
+ else securePrivateSqliteSidecar(path);
176
284
  }
177
285
  }
178
286
  get scopeKey() {
179
287
  return this.key;
180
288
  }
289
+ gateFromRow(row) {
290
+ const state = String(row.state);
291
+ if (state !== "active" && state !== "draining" && state !== "retired") {
292
+ throw new EdgeConflictError("edge migration gate state is invalid");
293
+ }
294
+ const operationId = row.operation_id === null || row.operation_id === void 0 ? void 0 : safeOperationId(String(row.operation_id));
295
+ const leaseExpiresAt = row.lease_expires_at === null || row.lease_expires_at === void 0 ? void 0 : String(row.lease_expires_at);
296
+ const updatedAt = String(row.updated_at ?? "");
297
+ if (!updatedAt || !Number.isFinite(Date.parse(updatedAt))) {
298
+ throw new EdgeConflictError("edge migration gate timestamp is invalid");
299
+ }
300
+ const leaseToken = row.lease_token === null || row.lease_token === void 0 ? void 0 : String(row.lease_token);
301
+ if (state === "active" && (operationId || leaseExpiresAt || leaseToken) || state === "draining" && (!operationId || !leaseExpiresAt || !leaseToken) || state === "retired" && (!operationId || leaseExpiresAt || leaseToken)) {
302
+ throw new EdgeConflictError("edge migration gate state is invalid");
303
+ }
304
+ return { scopeKey: this.key, state, operationId, leaseExpiresAt, updatedAt };
305
+ }
306
+ gateInTransaction() {
307
+ const row = this.db.prepare(`SELECT state,operation_id,lease_token,lease_expires_at,updated_at
308
+ FROM edge_migration_gates WHERE scope_key=?`).get(this.key);
309
+ if (!row) throw new EdgeConflictError("edge migration gate is missing");
310
+ return this.gateFromRow(row);
311
+ }
312
+ assertScopeActiveInTransaction() {
313
+ const gate = this.gateInTransaction();
314
+ if (gate.state === "draining") throw new EdgeMigrationDrainingError(gate);
315
+ if (gate.state === "retired") throw new EdgeMigrationRetiredError(gate);
316
+ }
317
+ assertDrainLeaseInTransaction(lease, now) {
318
+ const gate = this.gateInTransaction();
319
+ if (gate.state !== "draining" || gate.operationId !== lease.operationId || gate.leaseExpiresAt !== lease.leaseExpiresAt || Date.parse(gate.leaseExpiresAt) <= now.getTime()) {
320
+ throw new EdgeMigrationLeaseError();
321
+ }
322
+ const row = this.db.prepare("SELECT lease_token FROM edge_migration_gates WHERE scope_key=?").get(this.key);
323
+ if (row.lease_token !== lease.leaseToken) throw new EdgeMigrationLeaseError();
324
+ }
325
+ assertClaimAuthorityInTransaction(now, lease) {
326
+ if (lease) this.assertDrainLeaseInTransaction(lease, now);
327
+ else this.assertScopeActiveInTransaction();
328
+ }
181
329
  async initialize() {
182
330
  if (this.initialized) return;
183
331
  if (!this.initialization) {
@@ -192,8 +340,9 @@ var SQLiteEdgeStore = class {
192
340
  }
193
341
  async initializeOnce() {
194
342
  assertEdgeUpgradeCandidate(this.db);
195
- await retrySqliteBusy(() => this.db.exec(schema), this.busyTimeoutMs);
196
- await retrySqliteBusy(() => this.db.exec("BEGIN IMMEDIATE"), this.busyTimeoutMs);
343
+ const initializationTimeoutMs = Math.max(this.busyTimeoutMs, SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS);
344
+ await retrySqliteBusy(() => this.db.exec(schema), initializationTimeoutMs);
345
+ await retrySqliteBusy(() => this.db.exec("BEGIN IMMEDIATE"), initializationTimeoutMs);
197
346
  try {
198
347
  const scopeColumns = this.db.prepare("PRAGMA table_info(edge_scopes)").all();
199
348
  if (!scopeColumns.some((column) => column.name === "cache_contract")) {
@@ -223,6 +372,10 @@ var SQLiteEdgeStore = class {
223
372
  const endpointHash = createHash("sha256").update(this.scope.endpoint.replace(/\/$/, "")).digest("hex");
224
373
  this.db.prepare(`INSERT INTO edge_scopes (scope_key, endpoint_hash, workspace, agent)
225
374
  VALUES (?, ?, ?, ?) ON CONFLICT(scope_key) DO NOTHING`).run(this.key, endpointHash, this.scope.principal.workspace, this.scope.principal.agent);
375
+ this.db.prepare(`INSERT INTO edge_migration_gates
376
+ (scope_key,state,operation_id,lease_token,lease_expires_at,updated_at)
377
+ SELECT scope_key,'active',NULL,NULL,NULL,? FROM edge_scopes
378
+ WHERE scope_key=? ON CONFLICT(scope_key) DO NOTHING`).run((/* @__PURE__ */ new Date()).toISOString(), this.key);
226
379
  const contract = this.db.prepare("SELECT cache_contract FROM edge_scopes WHERE scope_key=?").get(this.key);
227
380
  if (Number(contract.cache_contract) < 1) {
228
381
  this.db.exec("BEGIN IMMEDIATE");
@@ -238,6 +391,162 @@ var SQLiteEdgeStore = class {
238
391
  async ready() {
239
392
  await this.initialize();
240
393
  }
394
+ async migrationGate() {
395
+ await this.ready();
396
+ return this.gateInTransaction();
397
+ }
398
+ /** Refuse new normal-client writes before any remote attempt begins. */
399
+ async assertScopeActive() {
400
+ await this.ready();
401
+ this.db.exec("BEGIN IMMEDIATE");
402
+ try {
403
+ this.assertScopeActiveInTransaction();
404
+ this.db.exec("COMMIT");
405
+ } catch (error) {
406
+ this.db.exec("ROLLBACK");
407
+ throw error;
408
+ }
409
+ }
410
+ /** Refuse remote publication unless this worker still owns the durable drain lease. */
411
+ async assertDrainLease(lease, now = /* @__PURE__ */ new Date()) {
412
+ await this.ready();
413
+ if (lease.scopeKey !== this.key) throw new EdgeMigrationLeaseError();
414
+ this.db.exec("BEGIN IMMEDIATE");
415
+ try {
416
+ this.assertDrainLeaseInTransaction(lease, now);
417
+ this.db.exec("COMMIT");
418
+ } catch (error) {
419
+ this.db.exec("ROLLBACK");
420
+ throw error;
421
+ }
422
+ }
423
+ async claimDrainLease(operationId, now, leaseMs, mode) {
424
+ await this.ready();
425
+ const normalizedOperationId = safeOperationId(operationId);
426
+ const duration = Math.max(1, Math.trunc(leaseMs));
427
+ this.db.exec("BEGIN IMMEDIATE");
428
+ try {
429
+ const gate = this.gateInTransaction();
430
+ if (gate.state === "retired") throw new EdgeMigrationRetiredError(gate);
431
+ if (mode === "begin" && gate.state !== "active") throw new EdgeMigrationDrainingError(gate);
432
+ if (mode === "resume") {
433
+ if (gate.state !== "draining" || gate.operationId !== normalizedOperationId || !gate.leaseExpiresAt || Date.parse(gate.leaseExpiresAt) > now.getTime()) {
434
+ throw new EdgeMigrationDrainingError(gate);
435
+ }
436
+ }
437
+ const leaseToken = randomUUID();
438
+ const leaseExpiresAt = new Date(now.getTime() + duration).toISOString();
439
+ const updated = this.db.prepare(`UPDATE edge_migration_gates
440
+ SET state='draining',operation_id=?,lease_token=?,lease_expires_at=?,updated_at=?
441
+ WHERE scope_key=?`).run(
442
+ normalizedOperationId,
443
+ leaseToken,
444
+ leaseExpiresAt,
445
+ now.toISOString(),
446
+ this.key
447
+ );
448
+ if (updated.changes !== 1) throw new EdgeConflictError("edge migration gate could not be acquired");
449
+ this.db.exec("COMMIT");
450
+ return { scopeKey: this.key, operationId: normalizedOperationId, leaseToken, leaseExpiresAt };
451
+ } catch (error) {
452
+ this.db.exec("ROLLBACK");
453
+ throw error;
454
+ }
455
+ }
456
+ /** Start a new durable drain only from an active edge scope. */
457
+ async beginDrain(operationId, now = /* @__PURE__ */ new Date(), leaseMs = 3e4) {
458
+ return this.claimDrainLease(operationId, now, leaseMs, "begin");
459
+ }
460
+ /** Resume the same persisted drain only after the prior worker lease expires. */
461
+ async resumeDrain(operationId, now = /* @__PURE__ */ new Date(), leaseMs = 3e4) {
462
+ return this.claimDrainLease(operationId, now, leaseMs, "resume");
463
+ }
464
+ /** Backward-compatible dispatcher for callers that have already inspected the gate. */
465
+ async acquireDrainLease(operationId, now = /* @__PURE__ */ new Date(), leaseMs = 3e4) {
466
+ const gate = await this.migrationGate();
467
+ if (gate.state === "active") return this.beginDrain(operationId, now, leaseMs);
468
+ return this.resumeDrain(operationId, now, leaseMs);
469
+ }
470
+ /** Verify the held lease and require an exact zero-work outbox before a cutover continues. */
471
+ async assertDrainComplete(lease, now = /* @__PURE__ */ new Date()) {
472
+ await this.ready();
473
+ if (lease.scopeKey !== this.key) throw new EdgeMigrationLeaseError();
474
+ this.db.exec("BEGIN IMMEDIATE");
475
+ try {
476
+ this.assertDrainLeaseInTransaction(lease, now);
477
+ const counts = this.db.prepare(`SELECT
478
+ sum(CASE WHEN state='pending' THEN 1 ELSE 0 END) AS pending,
479
+ sum(CASE WHEN state='pending' AND available_at<=? AND (lease_expires_at IS NULL OR lease_expires_at<=?) THEN 1 ELSE 0 END) AS due,
480
+ sum(CASE WHEN state='pending' AND available_at>? AND (lease_expires_at IS NULL OR lease_expires_at<=?) THEN 1 ELSE 0 END) AS scheduled,
481
+ sum(CASE WHEN state='pending' AND lease_expires_at>? THEN 1 ELSE 0 END) AS leased,
482
+ sum(CASE WHEN state='blocked' THEN 1 ELSE 0 END) AS blocked
483
+ FROM edge_outbox WHERE scope_key=?`).get(
484
+ now.toISOString(),
485
+ now.toISOString(),
486
+ now.toISOString(),
487
+ now.toISOString(),
488
+ now.toISOString(),
489
+ this.key
490
+ );
491
+ if (Number(counts.pending ?? 0) !== 0 || Number(counts.due ?? 0) !== 0 || Number(counts.scheduled ?? 0) !== 0 || Number(counts.leased ?? 0) !== 0 || Number(counts.blocked ?? 0) !== 0) {
492
+ throw new EdgeConflictError("edge scope cannot complete a drain with retained outbox work");
493
+ }
494
+ this.db.exec("COMMIT");
495
+ } catch (error) {
496
+ this.db.exec("ROLLBACK");
497
+ throw error;
498
+ }
499
+ }
500
+ async renewDrainLease(lease, now = /* @__PURE__ */ new Date(), leaseMs = 3e4) {
501
+ await this.ready();
502
+ if (lease.scopeKey !== this.key) throw new EdgeMigrationLeaseError();
503
+ const duration = Math.max(1, Math.trunc(leaseMs));
504
+ this.db.exec("BEGIN IMMEDIATE");
505
+ try {
506
+ this.assertDrainLeaseInTransaction(lease, now);
507
+ const leaseExpiresAt = new Date(now.getTime() + duration).toISOString();
508
+ const updated = this.db.prepare(`UPDATE edge_migration_gates
509
+ SET lease_expires_at=?,updated_at=? WHERE scope_key=? AND state='draining'
510
+ AND operation_id=? AND lease_token=? AND lease_expires_at=?`).run(
511
+ leaseExpiresAt,
512
+ now.toISOString(),
513
+ this.key,
514
+ lease.operationId,
515
+ lease.leaseToken,
516
+ lease.leaseExpiresAt
517
+ );
518
+ if (updated.changes !== 1) throw new EdgeMigrationLeaseError();
519
+ this.db.exec("COMMIT");
520
+ return { ...lease, leaseExpiresAt };
521
+ } catch (error) {
522
+ this.db.exec("ROLLBACK");
523
+ throw error;
524
+ }
525
+ }
526
+ async retireScope(lease, now = /* @__PURE__ */ new Date()) {
527
+ await this.ready();
528
+ if (lease.scopeKey !== this.key) throw new EdgeMigrationLeaseError();
529
+ this.db.exec("BEGIN IMMEDIATE");
530
+ try {
531
+ this.assertDrainLeaseInTransaction(lease, now);
532
+ const stats = this.db.prepare(`SELECT count(*) AS count FROM edge_outbox
533
+ WHERE scope_key=?`).get(this.key);
534
+ if (Number(stats.count) !== 0) throw new EdgeConflictError("edge scope cannot retire with retained outbox work");
535
+ const updated = this.db.prepare(`UPDATE edge_migration_gates
536
+ SET state='retired',lease_token=NULL,lease_expires_at=NULL,updated_at=?
537
+ WHERE scope_key=? AND state='draining' AND operation_id=? AND lease_token=?`).run(
538
+ now.toISOString(),
539
+ this.key,
540
+ lease.operationId,
541
+ lease.leaseToken
542
+ );
543
+ if (updated.changes !== 1) throw new EdgeMigrationLeaseError();
544
+ this.db.exec("COMMIT");
545
+ } catch (error) {
546
+ this.db.exec("ROLLBACK");
547
+ throw error;
548
+ }
549
+ }
241
550
  async enqueue(input, now = /* @__PURE__ */ new Date()) {
242
551
  await this.ready();
243
552
  const draft = stableIdempotency(input);
@@ -246,6 +555,7 @@ var SQLiteEdgeStore = class {
246
555
  const nowText = now.toISOString();
247
556
  this.db.exec("BEGIN IMMEDIATE");
248
557
  try {
558
+ this.assertScopeActiveInTransaction();
249
559
  const existing = this.db.prepare(`SELECT * FROM edge_outbox
250
560
  WHERE scope_key=? AND (message_id=? OR idempotency_key=?) ORDER BY position LIMIT 1`).get(this.key, draft.id, draft.idempotencyKey);
251
561
  if (existing) {
@@ -265,11 +575,12 @@ var SQLiteEdgeStore = class {
265
575
  throw error;
266
576
  }
267
577
  }
268
- async claimNext(now = /* @__PURE__ */ new Date(), leaseMs = 3e4) {
578
+ async claimNext(now = /* @__PURE__ */ new Date(), leaseMs = 3e4, migrationLease) {
269
579
  await this.ready();
270
580
  const nowText = now.toISOString();
271
581
  this.db.exec("BEGIN IMMEDIATE");
272
582
  try {
583
+ this.assertClaimAuthorityInTransaction(now, migrationLease);
273
584
  const head = this.db.prepare(`SELECT * FROM edge_outbox
274
585
  WHERE scope_key=? AND state='pending' AND available_at<=?
275
586
  AND (lease_expires_at IS NULL OR lease_expires_at<=?)
@@ -499,6 +810,7 @@ var SQLiteEdgeStore = class {
499
810
  const state = this.db.prepare(`SELECT pull_cursor, last_sync_at, last_error,
500
811
  last_outbound_sync_at,last_inbound_sync_at,last_attempt_at
501
812
  FROM edge_scopes WHERE scope_key=?`).get(this.key);
813
+ const migrationGate = this.gateInTransaction();
502
814
  const oldestDueAt = counts.oldest_due ? String(counts.oldest_due) : void 0;
503
815
  const oldestBlockedAt = Number(counts.blocked_age_unknown ?? 0) > 0 ? void 0 : counts.oldest_blocked ? String(counts.oldest_blocked) : void 0;
504
816
  return {
@@ -520,7 +832,10 @@ var SQLiteEdgeStore = class {
520
832
  nextRetryAt: counts.next_retry ? String(counts.next_retry) : void 0,
521
833
  lastOutboundSyncAt: state.last_outbound_sync_at ? String(state.last_outbound_sync_at) : void 0,
522
834
  lastInboundSyncAt: state.last_inbound_sync_at ? String(state.last_inbound_sync_at) : void 0,
523
- lastAttemptAt: state.last_attempt_at ? String(state.last_attempt_at) : void 0
835
+ lastAttemptAt: state.last_attempt_at ? String(state.last_attempt_at) : void 0,
836
+ migrationState: migrationGate.state,
837
+ migrationOperationId: migrationGate.operationId,
838
+ migrationLeaseExpiresAt: migrationGate.leaseExpiresAt
524
839
  };
525
840
  }
526
841
  async close() {
@@ -545,7 +860,7 @@ function isRetryableSyncError(error) {
545
860
  const status = statusOf(error);
546
861
  const code = codeOf(error);
547
862
  if (["network_error", "request_timeout"].includes(code)) return true;
548
- if (["invalid_input", "idempotency_conflict", "edge_idempotency_conflict", "principal_mismatch", "protocol_mismatch", "store_closed"].includes(code)) return false;
863
+ if (["invalid_input", "idempotency_conflict", "edge_idempotency_conflict", "principal_mismatch", "protocol_mismatch", "store_closed", "client_migration_draining", "client_migration_retired", "client_migration_lease_lost"].includes(code)) return false;
549
864
  return status === 0 || status === 408 || status === 425 || status === 429 || status !== void 0 && status >= 500;
550
865
  }
551
866
  function provisional(draft, createdAt) {
@@ -567,12 +882,18 @@ var SyncingBridgeStore = class {
567
882
  this.leaseMs = options.leaseMs ?? 3e4;
568
883
  this.autoSync = options.autoSync ?? true;
569
884
  this.idleDelayMs = Math.max(this.baseDelayMs + 1, options.idleDelayMs ?? 3e4);
885
+ this.edgeDrainLease = options.edgeDrainLease;
886
+ this.closeEdge = options.closeEdge ?? true;
570
887
  this.loopState = this.autoSync ? "idle" : "disabled";
571
888
  }
572
889
  async initialize() {
573
890
  await this.edge.initialize();
574
891
  if (this.autoSync) this.startTransportLoop();
575
892
  }
893
+ /** Replace a renewed maintenance lease without creating another edge wrapper. */
894
+ setDrainLease(lease) {
895
+ this.edgeDrainLease = lease;
896
+ }
576
897
  startTransportLoop() {
577
898
  if (this.background) return;
578
899
  this.background = this.transportLoop().catch((error) => {
@@ -620,6 +941,11 @@ var SyncingBridgeStore = class {
620
941
  this.loopState = "running";
621
942
  const report = await this.sync({ maxPush: 100, maxPages: 1, signal: this.closeController.signal });
622
943
  if (this.stopped) break;
944
+ if (report.migrationState !== "active" && !this.edgeDrainLease) {
945
+ this.loopState = "paused";
946
+ await this.wait(this.idleDelayMs);
947
+ continue;
948
+ }
623
949
  if (report.failureRetryable === false) {
624
950
  this.backgroundError = Object.assign(
625
951
  new Error(`gateway synchronization failed permanently: ${report.lastError ?? "sync_error"}`),
@@ -668,6 +994,13 @@ var SyncingBridgeStore = class {
668
994
  }
669
995
  async flushOne(signal) {
670
996
  await this.edge.noteAttempt(this.now());
997
+ try {
998
+ if (this.edgeDrainLease) await this.edge.assertDrainLease(this.edgeDrainLease, this.now());
999
+ else await this.edge.assertScopeActive();
1000
+ } catch (error) {
1001
+ if (error instanceof EdgeMigrationDrainingError || error instanceof EdgeMigrationRetiredError || error instanceof EdgeMigrationLeaseError) return { state: "paused", error };
1002
+ throw error;
1003
+ }
671
1004
  try {
672
1005
  await this.ensureRemote(signal);
673
1006
  } catch (error) {
@@ -678,7 +1011,13 @@ var SyncingBridgeStore = class {
678
1011
  retryable: isRetryableSyncError(error)
679
1012
  };
680
1013
  }
681
- const record = await this.edge.claimNext(this.now(), this.leaseMs);
1014
+ let record;
1015
+ try {
1016
+ record = await this.edge.claimNext(this.now(), this.leaseMs, this.edgeDrainLease);
1017
+ } catch (error) {
1018
+ if (error instanceof EdgeMigrationDrainingError || error instanceof EdgeMigrationRetiredError || error instanceof EdgeMigrationLeaseError) return { state: "paused", error };
1019
+ throw error;
1020
+ }
682
1021
  if (!record) return { state: "empty" };
683
1022
  try {
684
1023
  const result = await this.remote.insertMessage(record.draft, { signal });
@@ -723,6 +1062,7 @@ var SyncingBridgeStore = class {
723
1062
  async insertMessage(message) {
724
1063
  this.assertHealthy();
725
1064
  this.assertPrincipal({ workspace: message.workspace, agent: message.source }, false);
1065
+ await this.edge.assertScopeActive();
726
1066
  let remoteError;
727
1067
  try {
728
1068
  await this.ensureRemote();
@@ -829,13 +1169,17 @@ var SyncingBridgeStore = class {
829
1169
  continue;
830
1170
  }
831
1171
  if (result.state === "fatal") throw result.error;
1172
+ if (result.state === "paused") {
1173
+ lastError = codeOf(result.error);
1174
+ break;
1175
+ }
832
1176
  lastError = codeOf(result.error);
833
1177
  failureRetryable = result.state === "retry" ? result.retryable : void 0;
834
1178
  if (result.state === "retry") online = false;
835
1179
  if (result.state === "retry") break;
836
1180
  }
837
1181
  let pulled = 0;
838
- if (maxPages > 0 && !this.cancelled(options.signal)) {
1182
+ if (maxPages > 0 && !this.cancelled(options.signal) && !lastError?.startsWith("client_migration_")) {
839
1183
  const pull = await this.pull(maxPages, options.signal);
840
1184
  online = online && pull.online;
841
1185
  pulled = pull.pulled;
@@ -856,7 +1200,10 @@ var SyncingBridgeStore = class {
856
1200
  cursor: stats.pullCursor,
857
1201
  lastSyncedAt: stats.lastSyncAt,
858
1202
  lastError: lastError ?? stats.lastError,
859
- failureRetryable
1203
+ failureRetryable,
1204
+ migrationState: stats.migrationState,
1205
+ migrationOperationId: stats.migrationOperationId,
1206
+ migrationLeaseExpiresAt: stats.migrationLeaseExpiresAt
860
1207
  };
861
1208
  }
862
1209
  async listMessages(principal, query = {}) {
@@ -1001,7 +1348,10 @@ var SyncingBridgeStore = class {
1001
1348
  lastSyncAttemptAt: edge.lastAttemptAt,
1002
1349
  syncLoopState: this.backgroundError ? "failed" : this.loopState,
1003
1350
  syncLoopError: this.backgroundError ? codeOf(this.backgroundError) : void 0,
1004
- remoteError
1351
+ remoteError,
1352
+ migrationState: edge.migrationState,
1353
+ migrationOperationId: edge.migrationOperationId,
1354
+ migrationLeaseExpiresAt: edge.migrationLeaseExpiresAt
1005
1355
  };
1006
1356
  }
1007
1357
  async heartbeat(principal, leaseMs, runtimeType, capabilities = []) {
@@ -1028,10 +1378,12 @@ var SyncingBridgeStore = class {
1028
1378
  ]);
1029
1379
  const failures = results.filter((result) => result.status === "rejected").map((result) => result.reason);
1030
1380
  if (this.backgroundError) failures.push(this.backgroundError);
1031
- try {
1032
- await this.edge.close();
1033
- } catch (error) {
1034
- failures.push(error);
1381
+ if (this.closeEdge) {
1382
+ try {
1383
+ await this.edge.close();
1384
+ } catch (error) {
1385
+ failures.push(error);
1386
+ }
1035
1387
  }
1036
1388
  if (failures.length === 1) throw failures[0];
1037
1389
  if (failures.length > 1) throw Object.assign(new Error("failed to close synchronized bridge store"), { errors: failures });
@@ -1039,7 +1391,11 @@ var SyncingBridgeStore = class {
1039
1391
  };
1040
1392
 
1041
1393
  export {
1394
+ inspectEdgeScopeReadOnly,
1042
1395
  EdgeConflictError,
1396
+ EdgeMigrationDrainingError,
1397
+ EdgeMigrationRetiredError,
1398
+ EdgeMigrationLeaseError,
1043
1399
  edgeMessageFingerprint,
1044
1400
  stableIdempotency,
1045
1401
  edgeScopeKey,
@@ -1047,4 +1403,4 @@ export {
1047
1403
  isRetryableSyncError,
1048
1404
  SyncingBridgeStore
1049
1405
  };
1050
- //# sourceMappingURL=chunk-Q6PPMXJC.js.map
1406
+ //# sourceMappingURL=chunk-SV3SVBYS.js.map