@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +269 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +90 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +196 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +46 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +122 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +68 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +11 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +95 -41
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +37 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +5 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +20 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +26 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +71 -50
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +5 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +6 -41
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +176 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +44 -0
- package/runtime/runtime.json +1 -1
- 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:
|
|
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:
|
|
94
|
+
private publish(rows: RuntimeRow[]): void {
|
|
84
95
|
const metadata: GateRow[] = rows.filter((row) => row.state === 'ready').map((row) => ({
|
|
85
|
-
id:
|
|
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:
|
|
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
|
-
|
|
107
|
-
this.entries.delete(id);
|
|
108
|
-
this.abortEntry(entry, error);
|
|
117
|
+
this.invalidateEntry(entry, row, error);
|
|
109
118
|
}
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
private
|
|
113
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
216
|
-
const error = rowError(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
46
|
+
organization = metadata(rows);
|
|
32
47
|
publishConnectors();
|
|
33
48
|
}
|
|
@@ -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) =>
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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(
|