@stage-labs/metro 0.1.0-beta.289 → 0.1.0-beta.290

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 (31) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +6 -4
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +55 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +269 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +90 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +196 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +46 -11
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +23 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +122 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +68 -45
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +11 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +4 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +95 -41
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +37 -3
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +15 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +5 -3
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +20 -5
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +1 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +26 -11
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +71 -50
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +5 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
  25. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  26. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +6 -41
  27. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +176 -0
  28. package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +3 -0
  29. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +44 -0
  30. package/runtime/runtime.json +1 -1
  31. package/runtime/sdk-runner/runner.json +1 -1
@@ -1,10 +1,13 @@
1
1
  import { log } from '@metro-labs/core/log';
2
+ import { agentsDir } from '../agents/files.js';
3
+ import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
2
4
  import type { FetchLike } from '@metro-labs/core/stations/oauth';
3
5
  import type { ToolGroup } from '@metro-labs/core/stations/types';
4
6
  import type { DeliveredConnector } from '@metro-labs/http/connector-delivery';
5
7
  import type { ApiError } from '@metro-labs/http/api-error';
6
8
  import { requestConnectorBlocker, type GateRow } from './gates.js';
7
9
  import { publishOrgConnectors } from './publication.js';
10
+ import type { MigrationRuntimeMapping } from './migration-guard.js';
8
11
  import { isOrgRelayId, orgRelayId } from './relay-id.js';
9
12
  import type { RelaySession } from './relay-session.js';
10
13
  import type { RelayTarget } from './relay-target.js';
@@ -13,12 +16,20 @@ import { discoverOrgGroups, orgMissing, orgRuntimeError, orgSignin, orgUnavailab
13
16
 
14
17
  interface RuntimeDeps {
15
18
  enabled?: () => boolean;
19
+ mapping?: () => MigrationRuntimeMapping;
20
+ migrationDir?: string;
16
21
  publish?: (rows: readonly GateRow[]) => void;
17
22
  fetch?: FetchLike;
18
23
  }
19
24
 
25
+ interface RuntimeRow extends DeliveredConnector {
26
+ relayId: string;
27
+ relayName: string;
28
+ }
29
+
20
30
  interface Entry {
21
31
  id: string;
32
+ relayId: string;
22
33
  epoch: number;
23
34
  stamp: string;
24
35
  controller: AbortController;
@@ -30,13 +41,14 @@ interface Entry {
30
41
 
31
42
  interface Session {
32
43
  entry: Entry;
44
+ operation: MigrationOperation | null;
33
45
  controller: AbortController;
34
46
  revision: string | null;
35
47
  error: ApiError | null;
36
48
  }
37
49
 
38
- const stampOf = (row: DeliveredConnector): string => JSON.stringify([
39
- row.name, row.url, row.auth, row.policy.read ?? 'allow', row.policy.write ?? 'allow',
50
+ const stampOf = (row: RuntimeRow): string => JSON.stringify([
51
+ row.relayId, row.relayName, row.name, row.url, row.auth, row.policy.read ?? 'allow', row.policy.write ?? 'allow',
40
52
  Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)),
41
53
  ]);
42
54
 
@@ -74,15 +86,14 @@ export class OrgConnectorRuntime {
74
86
  return !this.stopped && (this.deps.enabled?.() ?? process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true');
75
87
  }
76
88
 
77
- private abort(session: Session, error: ApiError): void {
89
+ private abort(session: Session, error: ApiError, drain = false): void {
78
90
  session.error ??= error;
79
- session.controller.abort();
80
- this.sessions.delete(session);
91
+ if (!drain) session.controller.abort();
81
92
  }
82
93
 
83
- private publish(rows: DeliveredConnector[]): void {
94
+ private publish(rows: RuntimeRow[]): void {
84
95
  const metadata: GateRow[] = rows.filter((row) => row.state === 'ready').map((row) => ({
85
- id: orgRelayId(row.id), name: row.name,
96
+ id: row.relayId, name: row.relayName,
86
97
  config: { policy: structuredClone(row.policy), toolGroups: { ...this.entries.get(row.id)?.groups } },
87
98
  }));
88
99
  const text = JSON.stringify(metadata);
@@ -97,33 +108,66 @@ export class OrgConnectorRuntime {
97
108
  }
98
109
  }
99
110
 
100
- private invalidateEntries(rows: DeliveredConnector[], epoch: number, available: boolean): void {
111
+ private invalidateEntries(rows: RuntimeRow[], epoch: number, available: boolean): void {
101
112
  for (const [id, entry] of this.entries) {
102
113
  const row = rows.find((value) => value.id === id);
103
114
  const valid = available && entry.epoch === epoch;
104
115
  if (valid && row?.state === 'ready' && entry.stamp === stampOf(row)) continue;
105
116
  const error = valid ? rowError(row) ?? orgUnavailable() : orgUnavailable();
106
- entry.controller.abort();
107
- this.entries.delete(id);
108
- this.abortEntry(entry, error);
117
+ this.invalidateEntry(entry, row, error);
109
118
  }
110
119
  }
111
120
 
112
- private abortEntry(entry: Entry, error: ApiError): void {
113
- for (const session of this.sessions) if (session.entry === entry) this.abort(session, error);
121
+ private invalidateEntry(entry: Entry, row: RuntimeRow | undefined, error: ApiError): void {
122
+ const drain = !isOrgRelayId(entry.relayId) && row?.relayId !== entry.relayId;
123
+ if (!drain) entry.controller.abort();
124
+ this.entries.delete(entry.id);
125
+ this.abortEntry(entry, error, drain);
126
+ }
127
+
128
+ private abortEntry(entry: Entry, error: ApiError, drain = false): void {
129
+ for (const session of this.sessions) if (session.entry === entry) this.abort(session, error, drain);
130
+ }
131
+
132
+ private mappedRows(rows: DeliveredConnector[], mapping: MigrationRuntimeMapping): RuntimeRow[] {
133
+ return rows.flatMap((row) => {
134
+ const alias = mapping.aliases.find((value) => value.destinationId === row.id);
135
+ if (alias !== undefined) return [{ ...row, relayId: alias.sourceId, relayName: alias.name }];
136
+ return this.enabled() && !mapping.reserved.includes(row.id) ? [{ ...row, relayId: orgRelayId(row.id), relayName: row.name }] : [];
137
+ });
114
138
  }
115
139
 
116
- private update(): DeliveredConnector[] | null {
117
- const snapshot = this.enabled() ? this.sync.read() : null;
118
- const rows = snapshot?.connectors ?? [];
119
- const epoch = this.sync.epoch();
120
- this.invalidateEntries(rows, epoch, snapshot !== null);
121
- for (const row of rows) {
122
- if (row.state !== 'ready' || this.entries.has(row.id)) continue;
123
- this.entries.set(row.id, { id: row.id, epoch, stamp: stampOf(row), controller: new AbortController(), groups: {}, scan: null, nextScan: 0, error: null });
140
+ private mapping(): MigrationRuntimeMapping {
141
+ if (this.stopped) return { aliases: [], reserved: [] };
142
+ return this.deps.mapping?.() ?? { aliases: [], reserved: [] };
143
+ }
144
+
145
+ private update(): RuntimeRow[] | null {
146
+ try {
147
+ const mapping = this.mapping();
148
+ const snapshot = this.enabled() || mapping.aliases.length > 0 ? this.sync.read() : null;
149
+ const rows = this.mappedRows(snapshot?.connectors ?? [], mapping);
150
+ const epoch = this.sync.epoch();
151
+ this.invalidateEntries(rows, epoch, snapshot !== null);
152
+ for (const row of rows) {
153
+ if (row.state !== 'ready' || this.entries.has(row.id)) continue;
154
+ this.entries.set(row.id, { id: row.id, relayId: row.relayId, epoch, stamp: stampOf(row), controller: new AbortController(), groups: {}, scan: null, nextScan: 0, error: null });
155
+ }
156
+ this.publish(rows);
157
+ return snapshot === null ? null : rows;
158
+ } catch {
159
+ this.invalidateEntries([], this.sync.epoch(), false);
160
+ this.publish([]);
161
+ throw orgUnavailable();
124
162
  }
125
- this.publish(rows);
126
- return snapshot === null ? null : rows;
163
+ }
164
+
165
+ refresh(): void {
166
+ this.update();
167
+ }
168
+
169
+ idle(id?: string): boolean {
170
+ return ![...this.sessions].some((session) => id === undefined || session.entry.relayId === id);
127
171
  }
128
172
 
129
173
  private currentEntry(entry: Entry): DeliveredConnector {
@@ -134,6 +178,7 @@ export class OrgConnectorRuntime {
134
178
  const error = rowError(row);
135
179
  if (error !== null) throw error;
136
180
  if (entry.controller.signal.aborted || this.entries.get(entry.id) !== entry) throw orgUnavailable();
181
+ if (!isOrgRelayId(entry.relayId)) assertMigrationOutcomeKnown(entry.relayId, this.deps.migrationDir);
137
182
  return row;
138
183
  }
139
184
 
@@ -156,21 +201,30 @@ export class OrgConnectorRuntime {
156
201
  return this.currentEntry(entry);
157
202
  }
158
203
 
204
+ private migrationOperation(entry: Entry): MigrationOperation | null {
205
+ return isOrgRelayId(entry.relayId) ? null : beginMigrationOperation(entry.relayId, this.deps.migrationDir ?? agentsDir(), true);
206
+ }
207
+
159
208
  private async scan(entry: Entry): Promise<void> {
160
209
  const row = this.currentEntry(entry);
161
210
  const signal = AbortSignal.any([entry.controller.signal, AbortSignal.timeout(45_000)]);
162
- const groups = await discoverOrgGroups(row.url, {
163
- fresh: (revision) => this.fresh(entry, revision === undefined ? undefined : { connectorId: entry.id, revision }),
164
- current: () => this.currentEntry(entry), signal, fetch: this.deps.fetch ?? fetch,
165
- });
166
- this.currentEntry(entry);
167
- if (JSON.stringify(groups) !== JSON.stringify(entry.groups)) {
168
- for (const session of this.sessions) if (session.entry === entry && session.revision !== null) this.abort(session, orgUnavailable());
169
- }
170
- entry.groups = groups;
171
- entry.error = null;
172
- entry.nextScan = performance.now() + 60_000;
173
- this.update();
211
+ const operation = this.migrationOperation(entry);
212
+ try {
213
+ const send = this.deps.fetch ?? fetch;
214
+ const groups = await discoverOrgGroups(row.url, {
215
+ fresh: (revision) => this.fresh(entry, revision === undefined ? undefined : { connectorId: entry.id, revision }),
216
+ current: () => this.currentEntry(entry), signal,
217
+ fetch: (input, init) => operation?.dispatch(() => send(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }) ?? send(input, init),
218
+ });
219
+ this.currentEntry(entry);
220
+ if (JSON.stringify(groups) !== JSON.stringify(entry.groups)) {
221
+ for (const session of this.sessions) if (session.entry === entry && session.revision !== null) this.abort(session, orgUnavailable());
222
+ }
223
+ entry.groups = groups;
224
+ entry.error = null;
225
+ entry.nextScan = performance.now() + 60_000;
226
+ this.update();
227
+ } finally { operation?.close(); }
174
228
  }
175
229
 
176
230
  private discover(entry: Entry): Promise<void> | null {
@@ -209,18 +263,18 @@ export class OrgConnectorRuntime {
209
263
  }
210
264
 
211
265
  open(id: string): RelaySession {
212
- if (!isOrgRelayId(id)) throw orgMissing();
213
266
  const rows = this.update();
214
267
  if (rows === null) throw orgUnavailable();
215
- const rawId = id.slice(4);
216
- const error = rowError(rows.find((row) => row.id === rawId));
268
+ const mapped = rows.find((row) => row.relayId === id);
269
+ const error = rowError(mapped);
217
270
  if (error !== null) throw error;
218
- const entry = this.entries.get(rawId);
271
+ const entry = mapped === undefined ? undefined : this.entries.get(mapped.id);
219
272
  if (entry === undefined) throw orgUnavailable();
220
- const session: Session = { entry, controller: new AbortController(), revision: null, error: null };
273
+ const session: Session = { entry, operation: this.migrationOperation(entry), controller: new AbortController(), revision: null, error: null };
221
274
  this.sessions.add(session);
222
275
  const blocker = requestConnectorBlocker();
223
276
  return {
277
+ dispatch: (send, effectful) => { this.currentSession(session); return session.operation?.dispatch(send, { effectful }) ?? send(); },
224
278
  target: (force) => this.target(session, force),
225
279
  current: () => {
226
280
  const row = this.currentSession(session);
@@ -232,7 +286,7 @@ export class OrgConnectorRuntime {
232
286
  return blocker();
233
287
  },
234
288
  signal: session.controller.signal,
235
- close: () => { this.abort(session, orgUnavailable()); },
289
+ close: () => { this.abort(session, orgUnavailable()); session.operation?.close(); this.sessions.delete(session); },
236
290
  };
237
291
  }
238
292
 
@@ -8,6 +8,9 @@ import { OrgConnectorSync, type OrgSyncDeps } from './org-sync.js';
8
8
  import { OrgConnectorRuntime } from './org-runtime.js';
9
9
  import { connectorSyncScope, disableSavedSync, saveSyncSetting, syncOptedIn, syncSetting } from './org-sync-setting.js';
10
10
  import type { RelaySession } from './relay-session.js';
11
+ import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
12
+ import { localMigrationIdle, migrationRuntimeMapping } from './migration-guard.js';
13
+ import { publishConnectors } from './publication.js';
11
14
 
12
15
  type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & { api?: MetroApi; dir?: string };
13
16
  type Context = Pick<ConnectorSyncStatus, 'owner' | 'agentId' | 'scope' | 'enrolled'>;
@@ -20,6 +23,7 @@ export class OrgConnectorControl {
20
23
  private readonly api: MetroApi;
21
24
  private sync: OrgConnectorSync | null = null;
22
25
  private runtime: OrgConnectorRuntime | null = null;
26
+ private readonly draining = new Set<OrgConnectorRuntime>();
23
27
  private scope: string | null = null;
24
28
  private operation = randomBytes(16).toString('hex');
25
29
  private timer: ReturnType<typeof setInterval> | null = null;
@@ -43,6 +47,8 @@ export class OrgConnectorControl {
43
47
  this.sync = null;
44
48
  const runtime = this.runtime;
45
49
  this.runtime = null;
50
+ for (const previous of this.draining) if (previous.idle()) this.draining.delete(previous);
51
+ if (runtime !== null && !runtime.idle()) this.draining.add(runtime);
46
52
  try {
47
53
  runtime?.stop();
48
54
  } catch {
@@ -64,11 +70,17 @@ export class OrgConnectorControl {
64
70
  else if (start && this.sync === null) {
65
71
  const scope = context.scope;
66
72
  this.sync = new OrgConnectorSync({ ...this.deps, api: this.api, enabled: () => this.context().scope === scope && syncSetting(this.dir, scope).enabled });
67
- if (liveEnabled()) this.runtime = new OrgConnectorRuntime(this.sync, { enabled: liveEnabled });
68
73
  }
74
+ if (start) this.startRuntime();
69
75
  return { context, setting };
70
76
  }
71
77
 
78
+ private startRuntime(): void {
79
+ if (this.sync === null || this.runtime !== null) return;
80
+ const mapping = () => migrationRuntimeMapping(this.deps.owner(), this.deps.key(), this.dir);
81
+ if (liveEnabled() || mapping().aliases.length > 0) this.runtime = new OrgConnectorRuntime(this.sync, { enabled: liveEnabled, mapping, migrationDir: this.dir });
82
+ }
83
+
72
84
  status(): ConnectorSyncStatus {
73
85
  try {
74
86
  const { context, setting } = this.reconcile(false);
@@ -104,6 +116,28 @@ export class OrgConnectorControl {
104
116
  await this.sync?.sync();
105
117
  }
106
118
 
119
+ async fresh(): Promise<DeliverySnapshot> {
120
+ this.reconcile(true);
121
+ const sync = this.sync;
122
+ if (sync === null) throw new ApiError('Enable organization connector sync before moving this connector.', 409);
123
+ await sync.syncFresh();
124
+ this.reconcile(false);
125
+ const snapshot = this.sync === sync ? sync.read() : null;
126
+ if (snapshot === null) throw new ApiError('A fresh organization connector snapshot is unavailable.', 503);
127
+ return snapshot;
128
+ }
129
+
130
+ refreshRuntime(): void {
131
+ this.reconcile(true);
132
+ this.runtime?.refresh();
133
+ publishConnectors();
134
+ }
135
+
136
+ migrationIdle(id: string): boolean {
137
+ for (const runtime of this.draining) if (runtime.idle()) this.draining.delete(runtime);
138
+ return localMigrationIdle(id, this.dir) && (this.runtime?.idle(id) ?? true) && [...this.draining].every((runtime) => runtime.idle(id));
139
+ }
140
+
107
141
  private tick(): void {
108
142
  this.poll().catch(() => {
109
143
  log.warn('organization connectors: memory sync unavailable; cached credentials were cleared');
@@ -130,9 +164,9 @@ export class OrgConnectorControl {
130
164
  }
131
165
 
132
166
  open(id: string): RelaySession {
167
+ if (this.runtime === null && !syncOptedIn(this.dir)) throw new ApiError('Organization connectors are not available on this agent.', 503);
168
+ this.reconcile(true);
133
169
  if (this.runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
134
- this.reconcile(false);
135
- if (!liveEnabled() || this.runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
136
170
  return this.runtime.open(id);
137
171
  }
138
172
  }
@@ -3,6 +3,8 @@ import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
3
3
  import { OrgConnectorControl } from './org-sync-control.js';
4
4
  import { isOrgRelayId } from './relay-id.js';
5
5
  import type { RelaySession } from './relay-session.js';
6
+ import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
7
+ import { migrationSource } from './migration-guard.js';
6
8
 
7
9
  let active: OrgConnectorControl | null = null;
8
10
 
@@ -12,7 +14,19 @@ function control(): OrgConnectorControl {
12
14
  }
13
15
 
14
16
  export function orgConnectorRelaySession(id: string): RelaySession | null {
15
- return isOrgRelayId(id) ? control().open(id) : null;
17
+ return isOrgRelayId(id) || migrationSource(id)?.phase === 'active' ? control().open(id) : null;
18
+ }
19
+
20
+ export function freshOrgConnectors(): Promise<DeliverySnapshot> {
21
+ return control().fresh();
22
+ }
23
+
24
+ export function refreshOrgConnectorRuntime(): void {
25
+ control().refreshRuntime();
26
+ }
27
+
28
+ export function orgConnectorMigrationIdle(id: string): boolean {
29
+ return control().migrationIdle(id);
16
30
  }
17
31
 
18
32
  export function orgConnectorSyncStatus(): ConnectorSyncStatus {
@@ -54,9 +54,10 @@ export function serverKeysOf(rows: PluginRow[]): Map<string, string> {
54
54
  return keys;
55
55
  }
56
56
 
57
- export function pluginServers(rows: PluginRow[], base: string): Record<string, PluginServer> {
57
+ export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows)): Record<string, PluginServer> {
58
58
  const out: Record<string, PluginServer> = {};
59
- for (const [id, key] of serverKeysOf(rows)) out[key] = { type: 'http', url: `${base}/relay/${id}`, headersHelper: HELPER };
59
+ const visible = new Set(rows.map((row) => row.id));
60
+ for (const [id, key] of keys) if (visible.has(id)) out[key] = { type: 'http', url: `${base}/relay/${id}`, headersHelper: HELPER };
60
61
  return out;
61
62
  }
62
63
 
@@ -112,6 +113,7 @@ export interface PluginSyncOptions {
112
113
  dir?: string;
113
114
  base?: string;
114
115
  staged?: string | null;
116
+ keys?: ReadonlyMap<string, string>;
115
117
  }
116
118
 
117
119
  function filesFor(opts: PluginSyncOptions): string[] {
@@ -122,7 +124,7 @@ function filesFor(opts: PluginSyncOptions): string[] {
122
124
  export function syncPluginServers(rows: PluginRow[], opts: PluginSyncOptions = {}): number {
123
125
  const files = filesFor(opts);
124
126
  if (files.length === 0) return 0;
125
- const servers = pluginServers(rows, opts.base ?? loopbackBase());
127
+ const servers = pluginServers(rows, opts.base ?? loopbackBase(), opts.keys);
126
128
  const text = `${JSON.stringify(servers, null, 2)}\n`;
127
129
  let written = 0;
128
130
  for (const file of files) {
@@ -1,6 +1,7 @@
1
1
  import { registerConnectors, type GateRow } from './gates.js';
2
- import { syncPluginServers } from './plugin-sync.js';
2
+ import { serverKeysOf, syncPluginServers } from './plugin-sync.js';
3
3
  import { isOrgRelayId } from './relay-id.js';
4
+ import { migrationRecords } from './migration-state.js';
4
5
 
5
6
  let local: GateRow[] = [];
6
7
  let organization: GateRow[] = [];
@@ -17,9 +18,23 @@ function metadata(rows: readonly GateRow[]): GateRow[] {
17
18
  }
18
19
 
19
20
  export function publishConnectors(): void {
20
- const rows = [...local, ...organization];
21
- registerConnectors(rows);
22
- syncPluginServers(rows);
21
+ let records: ReturnType<typeof migrationRecords>;
22
+ try { records = migrationRecords().filter((record) => record.phase !== 'rolled_back'); }
23
+ catch (error) {
24
+ registerConnectors([], local.map((row) => row.id));
25
+ syncPluginServers([]);
26
+ throw error;
27
+ }
28
+ const retired = new Set(records.filter((record) => !['preparing', 'prepared'].includes(record.phase)).map((record) => record.sourceId));
29
+ const aliases = new Map(organization.filter((row) => records.some((record) => record.phase === 'active' && record.sourceId === row.id && record.name === row.name)).map((row) => [row.id, row]));
30
+ const rows = local.flatMap((row) => {
31
+ const alias = aliases.get(row.id);
32
+ return alias !== undefined ? [alias] : retired.has(row.id) ? [] : [row];
33
+ });
34
+ rows.push(...organization.filter((row) => isOrgRelayId(row.id) && !records.some((record) => row.id === `org-${record.destinationId ?? ''}`)));
35
+ const keys = serverKeysOf([...local, ...rows.filter((row) => isOrgRelayId(row.id))]);
36
+ registerConnectors(rows, [...retired, ...aliases.keys()], keys);
37
+ syncPluginServers(rows, { keys });
23
38
  }
24
39
 
25
40
  export function publishLocalConnectors(rows: readonly GateRow[]): void {
@@ -28,6 +43,6 @@ export function publishLocalConnectors(rows: readonly GateRow[]): void {
28
43
  }
29
44
 
30
45
  export function publishOrgConnectors(rows: readonly GateRow[]): void {
31
- organization = metadata(rows.filter((row) => isOrgRelayId(row.id)));
46
+ organization = metadata(rows);
32
47
  publishConnectors();
33
48
  }
@@ -7,4 +7,5 @@ export interface RelaySession {
7
7
  blocker: () => BlockedReason;
8
8
  signal: AbortSignal;
9
9
  close: () => void;
10
+ dispatch?: (send: () => Promise<Response>, effectful?: boolean) => Promise<Response>;
10
11
  }
@@ -5,11 +5,15 @@ import type { RelayTarget } from './relay-target.js';
5
5
  import type { RelaySession } from './relay-session.js';
6
6
  import { isOrgRelayId } from './relay-id.js';
7
7
  import { exchangeOrg } from './org-relay.js';
8
+ import { assertLocalMigrationUse, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
9
+ import { agentsDir } from '../agents/files.js';
10
+ import { migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
8
11
  import { ApiError } from '@metro-labs/http/api-error';
9
12
  import { errMsg, log } from '@metro-labs/core/log';
10
13
  import { answerBlocked, mergeBlocked, screenCalls, type BlockedReason } from './relay-policy.js';
11
14
 
12
15
  export interface RelayApiDeps {
16
+ migrationDir?: string;
13
17
  target: (connectorId: string, force: boolean) => Promise<RelayTarget>;
14
18
  identify: (req: IncomingMessage) => { subject: string; agentId: string } | null;
15
19
  signedOut: (connectorId: string) => void;
@@ -223,15 +227,20 @@ async function exchange(
223
227
  deps: RelayApiDeps,
224
228
  body: Uint8Array<ArrayBuffer> | null,
225
229
  signal: AbortSignal,
230
+ dispatch: MigrationOperation['dispatch'],
226
231
  ): Promise<Exchanged> {
232
+ assertLocalMigrationUse(connectorId, deps.migrationDir);
227
233
  const target = await deps.target(connectorId, false);
234
+ assertLocalMigrationUse(connectorId, deps.migrationDir);
228
235
  if (target.kind !== 'ok') return { kind: target.kind };
229
- let upstream = await forward(req, target, body, signal);
236
+ let upstream = await dispatch(() => forward(req, target, body, signal), { effectful: migrationRequestEffectful(req.method, body) });
230
237
  if (!authFailed(upstream.status)) return { kind: 'response', upstream };
231
238
  await upstream.body?.cancel();
239
+ assertLocalMigrationUse(connectorId, deps.migrationDir);
232
240
  const fresh = await deps.target(connectorId, true);
241
+ assertLocalMigrationUse(connectorId, deps.migrationDir);
233
242
  if (fresh.kind !== 'ok') return { kind: 'signin' };
234
- upstream = await forward(req, fresh, body, signal);
243
+ upstream = await dispatch(() => forward(req, fresh, body, signal), { effectful: migrationRequestEffectful(req.method, body) });
235
244
  if (!authFailed(upstream.status)) return { kind: 'response', upstream };
236
245
  await upstream.body?.cancel();
237
246
  return { kind: 'signin' };
@@ -293,7 +302,10 @@ async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, d
293
302
  const session = deps.open?.(id);
294
303
  if (session === undefined || session === null) throw new ApiError('Organization connector tools are not switched on yet.', 503);
295
304
  try {
296
- const out = await exchangeOrg(id, session, read, signal, (target, body, guard) => forward(req, target, body, guard, true));
305
+ const out = await exchangeOrg(id, session, read, signal, (target, body, guard) => {
306
+ const send = () => forward(req, target, body, guard, true);
307
+ return session.dispatch?.(send, migrationRequestEffectful(req.method, body)) ?? send();
308
+ });
297
309
  if (out.kind === 'blocked') answerBlocked(res, out.screened);
298
310
  else await deliver(res, id, deps, out, out.answers);
299
311
  } finally { session.close(); }
@@ -307,14 +319,17 @@ async function relayOnce(
307
319
  signal: AbortSignal,
308
320
  ): Promise<void> {
309
321
  const read = req.method === 'POST' ? await readCapped(req) : null;
310
- if (isOrgRelayId(connectorId)) return relayOrg(req, res, connectorId, deps, read, signal);
311
- const screened = read === null || deps.blocked === undefined ? null : screenCalls(read, connectorId, deps.blocked);
312
- if (screened?.forward === null) {
313
- answerBlocked(res, screened);
314
- return;
315
- }
316
- const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal);
317
- await deliver(res, connectorId, deps, out, screened?.answers ?? []);
322
+ const dir = deps.migrationDir ?? agentsDir();
323
+ if (isOrgRelayId(connectorId) || migrationSource(connectorId, dir)?.phase === 'active') return relayOrg(req, res, connectorId, deps, read, signal);
324
+ await withLocalMigrationOperation(connectorId, dir, async (operation) => {
325
+ const screened = read === null || deps.blocked === undefined ? null : screenCalls(read, connectorId, deps.blocked);
326
+ if (screened?.forward === null) {
327
+ answerBlocked(res, screened);
328
+ return;
329
+ }
330
+ const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal, operation.dispatch);
331
+ await deliver(res, connectorId, deps, out, screened?.answers ?? []);
332
+ });
318
333
  }
319
334
 
320
335
  function dispatch(