agent-relay 9.2.2 → 9.2.4

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.
@@ -1,8 +1,11 @@
1
- import { createHash, randomBytes } from 'node:crypto';
1
+ import { createHash, pbkdf2Sync, randomBytes } from 'node:crypto';
2
+ import { hostname } from 'node:os';
2
3
  import { getProjectPaths } from '@agent-relay/config';
4
+ import { cleanupEntryKey, fileCleanupJournal, } from './integration-cleanup-journal.js';
3
5
  import { addSdkOptions, printJson, runSdk, sdkOptionsFromOpts, withSdkDefaults, } from '../lib/sdk-command.js';
4
6
  import { connectProjectBrokerClient } from '../lib/project-broker-client.js';
5
7
  import { MIN_RELAYFILE_VERSION, RelayfileControlPlaneClient, RelayfileControlPlaneError, assertRelayfileVersion, } from '@relayfile/client';
8
+ import { resolveBaseUrl, resolveWorkspaceKey } from '../lib/sdk-client.js';
6
9
  // Re-export the version gate so existing tests importing it from this module
7
10
  // (and any callers) keep working after it moved to the published client package.
8
11
  export { MIN_RELAYFILE_VERSION, assertRelayfileVersion };
@@ -12,6 +15,7 @@ export { MIN_RELAYFILE_VERSION, assertRelayfileVersion };
12
15
  * import time. One instance is enough — it negotiates the daemon version once.
13
16
  */
14
17
  let sharedClient;
18
+ const RELAYFILE_WEBHOOK_BINDINGS_API_VERSION = 3;
15
19
  function controlPlaneClient(options) {
16
20
  if (options)
17
21
  return new RelayfileControlPlaneClient(options);
@@ -87,19 +91,28 @@ export function defaultRelayfileBridge(options) {
87
91
  webhookId: input.webhookId,
88
92
  webhookToken: input.webhookToken,
89
93
  subscriptionId: input.subscriptionId,
94
+ webhookSubscriptionId: input.webhookSubscriptionId,
95
+ ...(input.webhookSubscriptionWorkspaceId
96
+ ? { webhookSubscriptionWorkspaceId: input.webhookSubscriptionWorkspaceId }
97
+ : {}),
90
98
  });
91
99
  },
92
100
  async listBindings() {
93
101
  const bindings = await client.listBindings();
94
102
  // relayfile keys bindings on `pathGlob`; surface it as `resource` so callers
95
103
  // (find/unbind) match on the canonical glob.
96
- return bindings.map((b) => ({
97
- provider: b.provider ?? '',
98
- resource: b.pathGlob ?? '',
99
- channel: b.channel ?? '',
100
- webhookId: b.webhookId ?? '',
101
- subscriptionId: b.subscriptionId ?? '',
102
- }));
104
+ return bindings.map((b) => {
105
+ const { webhookSubscriptionId, webhookSubscriptionWorkspaceId } = b;
106
+ return {
107
+ provider: b.provider ?? '',
108
+ resource: b.pathGlob ?? '',
109
+ channel: b.channel ?? '',
110
+ webhookId: b.webhookId ?? '',
111
+ subscriptionId: b.subscriptionId ?? '',
112
+ webhookSubscriptionId,
113
+ webhookSubscriptionWorkspaceId,
114
+ };
115
+ });
103
116
  },
104
117
  async unbind(provider, resource) {
105
118
  await client.unbind(provider, resource);
@@ -117,13 +130,17 @@ export function defaultRelayfileBridge(options) {
117
130
  // Connecting negotiates the daemon version via /v1/hello; a too-old daemon
118
131
  // (or one that can't start) throws a typed, actionable error here.
119
132
  await client.ensureReady();
133
+ const hello = await client.hello();
134
+ if (!hello.supportedApiVersions?.includes(RELAYFILE_WEBHOOK_BINDINGS_API_VERSION)) {
135
+ throw new RelayfileControlPlaneError('VERSION_INCOMPATIBLE', `relayfile must support control-plane API v${RELAYFILE_WEBHOOK_BINDINGS_API_VERSION} to safely clean up inbound webhook subscriptions. Upgrade relayfile and retry.`);
136
+ }
120
137
  },
121
138
  async resolveWritebackBinding(channel) {
122
139
  try {
123
- const { url, secret } = await client.writebackSecret(channel);
140
+ const { url, secret, workspaceId } = await client.writebackSecret(channel);
124
141
  if (!url?.trim() || !secret?.trim())
125
142
  return undefined;
126
- return { url: url.trim(), secret: secret.trim() };
143
+ return { url: url.trim(), secret: secret.trim(), workspaceId: workspaceId?.trim() || undefined };
127
144
  }
128
145
  catch (err) {
129
146
  // A missing/disconnected writeback secret is a soft miss (caller falls
@@ -137,6 +154,15 @@ export function defaultRelayfileBridge(options) {
137
154
  return undefined;
138
155
  }
139
156
  },
157
+ async createWebhookSubscription(input) {
158
+ return client.createWebhookSubscription(input);
159
+ },
160
+ async deleteWebhookSubscription(subscriptionId, workspace) {
161
+ await client.deleteWebhookSubscription(subscriptionId, workspace);
162
+ },
163
+ async listWebhookSubscriptions(workspace) {
164
+ return client.listWebhookSubscriptions(workspace);
165
+ },
140
166
  };
141
167
  }
142
168
  async function promptLine(question) {
@@ -177,6 +203,7 @@ function withIntegrationDefaults(overrides = {}) {
177
203
  ...withSdkDefaults(overrides),
178
204
  resolveLocalRelayOptions: resolveLocalBrokerRelayOptions,
179
205
  relayfile: defaultRelayfileBridge(),
206
+ cleanupJournal: fileCleanupJournal(),
180
207
  isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
181
208
  prompt: promptLine,
182
209
  ...overrides,
@@ -185,6 +212,9 @@ function withIntegrationDefaults(overrides = {}) {
185
212
  function explicitWorkspaceKey(opts) {
186
213
  return typeof opts.workspaceKey === 'string' && opts.workspaceKey.trim() !== '';
187
214
  }
215
+ function isRecord(value) {
216
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
217
+ }
188
218
  function shouldRetryWithLocalWorkspaceKey(error) {
189
219
  const message = error instanceof Error ? error.message : String(error);
190
220
  return (/invalid (api|workspace) key/i.test(message) ||
@@ -242,7 +272,69 @@ async function resolveWriteback(deps, commandOpts, channel) {
242
272
  throw new Error('Could not resolve the writeback signing secret. Ensure relayfile is logged ' +
243
273
  'in (relayfile login) and supports `integration writeback-secret`, or pass --bridge-url and --bridge-secret.');
244
274
  }
245
- return { url: binding.url, secret: binding.secret };
275
+ return { url: binding.url, secret: binding.secret, workspaceId: binding.workspaceId };
276
+ }
277
+ async function createRelayfileInboundTarget(commandOpts, local, input) {
278
+ const options = sdkOptionsFromOpts(commandOpts);
279
+ const authOptions = local && !explicitWorkspaceKey(commandOpts) ? localRetryOptions(options, local) : options;
280
+ const workspaceKey = resolveWorkspaceKey(authOptions);
281
+ const baseUrl = resolveInboundTargetBaseUrl(options);
282
+ const response = await fetch(new URL('/v1/integrations/relayfile/inbound-target', baseUrl), {
283
+ method: 'POST',
284
+ headers: {
285
+ Authorization: `Bearer ${workspaceKey}`,
286
+ 'Content-Type': 'application/json',
287
+ },
288
+ body: JSON.stringify({
289
+ channel: input.channel,
290
+ provider: input.provider,
291
+ pathGlob: input.pathGlob,
292
+ }),
293
+ });
294
+ let body;
295
+ try {
296
+ body = await response.json();
297
+ }
298
+ catch {
299
+ body = undefined;
300
+ }
301
+ if (!response.ok) {
302
+ const message = isRecord(body) && typeof body.message === 'string'
303
+ ? body.message
304
+ : `relaycast returned ${response.status}`;
305
+ throw new Error(`Could not create relayfile inbound target: ${message}`);
306
+ }
307
+ return parseRelayfileInboundTargetResponse(body);
308
+ }
309
+ function parseRelayfileInboundTargetResponse(body) {
310
+ const data = isRecord(body) && isRecord(body.data) ? body.data : body;
311
+ if (!isRecord(data) || typeof data.url !== 'string' || typeof data.secret !== 'string') {
312
+ throw new Error('relaycast returned an invalid relayfile inbound target response');
313
+ }
314
+ const url = data.url.trim();
315
+ const secret = data.secret.trim();
316
+ if (!url || !secret) {
317
+ throw new Error('relaycast returned an invalid relayfile inbound target response');
318
+ }
319
+ let parsedUrl;
320
+ try {
321
+ parsedUrl = new URL(url);
322
+ }
323
+ catch {
324
+ throw new Error('relaycast returned an invalid relayfile inbound target URL');
325
+ }
326
+ if (parsedUrl.protocol !== 'https:') {
327
+ throw new Error('relaycast returned a non-https relayfile inbound target URL');
328
+ }
329
+ return { url: parsedUrl.toString(), secret };
330
+ }
331
+ function resolveInboundTargetBaseUrl(options) {
332
+ const baseUrl = resolveBaseUrl(options) ?? 'https://cast.agentrelay.com';
333
+ const parsed = new URL(baseUrl);
334
+ if (parsed.protocol !== 'https:') {
335
+ throw new Error('Inbound relayfile target provisioning requires an https Relaycast base URL.');
336
+ }
337
+ return parsed.toString();
246
338
  }
247
339
  function targetChannel(target) {
248
340
  const trimmed = target.trim();
@@ -340,10 +432,444 @@ function webhookNamePrefix(provider, resource) {
340
432
  function inboundWebhookName(prefix) {
341
433
  return `${prefix}:${randomBytes(5).toString('hex')}`;
342
434
  }
343
- /** Surface a best-effort cleanup failure instead of swallowing it — a silently
344
- * failed cleanup is exactly what leaves orphaned webhooks behind. */
345
- function warnCleanup(deps, kind, id, err) {
346
- deps.error(`Warning: failed to clean up ${kind} ${id}: ${err instanceof Error ? err.message : String(err)}`);
435
+ function isAlreadyDeletedWebhookSubscription(err) {
436
+ return err instanceof RelayfileControlPlaneError && err.status === 404;
437
+ }
438
+ /** Relaycast SDK errors expose statusCode/status; a not-found delete means the
439
+ * resource is already gone — the retry must converge, not persist forever. */
440
+ function isNotFoundRelayError(err) {
441
+ if (!err || typeof err !== 'object')
442
+ return false;
443
+ const { statusCode, status } = err;
444
+ return statusCode === 404 || status === 404;
445
+ }
446
+ /**
447
+ * Non-secret identity of the relay connection a journal entry may retry
448
+ * through. The workspace key is a credential, so it is run through a
449
+ * purpose-domain PBKDF2 (never a bare hash — CodeQL
450
+ * js/insufficient-password-hash) with the base URL as the salt domain; only
451
+ * the derived digest is ever stored.
452
+ */
453
+ export function relayCleanupScope(options) {
454
+ let workspaceKey = '';
455
+ try {
456
+ workspaceKey = resolveWorkspaceKey(options);
457
+ }
458
+ catch {
459
+ // No resolvable key: scope on the base URL alone. Entries recorded this
460
+ // way still never leak the key (only a derived digest is stored).
461
+ }
462
+ const digest = pbkdf2Sync(workspaceKey, `agent-relay:cleanup-scope:${resolveBaseUrl(options) ?? ''}`, 10_000, 8, 'sha256').toString('hex');
463
+ return `relay:${digest}`;
464
+ }
465
+ /** The default bridge always talks to the project-local relayfile daemon; the
466
+ * per-entry relayfileWorkspaceId pin (not this scope) guards against active
467
+ * workspace switches between runs. */
468
+ function relayfileCleanupScope() {
469
+ return 'relayfile:project-daemon';
470
+ }
471
+ /**
472
+ * Bounded lease window for entry owners, set safely ABOVE every remote
473
+ * request timeout any lifecycle or recovery performs (each spans seconds; a
474
+ * reconciliation at most a few request timeouts). Explicit bounded-lease
475
+ * tradeoff: a process paused (e.g. SIGSTOP) past this window loses its
476
+ * lease and a rival may proceed — accepted deliberately, because the
477
+ * alternative (pure pid probing) lets PID reuse wedge lifecycles FOREVER.
478
+ */
479
+ /**
480
+ * Lease timing knobs, exported as one mutable object ONLY so tests can run
481
+ * deterministic renewal scenarios with tiny windows; production code never
482
+ * mutates it. renewalMs sits well below leaseMs so an active lifecycle
483
+ * refreshes its lease several times per window.
484
+ */
485
+ export const OWNER_LEASE_CONFIG = {
486
+ leaseMs: 15 * 60_000,
487
+ renewalMs: 5 * 60_000,
488
+ /** Tolerated forward clock skew. A heartbeat further in the FUTURE than
489
+ * this is malformed or from a skewed clock and counts as expired —
490
+ * otherwise a finite future value (e.g. 9e15) would make its owner live
491
+ * forever and unbound the lease again. */
492
+ skewMs: 2 * 60_000,
493
+ };
494
+ /** A journal entry lease-owner is live ONLY while its bounded lease is
495
+ * fresh AND (same host) its pid probes alive. The heartbeat bound is what
496
+ * makes PID reuse — which can make a dead owner's pid probe pass forever —
497
+ * and unprobeable foreign-host owners an availability delay of at most
498
+ * OWNER_LEASE_MS, never a permanent wedge. Within the window, an alive
499
+ * probe or a foreign host still fails closed. */
500
+ function isOwnerLive(owner) {
501
+ if (!owner)
502
+ return false;
503
+ const age = Date.now() - owner.heartbeatAt;
504
+ if (age > OWNER_LEASE_CONFIG.leaseMs)
505
+ return false; // lease expired
506
+ if (age < -OWNER_LEASE_CONFIG.skewMs)
507
+ return false; // future-dated beyond skew: bounded, not immortal
508
+ if (owner.host !== hostname())
509
+ return true; // cannot prove death — fail closed within the lease
510
+ try {
511
+ process.kill(owner.pid, 0);
512
+ return true;
513
+ }
514
+ catch (err) {
515
+ return err.code !== 'ESRCH';
516
+ }
517
+ }
518
+ function newCleanupOwner() {
519
+ return {
520
+ pid: process.pid,
521
+ host: hostname(),
522
+ attemptId: randomBytes(8).toString('hex'),
523
+ heartbeatAt: Date.now(),
524
+ };
525
+ }
526
+ /**
527
+ * Periodic lease renewal for an owned journal record: an unref'd timer
528
+ * atomically re-stamps heartbeatAt on entries owned by exactly this
529
+ * attemptId, so a legitimate lifecycle awaiting external operations past
530
+ * OWNER_LEASE_CONFIG.leaseMs keeps a LIVE reservation (a paused event loop
531
+ * past the bound remains the documented tradeoff). Started only after the
532
+ * reservation is durably acquired; MUST be stopped before the record is
533
+ * cleared on every exit path. A renewal failure latches: assertHealthy()
534
+ * then throws so later lifecycle mutations/commits never proceed blindly on
535
+ * a possibly-lost lease.
536
+ */
537
+ function startLeaseRenewal(deps, attemptId) {
538
+ let failed;
539
+ let renewing = false;
540
+ let stopped = false;
541
+ const timer = setInterval(() => {
542
+ if (renewing || failed || stopped)
543
+ return;
544
+ renewing = true;
545
+ let matched = 0;
546
+ deps.cleanupJournal
547
+ .update((entries) => entries.map((entry) => {
548
+ if (entry.owner?.attemptId !== attemptId)
549
+ return entry;
550
+ matched += 1;
551
+ return { ...entry, owner: { ...entry.owner, heartbeatAt: Date.now() } };
552
+ }))
553
+ .then(() => {
554
+ // A tick that was already in flight when stop() ran settles as a
555
+ // no-op: after settle removed the record, its zero-match would
556
+ // otherwise latch a FALSE lost-lease warning.
557
+ if (stopped)
558
+ return;
559
+ if (matched === 0) {
560
+ // The owned record is GONE — the lease was lost (e.g. reclaimed);
561
+ // latch so no further external mutation proceeds on it.
562
+ failed = 'reservation record no longer present';
563
+ deps.error('Warning: the lifecycle lease record disappeared during renewal; aborting before any further external mutation.');
564
+ }
565
+ })
566
+ .catch((err) => {
567
+ if (stopped)
568
+ return;
569
+ failed = err instanceof Error ? err.message : String(err);
570
+ deps.error('Warning: could not renew the lifecycle lease; aborting before any further external mutation.');
571
+ })
572
+ .finally(() => {
573
+ renewing = false;
574
+ });
575
+ }, OWNER_LEASE_CONFIG.renewalMs);
576
+ timer.unref?.();
577
+ return {
578
+ stop() {
579
+ // Latch BEFORE clearing so an in-flight tick's settlement observes it.
580
+ stopped = true;
581
+ clearInterval(timer);
582
+ },
583
+ assertHealthy(context) {
584
+ if (failed) {
585
+ throw new Error(`The lifecycle lease for ${context} could not be renewed (${failed}); aborting instead of proceeding on a possibly-lost reservation.`);
586
+ }
587
+ },
588
+ };
589
+ }
590
+ function sameGlobSet(a, b) {
591
+ const left = [...(a ?? [])].sort();
592
+ const right = [...(b ?? [])].sort();
593
+ return left.length === right.length && left.every((glob, i) => glob === right[i]);
594
+ }
595
+ /**
596
+ * Record cleanup work that already exists remotely (the mutation happened; a
597
+ * journal failure here can only be surfaced, not used to abort). Returns
598
+ * whether the entry is durably recorded. Never logs entry urls or ids — the
599
+ * journal file is their only home.
600
+ */
601
+ async function tryRecordCleanup(deps, entry, context) {
602
+ try {
603
+ await deps.cleanupJournal.update((entries) => entries.some((existing) => cleanupEntryKey(existing) === cleanupEntryKey(entry))
604
+ ? entries
605
+ : [...entries, entry]);
606
+ deps.error(`Warning: could not clean up a ${describeCleanupKind(entry.kind)} for ${context}; recorded for retry on a later run.`);
607
+ return true;
608
+ }
609
+ catch (journalErr) {
610
+ deps.error(`Warning: could not clean up a ${describeCleanupKind(entry.kind)} for ${context} AND could not record it for retry (${journalErr instanceof Error ? journalErr.message : String(journalErr)}).`);
611
+ return false;
612
+ }
613
+ }
614
+ function describeCleanupKind(kind) {
615
+ switch (kind) {
616
+ case 'relayfile-webhook-subscription':
617
+ return 'relayfile webhook subscription';
618
+ case 'relay-webhook':
619
+ return 'relay inbound webhook';
620
+ case 'relay-subscription':
621
+ return 'relay event subscription';
622
+ case 'subscribe-attempt':
623
+ return 'subscribe attempt';
624
+ }
625
+ }
626
+ async function removeCleanupEntry(deps, entry) {
627
+ await deps.cleanupJournal.update((entries) => entries.filter((existing) => cleanupEntryKey(existing) !== cleanupEntryKey(entry)));
628
+ }
629
+ /**
630
+ * Retry every journal entry the current run's connections can act on. Runs on
631
+ * each subscribe/unsubscribe before that run's own lifecycle mutations.
632
+ *
633
+ * Guards: an entry whose lease-owner is live (or unprovable) is untouched —
634
+ * it belongs to an in-flight transaction; entries from other scopes are
635
+ * untouched; ids referenced by an active binding or the caller's keep-set are
636
+ * never deleted; cloud deletes are pinned to the entry's recorded workspace
637
+ * and a cloud 404 only converges when pinned (an unpinned 404 might be the
638
+ * wrong active workspace, so the entry is retained); attempt reconciliation
639
+ * uses the API-v3 list operation. Failures keep the entry. Never logs entry
640
+ * urls or ids.
641
+ */
642
+ async function sweepPendingCleanups(deps, relay, options) {
643
+ let entries;
644
+ let bindings;
645
+ try {
646
+ [entries, bindings] = await Promise.all([deps.cleanupJournal.list(), deps.relayfile.listBindings()]);
647
+ }
648
+ catch {
649
+ return; // fail closed: unknown journal or binding state sweeps nothing
650
+ }
651
+ if (entries.length === 0)
652
+ return;
653
+ const activeWebhookSubscriptionIds = new Set(bindings.map((b) => b.webhookSubscriptionId).filter((id) => Boolean(id)));
654
+ if (options.keepWebhookSubscriptionId)
655
+ activeWebhookSubscriptionIds.add(options.keepWebhookSubscriptionId);
656
+ const activeWebhookIds = new Set(bindings.map((b) => b.webhookId).filter(Boolean));
657
+ if (options.keepWebhookId)
658
+ activeWebhookIds.add(options.keepWebhookId);
659
+ const activeSubscriptionIds = new Set(bindings.map((b) => b.subscriptionId).filter(Boolean));
660
+ const deleteCloudSubscription = async (id, workspace) => {
661
+ try {
662
+ await deps.relayfile.deleteWebhookSubscription(id, workspace);
663
+ return 'deleted';
664
+ }
665
+ catch (err) {
666
+ if (isAlreadyDeletedWebhookSubscription(err) && workspace)
667
+ return 'deleted';
668
+ if (isAlreadyDeletedWebhookSubscription(err))
669
+ return 'retained'; // unpinned 404: maybe wrong workspace
670
+ throw err;
671
+ }
672
+ };
673
+ for (const entry of entries) {
674
+ if (isOwnerLive(entry.owner))
675
+ continue; // an in-flight transaction's lease
676
+ try {
677
+ switch (entry.kind) {
678
+ case 'relayfile-webhook-subscription': {
679
+ if (entry.scope !== options.relayfileScope || !entry.id)
680
+ continue;
681
+ if (activeWebhookSubscriptionIds.has(entry.id))
682
+ continue;
683
+ if ((await deleteCloudSubscription(entry.id, entry.relayfileWorkspaceId)) === 'retained') {
684
+ continue;
685
+ }
686
+ await removeCleanupEntry(deps, entry);
687
+ deps.log('Retired a relayfile webhook subscription from an earlier failed cleanup.');
688
+ break;
689
+ }
690
+ case 'subscribe-attempt': {
691
+ if (entry.scope !== options.relayfileScope)
692
+ continue;
693
+ if (entry.operation === 'unsubscribe') {
694
+ // An unsubscribe reservation creates nothing; a dead one has
695
+ // nothing to recover (its failures were recorded as ownerless
696
+ // concrete entries) and is simply released.
697
+ await removeCleanupEntry(deps, entry);
698
+ break;
699
+ }
700
+ // CLAIM the stale attempt as this process's live recovery lease
701
+ // BEFORE any remote list/delete. Recovery then holds the same
702
+ // per-resource lease new lifecycles reserve under (prepare aborts
703
+ // on a live owner), so a new subscribe can never create a same-key
704
+ // pre-bind resource while this reconciliation is mid-flight — and
705
+ // if the entry vanished or was claimed meanwhile, recovery skips.
706
+ const recoveryOwner = newCleanupOwner();
707
+ let claimed = false;
708
+ await deps.cleanupJournal.update((fresh) => {
709
+ const index = fresh.findIndex((candidate) => cleanupEntryKey(candidate) === cleanupEntryKey(entry));
710
+ if (index < 0 || isOwnerLive(fresh[index].owner))
711
+ return fresh;
712
+ claimed = true;
713
+ const next = [...fresh];
714
+ next[index] = { ...next[index], owner: recoveryOwner };
715
+ return next;
716
+ });
717
+ if (!claimed)
718
+ break;
719
+ const claimedEntry = { ...entry, owner: recoveryOwner };
720
+ const releaseClaim = () => deps.cleanupJournal
721
+ .update((fresh) => fresh.map((candidate) => cleanupEntryKey(candidate) === cleanupEntryKey(claimedEntry)
722
+ ? { ...candidate, owner: entry.owner }
723
+ : candidate))
724
+ .catch(() => undefined);
725
+ try {
726
+ let cloudResolved = false;
727
+ // Cloud side: a concrete id beats list-matching; both are pinned to
728
+ // the attempt's recorded workspace when known.
729
+ if (entry.webhookSubscriptionId &&
730
+ !activeWebhookSubscriptionIds.has(entry.webhookSubscriptionId)) {
731
+ cloudResolved =
732
+ (await deleteCloudSubscription(entry.webhookSubscriptionId, entry.relayfileWorkspaceId)) ===
733
+ 'deleted';
734
+ }
735
+ else if (entry.webhookSubscriptionId) {
736
+ cloudResolved = true; // bound and active — nothing to clean
737
+ }
738
+ else if (entry.relayfileWorkspaceId) {
739
+ // Only a workspace-pinned attempt may list-reconcile: an unpinned
740
+ // list would query whatever workspace is active NOW and could
741
+ // falsely settle an attempt whose create landed elsewhere.
742
+ const listed = await deps.relayfile.listWebhookSubscriptions(entry.relayfileWorkspaceId);
743
+ if (listed.workspaceId !== entry.relayfileWorkspaceId) {
744
+ // Fail closed unless the daemon EXPLICITLY confirms it answered
745
+ // for the pinned workspace — a missing echo is not good enough.
746
+ cloudResolved = false;
747
+ }
748
+ else {
749
+ const orphans = listed.subscriptions.filter((subscription) => subscription.url === entry.url &&
750
+ sameGlobSet(subscription.pathGlobs, entry.pathGlobs) &&
751
+ !activeWebhookSubscriptionIds.has(subscription.subscriptionId));
752
+ for (const orphan of orphans) {
753
+ await deleteCloudSubscription(orphan.subscriptionId, entry.relayfileWorkspaceId);
754
+ }
755
+ cloudResolved = true;
756
+ }
757
+ }
758
+ // Relay side: recover by concrete id or deterministic key.
759
+ const relayResolved = Boolean(relay) && entry.relayScope === options.relayScope;
760
+ if (relay && entry.relayScope === options.relayScope) {
761
+ const webhookIds = new Set();
762
+ if (entry.webhookId) {
763
+ webhookIds.add(entry.webhookId);
764
+ }
765
+ else if (entry.webhookName) {
766
+ // Exact-name match only: a prefix match could hit a DIFFERENT
767
+ // attempt's pre-bind webhook for the same resource.
768
+ const hooks = await relay.webhooks.list();
769
+ for (const hook of hooks) {
770
+ if (hook.name === entry.webhookName && !activeWebhookIds.has(hook.webhookId)) {
771
+ webhookIds.add(hook.webhookId);
772
+ }
773
+ }
774
+ }
775
+ for (const webhookId of webhookIds) {
776
+ if (activeWebhookIds.has(webhookId))
777
+ continue;
778
+ try {
779
+ await relay.webhooks.delete(webhookId);
780
+ }
781
+ catch (err) {
782
+ if (!isNotFoundRelayError(err))
783
+ throw err;
784
+ }
785
+ }
786
+ if (entry.subscriptionId) {
787
+ if (!activeSubscriptionIds.has(entry.subscriptionId)) {
788
+ try {
789
+ await relay.webhooks.unsubscribe(entry.subscriptionId);
790
+ }
791
+ catch (err) {
792
+ if (!isNotFoundRelayError(err))
793
+ throw err;
794
+ }
795
+ }
796
+ }
797
+ else if (entry.writebackUrl) {
798
+ // The attempt's subscription targeted the writeback url with a
799
+ // per-attempt marker appended, so an exact-url match can only be
800
+ // THIS dead attempt's subscription — never another attempt's on
801
+ // the same channel. The active-id guard stays as belt.
802
+ const subscriptions = await relay.webhooks.subscriptions();
803
+ for (const subscription of subscriptions) {
804
+ if (subscription.url === entry.writebackUrl &&
805
+ !activeSubscriptionIds.has(subscription.id)) {
806
+ try {
807
+ await relay.webhooks.unsubscribe(subscription.id);
808
+ }
809
+ catch (err) {
810
+ if (!isNotFoundRelayError(err))
811
+ throw err;
812
+ }
813
+ }
814
+ }
815
+ }
816
+ }
817
+ if (cloudResolved && relayResolved) {
818
+ await removeCleanupEntry(deps, claimedEntry);
819
+ deps.log(`Reconciled resources left by an interrupted subscribe of ${entry.provider ?? 'unknown'} ${entry.resource ?? ''}.`);
820
+ }
821
+ else {
822
+ // Partial: hand the lease back to its (dead) owner so a later
823
+ // sweep — including one in this same process — can reclaim it.
824
+ await releaseClaim();
825
+ }
826
+ }
827
+ catch (err) {
828
+ await releaseClaim();
829
+ throw err;
830
+ }
831
+ break;
832
+ }
833
+ case 'relay-webhook': {
834
+ if (!relay || entry.scope !== options.relayScope || !entry.id)
835
+ continue;
836
+ if (activeWebhookIds.has(entry.id))
837
+ continue;
838
+ try {
839
+ await relay.webhooks.delete(entry.id);
840
+ }
841
+ catch (err) {
842
+ // 404: an earlier delete succeeded remotely but its response was
843
+ // lost — the entry has converged and must not be retained forever.
844
+ if (!isNotFoundRelayError(err))
845
+ throw err;
846
+ }
847
+ await removeCleanupEntry(deps, entry);
848
+ deps.log('Retired a relay inbound webhook from an earlier failed cleanup.');
849
+ break;
850
+ }
851
+ case 'relay-subscription': {
852
+ if (!relay || entry.scope !== options.relayScope || !entry.id)
853
+ continue;
854
+ if (activeSubscriptionIds.has(entry.id))
855
+ continue;
856
+ try {
857
+ await relay.webhooks.unsubscribe(entry.id);
858
+ }
859
+ catch (err) {
860
+ if (!isNotFoundRelayError(err))
861
+ throw err;
862
+ }
863
+ await removeCleanupEntry(deps, entry);
864
+ deps.log('Retired a relay event subscription from an earlier failed cleanup.');
865
+ break;
866
+ }
867
+ }
868
+ }
869
+ catch {
870
+ deps.error(`Warning: a recorded ${describeCleanupKind(entry.kind)} cleanup still failed; it stays recorded for the next run.`);
871
+ }
872
+ }
347
873
  }
348
874
  /**
349
875
  * The relayfile binding currently backing this (provider, pathGlob), if any.
@@ -370,12 +896,63 @@ async function findExistingBinding(deps, provider, pathGlob) {
370
896
  * binding references it.
371
897
  */
372
898
  async function retireSupersededWebhooks(deps, relay, options) {
373
- const { provider, prefix, keepWebhookId, priorBinding } = options;
899
+ const { provider, resource, prefix, keepWebhookId, keepWebhookSubscriptionId, priorBinding } = options;
900
+ // The prior binding's ids were pre-recorded in the journal BEFORE bind()
901
+ // overwrote their only other persisted home (see prepareSubscribeIntent), so
902
+ // a failed delete here needs no recording — the entry simply stays; a
903
+ // successful delete clears it.
374
904
  if (priorBinding && priorBinding.subscriptionId) {
375
- await relay.webhooks
376
- .unsubscribe(priorBinding.subscriptionId)
377
- .catch((err) => warnCleanup(deps, 'subscription', priorBinding.subscriptionId, err));
905
+ const entry = {
906
+ kind: 'relay-subscription',
907
+ scope: options.relayScope,
908
+ id: priorBinding.subscriptionId,
909
+ };
910
+ try {
911
+ await relay.webhooks.unsubscribe(priorBinding.subscriptionId);
912
+ // A failed remove only leaves a lingering entry; the 404-tolerant sweep
913
+ // self-heals it, so it must not fail the already-successful subscribe.
914
+ await removeCleanupEntry(deps, entry).catch(() => undefined);
915
+ }
916
+ catch (err) {
917
+ if (isNotFoundRelayError(err)) {
918
+ await removeCleanupEntry(deps, entry).catch(() => undefined);
919
+ }
920
+ else {
921
+ deps.error(`Warning: could not retire the prior relay event subscription for ${provider} ${resource}; it stays recorded for a later run.`);
922
+ }
923
+ }
924
+ }
925
+ const priorWebhookSubId = priorBinding?.webhookSubscriptionId;
926
+ if (priorWebhookSubId && priorWebhookSubId !== keepWebhookSubscriptionId) {
927
+ const priorWorkspaceId = priorBinding?.webhookSubscriptionWorkspaceId;
928
+ const entry = {
929
+ kind: 'relayfile-webhook-subscription',
930
+ scope: options.relayfileScope,
931
+ id: priorWebhookSubId,
932
+ ...(priorWorkspaceId ? { relayfileWorkspaceId: priorWorkspaceId } : {}),
933
+ };
934
+ try {
935
+ await deps.relayfile.deleteWebhookSubscription(priorWebhookSubId, priorWorkspaceId);
936
+ await removeCleanupEntry(deps, entry).catch(() => undefined);
937
+ }
938
+ catch (err) {
939
+ // A 404 only converges when the delete was workspace-pinned; an
940
+ // unpinned "not found" might just mean the daemon's active workspace
941
+ // changed. Either way the pre-recorded entry stays until attributable.
942
+ if (isAlreadyDeletedWebhookSubscription(err) && priorWorkspaceId) {
943
+ await removeCleanupEntry(deps, entry).catch(() => undefined);
944
+ }
945
+ else {
946
+ deps.error(`Warning: could not retire the prior relayfile webhook subscription for ${provider} ${resource}; it stays recorded for a later run.`);
947
+ }
948
+ }
378
949
  }
950
+ await sweepPendingCleanups(deps, relay, {
951
+ relayScope: options.relayScope,
952
+ relayfileScope: options.relayfileScope,
953
+ keepWebhookSubscriptionId,
954
+ keepWebhookId,
955
+ });
379
956
  let webhooks;
380
957
  let activeWebhookIds;
381
958
  try {
@@ -386,22 +963,104 @@ async function retireSupersededWebhooks(deps, relay, options) {
386
963
  catch {
387
964
  return; // best-effort hygiene; the new binding is already live
388
965
  }
966
+ // Deletion here is strictly ATTRIBUTABLE: the prior binding's exact webhook
967
+ // id and the unbound legacy fixed name. Broad same-prefix orphan deletion
968
+ // was removed — a concurrent attempt's pre-bind webhook shares the prefix
969
+ // and no snapshot ordering can make deleting it provably safe, while every
970
+ // attributable orphan is already covered by the prior id, the journaled
971
+ // exact attempt names, or recorded relay-webhook entries. An unattributable
972
+ // ancient orphan is leaked rather than risked.
389
973
  const legacyName = `relayfile:${provider}`;
390
974
  for (const hook of webhooks) {
391
975
  // Never delete a webhook an active binding references — that includes the
392
976
  // one we just bound, and guards against a concurrent re-subscribe that
393
- // upserted a newer same-prefix binding between our bind and this sweep.
977
+ // upserted a newer binding between our bind and this sweep.
394
978
  if (hook.webhookId === keepWebhookId || activeWebhookIds.has(hook.webhookId))
395
979
  continue;
396
980
  const isPrior = priorBinding?.webhookId === hook.webhookId;
397
- const isSameResourceOrphan = hook.name?.startsWith(`${prefix}:`) ?? false;
398
981
  const isUnboundLegacy = hook.name === legacyName;
399
- if (!isPrior && !isSameResourceOrphan && !isUnboundLegacy)
982
+ if (!isPrior && !isUnboundLegacy)
400
983
  continue;
984
+ // A failed delete here needs no journal entry: this same sweep re-discovers
985
+ // the webhook by name/binding on the next run. Logs stay id-free — the
986
+ // human-meaningful webhook NAME identifies it for operators.
401
987
  await relay.webhooks
402
988
  .delete(hook.webhookId)
403
- .then(() => deps.log(`Retired superseded webhook ${hook.webhookId} (${hook.name ?? 'unnamed'}).`))
404
- .catch((err) => warnCleanup(deps, 'webhook', hook.webhookId, err));
989
+ .then(() => deps.log(`Retired superseded webhook ${hook.name ?? 'unnamed'}.`))
990
+ .catch(() => deps.error(`Warning: failed to retire superseded webhook ${hook.name ?? 'unnamed'}; the next subscribe run retries it.`));
991
+ }
992
+ }
993
+ /**
994
+ * Enforce the crash-window contract before ANY external create.
995
+ *
996
+ * 1. Retry all recorded cleanup work first (every subscribe run sweeps).
997
+ * 2. A retained attempt with the same (scope, provider, resource, url, glob
998
+ * set) after the sweep is either a LIVE concurrent transaction (its
999
+ * lease-owner is alive — abort rather than interleave with it) or an
1000
+ * interrupted one that could not be reconciled because the daemon or cloud
1001
+ * was unreachable — abort rather than double-subscribe).
1002
+ * 3. Journal, in ONE atomic write, this run's owner-leased attempt record
1003
+ * (whose deterministic keys can recover all three creates after a crash)
1004
+ * AND the prior binding's non-rediscoverable ids — bind() is about to
1005
+ * overwrite their only other persisted home. Until then the sweep's
1006
+ * active-binding guard keeps the pre-recorded ids untouchable. Any journal
1007
+ * failure aborts the run (nothing external has been created yet).
1008
+ */
1009
+ async function prepareSubscribeAttempt(deps, relay, attempt, channel, scopes) {
1010
+ // Ownership conflicts match on the durable binding identity ONLY — a
1011
+ // regenerated inbound-target url or glob spelling must not let two
1012
+ // same-resource lifecycles run concurrently. (url/globs remain the cloud
1013
+ // RECOVERY keys, not the ownership key.)
1014
+ const matchesAttemptKey = (entry) => entry.kind === 'subscribe-attempt' &&
1015
+ entry.scope === attempt.scope &&
1016
+ entry.provider === attempt.provider &&
1017
+ entry.resource === attempt.resource;
1018
+ await sweepPendingCleanups(deps, relay, scopes);
1019
+ // Pin the exact relayfile workspace BEFORE any cloud create: a crash after
1020
+ // the create reaches the server but before its response would otherwise
1021
+ // leave the attempt unpinned, and an unpinned list/delete after an
1022
+ // active-workspace switch could falsely settle it. The pin is MANDATORY on
1023
+ // every path — no pin, no create. writeback-secret resolution provides it
1024
+ // normally; with --bridge-url/--bridge-secret overrides the control plane is
1025
+ // still consulted for the workspace alone, then list subscriptions provides
1026
+ // the fallback. A run that cannot obtain a pin aborts here.
1027
+ if (!attempt.relayfileWorkspaceId) {
1028
+ // Even with explicit --bridge-url/--bridge-secret overrides, the control
1029
+ // plane is still consulted for the workspace identity alone.
1030
+ const resolved = await deps.relayfile.resolveWritebackBinding(channel).catch(() => undefined);
1031
+ if (resolved?.workspaceId)
1032
+ attempt.relayfileWorkspaceId = resolved.workspaceId;
1033
+ }
1034
+ if (!attempt.relayfileWorkspaceId) {
1035
+ const { workspaceId } = await deps.relayfile.listWebhookSubscriptions();
1036
+ if (workspaceId)
1037
+ attempt.relayfileWorkspaceId = workspaceId;
1038
+ }
1039
+ if (!attempt.relayfileWorkspaceId) {
1040
+ // No pin, no create: an unpinned cloud subscription would be unretryable
1041
+ // across an active-workspace switch.
1042
+ throw new Error(`Could not determine the active relayfile workspace before subscribing ${attempt.provider} ${attempt.resource}; upgrade the relayfile daemon (>= control-plane API v3 with workspace identity) and re-run.`);
1043
+ }
1044
+ // Check-and-reserve happens INSIDE one journal update, under its exclusive
1045
+ // lock — two racing prepares cannot both observe "no matching attempt" and
1046
+ // both append their own reservation (check-then-write TOCTOU). The prior
1047
+ // binding is read (and its ids pre-recorded) only AFTER this lease is held,
1048
+ // making the lease the linearization point for the whole lifecycle.
1049
+ let conflict;
1050
+ await deps.cleanupJournal.update((entries) => {
1051
+ const retained = entries.filter(matchesAttemptKey);
1052
+ if (retained.length > 0) {
1053
+ conflict = retained.some((entry) => isOwnerLive(entry.owner)) ? 'live' : 'unreconciled';
1054
+ return entries;
1055
+ }
1056
+ const keys = new Set(entries.map(cleanupEntryKey));
1057
+ return keys.has(cleanupEntryKey(attempt)) ? entries : [...entries, attempt];
1058
+ });
1059
+ if (conflict === 'live') {
1060
+ throw new Error(`Another subscribe of ${attempt.provider} ${attempt.resource} appears to be in progress. Wait for it to finish (or for its process to exit) and re-run.`);
1061
+ }
1062
+ if (conflict === 'unreconciled') {
1063
+ throw new Error(`Could not reconcile the resources left by an interrupted subscribe of ${attempt.provider} ${attempt.resource}. Aborting before creating another subscription; re-run once relayfile-cloud is reachable.`);
405
1064
  }
406
1065
  }
407
1066
  async function runSubscribe(deps, providerArg, opts) {
@@ -431,64 +1090,236 @@ async function runSubscribe(deps, providerArg, opts) {
431
1090
  deps.error(`Warning: ${resolved.warning}`);
432
1091
  }
433
1092
  const relayOptions = sdkOptionsFromOpts(opts);
434
- const relay = deps.createAgentRelay(local && !explicitWorkspaceKey(opts) ? localRetryOptions(relayOptions, local) : relayOptions);
1093
+ const effectiveRelayOptions = local && !explicitWorkspaceKey(opts) ? localRetryOptions(relayOptions, local) : relayOptions;
1094
+ const relay = deps.createAgentRelay(effectiveRelayOptions);
435
1095
  await ensureRecipient(relay, provider, to, opts);
436
1096
  const channel = targetChannel(to);
437
1097
  const events = commaList(opts.events);
438
1098
  const writeback = await resolveWriteback(deps, opts, channel);
1099
+ const inboundTarget = await createRelayfileInboundTarget(opts, local, {
1100
+ channel,
1101
+ provider,
1102
+ pathGlob,
1103
+ });
439
1104
  const prefix = webhookNamePrefix(provider, pathGlob);
440
1105
  const name = inboundWebhookName(prefix);
441
- // Capture the binding we're about to replace *before* touching anything, so we
442
- // can retire it only after the new one is fully live (create-first). A
443
- // per-attempt nonce in `name` means createInbound never collides on the unique
444
- // (workspace, name) index, so the replacement can exist alongside the old one.
445
- const priorBinding = await findExistingBinding(deps, provider, pathGlob);
1106
+ const relayScope = relayCleanupScope(effectiveRelayOptions);
1107
+ const relayfileScope = relayfileCleanupScope();
1108
+ // The attempt record is this transaction's lease AND its crash-recovery
1109
+ // anchor: written before any create, upgraded with each server-assigned id,
1110
+ // removed only on settle. Its deterministic keys — the inbound (url, glob
1111
+ // set) for the cloud subscription, the resource-scoped webhook name prefix,
1112
+ // and the per-channel writeback url — let a later run reconcile whichever
1113
+ // creates landed if this process dies at ANY point in between.
1114
+ const owner = newCleanupOwner();
1115
+ // The relay event subscription targets the per-channel writeback url with a
1116
+ // NONSECRET per-attempt marker appended. relayfile-cloud's writeback route
1117
+ // ignores the query string and its HMAC covers headers+body only, so
1118
+ // delivery is unaffected — but the exact url now uniquely identifies THIS
1119
+ // attempt's subscription, making post-create/pre-id crash recovery
1120
+ // deterministic with no cross-resource deletion risk on shared channels.
1121
+ const subscriptionTargetUrl = `${writeback.url}${writeback.url.includes('?') ? '&' : '?'}relaySubscribeAttempt=${owner.attemptId}`;
1122
+ const attempt = {
1123
+ kind: 'subscribe-attempt',
1124
+ scope: relayfileScope,
1125
+ provider,
1126
+ resource: pathGlob,
1127
+ url: inboundTarget.url,
1128
+ pathGlobs: [pathGlob],
1129
+ webhookName: name,
1130
+ writebackUrl: subscriptionTargetUrl,
1131
+ relayScope,
1132
+ ...(writeback.workspaceId ? { relayfileWorkspaceId: writeback.workspaceId } : {}),
1133
+ owner,
1134
+ };
1135
+ const upgradeAttempt = async (patch) => {
1136
+ Object.assign(attempt, patch);
1137
+ // Best-effort: if the write fails, the previous attempt state (already
1138
+ // durable) still recovers this create through its deterministic key.
1139
+ await deps.cleanupJournal
1140
+ .update((entries) => entries.map((entry) => entry.owner?.attemptId === attempt.owner?.attemptId && entry.kind === 'subscribe-attempt'
1141
+ ? { ...entry, ...patch }
1142
+ : entry))
1143
+ .catch(() => deps.error('Warning: could not update the subscribe attempt record; recovery falls back to its deterministic keys.'));
1144
+ };
1145
+ await prepareSubscribeAttempt(deps, relay, attempt, channel, { relayScope, relayfileScope });
1146
+ // The reservation is durable — keep its lease fresh for as long as this
1147
+ // transaction runs, however long the external operations below take.
1148
+ const lease = startLeaseRenewal(deps, owner.attemptId);
1149
+ // With the lease held, capture the binding we're about to replace
1150
+ // (create-first: the per-attempt nonce in `name` means createInbound never
1151
+ // collides on the unique (workspace, name) index, so the replacement can
1152
+ // exist alongside the old one) and pre-record its non-rediscoverable ids
1153
+ // BEFORE any create/bind. A crash between the lease and this pre-record is
1154
+ // safe — nothing external has mutated; any failure here releases the lease
1155
+ // and aborts before mutations.
1156
+ let priorBinding;
1157
+ try {
1158
+ priorBinding = await findExistingBinding(deps, provider, pathGlob);
1159
+ const priorEntries = [];
1160
+ if (priorBinding?.subscriptionId) {
1161
+ priorEntries.push({ kind: 'relay-subscription', scope: relayScope, id: priorBinding.subscriptionId });
1162
+ }
1163
+ if (priorBinding?.webhookSubscriptionId) {
1164
+ priorEntries.push({
1165
+ kind: 'relayfile-webhook-subscription',
1166
+ scope: relayfileScope,
1167
+ id: priorBinding.webhookSubscriptionId,
1168
+ ...(priorBinding.webhookSubscriptionWorkspaceId
1169
+ ? { relayfileWorkspaceId: priorBinding.webhookSubscriptionWorkspaceId }
1170
+ : {}),
1171
+ });
1172
+ }
1173
+ if (priorEntries.length > 0) {
1174
+ await deps.cleanupJournal.update((entries) => {
1175
+ const keys = new Set(entries.map(cleanupEntryKey));
1176
+ return [...entries, ...priorEntries.filter((entry) => !keys.has(cleanupEntryKey(entry)))];
1177
+ });
1178
+ }
1179
+ }
1180
+ catch (err) {
1181
+ lease.stop();
1182
+ await removeCleanupEntry(deps, attempt).catch(() => undefined);
1183
+ throw err;
1184
+ }
446
1185
  let webhook;
447
1186
  let subscription;
1187
+ let relayfileWebhook;
1188
+ let bindingInput;
448
1189
  try {
1190
+ lease.assertHealthy(`${provider} ${pathGlob}`);
449
1191
  webhook = await relay.webhooks.createInbound({ channel, name });
1192
+ await upgradeAttempt({ webhookId: webhook.webhookId });
1193
+ lease.assertHealthy(`${provider} ${pathGlob}`);
1194
+ const createdRelayfileWebhook = await deps.relayfile.createWebhookSubscription({
1195
+ url: inboundTarget.url,
1196
+ pathGlobs: [pathGlob],
1197
+ secret: inboundTarget.secret,
1198
+ // Pin the create to the journaled workspace: an active-workspace switch
1199
+ // between writeback-secret resolution and this POST must not land the
1200
+ // subscription in a workspace other than the recorded pin.
1201
+ ...(attempt.relayfileWorkspaceId ? { workspace: attempt.relayfileWorkspaceId } : {}),
1202
+ });
1203
+ relayfileWebhook = createdRelayfileWebhook;
1204
+ if (attempt.relayfileWorkspaceId &&
1205
+ createdRelayfileWebhook.workspaceId &&
1206
+ createdRelayfileWebhook.workspaceId !== attempt.relayfileWorkspaceId) {
1207
+ // The daemon created the subscription somewhere other than the pinned
1208
+ // workspace. Never overwrite the durable pin silently — fail into the
1209
+ // rollback (which deletes/records using the CREATE's actual echo).
1210
+ throw new Error(`relayfile created the webhook subscription in a different workspace than the one pinned for ${provider} ${pathGlob}; aborting and rolling back.`);
1211
+ }
1212
+ await upgradeAttempt({
1213
+ webhookSubscriptionId: createdRelayfileWebhook.subscriptionId,
1214
+ ...(createdRelayfileWebhook.workspaceId
1215
+ ? { relayfileWorkspaceId: createdRelayfileWebhook.workspaceId }
1216
+ : {}),
1217
+ });
1218
+ lease.assertHealthy(`${provider} ${pathGlob}`);
450
1219
  subscription = await relay.integrations.subscriptions.create({
451
1220
  event: events.length === 1 ? events[0] : 'message.created',
452
1221
  events: events.length ? events : ['message.created', 'thread.reply'],
453
1222
  filter: { channel },
454
- url: writeback.url,
1223
+ url: subscriptionTargetUrl,
455
1224
  secret: writeback.secret,
456
1225
  });
457
- await deps.relayfile.bind({
1226
+ await upgradeAttempt({ subscriptionId: subscription.id });
1227
+ lease.assertHealthy(`${provider} ${pathGlob}`);
1228
+ bindingInput = {
458
1229
  provider,
459
1230
  resource: pathGlob,
460
1231
  channel,
461
1232
  webhookId: webhook.webhookId,
462
1233
  webhookToken: webhook.token,
463
1234
  subscriptionId: subscription.id,
464
- });
1235
+ webhookSubscriptionId: createdRelayfileWebhook.subscriptionId,
1236
+ ...(attempt.relayfileWorkspaceId || createdRelayfileWebhook.workspaceId
1237
+ ? {
1238
+ webhookSubscriptionWorkspaceId: attempt.relayfileWorkspaceId ?? createdRelayfileWebhook.workspaceId,
1239
+ }
1240
+ : {}),
1241
+ };
1242
+ await deps.relayfile.bind(bindingInput);
465
1243
  }
466
1244
  catch (err) {
467
- // The new binding never fully landed: roll back only what we just created
468
- // and leave any prior working binding untouched.
1245
+ // The new binding never fully landed: roll back only what we just created,
1246
+ // leave any prior working binding untouched, and keep/record a durable
1247
+ // entry for every id whose rollback delete fails. Successful deletes
1248
+ // resolve; anything unresolved keeps the attempt record as its anchor.
1249
+ let unresolved = false;
469
1250
  if (subscription) {
470
- await relay.integrations.subscriptions
471
- .delete(subscription.id)
472
- .catch((cleanupErr) => warnCleanup(deps, 'subscription', subscription.id, cleanupErr));
1251
+ try {
1252
+ await relay.integrations.subscriptions.delete(subscription.id);
1253
+ }
1254
+ catch (cleanupErr) {
1255
+ if (!isNotFoundRelayError(cleanupErr)) {
1256
+ const recorded = await tryRecordCleanup(deps, { kind: 'relay-subscription', scope: relayScope, id: subscription.id }, `${provider} ${pathGlob}`);
1257
+ unresolved ||= !recorded;
1258
+ }
1259
+ }
473
1260
  }
474
1261
  if (webhook) {
475
- await relay.webhooks
476
- .delete(webhook.webhookId)
477
- .catch((cleanupErr) => warnCleanup(deps, 'webhook', webhook.webhookId, cleanupErr));
1262
+ try {
1263
+ await relay.webhooks.delete(webhook.webhookId);
1264
+ }
1265
+ catch (cleanupErr) {
1266
+ if (!isNotFoundRelayError(cleanupErr)) {
1267
+ const recorded = await tryRecordCleanup(deps, { kind: 'relay-webhook', scope: relayScope, id: webhook.webhookId }, `${provider} ${pathGlob}`);
1268
+ unresolved ||= !recorded;
1269
+ }
1270
+ }
1271
+ }
1272
+ if (relayfileWebhook) {
1273
+ const rollbackWorkspaceId = relayfileWebhook.workspaceId ?? attempt.relayfileWorkspaceId;
1274
+ try {
1275
+ await deps.relayfile.deleteWebhookSubscription(relayfileWebhook.subscriptionId, rollbackWorkspaceId);
1276
+ }
1277
+ catch (cleanupErr) {
1278
+ if (!isAlreadyDeletedWebhookSubscription(cleanupErr) || !rollbackWorkspaceId) {
1279
+ const recorded = await tryRecordCleanup(deps, {
1280
+ kind: 'relayfile-webhook-subscription',
1281
+ scope: relayfileScope,
1282
+ id: relayfileWebhook.subscriptionId,
1283
+ ...(rollbackWorkspaceId ? { relayfileWorkspaceId: rollbackWorkspaceId } : {}),
1284
+ }, `${provider} ${pathGlob}`);
1285
+ unresolved ||= !recorded;
1286
+ }
1287
+ }
1288
+ }
1289
+ lease.stop();
1290
+ if (!unresolved) {
1291
+ // Everything we created is deleted or durably recorded as an ownerless
1292
+ // entry — the attempt record has served its purpose. If it cannot be
1293
+ // removed it is only over-cautious: a later run reconciles it.
1294
+ await removeCleanupEntry(deps, attempt).catch(() => undefined);
478
1295
  }
479
1296
  throw err;
480
1297
  }
481
- // New binding is live (relayfile.bind upserts on (provider, pathGlob)). Now
482
- // retire what it superseded old/orphaned webhooks and the prior subscription.
1298
+ // New binding is live (relayfile.bind upserts on (provider, pathGlob)); its
1299
+ // record now persists all three ids, so the attempt record and the
1300
+ // pre-recorded prior-id entries it superseded are resolved. Then retire
1301
+ // what the binding replaced and sweep.
1302
+ lease.stop();
1303
+ await deps.cleanupJournal
1304
+ .update((entries) => {
1305
+ const resolved = new Set([cleanupEntryKey(attempt)]);
1306
+ return entries.filter((entry) => !resolved.has(cleanupEntryKey(entry)));
1307
+ })
1308
+ .catch(() => deps.error('Warning: could not clear the completed subscribe attempt record; a later run will reconcile it (guarded by the active binding).'));
483
1309
  await retireSupersededWebhooks(deps, relay, {
484
1310
  provider,
1311
+ resource: pathGlob,
485
1312
  prefix,
486
1313
  keepWebhookId: webhook.webhookId,
1314
+ keepWebhookSubscriptionId: bindingInput.webhookSubscriptionId,
487
1315
  priorBinding,
1316
+ relayScope,
1317
+ relayfileScope,
488
1318
  });
489
1319
  // Show the native resource the user typed, plus the resolved glob when they differ.
490
1320
  const boundLabel = pathGlob === resource ? resource : `${resource} (${pathGlob})`;
491
1321
  deps.log(`✓ ${provider} ${boundLabel} bound -> ${to}`);
1322
+ deps.log('✓ Server-side inbound webhook subscription created.');
492
1323
  deps.log('✓ Listening. Replies will post back in-thread.');
493
1324
  }
494
1325
  async function runUnsubscribe(deps, provider, opts) {
@@ -500,27 +1331,124 @@ async function runUnsubscribe(deps, provider, opts) {
500
1331
  // Resolve native -> glob: relayfile keys bindings on the glob, so the user's
501
1332
  // native `--resource` (e.g. owner/repo) must be canonicalized to match.
502
1333
  const { pathGlob } = await deps.relayfile.resolveResourcePath(provider, resource);
503
- const bindings = await deps.relayfile.listBindings();
504
- const binding = bindings.find((item) => item.provider === provider && item.resource === pathGlob);
505
- if (!binding) {
506
- throw new Error(`No binding found for ${provider} ${resource}.`);
507
- }
508
1334
  const local = await deps.resolveLocalRelayOptions();
509
1335
  const relayOptions = sdkOptionsFromOpts(opts);
510
- const relay = deps.createAgentRelay(local && !explicitWorkspaceKey(opts) ? localRetryOptions(relayOptions, local) : relayOptions);
1336
+ const effectiveRelayOptions = local && !explicitWorkspaceKey(opts) ? localRetryOptions(relayOptions, local) : relayOptions;
1337
+ const relay = deps.createAgentRelay(effectiveRelayOptions);
1338
+ const relayScope = relayCleanupScope(effectiveRelayOptions);
1339
+ const relayfileScope = relayfileCleanupScope();
1340
+ // Retry recorded cleanup work before this run's own lifecycle mutations.
1341
+ await sweepPendingCleanups(deps, relay, { relayScope, relayfileScope });
1342
+ // Unsubscribe shares the per-(scope, provider, resource) lifecycle lease
1343
+ // with subscribe: without it, a stale unsubscribe could finish its deletes
1344
+ // AFTER a concurrent re-subscribe bound a fresh replacement and then
1345
+ // unbind() would discard that new binding, orphaning its resources.
1346
+ const reservation = {
1347
+ kind: 'subscribe-attempt',
1348
+ operation: 'unsubscribe',
1349
+ scope: relayfileScope,
1350
+ provider,
1351
+ resource: pathGlob,
1352
+ owner: newCleanupOwner(),
1353
+ };
1354
+ let lifecycleConflict = false;
1355
+ await deps.cleanupJournal.update((entries) => {
1356
+ const conflicting = entries.some((entry) => entry.kind === 'subscribe-attempt' &&
1357
+ entry.scope === relayfileScope &&
1358
+ entry.provider === provider &&
1359
+ entry.resource === pathGlob &&
1360
+ isOwnerLive(entry.owner));
1361
+ if (conflicting) {
1362
+ lifecycleConflict = true;
1363
+ return entries;
1364
+ }
1365
+ return [...entries, reservation];
1366
+ });
1367
+ if (lifecycleConflict) {
1368
+ throw new Error(`Another subscribe/unsubscribe of ${provider} ${resource} appears to be in progress. Wait for it to finish and re-run.`);
1369
+ }
1370
+ const unsubscribeLease = startLeaseRenewal(deps, reservation.owner.attemptId);
1371
+ const releaseReservation = () => {
1372
+ unsubscribeLease.stop();
1373
+ return removeCleanupEntry(deps, reservation).catch(() => undefined);
1374
+ };
1375
+ // The binding is read only AFTER the lease is held — the lease is the
1376
+ // linearization point, so these ids cannot be a stale snapshot from before
1377
+ // a concurrent replacement completed.
1378
+ let binding;
511
1379
  try {
512
- await relay.webhooks.delete(binding.webhookId);
1380
+ const bindings = await deps.relayfile.listBindings();
1381
+ binding = bindings.find((item) => item.provider === provider && item.resource === pathGlob);
513
1382
  }
514
1383
  catch (err) {
515
- deps.log(`Warning: failed to delete webhook ${binding.webhookId}: ${err instanceof Error ? err.message : String(err)}`);
1384
+ await releaseReservation();
1385
+ throw err;
1386
+ }
1387
+ if (!binding) {
1388
+ await releaseReservation();
1389
+ throw new Error(`No binding found for ${provider} ${resource}.`);
516
1390
  }
1391
+ // The binding record about to be unbound holds the only persisted copy of
1392
+ // these ids. Any delete failure below must be durably journaled BEFORE
1393
+ // unbind, or the run aborts with the binding (and its ids) intact. The
1394
+ // whole post-reservation section always releases the lease — an abort that
1395
+ // left it live would wedge later lifecycles until this pid exits.
517
1396
  try {
518
- await relay.webhooks.unsubscribe(binding.subscriptionId);
1397
+ const abortRetainingBinding = (what) => {
1398
+ throw new Error(`Failed to remove the ${what} for ${provider} ${resource} and could not record it for retry. The binding was left in place — re-run \`integration unsubscribe\`.`);
1399
+ };
1400
+ const webhookSubId = binding.webhookSubscriptionId;
1401
+ if (webhookSubId) {
1402
+ unsubscribeLease.assertHealthy(`${provider} ${resource}`);
1403
+ const bindingWorkspaceId = binding.webhookSubscriptionWorkspaceId;
1404
+ const entry = {
1405
+ kind: 'relayfile-webhook-subscription',
1406
+ scope: relayfileScope,
1407
+ id: webhookSubId,
1408
+ ...(bindingWorkspaceId ? { relayfileWorkspaceId: bindingWorkspaceId } : {}),
1409
+ };
1410
+ try {
1411
+ await deps.relayfile.deleteWebhookSubscription(webhookSubId, bindingWorkspaceId);
1412
+ }
1413
+ catch (err) {
1414
+ // A pinned 404 proves the subscription is gone. An UNPINNED 404 might
1415
+ // just be the wrong active workspace — record the id (retained until a
1416
+ // matching-workspace delete succeeds) before discarding the binding.
1417
+ if (!isAlreadyDeletedWebhookSubscription(err) || !bindingWorkspaceId) {
1418
+ const recorded = await tryRecordCleanup(deps, entry, `${provider} ${resource}`);
1419
+ if (!recorded)
1420
+ abortRetainingBinding('relayfile webhook subscription');
1421
+ }
1422
+ }
1423
+ }
1424
+ unsubscribeLease.assertHealthy(`${provider} ${resource}`);
1425
+ try {
1426
+ await relay.webhooks.delete(binding.webhookId);
1427
+ }
1428
+ catch (err) {
1429
+ if (!isNotFoundRelayError(err)) {
1430
+ const recorded = await tryRecordCleanup(deps, { kind: 'relay-webhook', scope: relayScope, id: binding.webhookId }, `${provider} ${resource}`);
1431
+ if (!recorded)
1432
+ abortRetainingBinding('relay inbound webhook');
1433
+ }
1434
+ }
1435
+ unsubscribeLease.assertHealthy(`${provider} ${resource}`);
1436
+ try {
1437
+ await relay.webhooks.unsubscribe(binding.subscriptionId);
1438
+ }
1439
+ catch (err) {
1440
+ if (!isNotFoundRelayError(err)) {
1441
+ const recorded = await tryRecordCleanup(deps, { kind: 'relay-subscription', scope: relayScope, id: binding.subscriptionId }, `${provider} ${resource}`);
1442
+ if (!recorded)
1443
+ abortRetainingBinding('relay event subscription');
1444
+ }
1445
+ }
1446
+ unsubscribeLease.assertHealthy(`${provider} ${resource}`);
1447
+ await deps.relayfile.unbind(provider, pathGlob);
519
1448
  }
520
- catch (err) {
521
- deps.log(`Warning: failed to remove subscription ${binding.subscriptionId}: ${err instanceof Error ? err.message : String(err)}`);
1449
+ finally {
1450
+ await releaseReservation();
522
1451
  }
523
- await deps.relayfile.unbind(provider, pathGlob);
524
1452
  deps.log(`Unsubscribed ${provider} ${resource}.`);
525
1453
  }
526
1454
  export function registerIntegrationCommands(program, overrides = {}) {