@stage-labs/metro 0.1.0-beta.291 → 0.1.0-beta.293
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/marketplace/plugin/bin/policy.mjs +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +27 -23
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +107 -30
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +17 -13
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +36 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +29 -13
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +18 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +20 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +39 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +22 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +96 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +19 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +46 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +21 -24
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +6 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +1 -2
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +59 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-mcp.ts +2 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +23 -16
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.293",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -59,7 +59,10 @@ function decide(policy, name, group) {
|
|
|
59
59
|
const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
60
60
|
|
|
61
61
|
function connectorCall(snap, toolName) {
|
|
62
|
-
|
|
62
|
+
const entries = Object.entries(isRecord(snap.connectors) ? snap.connectors : {});
|
|
63
|
+
const legacy = entries.filter(([, entry]) => isRecord(entry) && !/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id)) && toolName.startsWith(`mcp__${normalized(`metro.box ${text(entry.name)}`)}__`));
|
|
64
|
+
if (legacy.length > 1) return { name: 'ambiguous connector name; use its instance server key', tool: toolName, group: 'write', policy: { write: 'deny' } };
|
|
65
|
+
for (const [server, entry] of entries) {
|
|
63
66
|
if (!isRecord(entry)) continue;
|
|
64
67
|
const name = text(entry.name);
|
|
65
68
|
const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
|
|
@@ -44,6 +44,8 @@ function readOAuth(raw: Record<string, unknown>): ConnectorAuth {
|
|
|
44
44
|
clientId,
|
|
45
45
|
tokenEndpoint,
|
|
46
46
|
issuer: stringOf(raw.issuer),
|
|
47
|
+
...(typeof raw.resource === 'string' ? { resource: raw.resource } : {}),
|
|
48
|
+
...(typeof raw.tokenEndpointAuthMethod === 'string' ? { tokenEndpointAuthMethod: raw.tokenEndpointAuthMethod } : {}),
|
|
47
49
|
...(refreshToken === '' ? {} : { refreshToken }),
|
|
48
50
|
...(clientSecret === '' ? {} : { clientSecret }),
|
|
49
51
|
...(scope === '' ? {} : { scope }),
|
|
@@ -20,7 +20,6 @@ export interface ConnectorGate {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
let gates: ConnectorGate[] = [];
|
|
23
|
-
let managed = new Set<string>();
|
|
24
23
|
|
|
25
24
|
const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
26
25
|
|
|
@@ -31,21 +30,21 @@ function approvalChanged(gate: ConnectorGate, tool: string, next: GateRow | unde
|
|
|
31
30
|
return before !== after;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
export function registerConnectors(rows: readonly GateRow[],
|
|
35
|
-
const
|
|
36
|
-
revokeToolPrompts((tool) => {
|
|
37
|
-
const found = connectorToolOf(tool);
|
|
38
|
-
if (found === undefined || (!isOrgRelayId(found.gate.id) && !managed.has(found.gate.id) && !nextManaged.has(found.gate.id))) return false;
|
|
39
|
-
return approvalChanged(found.gate, found.tool, rows.find((row) => row.id === found.gate.id), keys);
|
|
40
|
-
});
|
|
41
|
-
managed = nextManaged;
|
|
42
|
-
gates = rows.map((row) => ({
|
|
33
|
+
export function registerConnectors(rows: readonly GateRow[], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
|
|
34
|
+
const next = rows.map((row) => ({
|
|
43
35
|
id: row.id,
|
|
44
36
|
name: row.name,
|
|
45
37
|
server: keys.get(row.id) ?? '',
|
|
46
38
|
policy: row.config.policy ?? {},
|
|
47
39
|
tools: row.config.toolGroups ?? {},
|
|
48
40
|
}));
|
|
41
|
+
revokeToolPrompts((tool) => {
|
|
42
|
+
const before = connectorToolOf(tool);
|
|
43
|
+
const after = connectorToolOf(tool, next);
|
|
44
|
+
if (before?.gate.id !== after?.gate.id) return true;
|
|
45
|
+
return before !== undefined && approvalChanged(before.gate, before.tool, rows.find((row) => row.id === before.gate.id), keys);
|
|
46
|
+
});
|
|
47
|
+
gates = next;
|
|
49
48
|
const entries = rows.flatMap((row): [PolicyTarget, ToolPolicy][] =>
|
|
50
49
|
row.config.policy === undefined ? [] : [[{ kind: 'connector', id: row.id }, row.config.policy]],
|
|
51
50
|
);
|
|
@@ -63,7 +62,7 @@ const promptFor = (connectorId: string, tool: string) => (asked: string): boolea
|
|
|
63
62
|
|
|
64
63
|
export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
|
|
65
64
|
const gate = gates.find((g) => g.id === connectorId);
|
|
66
|
-
if (gate === undefined) return
|
|
65
|
+
if (gate === undefined) return 'This connector is not available.';
|
|
67
66
|
settlePrompts(promptFor(connectorId, tool), args);
|
|
68
67
|
const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
|
|
69
68
|
if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
|
|
@@ -92,19 +91,24 @@ export function requestConnectorBlocker(): () => BlockedReason {
|
|
|
92
91
|
};
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
function
|
|
96
|
-
const
|
|
97
|
-
return
|
|
94
|
+
function resolvedTool(gate: ConnectorGate, called: string): { gate: ConnectorGate; tool: string } {
|
|
95
|
+
const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
|
|
96
|
+
return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
export function connectorToolOf(toolName: string): { gate: ConnectorGate; tool: string } | undefined {
|
|
99
|
+
export function connectorToolOf(toolName: string, entries: readonly ConnectorGate[] = gates): { gate: ConnectorGate; tool: string } | undefined {
|
|
101
100
|
if (toolName.startsWith('mcp__metro__')) return undefined;
|
|
102
|
-
for (const gate of
|
|
103
|
-
for (const prefix of
|
|
104
|
-
if (toolName.startsWith(prefix) && toolName.length > prefix.length)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return undefined;
|
|
101
|
+
for (const gate of entries)
|
|
102
|
+
for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
|
|
103
|
+
if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
|
|
104
|
+
return legacyToolOf(toolName, entries);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function legacyToolOf(toolName: string, entries: readonly ConnectorGate[]): { gate: ConnectorGate; tool: string } | undefined {
|
|
108
|
+
if (!toolName.startsWith('mcp__metro_box_')) return undefined;
|
|
109
|
+
const legacy = entries.filter((gate) => !isOrgRelayId(gate.id) && toolName.startsWith(`mcp__${normalized(`metro.box ${gate.name}`)}__`));
|
|
110
|
+
const gate = legacy.length === 1 ? legacy[0] : undefined;
|
|
111
|
+
if (gate === undefined) return undefined;
|
|
112
|
+
const called = toolName.slice(`mcp__${normalized(`metro.box ${gate.name}`)}__`.length);
|
|
113
|
+
return called === '' ? undefined : resolvedTool(gate, called);
|
|
110
114
|
}
|
|
@@ -7,7 +7,7 @@ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
|
7
7
|
import type { ConnectorMigrations, MigrationAction } from './migration-control.js';
|
|
8
8
|
|
|
9
9
|
const PATH = '/api/connectors/migrations';
|
|
10
|
-
const actions: readonly MigrationAction[] = ['prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
|
|
10
|
+
const actions: readonly MigrationAction[] = ['move', 'prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
|
|
11
11
|
export type MigrationApiDeps = Pick<ConnectorMigrations, 'status' | 'run'>;
|
|
12
12
|
|
|
13
13
|
interface MigrationInput {
|
|
@@ -9,17 +9,19 @@ import { agentsDir } from '../agents/files.js';
|
|
|
9
9
|
import { readAgentKey, type AgentKey } from './agent-key.js';
|
|
10
10
|
import { agentCall, METRO_API, MetroApiRefusal, type MetroApi } from './metro-api.js';
|
|
11
11
|
import { migrationSource } from './migration-preview.js';
|
|
12
|
-
import { migrationPayload } from './migration-source.js';
|
|
12
|
+
import { MigrationBlocked, migrationPayload } from './migration-source.js';
|
|
13
13
|
import { localMigrationIdle } from './migration-guard.js';
|
|
14
|
-
import { migrationUncertaintyReceipt, resolveMigrationUncertainty } from './migration-operations.js';
|
|
15
|
-
import { localRollbackMigrationSource } from './store.js';
|
|
14
|
+
import { migrationUncertaintyReceipt, migrationRefreshUncertain, migrationOperationsIdle, resolveMigrationUncertainty } from './migration-operations.js';
|
|
15
|
+
import { flushMigrationSource, localRollbackMigrationSource } from './store.js';
|
|
16
16
|
import { freshOrgConnectors, orgConnectorMigrationIdle, orgConnectorSyncStatus, refreshOrgConnectorRuntime } from './org-sync-watch.js';
|
|
17
17
|
import { migrationRecord, migrationRecords, saveMigration, type MigrationRecord } from './migration-state.js';
|
|
18
18
|
|
|
19
|
-
export type MigrationAction = 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
|
|
19
|
+
export type MigrationAction = 'move' | 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
|
|
20
20
|
interface Context { key: AgentKey; owner: string; agentId: string; scope: string }
|
|
21
21
|
export interface MigrationView extends Pick<MigrationRecord, 'id' | 'sourceId' | 'name' | 'destinationId' | 'phase' | 'auth' | 'reason'> {
|
|
22
22
|
uncertainty: string | null;
|
|
23
|
+
move: boolean;
|
|
24
|
+
grantImported: boolean;
|
|
23
25
|
}
|
|
24
26
|
export interface MigrationStatus {
|
|
25
27
|
scope: string;
|
|
@@ -44,12 +46,23 @@ interface MigrationDeps {
|
|
|
44
46
|
|
|
45
47
|
const conflict = (message: string): ApiError => new ApiError(message, 409);
|
|
46
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);
|
|
47
|
-
const viewOf = ({ id, sourceId, name, destinationId, phase, auth, reason }: MigrationRecord, uncertainty: string | null): MigrationView => ({ id, sourceId, name, destinationId, phase, auth, reason, uncertainty });
|
|
48
|
-
const reasons: Record<RemoteMigration['readiness'], string | null> = { pending: 'not_verified', ready: null, signin: 'sign_in', changed: 'destination_changed', unavailable: 'unavailable' };
|
|
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' };
|
|
49
51
|
const samePolicy = (a: MigrationRecord['policy'], b: MigrationRecord['policy']): boolean =>
|
|
50
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());
|
|
51
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
|
+
|
|
52
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.');
|
|
53
66
|
if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
|
|
54
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);
|
|
55
68
|
}
|
|
@@ -82,7 +95,7 @@ export class ConnectorMigrations {
|
|
|
82
95
|
const keyId = this.key()?.keyId;
|
|
83
96
|
const migrations = migrationRecords(this.dir).filter((row) => row.owner === owner).map((row) => {
|
|
84
97
|
const uncertainty = migrationUncertaintyReceipt(row.sourceId, this.dir);
|
|
85
|
-
const reason = uncertainty === null ? row.reason : 'requests_uncertain';
|
|
98
|
+
const reason = uncertainty === null ? row.reason : migrationRefreshUncertain(row.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain';
|
|
86
99
|
return viewOf({ ...row, reason: row.agentId !== agentId || row.keyId !== keyId ? 'identity_changed' : reason }, uncertainty);
|
|
87
100
|
});
|
|
88
101
|
return { scope, owner, agentId, enabled, enrolled, migrations };
|
|
@@ -111,12 +124,12 @@ export class ConnectorMigrations {
|
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
private source(record: MigrationRecord): ReturnType<typeof migrationPayload> {
|
|
114
|
-
const payload = migrationPayload(migrationSource(record.sourceId, this.dir), record.oauthResource);
|
|
127
|
+
const payload = migrationPayload(migrationSource(record.sourceId, this.dir, record.move === true && record.captured === true), record.oauthResource, record.move === true && record.captured === true);
|
|
115
128
|
if (payload.sourceStamp !== record.sourceStamp) throw conflict('The retained local connector changed. Restore its original settings before continuing; Metro will not overwrite it.');
|
|
116
129
|
return payload;
|
|
117
130
|
}
|
|
118
131
|
|
|
119
|
-
private async call(context: Context, record: MigrationRecord, action: 'begin' | 'prepare' | 'status' | 'activate' | 'rollback', sealed?: ReturnType<typeof sealConnectorMigration>): Promise<MigrationResponse> {
|
|
132
|
+
private async call(context: Context, record: MigrationRecord, action: 'begin' | 'prepare' | 'handover' | 'status' | 'activate' | 'rollback', sealed?: ReturnType<typeof sealConnectorMigration>): Promise<MigrationResponse> {
|
|
120
133
|
this.check(context, record);
|
|
121
134
|
let response: MigrationResponse;
|
|
122
135
|
try {
|
|
@@ -127,9 +140,7 @@ export class ConnectorMigrations {
|
|
|
127
140
|
throw callFailure(error, action);
|
|
128
141
|
}
|
|
129
142
|
this.check(context, record);
|
|
130
|
-
|
|
131
|
-
if (remote.operationId !== record.id || remote.sourceId !== record.sourceId || (record.destinationId !== null && remote.destinationId !== record.destinationId)) throw uncertain();
|
|
132
|
-
if (remote.sourceStamp !== null && remote.sourceStamp !== record.sourceStamp) throw conflict('The prepared destination no longer matches this local connector. Roll back this preparation before trying again.');
|
|
143
|
+
checkResponse(record, response.migration);
|
|
133
144
|
return response;
|
|
134
145
|
}
|
|
135
146
|
|
|
@@ -137,14 +148,14 @@ export class ConnectorMigrations {
|
|
|
137
148
|
if (remote.phase !== 'preparing' && remote.phase !== 'rolled_back' && (remote.name !== record.name || remote.auth !== record.auth || remote.policy === null || !samePolicy(record.policy, remote.policy))) {
|
|
138
149
|
return this.save(context, { ...record, destinationId: remote.destinationId, reason: 'destination_changed' });
|
|
139
150
|
}
|
|
140
|
-
return this.save(context, { ...record, destinationId: remote.destinationId, reason: reasons[remote.readiness] });
|
|
151
|
+
return this.save(context, { ...record, destinationId: remote.destinationId, grantImported: remote.grantImported === true, reason: reasons[remote.readiness] });
|
|
141
152
|
}
|
|
142
153
|
|
|
143
|
-
private create(context: Context, id: string, oauthResource?: string): MigrationRecord {
|
|
154
|
+
private create(context: Context, id: string, oauthResource?: string, move = false): MigrationRecord {
|
|
144
155
|
const payload = migrationPayload(migrationSource(id, this.dir), oauthResource);
|
|
145
156
|
return this.save(context, {
|
|
146
157
|
id: newId(), owner: context.owner, agentId: context.agentId, keyId: context.key.keyId,
|
|
147
|
-
sourceId: id, name: payload.name, destinationId: null, phase: 'preparing', auth: payload.auth.kind,
|
|
158
|
+
sourceId: id, name: payload.name, destinationId: null, phase: move ? 'freezing' : 'preparing', move, auth: payload.auth.kind,
|
|
148
159
|
sourceStamp: payload.sourceStamp, policy: payload.policy, reason: null,
|
|
149
160
|
...(payload.oauthResource === undefined ? {} : { oauthResource: payload.oauthResource }),
|
|
150
161
|
});
|
|
@@ -154,25 +165,54 @@ export class ConnectorMigrations {
|
|
|
154
165
|
const payload = this.source(record);
|
|
155
166
|
const begun = await this.call(context, record, 'begin');
|
|
156
167
|
record = this.accept(context, record, begun.migration);
|
|
157
|
-
if (begun.migration.phase === 'prepared') {
|
|
168
|
+
if (begun.migration.phase === 'prepared' && !upgrading(record, begun.migration)) {
|
|
158
169
|
this.save(context, { ...record, phase: 'prepared' });
|
|
159
170
|
return;
|
|
160
171
|
}
|
|
161
|
-
if (begun.migration.phase
|
|
172
|
+
if (!['preparing', 'prepared'].includes(begun.migration.phase) || begun.receiving === undefined) throw uncertain();
|
|
162
173
|
const receiving = begun.receiving;
|
|
163
174
|
const expected = { owner: context.owner, agent: context.agentId, keyId: context.key.keyId, operationId: record.id, sourceId: record.sourceId, destinationId: record.destinationId };
|
|
164
175
|
for (const [key, value] of Object.entries(expected)) if (receiving.context[key as keyof typeof receiving.context] !== value) throw uncertain();
|
|
165
176
|
const sealed = sealConnectorMigration(receiving.publicKey, receiving.context, payload);
|
|
166
|
-
const { migration } = await this.call(context, record, 'prepare', sealed);
|
|
177
|
+
const { migration } = await this.call(context, record, grantTransfer(record) ? 'handover' : 'prepare', sealed);
|
|
167
178
|
record = this.accept(context, record, migration);
|
|
168
179
|
if (migration.phase !== 'prepared') throw uncertain();
|
|
169
180
|
this.save(context, { ...record, phase: 'prepared' });
|
|
170
181
|
}
|
|
171
182
|
|
|
183
|
+
private async freeze(context: Context, record: MigrationRecord): Promise<MigrationRecord | null> {
|
|
184
|
+
const until = Date.now() + 10_000;
|
|
185
|
+
while (!this.localIdle(record.sourceId) && migrationUncertaintyReceipt(record.sourceId, this.dir) === null && Date.now() < until) {
|
|
186
|
+
await new Promise((resolve) => { setTimeout(resolve, 100); });
|
|
187
|
+
this.check(context, record);
|
|
188
|
+
}
|
|
189
|
+
if (this.waiting(context, record)) return null;
|
|
190
|
+
flushMigrationSource(this.dir);
|
|
191
|
+
const payload = migrationPayload(migrationSource(record.sourceId, this.dir, true), record.oauthResource, true);
|
|
192
|
+
return this.save(context, { ...record, sourceStamp: payload.sourceStamp, captured: true, phase: 'preparing', reason: null });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async move(context: Context, record: MigrationRecord): Promise<void> {
|
|
196
|
+
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
197
|
+
if (['active', 'rolled_back'].includes(record.phase)) return this.reconcile(context, record);
|
|
198
|
+
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
199
|
+
if (record.phase === 'freezing') {
|
|
200
|
+
const captured = await this.freeze(context, record);
|
|
201
|
+
if (captured === null) return;
|
|
202
|
+
record = captured;
|
|
203
|
+
}
|
|
204
|
+
if (record.phase === 'preparing') {
|
|
205
|
+
await this.prepare(context, record);
|
|
206
|
+
record = migrationRecord(record.sourceId, this.dir) ?? record;
|
|
207
|
+
}
|
|
208
|
+
if (record.phase === 'prepared' || record.phase === 'cutover') await this.cutover(context, record);
|
|
209
|
+
}
|
|
210
|
+
|
|
172
211
|
private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
|
|
173
212
|
const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
|
|
174
|
-
|
|
175
|
-
|
|
213
|
+
const localIdle = this.localIdle(record.sourceId);
|
|
214
|
+
if (!uncertain && localIdle && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
|
|
215
|
+
this.save(context, { ...record, reason: uncertain ? migrationRefreshUncertain(record.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain' : 'waiting_for_local' });
|
|
176
216
|
return true;
|
|
177
217
|
}
|
|
178
218
|
|
|
@@ -202,7 +242,15 @@ export class ConnectorMigrations {
|
|
|
202
242
|
}
|
|
203
243
|
|
|
204
244
|
private async rollback(context: Context, record: MigrationRecord): Promise<void> {
|
|
205
|
-
this.
|
|
245
|
+
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
246
|
+
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.');
|
|
247
|
+
const begun = await this.call(context, record, 'begin');
|
|
248
|
+
if (begun.migration.grantImported === true) {
|
|
249
|
+
record = this.accept(context, record, begun.migration);
|
|
250
|
+
this.save(context, { ...record, phase: begun.migration.phase === 'active' ? 'cutover' : 'prepared', reason: 'finish_forward' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (record.phase !== 'freezing') this.source(record);
|
|
206
254
|
record = this.save(context, { ...record, phase: 'rollback' });
|
|
207
255
|
if (this.waiting(context, record, true)) return;
|
|
208
256
|
const { migration } = await this.call(context, record, 'rollback');
|
|
@@ -211,15 +259,23 @@ export class ConnectorMigrations {
|
|
|
211
259
|
return;
|
|
212
260
|
}
|
|
213
261
|
this.check(context, record);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
262
|
+
this.restore(context, record);
|
|
263
|
+
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private restore(context: Context, record: MigrationRecord): void {
|
|
267
|
+
const signOut = record.auth === 'oauth' && record.move !== true;
|
|
268
|
+
if (this.deps.restore !== undefined) this.deps.restore(record.sourceId, signOut);
|
|
269
|
+
else localRollbackMigrationSource(record.sourceId, signOut, this.dir);
|
|
270
|
+
this.save(context, { ...record, phase: 'rolled_back', reason: signOut ? 'sign_in' : null });
|
|
217
271
|
}
|
|
218
272
|
|
|
219
273
|
private async reconcile(context: Context, record: MigrationRecord): Promise<void> {
|
|
274
|
+
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
275
|
+
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
276
|
+
if (record.move === true && record.phase !== 'active' && record.phase !== 'rolled_back') return this.move(context, record);
|
|
220
277
|
if (record.phase === 'preparing') return this.prepare(context, record);
|
|
221
278
|
if (record.phase === 'cutover') return this.cutover(context, record);
|
|
222
|
-
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
223
279
|
if (record.phase === 'rolled_back') return;
|
|
224
280
|
const { migration } = await this.call(context, record, 'status');
|
|
225
281
|
this.accept(context, record, migration);
|
|
@@ -228,25 +284,46 @@ export class ConnectorMigrations {
|
|
|
228
284
|
private obtain(context: Context, sourceId: string, action: MigrationAction, oauthResource?: string): MigrationRecord | null {
|
|
229
285
|
const record = migrationRecord(sourceId, this.dir);
|
|
230
286
|
if (record !== null) this.check(context, record);
|
|
231
|
-
if (
|
|
287
|
+
if (['prepare', 'move'].includes(action) && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId, oauthResource, action === 'move');
|
|
288
|
+
if (action === 'move' && record !== null && canUpgrade(record)) return this.save(context, { ...record, move: true, phase: 'freezing' });
|
|
232
289
|
if (record === null && action !== 'reconcile') throw conflict('Prepare this connector before moving it.');
|
|
233
290
|
return record;
|
|
234
291
|
}
|
|
235
292
|
|
|
236
293
|
private perform(context: Context, record: MigrationRecord, action: MigrationAction): Promise<void> {
|
|
294
|
+
if (action === 'move') return this.move(context, record);
|
|
237
295
|
if (action === 'prepare' && record.phase === 'preparing') return this.prepare(context, record);
|
|
238
296
|
if (action === 'cutover' && (record.phase === 'prepared' || record.phase === 'cutover')) return this.cutover(context, record);
|
|
239
297
|
if (action === 'rollback' && record.phase !== 'rolled_back') return this.rollback(context, record);
|
|
240
298
|
return this.reconcile(context, record);
|
|
241
299
|
}
|
|
242
300
|
|
|
243
|
-
private resolve(context: Context, record: MigrationRecord, receipt: string | null): void {
|
|
301
|
+
private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
|
|
244
302
|
this.check(context, record);
|
|
245
303
|
if (receipt === null || receipt !== migrationUncertaintyReceipt(record.sourceId, this.dir)) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
|
|
304
|
+
localMigrationIdle(record.sourceId, this.dir, true);
|
|
305
|
+
if (migrationRefreshUncertain(record.sourceId, this.dir)) return this.recoverRefresh(context, record, receipt);
|
|
246
306
|
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.');
|
|
247
307
|
this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
|
|
248
308
|
}
|
|
249
309
|
|
|
310
|
+
private async recoverRefresh(context: Context, record: MigrationRecord, receipt: string): Promise<void> {
|
|
311
|
+
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.');
|
|
312
|
+
const { migration } = await this.call(context, record, 'rollback');
|
|
313
|
+
if (migration.phase !== 'rolled_back') throw uncertain();
|
|
314
|
+
record = this.save(context, { ...record, phase: 'recovering', recoveryReceipt: receipt });
|
|
315
|
+
await this.finishRecovery(context, record);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async finishRecovery(context: Context, record: MigrationRecord): Promise<void> {
|
|
319
|
+
const { migration } = await this.call(context, record, 'rollback');
|
|
320
|
+
if (migration.phase !== 'rolled_back' || record.recoveryReceipt === undefined) throw uncertain();
|
|
321
|
+
localRollbackMigrationSource(record.sourceId, true, this.dir, true);
|
|
322
|
+
const receipt = migrationUncertaintyReceipt(record.sourceId, this.dir);
|
|
323
|
+
if (receipt !== null && !resolveMigrationUncertainty(record.sourceId, record.recoveryReceipt, this.dir)) throw uncertain();
|
|
324
|
+
this.save(context, { ...record, phase: 'rolled_back', recoveryReceipt: undefined, reason: 'sign_in' });
|
|
325
|
+
}
|
|
326
|
+
|
|
250
327
|
private validateResource(sourceId: string, action: MigrationAction, oauthResource: string | undefined): void {
|
|
251
328
|
if (oauthResource === undefined) return;
|
|
252
329
|
const resource = publicOAuthResource(oauthResource);
|
|
@@ -262,14 +339,14 @@ export class ConnectorMigrations {
|
|
|
262
339
|
|
|
263
340
|
async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null, oauthResource?: string): Promise<MigrationStatus> {
|
|
264
341
|
if (this.running) throw conflict('Another migration request is still running on this agent. Refresh after it finishes.');
|
|
265
|
-
const context = this.context(scope,
|
|
342
|
+
const context = this.context(scope, ['move', 'prepare', 'cutover'].includes(action));
|
|
266
343
|
this.validateIntent(sourceId, action, operationId, uncertainty);
|
|
267
344
|
this.validateResource(sourceId, action, oauthResource);
|
|
268
345
|
this.running = true;
|
|
269
346
|
try {
|
|
270
347
|
const record = this.obtain(context, sourceId, action, oauthResource);
|
|
271
348
|
if (record !== null) {
|
|
272
|
-
if (action === 'resolve') this.resolve(context, record, uncertainty);
|
|
349
|
+
if (action === 'resolve') await this.resolve(context, record, uncertainty);
|
|
273
350
|
else await this.perform(context, record, action);
|
|
274
351
|
}
|
|
275
352
|
this.check(context);
|
|
@@ -277,7 +354,7 @@ export class ConnectorMigrations {
|
|
|
277
354
|
} catch (error) {
|
|
278
355
|
const record = migrationRecord(sourceId, this.dir);
|
|
279
356
|
if (record !== null) {
|
|
280
|
-
try { this.save(context, { ...record, reason: 'unavailable' }); } catch {
|
|
357
|
+
try { this.save(context, { ...record, reason: error instanceof MigrationBlocked ? error.reason : 'unavailable' }); } catch {
|
|
281
358
|
log.warn('connector migration: the interrupted operation needs reconciliation in its original enrollment');
|
|
282
359
|
}
|
|
283
360
|
}
|
|
@@ -3,10 +3,10 @@ import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
|
3
3
|
import { agentsDir } from '../agents/files.js';
|
|
4
4
|
import type { AgentKey } from './agent-key.js';
|
|
5
5
|
import { migrationRecord, migrationRecords } from './migration-state.js';
|
|
6
|
-
import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
6
|
+
import { assertMigrationOutcomeKnown, beginMigrationOperation, drainMigrationListeners, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
7
7
|
|
|
8
8
|
const locked = (phase: string): boolean => phase !== 'rolled_back';
|
|
9
|
-
const retired = (
|
|
9
|
+
const retired = (record: NonNullable<ReturnType<typeof migrationRecord>>): boolean => record.move === true || record.phase === 'cutover' || record.phase === 'active' || record.phase === 'rollback';
|
|
10
10
|
const refused = (): ApiError => new ApiError('This local connector is locked while it moves to the organization.', 409);
|
|
11
11
|
|
|
12
12
|
export function migrationSource(id: string, dir = agentsDir()): ReturnType<typeof migrationRecords>[number] | null {
|
|
@@ -16,27 +16,29 @@ export function migrationSource(id: string, dir = agentsDir()): ReturnType<typeo
|
|
|
16
16
|
|
|
17
17
|
export function localMigrationEnabled(id: string, dir = agentsDir()): boolean {
|
|
18
18
|
const record = migrationSource(id, dir);
|
|
19
|
-
return record === null || !retired(record
|
|
19
|
+
return record === null || !retired(record);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function assertLocalMigrationUse(id: string, dir = agentsDir()): void {
|
|
23
23
|
const record = migrationSource(id, dir);
|
|
24
24
|
if (record === null) return;
|
|
25
|
-
if (retired(record
|
|
25
|
+
if (retired(record)) throw refused();
|
|
26
26
|
assertMigrationOutcomeKnown(id, dir);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function assertLocalMigrationEdit(id: string,
|
|
30
|
-
if (migrationRecords(dir).some((record) => locked(record.phase) &&
|
|
29
|
+
export function assertLocalMigrationEdit(id: string, dir = agentsDir()): void {
|
|
30
|
+
if (migrationRecords(dir).some((record) => locked(record.phase) && record.sourceId === id)) throw refused();
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function migrationExportableRows<T extends { id: string; name: string }>(rows: readonly T[], dir = agentsDir()): T[] {
|
|
34
34
|
const records = migrationRecords(dir).filter((record) => locked(record.phase));
|
|
35
|
-
return rows.filter((row) => !records.some((record) => record.sourceId === row.id
|
|
35
|
+
return rows.filter((row) => !records.some((record) => record.sourceId === row.id));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function localMigrationIdle(id: string, dir = agentsDir()): boolean {
|
|
39
|
-
|
|
38
|
+
export function localMigrationIdle(id: string, dir = agentsDir(), allowUncertain = false): boolean {
|
|
39
|
+
const record = migrationSource(id, dir);
|
|
40
|
+
if (record !== null && retired(record)) drainMigrationListeners(id, dir);
|
|
41
|
+
return migrationOperationsIdle(id, dir, !allowUncertain && record !== null);
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface LocalMigrationOperation extends MigrationOperation {
|
|
@@ -45,11 +47,11 @@ export interface LocalMigrationOperation extends MigrationOperation {
|
|
|
45
47
|
close: () => void;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false
|
|
50
|
+
export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false): LocalMigrationOperation {
|
|
49
51
|
let closed = false;
|
|
50
52
|
const check = (): void => {
|
|
51
53
|
if (closed) throw refused();
|
|
52
|
-
if (edit) assertLocalMigrationEdit(id,
|
|
54
|
+
if (edit) assertLocalMigrationEdit(id, dir);
|
|
53
55
|
else assertLocalMigrationUse(id, dir);
|
|
54
56
|
};
|
|
55
57
|
check();
|
|
@@ -61,13 +63,15 @@ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit
|
|
|
61
63
|
return {
|
|
62
64
|
check,
|
|
63
65
|
dispatch,
|
|
66
|
+
passiveListener: tracked.passiveListener,
|
|
67
|
+
persistedRefresh: tracked.persistedRefresh,
|
|
64
68
|
fetch: (input, init) => dispatch(() => fetch(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }),
|
|
65
69
|
close: () => { closed = true; tracked.close(); },
|
|
66
70
|
};
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false
|
|
70
|
-
const operation = beginLocalMigrationOperation(id, dir, edit
|
|
73
|
+
export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false): Promise<T> {
|
|
74
|
+
const operation = beginLocalMigrationOperation(id, dir, edit);
|
|
71
75
|
try { return await run(operation); }
|
|
72
76
|
finally { operation.close(); }
|
|
73
77
|
}
|
|
@@ -14,14 +14,23 @@ interface RequestOperation {
|
|
|
14
14
|
lease: string;
|
|
15
15
|
durable: boolean;
|
|
16
16
|
abandoned: boolean;
|
|
17
|
+
refresh: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PassiveListener {
|
|
21
|
+
idle: () => boolean;
|
|
22
|
+
stop: () => void;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export interface MigrationOperation {
|
|
20
|
-
|
|
26
|
+
passiveListener: (listener: PassiveListener) => void;
|
|
27
|
+
dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean; refresh?: boolean }) => Promise<Response>;
|
|
21
28
|
close: () => void;
|
|
29
|
+
persistedRefresh: () => void;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
const leases = new Map<string, Set<string>>();
|
|
33
|
+
const listeners = new Map<string, PassiveListener>();
|
|
25
34
|
const requests = new Map<string, RequestOperation>();
|
|
26
35
|
const held = new Map<string, RequestOperation>();
|
|
27
36
|
const keyOf = (source: string, dir: string): string => JSON.stringify([resolve(dir), source]);
|
|
@@ -57,7 +66,7 @@ function pendingOf(text: string): string[] {
|
|
|
57
66
|
const value: unknown = JSON.parse(text);
|
|
58
67
|
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.pending) || value.pending.length > 10_000) throw unavailable();
|
|
59
68
|
const ids: unknown[] = value.pending;
|
|
60
|
-
if (!ids.every((id): id is string => typeof id === 'string' && /^[a-f0-9]{32}$/.test(id))) throw unavailable();
|
|
69
|
+
if (!ids.every((id): id is string => typeof id === 'string' && /^r?[a-f0-9]{32}$/.test(id))) throw unavailable();
|
|
61
70
|
if (new Set(ids).size !== ids.length) throw unavailable();
|
|
62
71
|
return ids;
|
|
63
72
|
}
|
|
@@ -101,6 +110,17 @@ export function assertMigrationOutcomeKnown(source: string, dir = agentsDir()):
|
|
|
101
110
|
if (migrationUncertaintyReceipt(source, dir) !== null) throw uncertain();
|
|
102
111
|
}
|
|
103
112
|
|
|
113
|
+
export function drainMigrationListeners(source: string, dir: string): void {
|
|
114
|
+
const active = [...(leases.get(keyOf(source, dir)) ?? [])];
|
|
115
|
+
if (active.length === 0 || active.some((lease) => listeners.get(lease)?.idle() !== true)) return;
|
|
116
|
+
if (readPending(source, dir).some((id) => requests.has(id))) return;
|
|
117
|
+
for (const lease of active) {
|
|
118
|
+
const listener = listeners.get(lease);
|
|
119
|
+
listeners.delete(lease);
|
|
120
|
+
listener?.stop();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
104
124
|
export function migrationOperationsIdle(source: string, dir: string, protectedSource: boolean): boolean {
|
|
105
125
|
if (leases.has(keyOf(source, dir))) return false;
|
|
106
126
|
return !protectedSource || readPending(source, dir).length === 0;
|
|
@@ -114,7 +134,8 @@ export function resolveMigrationUncertainty(source: string, expectedReceipt: str
|
|
|
114
134
|
return true;
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
function finish(request: RequestOperation, completed: boolean): void {
|
|
137
|
+
function finish(request: RequestOperation, completed: boolean, persisted = false): void {
|
|
138
|
+
if (request.refresh && completed && !persisted) return;
|
|
118
139
|
if (request.abandoned) return;
|
|
119
140
|
requests.delete(request.id);
|
|
120
141
|
if (completed) held.delete(request.id);
|
|
@@ -153,10 +174,10 @@ function observed(response: Response, request: RequestOperation): Response {
|
|
|
153
174
|
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
154
175
|
}
|
|
155
176
|
|
|
156
|
-
async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, send: () => Promise<Response>): Promise<Response> {
|
|
177
|
+
async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, refresh: boolean, send: () => Promise<Response>): Promise<Response> {
|
|
157
178
|
if (protectedSource) assertMigrationOutcomeKnown(source, dir);
|
|
158
179
|
if (!effectful) return send();
|
|
159
|
-
const request: RequestOperation = { id: randomBytes(16).toString('hex')
|
|
180
|
+
const request: RequestOperation = { id: `${refresh ? 'r' : ''}${randomBytes(16).toString('hex')}`, source, dir, lease, durable: false, abandoned: false, refresh };
|
|
160
181
|
if (protectedSource) {
|
|
161
182
|
const pending = readPending(source, dir);
|
|
162
183
|
if (pending.length >= 10_000) throw unavailable();
|
|
@@ -178,13 +199,18 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
|
|
|
178
199
|
leases.set(key, active);
|
|
179
200
|
let closed = false;
|
|
180
201
|
return {
|
|
202
|
+
passiveListener: (listener) => { if (!closed) listeners.set(lease, listener); },
|
|
181
203
|
dispatch: (send, options = {}) => {
|
|
182
204
|
if (closed) return Promise.reject(uncertain());
|
|
183
|
-
return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, send);
|
|
205
|
+
return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, options.refresh ?? false, send);
|
|
206
|
+
},
|
|
207
|
+
persistedRefresh: () => {
|
|
208
|
+
for (const request of held.values()) if (request.lease === lease && request.refresh) finish(request, true, true);
|
|
184
209
|
},
|
|
185
210
|
close: () => {
|
|
186
211
|
if (closed) return;
|
|
187
212
|
closed = true;
|
|
213
|
+
listeners.delete(lease);
|
|
188
214
|
for (const request of held.values()) if (request.lease === lease) {
|
|
189
215
|
finish(request, false);
|
|
190
216
|
held.delete(request.id);
|
|
@@ -194,3 +220,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
|
|
|
194
220
|
},
|
|
195
221
|
};
|
|
196
222
|
}
|
|
223
|
+
|
|
224
|
+
export function migrationRefreshUncertain(source: string, dir = agentsDir()): boolean {
|
|
225
|
+
return readPending(source, dir).some((id) => id.startsWith('r') && !requests.has(id));
|
|
226
|
+
}
|