@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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +4 -1
  4. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
  5. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  6. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +4 -34
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
  17. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
  18. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
  19. package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +3 -3
  20. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
  21. package/runtime/runtime.json +1 -1
  22. package/runtime/sdk-runner/runner.json +1 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
  27. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
  28. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
  29. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
  30. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
  31. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
  32. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
  33. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
  34. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
  35. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
  36. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
  37. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
  38. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
  39. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
  40. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.301",
3
+ "version": "0.1.0-beta.303",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.301",
3
+ "version": "0.1.0-beta.303",
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",
@@ -34,7 +34,10 @@ function ensureUser(): AgentUser | null {
34
34
  openMetroHome();
35
35
  forgetAgentUser();
36
36
  const user = agentUser();
37
- if (user !== null) run(...asUser(user, 'chmod', ['711', user.home]));
37
+ if (user !== null) {
38
+ run(...asUser(user, 'chmod', ['711', user.home]));
39
+ run(...asUser(user, 'sh', ['-c', 'test ! -d "$1" || chmod go-w "$1"', 'sh', join(user.home, '.metro')]));
40
+ }
38
41
  return user;
39
42
  }
40
43
 
@@ -5,7 +5,7 @@ import { apiFailure, apiSession, cors, readJsonBody, requireAdmin, sendJson } fr
5
5
  import { ApiError } from '@metro-labs/http/api-error';
6
6
  import { isRecord } from '@metro-labs/core/is-record';
7
7
  import { parseId } from '@metro-labs/core/ids';
8
- import type { LoadedAccount, LoadedAgent, LoadedConnector } from '../stations/materialize.js';
8
+ import type { LoadedAccount, LoadedAgent } from '../stations/materialize.js';
9
9
  import { STATIONS } from '@metro-labs/core/station-names';
10
10
  import { approversOf } from './files.js';
11
11
 
@@ -17,7 +17,7 @@ const STATION_NAMES = new Set<string>(STATIONS);
17
17
  export interface AgentBundle {
18
18
  version: 1;
19
19
  agent: { id: string; name: string; stations: LoadedAccount[] };
20
- connectors: LoadedConnector[];
20
+ connectors: [];
21
21
  }
22
22
 
23
23
  export const loadedAgentOf = (bundle: AgentBundle): LoadedAgent => ({
@@ -68,13 +68,6 @@ function stationOf(raw: unknown): LoadedAccount {
68
68
  };
69
69
  }
70
70
 
71
- function connectorOf(raw: unknown): LoadedConnector {
72
- if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.url !== 'string')
73
- throw bad('a connector is missing its id, name or url');
74
- if (!isRecord(raw.config)) throw bad('a connector has no config');
75
- return { id: raw.id, name: raw.name, url: raw.url, config: raw.config };
76
- }
77
-
78
71
  function agentOf(raw: unknown): AgentBundle['agent'] {
79
72
  if (!isRecord(raw)) throw bad('not a v1 agent bundle');
80
73
  const { id, name, stations } = raw;
@@ -85,8 +78,7 @@ function agentOf(raw: unknown): AgentBundle['agent'] {
85
78
 
86
79
  export function parseBundle(raw: unknown): AgentBundle {
87
80
  if (!isRecord(raw) || raw.version !== 1) throw bad('not a v1 agent bundle');
88
- const connectors = Array.isArray(raw.connectors) ? raw.connectors : [];
89
- return { version: 1, agent: agentOf(raw.agent), connectors: connectors.map(connectorOf) };
81
+ return { version: 1, agent: agentOf(raw.agent), connectors: [] };
90
82
  }
91
83
 
92
84
  async function answer(req: IncomingMessage, deps: BundleApiDeps, path: string): Promise<unknown> {
@@ -21,10 +21,10 @@ import {
21
21
  trainEventToMetroEvent,
22
22
  } from '../routes/http.js';
23
23
  import { agentsDir, fileSource } from '../agents/files.js';
24
- import { publishLocalConnectors } from '../connectors/publication.js';
24
+ import { connectorsReady, publishConnectors } from '../connectors/publication.js';
25
+ import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
25
26
  import { readAgentKey } from '../connectors/agent-key.js';
26
27
  import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
27
- import { loadConnectorPolicies, readLocalConnectors } from '../connectors/store.js';
28
28
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
29
29
  import { runningAsRoot } from '../metro-user/privilege.js';
30
30
  import { provisionAgentUser } from '../agent-user/provision.js';
@@ -140,10 +140,8 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
140
140
  async function main(): Promise<void> {
141
141
  applyLocalOwner();
142
142
  installBearerSessions(agentsDir(), localOwner);
143
+ adoptOrgServerKeys();
143
144
  watchPolicySnapshot();
144
- loadConnectorPolicies().catch((err: unknown) => {
145
- log.warn({ err: errMsg(err) }, 'connector policy: loading the policies failed');
146
- });
147
145
  log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
148
146
  await materializeFrom(fileSource);
149
147
  ensureStationDeps();
@@ -160,7 +158,6 @@ installBearerSessions(agentsDir(), localOwner);
160
158
  );
161
159
  metroMcp.startInbound();
162
160
  startPromptExpiry();
163
- publishLocalConnectors(readLocalConnectors());
164
161
  startUploadReaper();
165
162
  startAttachReaper();
166
163
  watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
@@ -195,7 +192,7 @@ async function startClaude(): Promise<void> {
195
192
  ensureMetroPlugin()
196
193
  .then((outcome) => {
197
194
  log.info({ outcome }, 'plugin: metro plugin for Claude Code');
198
- if (outcome !== 'skipped') publishLocalConnectors(readLocalConnectors());
195
+ if (outcome !== 'skipped') publishConnectors();
199
196
  })
200
197
  .catch((err: unknown) => {
201
198
  log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
@@ -203,7 +200,7 @@ async function startClaude(): Promise<void> {
203
200
  tryClaudeSetup();
204
201
  try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
205
202
  tryMemoryJob(memoryRoutine());
206
- watchSession();
203
+ watchSession({ connectorsReady: () => connectorsReady() });
207
204
  }
208
205
 
209
206
  const SHUTDOWN_TIMEOUT_MS = 3_000;
@@ -49,6 +49,7 @@ export interface SessionDeps {
49
49
  now?: () => number;
50
50
  version?: string;
51
51
  prepare?: (runner: HarnessRunner) => Promise<void>;
52
+ connectorsReady?: () => boolean;
52
53
  }
53
54
 
54
55
  export interface SessionStatus {
@@ -316,9 +317,10 @@ function strike(now: number): boolean {
316
317
  return true;
317
318
  }
318
319
 
319
- export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused';
320
+ export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused' | 'waiting';
320
321
 
321
322
  function holdsBack(deps: SessionDeps, now: number): Ensured | null {
323
+ if (deps.connectorsReady?.() === false) return 'waiting';
322
324
  if (now < memory.pausedUntil) return 'paused';
323
325
  if (sessionBlocked(deps) !== null) return 'blocked';
324
326
  return strike(now) ? 'paused' : null;
@@ -2,7 +2,6 @@ import type { ToolGroup } from '@metro-labs/core/stations/types';
2
2
  import { decide, policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
3
3
  import { serverKeysOf } from './plugin-sync.js';
4
4
  import { NEEDS_APPROVAL, revokeToolPrompts, settlePrompts, takeGrant } from '../approvals/pending.js';
5
- import { isOrgRelayId } from './relay-id.js';
6
5
  import type { BlockedReason } from './relay-policy.js';
7
6
 
8
7
  export interface GateRow {
@@ -101,14 +100,5 @@ export function connectorToolOf(toolName: string, entries: readonly ConnectorGat
101
100
  for (const gate of entries)
102
101
  for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
103
102
  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);
103
+ return undefined;
114
104
  }
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { errMsg, log } from '@metro-labs/core/log';
5
+ import { agentsDir } from '../agents/files.js';
6
+ import { isOrgRelayId, orgRelayId } from './relay-id.js';
7
+ import { readServerKeys, writeServerKeys } from './server-keys.js';
8
+
9
+ interface Alias {
10
+ sourceId: string;
11
+ relayId: string;
12
+ }
13
+
14
+ function recordsOf(dir: string): unknown[] {
15
+ let raw: unknown;
16
+ try {
17
+ raw = JSON.parse(readFileSync(join(dir, 'connector-migrations.json'), 'utf8'));
18
+ } catch (err) {
19
+ if (isRecord(err) && err.code === 'ENOENT') return [];
20
+ log.warn({ err: errMsg(err) }, 'connectors: the old migration records could not be read; server keys left as they are');
21
+ return [];
22
+ }
23
+ if (isRecord(raw) && raw.version === 1 && Array.isArray(raw.records)) return raw.records;
24
+ log.warn('connectors: the old migration records are not valid; server keys left as they are');
25
+ return [];
26
+ }
27
+
28
+ function aliasOf(record: unknown): Alias[] {
29
+ if (!isRecord(record) || record.phase !== 'active' || typeof record.sourceId !== 'string' || typeof record.destinationId !== 'string') return [];
30
+ const relayId = orgRelayId(record.destinationId);
31
+ return isOrgRelayId(relayId) && record.sourceId !== relayId ? [{ sourceId: record.sourceId, relayId }] : [];
32
+ }
33
+
34
+ export function adoptOrgServerKeys(dir = agentsDir()): number {
35
+ try {
36
+ const aliases = recordsOf(dir).flatMap(aliasOf);
37
+ if (aliases.length === 0) return 0;
38
+ const keys = readServerKeys(dir);
39
+ if (keys === undefined) return 0;
40
+ let moved = 0;
41
+ for (const { sourceId, relayId } of aliases) {
42
+ const key = keys.get(sourceId);
43
+ if (key === undefined || keys.has(relayId)) continue;
44
+ keys.delete(sourceId);
45
+ keys.set(relayId, key);
46
+ moved += 1;
47
+ }
48
+ if (moved > 0) {
49
+ writeServerKeys(keys, dir);
50
+ log.info({ moved }, 'connectors: server keys moved to organization connector ids, tool names unchanged');
51
+ }
52
+ return moved;
53
+ } catch (err) {
54
+ log.warn({ err: errMsg(err) }, 'connectors: the server keys could not be moved to organization connector ids');
55
+ return 0;
56
+ }
57
+ }
@@ -1,35 +1,23 @@
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';
4
2
  import type { FetchLike } from '@metro-labs/core/stations/oauth';
5
3
  import type { ToolGroup } from '@metro-labs/core/stations/types';
6
4
  import { effectiveConnectorPolicy, type DeliveredConnector, type DeliverySnapshot } from '@metro-labs/http/connector-delivery';
7
5
  import type { ApiError } from '@metro-labs/http/api-error';
8
6
  import { requestConnectorBlocker, type GateRow } from './gates.js';
9
7
  import { publishOrgConnectors } from './publication.js';
10
- import type { MigrationRuntimeMapping } from './migration-guard.js';
11
- import { isOrgRelayId, orgRelayId } from './relay-id.js';
8
+ import { orgRelayId } from './relay-id.js';
12
9
  import type { RelaySession } from './relay-session.js';
13
10
  import type { RelayTarget } from './relay-target.js';
14
11
  import type { OrgConnectorSync } from './org-sync.js';
15
12
  import { discoverOrgGroups, orgMissing, orgRuntimeError, orgSignin, orgUnavailable } from './org-runtime-discovery.js';
16
13
 
17
- interface RuntimeDeps {
18
- enabled?: () => boolean;
19
- mapping?: () => MigrationRuntimeMapping;
20
- migrationDir?: string;
14
+ export interface RuntimeDeps {
21
15
  publish?: (rows: readonly GateRow[]) => void;
22
16
  fetch?: FetchLike;
23
17
  }
24
18
 
25
- interface RuntimeRow extends DeliveredConnector {
26
- relayId: string;
27
- relayName: string;
28
- }
29
-
30
19
  interface Entry {
31
20
  id: string;
32
- relayId: string;
33
21
  epoch: number;
34
22
  stamp: string;
35
23
  controller: AbortController;
@@ -41,14 +29,13 @@ interface Entry {
41
29
 
42
30
  interface Session {
43
31
  entry: Entry;
44
- operation: MigrationOperation | null;
45
32
  controller: AbortController;
46
33
  revision: string | null;
47
34
  error: ApiError | null;
48
35
  }
49
36
 
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',
37
+ const stampOf = (row: DeliveredConnector): string => JSON.stringify([
38
+ row.name, row.url, row.auth, row.policy.read ?? 'allow', row.policy.write ?? 'allow',
52
39
  Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)), row.policyLayers,
53
40
  ]);
54
41
 
@@ -85,18 +72,14 @@ export class OrgConnectorRuntime {
85
72
  this.update();
86
73
  }
87
74
 
88
- private enabled(): boolean {
89
- return !this.stopped && (this.deps.enabled?.() ?? process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true');
90
- }
91
-
92
- private abort(session: Session, error: ApiError, drain = false): void {
75
+ private abort(session: Session, error: ApiError): void {
93
76
  session.error ??= error;
94
- if (!drain) session.controller.abort();
77
+ session.controller.abort();
95
78
  }
96
79
 
97
- private publish(rows: RuntimeRow[]): void {
80
+ private publish(rows: DeliveredConnector[]): void {
98
81
  const metadata: GateRow[] = rows.map((row) => ({
99
- id: row.relayId, name: row.relayName,
82
+ id: orgRelayId(row.id), name: row.name,
100
83
  config: { policy: effectiveConnectorPolicy(row, this.entries.get(row.id)?.groups ?? {}), toolGroups: { ...this.entries.get(row.id)?.groups } },
101
84
  }));
102
85
  const text = JSON.stringify(metadata);
@@ -111,48 +94,27 @@ export class OrgConnectorRuntime {
111
94
  }
112
95
  }
113
96
 
114
- private invalidateEntries(rows: RuntimeRow[], epoch: number, available: boolean): void {
97
+ private invalidateEntries(rows: DeliveredConnector[], epoch: number, available: boolean): void {
115
98
  for (const [id, entry] of this.entries) {
116
99
  const row = rows.find((value) => value.id === id);
117
100
  const valid = available && entry.epoch === epoch;
118
101
  if (valid && row?.state === 'ready' && entry.stamp === stampOf(row)) continue;
119
- const error = valid ? rowError(row) ?? orgUnavailable() : orgUnavailable();
120
- this.invalidateEntry(entry, row, error);
102
+ this.invalidateEntry(entry, valid ? rowError(row) ?? orgUnavailable() : orgUnavailable());
121
103
  }
122
104
  }
123
105
 
124
- private invalidateEntry(entry: Entry, row: RuntimeRow | undefined, error: ApiError): void {
125
- const drain = !isOrgRelayId(entry.relayId) && row?.relayId !== entry.relayId;
126
- if (!drain) entry.controller.abort();
106
+ private invalidateEntry(entry: Entry, error: ApiError): void {
107
+ entry.controller.abort();
127
108
  this.entries.delete(entry.id);
128
- this.abortEntry(entry, error, drain);
129
- }
130
-
131
- private abortEntry(entry: Entry, error: ApiError, drain = false): void {
132
- for (const session of this.sessions) if (session.entry === entry) this.abort(session, error, drain);
109
+ for (const session of this.sessions) if (session.entry === entry) this.abort(session, error);
133
110
  }
134
111
 
135
- private mappedRows(rows: DeliveredConnector[], mapping: MigrationRuntimeMapping): RuntimeRow[] {
136
- return rows.flatMap((row) => {
137
- const alias = mapping.aliases.find((value) => value.destinationId === row.id);
138
- if (alias !== undefined) return [{ ...row, relayId: alias.sourceId, relayName: alias.name }];
139
- return this.enabled() && !mapping.reserved.includes(row.id) ? [{ ...row, relayId: orgRelayId(row.id), relayName: row.name }] : [];
140
- });
141
- }
142
-
143
- private mapping(): MigrationRuntimeMapping {
144
- if (this.stopped) return { aliases: [], reserved: [] };
145
- return this.deps.mapping?.() ?? { aliases: [], reserved: [] };
146
- }
147
-
148
- private update(): RuntimeRow[] | null {
112
+ private update(): DeliveredConnector[] | null {
149
113
  try {
150
- const mapping = this.mapping();
151
- const wanted = this.enabled() || mapping.aliases.length > 0;
152
- const snapshot = wanted ? this.sync.read() : null;
114
+ const snapshot = this.stopped ? null : this.sync.read();
153
115
  const epoch = this.sync.epoch();
154
- if (this.held(snapshot, wanted, epoch)) return null;
155
- const rows = this.mappedRows(snapshot?.connectors ?? [], mapping);
116
+ if (this.held(snapshot, epoch)) return null;
117
+ const rows = snapshot?.connectors ?? [];
156
118
  this.invalidateEntries(rows, epoch, snapshot !== null);
157
119
  this.track(rows, epoch);
158
120
  this.publish(rows);
@@ -164,27 +126,19 @@ export class OrgConnectorRuntime {
164
126
  }
165
127
  }
166
128
 
167
- private track(rows: RuntimeRow[], epoch: number): void {
129
+ private track(rows: DeliveredConnector[], epoch: number): void {
168
130
  for (const row of rows) {
169
131
  if (row.state !== 'ready' || this.entries.has(row.id)) continue;
170
- 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 });
132
+ this.entries.set(row.id, { id: row.id, epoch, stamp: stampOf(row), controller: new AbortController(), groups: {}, scan: null, nextScan: 0, error: null });
171
133
  }
172
134
  }
173
135
 
174
- private held(snapshot: DeliverySnapshot | null, wanted: boolean, epoch: number): boolean {
175
- if (snapshot !== null || !wanted || !this.sync.lapsed()) return false;
136
+ private held(snapshot: DeliverySnapshot | null, epoch: number): boolean {
137
+ if (snapshot !== null || this.stopped || !this.sync.lapsed()) return false;
176
138
  this.invalidateEntries([], epoch, false);
177
139
  return true;
178
140
  }
179
141
 
180
- refresh(): void {
181
- this.update();
182
- }
183
-
184
- idle(id?: string): boolean {
185
- return ![...this.sessions].some((session) => id === undefined || session.entry.relayId === id);
186
- }
187
-
188
142
  private currentEntry(entry: Entry): DeliveredConnector {
189
143
  const rows = this.update();
190
144
  if (rows === null || entry.epoch !== this.sync.epoch()) throw orgUnavailable();
@@ -193,7 +147,6 @@ export class OrgConnectorRuntime {
193
147
  const error = rowError(row);
194
148
  if (error !== null) throw error;
195
149
  if (entry.controller.signal.aborted || this.entries.get(entry.id) !== entry) throw orgUnavailable();
196
- if (!isOrgRelayId(entry.relayId)) assertMigrationOutcomeKnown(entry.relayId, this.deps.migrationDir);
197
150
  return row;
198
151
  }
199
152
 
@@ -216,30 +169,22 @@ export class OrgConnectorRuntime {
216
169
  return this.currentEntry(entry);
217
170
  }
218
171
 
219
- private migrationOperation(entry: Entry): MigrationOperation | null {
220
- return isOrgRelayId(entry.relayId) ? null : beginMigrationOperation(entry.relayId, this.deps.migrationDir ?? agentsDir(), true);
221
- }
222
-
223
172
  private async scan(entry: Entry): Promise<void> {
224
173
  const row = this.currentEntry(entry);
225
174
  const signal = AbortSignal.any([entry.controller.signal, AbortSignal.timeout(45_000)]);
226
- const operation = this.migrationOperation(entry);
227
- try {
228
- const send = this.deps.fetch ?? fetch;
229
- const groups = await discoverOrgGroups(row.url, {
230
- fresh: (revision) => this.fresh(entry, revision === undefined ? undefined : { connectorId: entry.id, revision }),
231
- current: () => this.currentEntry(entry), signal,
232
- fetch: (input, init) => operation?.dispatch(() => send(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }) ?? send(input, init),
233
- });
234
- this.currentEntry(entry);
235
- if (JSON.stringify(groups) !== JSON.stringify(entry.groups)) {
236
- for (const session of this.sessions) if (session.entry === entry && session.revision !== null) this.abort(session, orgUnavailable());
237
- }
238
- entry.groups = groups;
239
- entry.error = null;
240
- entry.nextScan = performance.now() + 60_000;
241
- this.update();
242
- } finally { operation?.close(); }
175
+ const groups = await discoverOrgGroups(row.url, {
176
+ fresh: (revision) => this.fresh(entry, revision === undefined ? undefined : { connectorId: entry.id, revision }),
177
+ current: () => this.currentEntry(entry), signal,
178
+ fetch: this.deps.fetch ?? fetch,
179
+ });
180
+ this.currentEntry(entry);
181
+ if (JSON.stringify(groups) !== JSON.stringify(entry.groups)) {
182
+ for (const session of this.sessions) if (session.entry === entry && session.revision !== null) this.abort(session, orgUnavailable());
183
+ }
184
+ entry.groups = groups;
185
+ entry.error = null;
186
+ entry.nextScan = performance.now() + 60_000;
187
+ this.update();
243
188
  }
244
189
 
245
190
  private discover(entry: Entry): Promise<void> | null {
@@ -291,16 +236,15 @@ export class OrgConnectorRuntime {
291
236
  open(id: string): RelaySession {
292
237
  const rows = this.update();
293
238
  if (rows === null) throw orgUnavailable();
294
- const mapped = rows.find((row) => row.relayId === id);
295
- const error = rowError(mapped);
239
+ const found = rows.find((row) => orgRelayId(row.id) === id);
240
+ const error = rowError(found);
296
241
  if (error !== null) throw error;
297
- const entry = mapped === undefined ? undefined : this.entries.get(mapped.id);
242
+ const entry = found === undefined ? undefined : this.entries.get(found.id);
298
243
  if (entry === undefined) throw orgUnavailable();
299
- const session: Session = { entry, operation: this.migrationOperation(entry), controller: new AbortController(), revision: null, error: null };
244
+ const session: Session = { entry, controller: new AbortController(), revision: null, error: null };
300
245
  this.sessions.add(session);
301
246
  const blocker = requestConnectorBlocker();
302
247
  return {
303
- dispatch: (send, effectful) => { this.currentSession(session); return session.operation?.dispatch(send, { effectful }) ?? send(); },
304
248
  target: (force) => this.target(session, force),
305
249
  current: () => {
306
250
  const row = this.currentSession(session);
@@ -312,7 +256,7 @@ export class OrgConnectorRuntime {
312
256
  return blocker();
313
257
  },
314
258
  signal: session.controller.signal,
315
- close: () => { this.abort(session, orgUnavailable()); session.operation?.close(); this.sessions.delete(session); },
259
+ close: () => { this.abort(session, orgUnavailable()); this.sessions.delete(session); },
316
260
  };
317
261
  }
318
262
 
@@ -1,22 +1,19 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
3
- import { log } from '@metro-labs/core/log';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { agentsDir } from '../agents/files.js';
6
6
  import { METRO_API, type MetroApi } from './metro-api.js';
7
7
  import { OrgConnectorSync, type OrgSyncDeps } from './org-sync.js';
8
- import { OrgConnectorRuntime } from './org-runtime.js';
8
+ import { OrgConnectorRuntime, type RuntimeDeps } 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
+ import { publishOrgConnectors } from './publication.js';
14
12
  import { orgRuntimeError, orgUnavailable } from './org-runtime-discovery.js';
15
13
 
16
- type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & { api?: MetroApi; dir?: string };
14
+ type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & RuntimeDeps & { api?: MetroApi; dir?: string };
17
15
  type Context = Pick<ConnectorSyncStatus, 'owner' | 'agentId' | 'scope' | 'enrolled'>;
18
16
  const POLL_MS = 60_000;
19
- const liveEnabled = (): boolean => process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
20
17
  const emptyReceipt = { lastReceivedAt: null, leaseExpiresAt: null, counts: null };
21
18
 
22
19
  export class OrgConnectorControl {
@@ -24,7 +21,7 @@ export class OrgConnectorControl {
24
21
  private readonly api: MetroApi;
25
22
  private sync: OrgConnectorSync | null = null;
26
23
  private runtime: OrgConnectorRuntime | null = null;
27
- private readonly draining = new Set<OrgConnectorRuntime>();
24
+ private unpublished = false;
28
25
  private scope: string | null = null;
29
26
  private operation = randomBytes(16).toString('hex');
30
27
  private timer: ReturnType<typeof setInterval> | null = null;
@@ -49,20 +46,23 @@ export class OrgConnectorControl {
49
46
  this.sync?.invalidate();
50
47
  this.sync = null;
51
48
  this.runtime = null;
52
- this.retire(runtime);
53
- }
54
-
55
- private retire(runtime: OrgConnectorRuntime | null): void {
56
- for (const previous of this.draining) if (previous.idle()) this.draining.delete(previous);
57
- if (runtime === null) return;
58
- if (!runtime.idle()) this.draining.add(runtime);
59
49
  try {
60
- runtime.stop();
50
+ runtime?.stop();
61
51
  } catch {
62
52
  log.warn('organization connectors: stopped runtime publication could not be cleared');
63
53
  }
64
54
  }
65
55
 
56
+ private unpublish(): void {
57
+ if (this.runtime !== null || this.unpublished) return;
58
+ try {
59
+ (this.deps.publish ?? publishOrgConnectors)([]);
60
+ this.unpublished = true;
61
+ } catch (err) {
62
+ log.warn({ err: errMsg(err) }, 'organization connectors: the connector list could not be cleared');
63
+ }
64
+ }
65
+
66
66
  private reconcile(start: boolean): { context: Context; setting: ReturnType<typeof syncSetting> } {
67
67
  const context = this.context();
68
68
  if (this.scope !== null && this.scope !== context.scope) {
@@ -73,8 +73,10 @@ export class OrgConnectorControl {
73
73
  this.scope = context.scope;
74
74
  const setting = syncSetting(this.dir, context.scope);
75
75
  if (setting.source === 'scope_changed') disableSavedSync(this.dir);
76
- if (!setting.enabled || !context.enrolled) this.clear();
77
- else if (start && this.sync === null) {
76
+ if (!setting.enabled || !context.enrolled) {
77
+ this.clear();
78
+ this.unpublish();
79
+ } else if (start && this.sync === null) {
78
80
  const scope = context.scope;
79
81
  this.sync = new OrgConnectorSync({ ...this.deps, api: this.api, enabled: () => this.context().scope === scope && syncSetting(this.dir, scope).enabled });
80
82
  }
@@ -84,8 +86,8 @@ export class OrgConnectorControl {
84
86
 
85
87
  private startRuntime(): void {
86
88
  if (this.sync === null || this.runtime !== null) return;
87
- const mapping = () => migrationRuntimeMapping(this.deps.owner(), this.deps.key(), this.dir);
88
- if (liveEnabled() || mapping().aliases.length > 0) this.runtime = new OrgConnectorRuntime(this.sync, { enabled: liveEnabled, mapping, migrationDir: this.dir });
89
+ this.unpublished = false;
90
+ this.runtime = new OrgConnectorRuntime(this.sync, this.deps);
89
91
  }
90
92
 
91
93
  status(): ConnectorSyncStatus {
@@ -94,7 +96,7 @@ export class OrgConnectorControl {
94
96
  const receipt = this.sync?.status() ?? { state: 'waiting' as const, ...emptyReceipt };
95
97
  const state = !setting.enabled ? 'off' : !context.enrolled ? 'not_enrolled' : receipt.state;
96
98
  const scope = createHash('sha256').update(`${context.scope}:${this.operation}`).digest('hex');
97
- return { ...context, ...setting, ...receipt, scope, state, liveEnabled: liveEnabled() };
99
+ return { ...context, ...setting, ...receipt, scope, state, liveEnabled: false };
98
100
  } catch {
99
101
  this.clear();
100
102
  throw new ApiError('Metro could not read the connector sync status.', 503);
@@ -105,7 +107,6 @@ export class OrgConnectorControl {
105
107
  const current = this.status();
106
108
  if (scope !== current.scope) throw new ApiError('The organization or enrollment changed. Refresh the sync status before trying again.', 409);
107
109
  if (enabled && !current.enrolled) throw new ApiError('Enroll this agent with its organization before enabling sync.', 409);
108
- if (enabled && liveEnabled()) throw new ApiError('Live organization tools are enabled separately on this server. Disable that setting before enabling memory-only sync here.', 409);
109
110
  saveSyncSetting(this.dir, enabled, this.context().scope);
110
111
  if (!enabled) this.clear();
111
112
  else this.tick();
@@ -113,7 +114,10 @@ export class OrgConnectorControl {
113
114
  }
114
115
 
115
116
  async poll(): Promise<void> {
116
- if (this.sync === null && !syncOptedIn(this.dir)) return;
117
+ if (this.sync === null && !syncOptedIn(this.dir)) {
118
+ this.unpublish();
119
+ return;
120
+ }
117
121
  try {
118
122
  this.reconcile(true);
119
123
  } catch (err) {
@@ -123,28 +127,6 @@ export class OrgConnectorControl {
123
127
  await this.sync?.sync();
124
128
  }
125
129
 
126
- async fresh(): Promise<DeliverySnapshot> {
127
- this.reconcile(true);
128
- const sync = this.sync;
129
- if (sync === null) throw new ApiError('Enable organization connector sync before moving this connector.', 409);
130
- await sync.syncFresh();
131
- this.reconcile(false);
132
- const snapshot = this.sync === sync ? sync.read() : null;
133
- if (snapshot === null) throw new ApiError('A fresh organization connector snapshot is unavailable.', 503);
134
- return snapshot;
135
- }
136
-
137
- refreshRuntime(): void {
138
- this.reconcile(true);
139
- this.runtime?.refresh();
140
- publishConnectors();
141
- }
142
-
143
- migrationIdle(id: string): boolean {
144
- for (const runtime of this.draining) if (runtime.idle()) this.draining.delete(runtime);
145
- return localMigrationIdle(id, this.dir) && (this.runtime?.idle(id) ?? true) && [...this.draining].every((runtime) => runtime.idle(id));
146
- }
147
-
148
130
  private tick(): void {
149
131
  this.poll().catch(() => {
150
132
  log.warn('organization connectors: memory sync unavailable; cached credentials were cleared');
@@ -1,10 +1,7 @@
1
1
  import { ApiError } from '@metro-labs/http/api-error';
2
2
  import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
3
3
  import { OrgConnectorControl } from './org-sync-control.js';
4
- import { isOrgRelayId } from './relay-id.js';
5
4
  import type { RelaySession } from './relay-session.js';
6
- import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
7
- import { migrationSource } from './migration-guard.js';
8
5
 
9
6
  let active: OrgConnectorControl | null = null;
10
7
 
@@ -13,20 +10,8 @@ function control(): OrgConnectorControl {
13
10
  return active;
14
11
  }
15
12
 
16
- export function orgConnectorRelaySession(id: string): Promise<RelaySession> | 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);
13
+ export function orgConnectorRelaySession(id: string): Promise<RelaySession> {
14
+ return control().open(id);
30
15
  }
31
16
 
32
17
  export function orgConnectorSyncStatus(): ConnectorSyncStatus {