@stage-labs/metro 0.1.0-beta.301 → 0.1.0-beta.303
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/agent-user/provision.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +4 -34
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
- package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
|
@@ -1,366 +0,0 @@
|
|
|
1
|
-
import { newId } from '@metro-labs/core/ids';
|
|
2
|
-
import { log } from '@metro-labs/core/log';
|
|
3
|
-
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
4
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
-
import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
6
|
-
import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
|
|
7
|
-
import { migrationResponseOf, sealConnectorMigration, type MigrationResponse, type MigrationView as RemoteMigration } from '@metro-labs/http/connector-migration';
|
|
8
|
-
import { agentsDir } from '../agents/files.js';
|
|
9
|
-
import { readAgentKey, type AgentKey } from './agent-key.js';
|
|
10
|
-
import { agentCall, METRO_API, MetroApiRefusal, type MetroApi } from './metro-api.js';
|
|
11
|
-
import { migrationSource } from './migration-preview.js';
|
|
12
|
-
import { MigrationBlocked, migrationPayload } from './migration-source.js';
|
|
13
|
-
import { localMigrationIdle } from './migration-guard.js';
|
|
14
|
-
import { migrationUncertaintyReceipt, migrationRefreshUncertain, migrationOperationsIdle, resolveMigrationUncertainty } from './migration-operations.js';
|
|
15
|
-
import { flushMigrationSource, localRollbackMigrationSource } from './store.js';
|
|
16
|
-
import { freshOrgConnectors, orgConnectorMigrationIdle, orgConnectorSyncStatus, refreshOrgConnectorRuntime } from './org-sync-watch.js';
|
|
17
|
-
import { migrationRecord, migrationRecords, saveMigration, type MigrationRecord } from './migration-state.js';
|
|
18
|
-
|
|
19
|
-
export type MigrationAction = 'move' | 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
|
|
20
|
-
interface Context { key: AgentKey; owner: string; agentId: string; scope: string }
|
|
21
|
-
export interface MigrationView extends Pick<MigrationRecord, 'id' | 'sourceId' | 'name' | 'destinationId' | 'phase' | 'auth' | 'reason'> {
|
|
22
|
-
uncertainty: string | null;
|
|
23
|
-
move: boolean;
|
|
24
|
-
grantImported: boolean;
|
|
25
|
-
}
|
|
26
|
-
export interface MigrationStatus {
|
|
27
|
-
scope: string;
|
|
28
|
-
owner: string | null;
|
|
29
|
-
agentId: string | null;
|
|
30
|
-
enabled: boolean;
|
|
31
|
-
enrolled: boolean;
|
|
32
|
-
migrations: MigrationView[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface MigrationDeps {
|
|
36
|
-
dir?: string;
|
|
37
|
-
api?: MetroApi;
|
|
38
|
-
status?: () => ConnectorSyncStatus;
|
|
39
|
-
key?: () => AgentKey | null;
|
|
40
|
-
fresh?: () => Promise<DeliverySnapshot>;
|
|
41
|
-
changed?: () => void;
|
|
42
|
-
localIdle?: (id: string) => boolean;
|
|
43
|
-
orgIdle?: (id: string) => boolean;
|
|
44
|
-
restore?: (id: string, oauth: boolean) => void;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const conflict = (message: string): ApiError => new ApiError(message, 409);
|
|
48
|
-
const uncertain = (): ApiError => new ApiError('The migration request was not confirmed. Refresh and reconcile this connector before trying another action; an interrupted request may already have reached Metro.', 503);
|
|
49
|
-
const viewOf = ({ id, sourceId, name, destinationId, phase, auth, reason, move, grantImported }: MigrationRecord, uncertainty: string | null): MigrationView => ({ id, sourceId, name, destinationId, phase, auth, reason, uncertainty, move: move === true, grantImported: grantImported === true });
|
|
50
|
-
const reasons: Record<RemoteMigration['readiness'], string | null> = { pending: 'not_verified', ready: null, signin: 'sign_in', changed: 'destination_changed', unavailable: 'unavailable', refresh_uncertain: 'refresh_uncertain' };
|
|
51
|
-
const samePolicy = (a: MigrationRecord['policy'], b: MigrationRecord['policy']): boolean =>
|
|
52
|
-
(a.read ?? 'allow') === (b.read ?? 'allow') && (a.write ?? 'allow') === (b.write ?? 'allow') && JSON.stringify(Object.entries(a.tools ?? {}).sort()) === JSON.stringify(Object.entries(b.tools ?? {}).sort());
|
|
53
|
-
|
|
54
|
-
const grantTransfer = (record: MigrationRecord): boolean => record.move === true && record.auth === 'oauth';
|
|
55
|
-
const upgrading = (record: MigrationRecord, remote: RemoteMigration): boolean => grantTransfer(record) && remote.grantImported !== true;
|
|
56
|
-
const canUpgrade = (record: MigrationRecord): boolean => record.move !== true && ['preparing', 'prepared'].includes(record.phase);
|
|
57
|
-
|
|
58
|
-
function checkResponse(record: MigrationRecord, remote: RemoteMigration): void {
|
|
59
|
-
if (remote.operationId !== record.id || remote.sourceId !== record.sourceId || (record.destinationId !== null && remote.destinationId !== record.destinationId)) throw uncertain();
|
|
60
|
-
if (remote.sourceStamp === null || remote.sourceStamp === record.sourceStamp || upgrading(record, remote)) return;
|
|
61
|
-
throw conflict('The prepared destination no longer matches this local connector. Reconcile its current operation before continuing.');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function callFailure(error: unknown, action: string): ApiError {
|
|
65
|
-
if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The mapped destination already has credentials or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
|
|
66
|
-
if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
|
|
67
|
-
return new ApiError("Enter the provider's public OAuth resource. Roll back this preparation, then prepare again with that public value; never enter credentials.", 400);
|
|
68
|
-
}
|
|
69
|
-
return uncertain();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function received(record: MigrationRecord, snapshot: DeliverySnapshot): boolean {
|
|
73
|
-
const delivered = snapshot.connectors.find((row) => row.id === record.destinationId);
|
|
74
|
-
if (delivered?.state !== 'ready' || delivered.name !== record.name) return false;
|
|
75
|
-
const layers = delivered.policyLayers;
|
|
76
|
-
return layers === undefined ? samePolicy(delivered.policy, record.policy) : samePolicy(layers.organization, record.policy) && samePolicy(layers.agent, {});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export class ConnectorMigrations {
|
|
80
|
-
private readonly dir: string;
|
|
81
|
-
private readonly api: MetroApi;
|
|
82
|
-
private running = false;
|
|
83
|
-
|
|
84
|
-
constructor(private readonly deps: MigrationDeps = {}) {
|
|
85
|
-
this.dir = deps.dir ?? agentsDir();
|
|
86
|
-
this.api = deps.api ?? METRO_API;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
private syncStatus(): ConnectorSyncStatus { return (this.deps.status ?? orgConnectorSyncStatus)(); }
|
|
90
|
-
private key(): AgentKey | null { return this.deps.key === undefined ? readAgentKey(this.dir) : this.deps.key(); }
|
|
91
|
-
private changed(): void { (this.deps.changed ?? refreshOrgConnectorRuntime)(); }
|
|
92
|
-
private localIdle(id: string): boolean { return this.deps.localIdle?.(id) ?? localMigrationIdle(id, this.dir); }
|
|
93
|
-
private orgIdle(id: string): boolean { return (this.deps.orgIdle ?? orgConnectorMigrationIdle)(id); }
|
|
94
|
-
|
|
95
|
-
status(): MigrationStatus {
|
|
96
|
-
const { scope, owner, agentId, enabled, enrolled } = this.syncStatus();
|
|
97
|
-
const keyId = this.key()?.keyId;
|
|
98
|
-
const migrations = migrationRecords(this.dir).filter((row) => row.owner === owner).map((row) => {
|
|
99
|
-
const uncertainty = migrationUncertaintyReceipt(row.sourceId, this.dir);
|
|
100
|
-
const reason = uncertainty === null ? row.reason : migrationRefreshUncertain(row.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain';
|
|
101
|
-
return viewOf({ ...row, reason: row.agentId !== agentId || row.keyId !== keyId ? 'identity_changed' : reason }, uncertainty);
|
|
102
|
-
});
|
|
103
|
-
return { scope, owner, agentId, enabled, enrolled, migrations };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
private context(scope: string, requireSync: boolean): Context {
|
|
107
|
-
const status = this.syncStatus();
|
|
108
|
-
const key = this.key();
|
|
109
|
-
if (scope !== status.scope) throw conflict('The organization, agent or enrollment changed. Refresh before continuing the migration.');
|
|
110
|
-
if (!status.enrolled || key?.enrollment?.organization !== status.owner || key.enrollment.server !== status.agentId) throw conflict('Enroll this agent with the current organization before migrating connectors.');
|
|
111
|
-
if (requireSync && !status.enabled) throw conflict('Enable organization connector sync on the Server page before preparing or switching a connector.');
|
|
112
|
-
return { key, owner: key.enrollment.organization, agentId: key.enrollment.server, scope };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private check(context: Context, record?: MigrationRecord): void {
|
|
116
|
-
const current = this.context(context.scope, false);
|
|
117
|
-
if (current.key.keyId !== context.key.keyId || current.owner !== context.owner || current.agentId !== context.agentId) throw conflict('The migration identity changed. Refresh before continuing.');
|
|
118
|
-
if (record !== undefined && (record.owner !== current.owner || record.agentId !== current.agentId || record.keyId !== current.key.keyId)) throw conflict('This migration belongs to a different enrollment. Restore its original enrollment before reconciling it.');
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
private save(context: Context, record: MigrationRecord): MigrationRecord {
|
|
122
|
-
this.check(context, record);
|
|
123
|
-
saveMigration(record, this.dir);
|
|
124
|
-
this.changed();
|
|
125
|
-
return record;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
private source(record: MigrationRecord): ReturnType<typeof migrationPayload> {
|
|
129
|
-
const payload = migrationPayload(migrationSource(record.sourceId, this.dir, record.move === true && record.captured === true), record.oauthResource, record.move === true && record.captured === true);
|
|
130
|
-
if (payload.sourceStamp !== record.sourceStamp) throw conflict('The retained local connector changed. Restore its original settings before continuing; Metro will not overwrite it.');
|
|
131
|
-
return payload;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
private async call(context: Context, record: MigrationRecord, action: 'begin' | 'prepare' | 'handover' | 'status' | 'activate' | 'rollback', sealed?: ReturnType<typeof sealConnectorMigration>): Promise<MigrationResponse> {
|
|
135
|
-
this.check(context, record);
|
|
136
|
-
let response: MigrationResponse;
|
|
137
|
-
try {
|
|
138
|
-
response = migrationResponseOf(await agentCall(this.api, context.key, '/api/agents/connector-migrations', {
|
|
139
|
-
action, operationId: record.id, sourceId: record.sourceId, ...(sealed === undefined ? {} : { sealed }),
|
|
140
|
-
}, { timeoutMs: 60_000 }));
|
|
141
|
-
} catch (error) {
|
|
142
|
-
throw callFailure(error, action);
|
|
143
|
-
}
|
|
144
|
-
this.check(context, record);
|
|
145
|
-
checkResponse(record, response.migration);
|
|
146
|
-
return response;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
private accept(context: Context, record: MigrationRecord, remote: RemoteMigration): MigrationRecord {
|
|
150
|
-
if (remote.phase !== 'preparing' && remote.phase !== 'rolled_back' && (remote.name !== record.name || remote.auth !== record.auth || remote.policy === null || !samePolicy(record.policy, remote.policy))) {
|
|
151
|
-
return this.save(context, { ...record, destinationId: remote.destinationId, reason: 'destination_changed' });
|
|
152
|
-
}
|
|
153
|
-
return this.save(context, { ...record, destinationId: remote.destinationId, grantImported: remote.grantImported === true, reason: reasons[remote.readiness] });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
private create(context: Context, id: string, oauthResource?: string, move = false): MigrationRecord {
|
|
157
|
-
const payload = migrationPayload(migrationSource(id, this.dir), oauthResource);
|
|
158
|
-
return this.save(context, {
|
|
159
|
-
id: newId(), owner: context.owner, agentId: context.agentId, keyId: context.key.keyId,
|
|
160
|
-
sourceId: id, name: payload.name, destinationId: null, phase: move ? 'freezing' : 'preparing', move, auth: payload.auth.kind,
|
|
161
|
-
sourceStamp: payload.sourceStamp, policy: payload.policy, reason: null,
|
|
162
|
-
...(payload.oauthResource === undefined ? {} : { oauthResource: payload.oauthResource }),
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
private async prepare(context: Context, record: MigrationRecord): Promise<void> {
|
|
167
|
-
const payload = this.source(record);
|
|
168
|
-
const begun = await this.call(context, record, 'begin');
|
|
169
|
-
record = this.accept(context, record, begun.migration);
|
|
170
|
-
if (begun.migration.phase === 'prepared' && !upgrading(record, begun.migration)) {
|
|
171
|
-
this.save(context, { ...record, phase: 'prepared' });
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
if (!['preparing', 'prepared'].includes(begun.migration.phase) || begun.receiving === undefined) throw uncertain();
|
|
175
|
-
const receiving = begun.receiving;
|
|
176
|
-
const expected = { owner: context.owner, agent: context.agentId, keyId: context.key.keyId, operationId: record.id, sourceId: record.sourceId, destinationId: record.destinationId };
|
|
177
|
-
for (const [key, value] of Object.entries(expected)) if (receiving.context[key as keyof typeof receiving.context] !== value) throw uncertain();
|
|
178
|
-
const sealed = sealConnectorMigration(receiving.publicKey, receiving.context, payload);
|
|
179
|
-
const { migration } = await this.call(context, record, grantTransfer(record) ? 'handover' : 'prepare', sealed);
|
|
180
|
-
record = this.accept(context, record, migration);
|
|
181
|
-
if (migration.phase !== 'prepared') throw uncertain();
|
|
182
|
-
this.save(context, { ...record, phase: 'prepared' });
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private async freeze(context: Context, record: MigrationRecord): Promise<MigrationRecord | null> {
|
|
186
|
-
const until = Date.now() + 10_000;
|
|
187
|
-
while (!this.localIdle(record.sourceId) && migrationUncertaintyReceipt(record.sourceId, this.dir) === null && Date.now() < until) {
|
|
188
|
-
await new Promise((resolve) => { setTimeout(resolve, 100); });
|
|
189
|
-
this.check(context, record);
|
|
190
|
-
}
|
|
191
|
-
if (this.waiting(context, record)) return null;
|
|
192
|
-
flushMigrationSource(this.dir);
|
|
193
|
-
const payload = migrationPayload(migrationSource(record.sourceId, this.dir, true), record.oauthResource, true);
|
|
194
|
-
return this.save(context, { ...record, sourceStamp: payload.sourceStamp, captured: true, phase: 'preparing', reason: null });
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
private async move(context: Context, record: MigrationRecord): Promise<void> {
|
|
198
|
-
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
199
|
-
if (['active', 'rolled_back'].includes(record.phase)) return this.reconcile(context, record);
|
|
200
|
-
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
201
|
-
if (record.phase === 'freezing') {
|
|
202
|
-
const captured = await this.freeze(context, record);
|
|
203
|
-
if (captured === null) return;
|
|
204
|
-
record = captured;
|
|
205
|
-
}
|
|
206
|
-
if (record.phase === 'preparing') {
|
|
207
|
-
await this.prepare(context, record);
|
|
208
|
-
record = migrationRecord(record.sourceId, this.dir) ?? record;
|
|
209
|
-
}
|
|
210
|
-
if (record.phase === 'prepared' || record.phase === 'cutover') await this.cutover(context, record);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
|
|
214
|
-
const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
|
|
215
|
-
const localIdle = this.localIdle(record.sourceId);
|
|
216
|
-
if (!uncertain && localIdle && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
|
|
217
|
-
this.save(context, { ...record, reason: uncertain ? migrationRefreshUncertain(record.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain' : 'waiting_for_local' });
|
|
218
|
-
return true;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private async cutover(context: Context, record: MigrationRecord): Promise<void> {
|
|
222
|
-
this.context(context.scope, true);
|
|
223
|
-
this.source(record);
|
|
224
|
-
if (record.phase === 'prepared') {
|
|
225
|
-
const { migration } = await this.call(context, record, 'status');
|
|
226
|
-
record = this.accept(context, record, migration);
|
|
227
|
-
if (migration.phase !== 'prepared' || record.reason !== null) return;
|
|
228
|
-
record = this.save(context, { ...record, phase: 'cutover' });
|
|
229
|
-
}
|
|
230
|
-
if (this.waiting(context, record)) return;
|
|
231
|
-
const { migration } = await this.call(context, record, 'activate');
|
|
232
|
-
record = this.accept(context, record, migration);
|
|
233
|
-
if (migration.phase !== 'active' || record.reason !== null) return;
|
|
234
|
-
const snapshot = await (this.deps.fresh ?? freshOrgConnectors)().catch(() => {
|
|
235
|
-
throw new ApiError('Fresh organization delivery could not be verified. The source stays stopped; restore sync and reconcile this migration.', 503);
|
|
236
|
-
});
|
|
237
|
-
this.check(context, record);
|
|
238
|
-
this.context(context.scope, true);
|
|
239
|
-
if (!received(record, snapshot)) {
|
|
240
|
-
this.save(context, { ...record, reason: 'sync_unavailable' });
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
this.save(context, { ...record, phase: 'active', reason: null });
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
private async rollback(context: Context, record: MigrationRecord): Promise<void> {
|
|
247
|
-
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
248
|
-
if (record.grantImported === true) throw new MigrationBlocked('finish_forward', 'Central owns this OAuth grant. Finish forward or sign in centrally; the old local tokens cannot be restored.');
|
|
249
|
-
const begun = await this.call(context, record, 'begin');
|
|
250
|
-
if (begun.migration.grantImported === true) {
|
|
251
|
-
record = this.accept(context, record, begun.migration);
|
|
252
|
-
this.save(context, { ...record, phase: begun.migration.phase === 'active' ? 'cutover' : 'prepared', reason: 'finish_forward' });
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
if (record.phase !== 'freezing') this.source(record);
|
|
256
|
-
record = this.save(context, { ...record, phase: 'rollback' });
|
|
257
|
-
if (this.waiting(context, record, true)) return;
|
|
258
|
-
const { migration } = await this.call(context, record, 'rollback');
|
|
259
|
-
if (migration.phase !== 'rolled_back') {
|
|
260
|
-
this.accept(context, record, migration);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
this.check(context, record);
|
|
264
|
-
this.restore(context, record);
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
private restore(context: Context, record: MigrationRecord): void {
|
|
269
|
-
const signOut = record.auth === 'oauth' && record.move !== true;
|
|
270
|
-
if (this.deps.restore !== undefined) this.deps.restore(record.sourceId, signOut);
|
|
271
|
-
else localRollbackMigrationSource(record.sourceId, signOut, this.dir);
|
|
272
|
-
this.save(context, { ...record, phase: 'rolled_back', reason: signOut ? 'sign_in' : null });
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
private async reconcile(context: Context, record: MigrationRecord): Promise<void> {
|
|
276
|
-
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
277
|
-
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
278
|
-
if (record.move === true && record.phase !== 'active' && record.phase !== 'rolled_back') return this.move(context, record);
|
|
279
|
-
if (record.phase === 'preparing') return this.prepare(context, record);
|
|
280
|
-
if (record.phase === 'cutover') return this.cutover(context, record);
|
|
281
|
-
if (record.phase === 'rolled_back') return;
|
|
282
|
-
const { migration } = await this.call(context, record, 'status');
|
|
283
|
-
this.accept(context, record, migration);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
private obtain(context: Context, sourceId: string, action: MigrationAction, oauthResource?: string): MigrationRecord | null {
|
|
287
|
-
const record = migrationRecord(sourceId, this.dir);
|
|
288
|
-
if (record !== null) this.check(context, record);
|
|
289
|
-
if (['prepare', 'move'].includes(action) && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId, oauthResource, action === 'move');
|
|
290
|
-
if (action === 'move' && record !== null && canUpgrade(record)) return this.save(context, { ...record, move: true, phase: 'freezing' });
|
|
291
|
-
if (record === null && action !== 'reconcile') throw conflict('Prepare this connector before moving it.');
|
|
292
|
-
return record;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
private perform(context: Context, record: MigrationRecord, action: MigrationAction): Promise<void> {
|
|
296
|
-
if (action === 'move') return this.move(context, record);
|
|
297
|
-
if (action === 'prepare' && record.phase === 'preparing') return this.prepare(context, record);
|
|
298
|
-
if (action === 'cutover' && (record.phase === 'prepared' || record.phase === 'cutover')) return this.cutover(context, record);
|
|
299
|
-
if (action === 'rollback' && record.phase !== 'rolled_back') return this.rollback(context, record);
|
|
300
|
-
return this.reconcile(context, record);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
|
|
304
|
-
this.check(context, record);
|
|
305
|
-
if (receipt === null || receipt !== migrationUncertaintyReceipt(record.sourceId, this.dir)) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
|
|
306
|
-
localMigrationIdle(record.sourceId, this.dir, true);
|
|
307
|
-
if (migrationRefreshUncertain(record.sourceId, this.dir)) return this.recoverRefresh(context, record, receipt);
|
|
308
|
-
if (!resolveMigrationUncertainty(record.sourceId, receipt, this.dir)) throw conflict('Requests are still running or their receipt changed. Wait for vendor completion, then refresh before confirming settlement.');
|
|
309
|
-
this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private async recoverRefresh(context: Context, record: MigrationRecord, receipt: string): Promise<void> {
|
|
313
|
-
if (record.move !== true || !['freezing', 'rollback'].includes(record.phase) || !migrationOperationsIdle(record.sourceId, this.dir, false)) throw conflict('The uncertain local refresh must stop before aborting the handover.');
|
|
314
|
-
const { migration } = await this.call(context, record, 'rollback');
|
|
315
|
-
if (migration.phase !== 'rolled_back') throw uncertain();
|
|
316
|
-
record = this.save(context, { ...record, phase: 'recovering', recoveryReceipt: receipt });
|
|
317
|
-
await this.finishRecovery(context, record);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
private async finishRecovery(context: Context, record: MigrationRecord): Promise<void> {
|
|
321
|
-
const { migration } = await this.call(context, record, 'rollback');
|
|
322
|
-
if (migration.phase !== 'rolled_back' || record.recoveryReceipt === undefined) throw uncertain();
|
|
323
|
-
localRollbackMigrationSource(record.sourceId, true, this.dir, true);
|
|
324
|
-
const receipt = migrationUncertaintyReceipt(record.sourceId, this.dir);
|
|
325
|
-
if (receipt !== null && !resolveMigrationUncertainty(record.sourceId, record.recoveryReceipt, this.dir)) throw uncertain();
|
|
326
|
-
this.save(context, { ...record, phase: 'rolled_back', recoveryReceipt: undefined, reason: 'sign_in' });
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
private validateResource(sourceId: string, action: MigrationAction, oauthResource: string | undefined): void {
|
|
330
|
-
if (oauthResource === undefined) return;
|
|
331
|
-
const resource = publicOAuthResource(oauthResource);
|
|
332
|
-
const record = migrationRecord(sourceId, this.dir);
|
|
333
|
-
if (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return;
|
|
334
|
-
if (record?.oauthResource !== resource) throw conflict('The public OAuth resource is fixed for this preparation. Roll back, then prepare again to change it.');
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private validateIntent(sourceId: string, action: MigrationAction, operationId: string | null, receipt: string | null): void {
|
|
338
|
-
if ((migrationRecord(sourceId, this.dir)?.id ?? null) !== operationId) throw conflict('This connector migration changed. Refresh before acting on its current operation.');
|
|
339
|
-
if (action === 'resolve' && (receipt === null || receipt !== migrationUncertaintyReceipt(sourceId, this.dir))) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null, oauthResource?: string): Promise<MigrationStatus> {
|
|
343
|
-
if (this.running) throw conflict('Another migration request is still running on this agent. Refresh after it finishes.');
|
|
344
|
-
const context = this.context(scope, ['move', 'prepare', 'cutover'].includes(action));
|
|
345
|
-
this.validateIntent(sourceId, action, operationId, uncertainty);
|
|
346
|
-
this.validateResource(sourceId, action, oauthResource);
|
|
347
|
-
this.running = true;
|
|
348
|
-
try {
|
|
349
|
-
const record = this.obtain(context, sourceId, action, oauthResource);
|
|
350
|
-
if (record !== null) {
|
|
351
|
-
if (action === 'resolve') await this.resolve(context, record, uncertainty);
|
|
352
|
-
else await this.perform(context, record, action);
|
|
353
|
-
}
|
|
354
|
-
this.check(context);
|
|
355
|
-
return this.status();
|
|
356
|
-
} catch (error) {
|
|
357
|
-
const record = migrationRecord(sourceId, this.dir);
|
|
358
|
-
if (record !== null) {
|
|
359
|
-
try { this.save(context, { ...record, reason: error instanceof MigrationBlocked ? error.reason : 'unavailable' }); } catch {
|
|
360
|
-
log.warn('connector migration: the interrupted operation needs reconciliation in its original enrollment');
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
throw error;
|
|
364
|
-
} finally { this.running = false; }
|
|
365
|
-
}
|
|
366
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
|
-
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
3
|
-
import { agentsDir } from '../agents/files.js';
|
|
4
|
-
import type { AgentKey } from './agent-key.js';
|
|
5
|
-
import { migrationRecord, migrationRecords } from './migration-state.js';
|
|
6
|
-
import { assertMigrationOutcomeKnown, beginMigrationOperation, drainMigrationListeners, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
7
|
-
|
|
8
|
-
const locked = (phase: string): boolean => phase !== 'rolled_back';
|
|
9
|
-
const retired = (record: NonNullable<ReturnType<typeof migrationRecord>>): boolean => record.move === true || record.phase === 'cutover' || record.phase === 'active' || record.phase === 'rollback';
|
|
10
|
-
const refused = (): ApiError => new ApiError('This local connector is locked while it moves to the organization.', 409);
|
|
11
|
-
const editRefused = (): ApiError => new ApiError('This local connector is locked while it moves to the organization. To change or remove it, open Move to organization and roll back its migration first.', 409);
|
|
12
|
-
|
|
13
|
-
export function migrationSource(id: string, dir = agentsDir()): ReturnType<typeof migrationRecords>[number] | null {
|
|
14
|
-
const record = migrationRecord(id, dir);
|
|
15
|
-
return record !== null && locked(record.phase) ? record : null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function localMigrationEnabled(id: string, dir = agentsDir()): boolean {
|
|
19
|
-
const record = migrationSource(id, dir);
|
|
20
|
-
return record === null || !retired(record);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function assertLocalMigrationUse(id: string, dir = agentsDir()): void {
|
|
24
|
-
const record = migrationSource(id, dir);
|
|
25
|
-
if (record === null) return;
|
|
26
|
-
if (retired(record)) throw refused();
|
|
27
|
-
assertMigrationOutcomeKnown(id, dir);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function assertLocalMigrationEdit(id: string, dir = agentsDir()): void {
|
|
31
|
-
if (migrationRecords(dir).some((record) => locked(record.phase) && record.sourceId === id)) throw editRefused();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function migrationExportableRows<T extends { id: string; name: string }>(rows: readonly T[], dir = agentsDir()): T[] {
|
|
35
|
-
const records = migrationRecords(dir).filter((record) => locked(record.phase));
|
|
36
|
-
return rows.filter((row) => !records.some((record) => record.sourceId === row.id));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function localMigrationIdle(id: string, dir = agentsDir(), allowUncertain = false): boolean {
|
|
40
|
-
const record = migrationSource(id, dir);
|
|
41
|
-
if (record !== null && retired(record)) drainMigrationListeners(id, dir);
|
|
42
|
-
return migrationOperationsIdle(id, dir, !allowUncertain && record !== null);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface LocalMigrationOperation extends MigrationOperation {
|
|
46
|
-
check: () => void;
|
|
47
|
-
fetch: FetchLike;
|
|
48
|
-
close: () => void;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false): LocalMigrationOperation {
|
|
52
|
-
let closed = false;
|
|
53
|
-
const check = (): void => {
|
|
54
|
-
if (closed) throw refused();
|
|
55
|
-
if (edit) assertLocalMigrationEdit(id, dir);
|
|
56
|
-
else assertLocalMigrationUse(id, dir);
|
|
57
|
-
};
|
|
58
|
-
check();
|
|
59
|
-
const tracked = beginMigrationOperation(id, dir, migrationSource(id, dir) !== null);
|
|
60
|
-
const dispatch: MigrationOperation['dispatch'] = (send, options) => {
|
|
61
|
-
check();
|
|
62
|
-
return tracked.dispatch(send, { ...options, protectedSource: migrationSource(id, dir) !== null });
|
|
63
|
-
};
|
|
64
|
-
return {
|
|
65
|
-
check,
|
|
66
|
-
dispatch,
|
|
67
|
-
passiveListener: tracked.passiveListener,
|
|
68
|
-
persistedRefresh: tracked.persistedRefresh,
|
|
69
|
-
fetch: (input, init) => dispatch(() => fetch(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }),
|
|
70
|
-
close: () => { closed = true; tracked.close(); },
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false): Promise<T> {
|
|
75
|
-
const operation = beginLocalMigrationOperation(id, dir, edit);
|
|
76
|
-
try { return await run(operation); }
|
|
77
|
-
finally { operation.close(); }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface MigrationRuntimeMapping {
|
|
81
|
-
aliases: { sourceId: string; destinationId: string; name: string }[];
|
|
82
|
-
reserved: string[];
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function migrationRuntimeMapping(owner: string | null, key: AgentKey | null, dir = agentsDir()): MigrationRuntimeMapping {
|
|
86
|
-
const records = migrationRecords(dir).filter((record) => locked(record.phase));
|
|
87
|
-
const aliases = records.flatMap((record) => {
|
|
88
|
-
if (record.phase !== 'active' || record.destinationId === null || owner === null || key === null) return [];
|
|
89
|
-
if (record.owner !== owner || record.keyId !== key.keyId || record.owner !== key.enrollment?.organization || record.agentId !== key.enrollment.server) return [];
|
|
90
|
-
return [{ sourceId: record.sourceId, destinationId: record.destinationId, name: record.name }];
|
|
91
|
-
});
|
|
92
|
-
const reserved = records.flatMap((record) => record.destinationId === null ? [] : [record.destinationId]);
|
|
93
|
-
if (new Set(aliases.map((alias) => alias.sourceId)).size !== aliases.length || new Set(reserved).size !== reserved.length) throw refused();
|
|
94
|
-
return { aliases, reserved };
|
|
95
|
-
}
|