@bjorntech/key-ferry 0.1.12 → 0.1.14

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 (43) hide show
  1. package/README.md +20 -0
  2. package/dist/broker-registry.d.ts +32 -0
  3. package/dist/broker-registry.d.ts.map +1 -1
  4. package/dist/broker-registry.js +376 -42
  5. package/dist/broker-registry.js.map +1 -1
  6. package/dist/contracts.d.ts +13 -3
  7. package/dist/contracts.d.ts.map +1 -1
  8. package/dist/contracts.js +3 -0
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/options.d.ts +1 -0
  11. package/dist/options.d.ts.map +1 -1
  12. package/dist/options.js +17 -0
  13. package/dist/options.js.map +1 -1
  14. package/dist/plugin.d.ts.map +1 -1
  15. package/dist/plugin.js +4 -4
  16. package/dist/plugin.js.map +1 -1
  17. package/dist/rpc.d.ts +62 -12
  18. package/dist/rpc.d.ts.map +1 -1
  19. package/dist/rpc.js +41 -27
  20. package/dist/rpc.js.map +1 -1
  21. package/dist/scoped-controller.d.ts +5 -3
  22. package/dist/scoped-controller.d.ts.map +1 -1
  23. package/dist/scoped-controller.js +110 -44
  24. package/dist/scoped-controller.js.map +1 -1
  25. package/dist/scopes/controller-helpers.d.ts.map +1 -1
  26. package/dist/scopes/controller-helpers.js +15 -6
  27. package/dist/scopes/controller-helpers.js.map +1 -1
  28. package/dist/services/process.d.ts +3 -0
  29. package/dist/services/process.d.ts.map +1 -1
  30. package/dist/services/process.js +22 -6
  31. package/dist/services/process.js.map +1 -1
  32. package/dist/varlock/diagnostics.d.ts +7 -0
  33. package/dist/varlock/diagnostics.d.ts.map +1 -0
  34. package/dist/varlock/diagnostics.js +35 -0
  35. package/dist/varlock/diagnostics.js.map +1 -0
  36. package/dist/varlock/manager.d.ts.map +1 -1
  37. package/dist/varlock/manager.js +265 -72
  38. package/dist/varlock/manager.js.map +1 -1
  39. package/dist/varlock/ownership.d.ts +60 -0
  40. package/dist/varlock/ownership.d.ts.map +1 -0
  41. package/dist/varlock/ownership.js +372 -0
  42. package/dist/varlock/ownership.js.map +1 -0
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -36,6 +36,11 @@ Direct provider/API transport protection is not included.
36
36
  - Varlock CLI 1.23.0 installed separately as `varlock`
37
37
  - An installed Varlock CLI and a configured Varlock environment/schema with the
38
38
  secrets and proxy rules your MCP servers need
39
+ - A private, writable runtime directory for ownership records. By default this
40
+ is `tmpdir()/key-ferry-<uid>`; `adapter.ownershipDirectory` may be used to
41
+ inject another private, writable directory. If it cannot be secured, startup
42
+ fails closed with the `ownership_unavailable` diagnostic (also shown by
43
+ `key-ferry.doctor`).
39
44
  - Configure secret sources and authentication following the
40
45
  [Varlock documentation](https://varlock.dev/)
41
46
 
@@ -194,6 +199,21 @@ filters requested keys. Mark each remote upstream host with `@proxy(domain="..."
194
199
  Key Ferry does not install Varlock, create its schema, or configure provider
195
200
  authentication. Never commit a real secret.
196
201
 
202
+ Broker processes remain available for `idleTimeoutMs` milliseconds after the
203
+ last lease is released (default: `600000`, or ten minutes). This reuses the
204
+ authenticated Varlock proxy when returning to a project. Set
205
+ `idleTimeoutMs: 0` for immediate teardown; idle brokers are always stopped on
206
+ host shutdown or when their schema/configuration identity changes. This is a
207
+ security/performance trade-off: resolved secrets remain in the proxy process's
208
+ memory for up to the idle timeout after leaving a project. On startup Key Ferry
209
+ also reaps orphaned, identity-checked proxy records left by an earlier process.
210
+ If stopping a proxy fails during shutdown, Key Ferry keeps its ownership record and
211
+ defers cleanup to the next startup, whose reaper retries the same session UUID.
212
+ On SIGINT or SIGTERM it performs bounded cleanup (up to three seconds), removes
213
+ its signal listener, and re-raises the signal when it is the only listener so
214
+ the host's normal termination semantics are preserved; host-owned signal
215
+ listeners remain in control.
216
+
197
217
  ## TUI commands
198
218
 
199
219
  | Palette command | Slash command | RPC |
@@ -10,20 +10,52 @@ export interface BrokerLease extends ProjectKeyFerryManagerService {
10
10
  }
11
11
  export interface BrokerRegistryOptions {
12
12
  readonly canonicalize?: (directory: string) => Effect.Effect<string, unknown>;
13
+ /** Supplies the schema fingerprint; the default retains the real filesystem implementation. */
14
+ readonly schemaHash?: (directory: string) => Effect.Effect<string, unknown>;
13
15
  readonly maxEntries?: number;
16
+ readonly now?: () => number;
17
+ readonly idleTimeoutMs?: number;
18
+ readonly logger?: (event: BrokerLogEvent) => void;
19
+ readonly hostShutdownHooks?: boolean;
20
+ }
21
+ export interface BrokerLogEvent {
22
+ readonly event: "start" | "ready" | "release" | "idle-scheduled" | "reused" | "disposed" | "stop-failed" | "dispose-failed" | "stale-reaped" | "stale-skipped";
23
+ readonly scope: "global" | "project";
24
+ readonly directory: string;
25
+ readonly keyHash: string;
26
+ readonly pid?: number;
27
+ readonly proxySessionId?: string;
14
28
  }
15
29
  type BrokerResult = {
16
30
  readonly status: SafeStatus;
17
31
  readonly environment: ResolvedEnvironment;
18
32
  };
33
+ export declare const RETRY_BASE_DELAY_MS = 5000;
34
+ export declare const RETRY_MAX_DELAY_MS = 300000;
35
+ export declare const retryDelayMs: (failureCount: number) => number;
19
36
  /** A module-local broker; it deliberately does not communicate across processes. */
20
37
  export declare function makeBrokerRegistry(configuration?: BrokerRegistryOptions): {
21
38
  acquire: <R>(factory: ProjectKeyFerryManagerFactory<R>, options: ProjectKeyFerryManagerOptions) => Effect.Effect<BrokerLease, any, never>;
22
39
  retry: (factory: ProjectKeyFerryManagerFactory<any>, options: ProjectKeyFerryManagerOptions) => Effect.Effect<BrokerResult, any, never>;
40
+ retryIfDue: (factory: ProjectKeyFerryManagerFactory<any>, options: ProjectKeyFerryManagerOptions) => Effect.Effect<BrokerResult | undefined, any, never>;
41
+ shutdown: Effect.Effect<void, never, never>;
23
42
  };
43
+ export interface HostSignalProcess {
44
+ readonly pid: number;
45
+ readonly on: (event: any, listener: (...args: any[]) => void) => unknown;
46
+ readonly removeListener: (event: any, listener: (...args: any[]) => void) => unknown;
47
+ readonly listeners: (event: any) => Function[];
48
+ readonly kill: (pid: number, signal: "SIGINT" | "SIGTERM") => unknown;
49
+ }
50
+ export declare function removeHostShutdownHooks(): void;
51
+ export declare function installHostShutdownHooks(shutdown: Effect.Effect<void>, signalProcess?: HostSignalProcess, options?: {
52
+ readonly cleanupTimeoutMs?: number;
53
+ }): (() => void) | undefined;
24
54
  export declare const brokerRegistry: {
25
55
  acquire: <R>(factory: ProjectKeyFerryManagerFactory<R>, options: ProjectKeyFerryManagerOptions) => Effect.Effect<BrokerLease, any, never>;
26
56
  retry: (factory: ProjectKeyFerryManagerFactory<any>, options: ProjectKeyFerryManagerOptions) => Effect.Effect<BrokerResult, any, never>;
57
+ retryIfDue: (factory: ProjectKeyFerryManagerFactory<any>, options: ProjectKeyFerryManagerOptions) => Effect.Effect<BrokerResult | undefined, any, never>;
58
+ shutdown: Effect.Effect<void, never, never>;
27
59
  };
28
60
  export type BrokerRegistry = ReturnType<typeof makeBrokerRegistry>;
29
61
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"broker-registry.d.ts","sourceRoot":"","sources":["../src/broker-registry.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,MAAM,EAON,MAAM,EACP,MAAM,QAAQ,CAAC;AAChB,OAAO,EAEL,KAAK,6BAA6B,EAClC,KAAK,6BAA6B,EAClC,KAAK,6BAA6B,EAClC,KAAK,mBAAmB,EACxB,KAAK,UAAU,EAEhB,MAAM,gBAAgB,CAAC;AAExB,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,WAAY,SAAQ,6BAA6B;IAChE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;IACtD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9E,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,KAAK,YAAY,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,mBAAmB,CAAA;CAAE,CAAC;AA0H/F,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,aAAa,GAAE,qBAA0B;cAwPzD,CAAC,WACP,6BAA6B,CAAC,CAAC,CAAC,WAChC,6BAA6B,KACrC,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,CAAC;qBA2B9B,6BAA6B,CAAC,GAAG,CAAC,WAClC,6BAA6B,KACrC,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,EAAE,KAAK,CAAC;EAiD3C;AAED,eAAO,MAAM,cAAc;cAnFR,CAAC,WACP,6BAA6B,CAAC,CAAC,CAAC,WAChC,6BAA6B,KACrC,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,CAAC;qBA2B9B,6BAA6B,CAAC,GAAG,CAAC,WAClC,6BAA6B,KACrC,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,EAAE,KAAK,CAAC;CAmDM,CAAC;AACnD,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC"}
1
+ {"version":3,"file":"broker-registry.d.ts","sourceRoot":"","sources":["../src/broker-registry.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,15 @@
1
- import { realpath } from "node:fs/promises";
2
- import { Context, Deferred, Effect, Exit, Fiber, Layer, PubSub, Scope, Semaphore, Stream, } from "effect";
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer, PubSub, Scope, Semaphore, Stream, } from "effect";
3
5
  import { ProjectKeyFerryManager, VarlockError, } from "./contracts.js";
6
+ import { setOwnershipLifecycleLogger } from "./varlock/ownership.js";
7
+ export const RETRY_BASE_DELAY_MS = 5_000;
8
+ export const RETRY_MAX_DELAY_MS = 300_000;
9
+ export const retryDelayMs = (failureCount) => Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** Math.max(0, failureCount - 1));
10
+ function retryPending(entry) {
11
+ return entry.attempt && entry.attemptPending ? { pending: entry.attempt } : undefined;
12
+ }
4
13
  const adapterKeys = new Set([
5
14
  "command",
6
15
  "environmentKeys",
@@ -9,6 +18,7 @@ const adapterKeys = new Set([
9
18
  "reloadTimeoutMs",
10
19
  "pollIntervalMs",
11
20
  "requireStrictEgress",
21
+ "idleTimeoutMs",
12
22
  ]);
13
23
  function safeError(error, code) {
14
24
  if (error instanceof VarlockError)
@@ -51,19 +61,70 @@ function validateOptions(options) {
51
61
  validateAdapterShape(adapter);
52
62
  return adapter;
53
63
  }
54
- function keyFor(directory, options) {
55
- const adapter = validateOptions(options);
56
- return stable({
57
- projectDirectory: directory,
58
- owner: options.owner,
59
- sessionID: options.sessionID,
60
- command: adapter.command,
61
- environmentKeys: [...new Set(adapter.environmentKeys)].sort(),
62
- startupTimeoutMs: adapter.startupTimeoutMs,
63
- commandTimeoutMs: adapter.commandTimeoutMs,
64
- reloadTimeoutMs: adapter.reloadTimeoutMs,
65
- pollIntervalMs: adapter.pollIntervalMs,
66
- requireStrictEgress: adapter.requireStrictEgress,
64
+ const schemaHashCache = new Map();
65
+ async function contentHash(directory) {
66
+ const hash = createHash("sha256");
67
+ try {
68
+ const names = (await readdir(directory))
69
+ .filter((name) => name === ".env.schema" || name.startsWith(".env.schema."))
70
+ .sort();
71
+ const metadata = await Promise.all(names.map(async (name) => {
72
+ const info = await stat(join(directory, name));
73
+ return `${name}:${info.mtimeMs}:${info.size}`;
74
+ }));
75
+ const signature = metadata.join("\0");
76
+ const cached = schemaHashCache.get(directory);
77
+ if (cached?.signature === signature)
78
+ return cached.hash;
79
+ for (const name of names) {
80
+ hash.update(name);
81
+ hash.update(await readFile(join(directory, name)));
82
+ }
83
+ const result = hash.digest("hex");
84
+ schemaHashCache.set(directory, { signature, hash: result });
85
+ return result;
86
+ }
87
+ catch {
88
+ // The manager performs the authoritative directory validation. Keep the
89
+ // registry usable with injected/test canonicalizers and distinguish an
90
+ // unreadable schema from a real schema without exposing its contents.
91
+ hash.update("schema-unavailable");
92
+ }
93
+ return hash.digest("hex");
94
+ }
95
+ function keyFor(directory, options, schemaHash) {
96
+ return Effect.gen(function* () {
97
+ const adapter = validateOptions(options);
98
+ const keys = [
99
+ ...new Set([
100
+ ...adapter.environmentKeys,
101
+ ...Object.keys(globalThis.process.env).filter((key) => /^OP_/.test(key)),
102
+ ]),
103
+ ].sort();
104
+ const valuesHash = createHash("sha256");
105
+ for (const key of keys)
106
+ valuesHash.update(`${key}\0${globalThis.process.env[key] ?? ""}\0`);
107
+ const context = {
108
+ projectDirectory: directory,
109
+ owner: options.owner,
110
+ sessionID: options.sessionID,
111
+ command: adapter.command,
112
+ environmentKeys: [...new Set(adapter.environmentKeys)].sort(),
113
+ startupTimeoutMs: adapter.startupTimeoutMs,
114
+ commandTimeoutMs: adapter.commandTimeoutMs,
115
+ reloadTimeoutMs: adapter.reloadTimeoutMs,
116
+ pollIntervalMs: adapter.pollIntervalMs,
117
+ requireStrictEgress: adapter.requireStrictEgress,
118
+ idleTimeoutMs: adapter.idleTimeoutMs,
119
+ };
120
+ return {
121
+ contextKey: stable(context),
122
+ key: stable({
123
+ ...context,
124
+ environmentValuesHash: valuesHash.digest("hex"),
125
+ schemaHash: yield* schemaHash(directory),
126
+ }),
127
+ };
67
128
  });
68
129
  }
69
130
  function readinessChanged(entry, revision, ready) {
@@ -96,31 +157,107 @@ function awaitCurrentReady(entry) {
96
157
  export function makeBrokerRegistry(configuration = {}) {
97
158
  const entries = new Set();
98
159
  const factories = new WeakMap();
160
+ const factoryMaps = new Set();
99
161
  const lock = Effect.runSync(Semaphore.make(1));
100
162
  const canonicalize = configuration.canonicalize ??
101
163
  ((directory) => Effect.tryPromise(() => realpath(directory)));
164
+ const schemaHash = configuration.schemaHash ??
165
+ ((directory) => Effect.tryPromise(() => contentHash(directory)));
102
166
  const maxEntries = configuration.maxEntries ?? 128;
167
+ const now = configuration.now ?? (() => Date.now());
168
+ // Direct registry consumers historically received immediate teardown. The
169
+ // validated plugin adapter always supplies the public ten-minute default;
170
+ // retaining this fallback keeps injected/test registries backwards compatible.
171
+ const idleTimeoutMs = configuration.idleTimeoutMs ?? 0;
172
+ const log = (entry, event) => {
173
+ configuration.logger?.({
174
+ event,
175
+ scope: entry.options.owner === "key-ferry" ? "project" : "global",
176
+ directory: entry.options.projectDirectory,
177
+ keyHash: createHash("sha256").update(entry.key).digest("hex").slice(0, 12),
178
+ ...(entry.status?.pid === undefined ? {} : { pid: entry.status.pid }),
179
+ ...(entry.status
180
+ ?.proxySessionId === undefined
181
+ ? {}
182
+ : {
183
+ proxySessionId: entry.status
184
+ .proxySessionId,
185
+ }),
186
+ });
187
+ };
103
188
  const publish = (entry, status) => {
104
189
  entry.status = status;
105
190
  return PubSub.publish(entry.events, status);
106
191
  };
192
+ const recordFailure = (entry, failure) => {
193
+ entry.failure = failure;
194
+ entry.failureCount += 1;
195
+ entry.lastFailureAt = new Date(now()).toISOString();
196
+ entry.nextRetryAt = new Date(now() + retryDelayMs(entry.failureCount)).toISOString();
197
+ return {
198
+ lastFailureAt: entry.lastFailureAt,
199
+ nextRetryAt: entry.nextRetryAt,
200
+ };
201
+ };
202
+ const clearFailure = (entry) => {
203
+ entry.failure = undefined;
204
+ entry.failureCount = 0;
205
+ entry.lastFailureAt = undefined;
206
+ entry.nextRetryAt = undefined;
207
+ entry.retryBatch = undefined;
208
+ };
209
+ const capacityFailure = () => Effect.die(new Error("broker registry capacity reached"));
210
+ const disposeStaleEntries = (factoryMap, contextKey) => Effect.gen(function* () {
211
+ for (const [oldKey, oldEntry] of factoryMap) {
212
+ if (oldEntry.contextKey !== contextKey || oldEntry.leases.size > 0)
213
+ continue;
214
+ yield* disposeEntry(oldEntry);
215
+ entries.delete(oldEntry);
216
+ factoryMap.delete(oldKey);
217
+ }
218
+ });
219
+ const disposeEntry = (entry) => Effect.gen(function* () {
220
+ log(entry, "disposed");
221
+ if (entry.idleFiber) {
222
+ yield* Fiber.interrupt(entry.idleFiber);
223
+ entry.idleFiber = undefined;
224
+ }
225
+ if (entry.startup)
226
+ yield* Fiber.interrupt(entry.startup);
227
+ if (entry.manager) {
228
+ yield* entry.manager.dispose.pipe(Effect.catch(() => Effect.sync(() => {
229
+ // The manager may have left the ownership record in place. Drop
230
+ // the in-process entry, but make the startup reaper handoff
231
+ // explicit so cleanup is still observable without error text.
232
+ log(entry, "dispose-failed");
233
+ })));
234
+ }
235
+ yield* Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
236
+ entry.manager = undefined;
237
+ entry.disposed = true;
238
+ entry.idleUntil = undefined;
239
+ }).pipe(Effect.catch(() => Effect.void));
107
240
  const start = (entry) => {
108
241
  const operation = Effect.gen(function* () {
109
242
  const context = yield* Layer.build(entry.factory.layer(entry.options)).pipe(Effect.provideService(Scope.Scope, entry.scope));
110
243
  const manager = Context.get(context, ProjectKeyFerryManager);
111
244
  entry.manager = manager;
245
+ entry.status = yield* manager.status;
246
+ log(entry, "start");
112
247
  const result = yield* manager.ensureReady;
248
+ log(entry, "ready");
113
249
  yield* publish(entry, result.status);
114
250
  return result;
115
251
  });
116
252
  return operation.pipe(Effect.catch((error) => {
117
253
  const failure = safeError(error, "start_failed");
118
- entry.failure = failure;
254
+ const metadata = recordFailure(entry, failure);
119
255
  return publish(entry, {
120
256
  phase: "failed",
121
257
  projectDirectory: entry.options.projectDirectory,
122
258
  generation: entry.status?.generation ?? 0,
123
259
  error: failure,
260
+ ...metadata,
124
261
  }).pipe(Effect.flatMap(() => Effect.fail(failure)));
125
262
  }));
126
263
  };
@@ -133,7 +270,9 @@ export function makeBrokerRegistry(configuration = {}) {
133
270
  }
134
271
  return yield* awaitCurrentReady(entry);
135
272
  });
136
- const completeStartup = (entry, target) => Fiber.join(entry.startup).pipe(Effect.flatMap((result) => Deferred.succeed(target, result)), Effect.catch((error) => Deferred.fail(target, safeError(error, "start_failed"))), Effect.tap(() => Effect.sync(() => {
273
+ const completeStartup = (entry, target) => Fiber.join(entry.startup).pipe(Effect.flatMap((result) => Effect.sync(() => clearFailure(entry)).pipe(Effect.andThen(Deferred.succeed(target, result)))), Effect.catch((error) => Effect.sync(() => {
274
+ entry.attemptPending = false;
275
+ }).pipe(Effect.andThen(Deferred.fail(target, safeError(error, "start_failed"))))), Effect.tap(() => Effect.sync(() => {
137
276
  entry.attemptPending = false;
138
277
  })), Effect.forkDetach);
139
278
  const refreshShared = (entry) => Effect.gen(function* () {
@@ -159,13 +298,14 @@ export function makeBrokerRegistry(configuration = {}) {
159
298
  entry.refresh = undefined;
160
299
  }).pipe(Effect.catch((error) => {
161
300
  const failure = safeError(error, "refresh_failed");
162
- entry.failure = failure;
301
+ const metadata = recordFailure(entry, failure);
163
302
  entry.refresh = undefined;
164
303
  return publish(entry, {
165
304
  phase: "failed",
166
305
  projectDirectory: entry.options.projectDirectory,
167
306
  generation: entry.status?.generation ?? 0,
168
307
  error: failure,
308
+ ...metadata,
169
309
  }).pipe(Effect.flatMap(() => Deferred.fail(next, failure)));
170
310
  }), Effect.forkIn(entry.scope));
171
311
  return next;
@@ -173,14 +313,16 @@ export function makeBrokerRegistry(configuration = {}) {
173
313
  return yield* Deferred.await(deferred);
174
314
  });
175
315
  const getOrCreateEntry = (factory, options, directory) => Effect.gen(function* () {
176
- const key = keyFor(directory, options);
316
+ const identity = yield* keyFor(directory, options, schemaHash).pipe(Effect.orDie);
317
+ const key = identity.key;
177
318
  const factoryMap = factories.get(factory) ?? new Map();
178
319
  factories.set(factory, factoryMap);
320
+ factoryMaps.add(factoryMap);
179
321
  const existing = factoryMap.get(key);
180
322
  if (existing)
181
323
  return existing;
182
324
  if (entries.size >= maxEntries)
183
- return yield* Effect.die(new Error("broker registry capacity reached"));
325
+ return yield* capacityFailure();
184
326
  const entry = {
185
327
  key,
186
328
  options: {
@@ -194,7 +336,10 @@ export function makeBrokerRegistry(configuration = {}) {
194
336
  leases: new Set(),
195
337
  readinessRevision: 0,
196
338
  disposed: false,
339
+ failureCount: 0,
340
+ contextKey: identity.contextKey,
197
341
  };
342
+ yield* disposeStaleEntries(factoryMap, identity.contextKey);
198
343
  factoryMap.set(key, entry);
199
344
  entries.add(entry);
200
345
  return entry;
@@ -202,9 +347,7 @@ export function makeBrokerRegistry(configuration = {}) {
202
347
  const disposeLeaseEntry = (entry, factoryMap, close) => Effect.gen(function* () {
203
348
  if (entry.startup)
204
349
  yield* Fiber.interrupt(entry.startup);
205
- if (entry.manager)
206
- yield* entry.manager.dispose;
207
- yield* Scope.close(entry.scope, Exit.void);
350
+ yield* disposeEntry(entry);
208
351
  }).pipe(Effect.catch(() => Effect.void), Effect.flatMap(() => lock.withPermit(Effect.gen(function* () {
209
352
  if (entry.closing !== close)
210
353
  return;
@@ -217,13 +360,71 @@ export function makeBrokerRegistry(configuration = {}) {
217
360
  entry.closing = undefined;
218
361
  yield* Deferred.succeed(close, undefined);
219
362
  }))));
363
+ const scheduleIdle = (entry, factoryMap, directory, timeout) => Effect.gen(function* () {
364
+ entry.idleUntil = new Date(now() + timeout).toISOString();
365
+ log(entry, "idle-scheduled");
366
+ yield* publish(entry, {
367
+ ...(entry.status ?? { phase: "idle", projectDirectory: directory, generation: 0 }),
368
+ phase: "idle",
369
+ idleUntil: entry.idleUntil,
370
+ });
371
+ entry.idleFiber = yield* Effect.sleep(Duration.millis(timeout)).pipe(Effect.andThen(Effect.gen(function* () {
372
+ if (entry.leases.size === 0 && !entry.disposed) {
373
+ entry.disposed = true;
374
+ const deferred = yield* Deferred.make();
375
+ entry.closing = deferred;
376
+ yield* disposeLeaseEntry(entry, factoryMap, deferred).pipe(Effect.forkDetach);
377
+ }
378
+ })), Effect.forkIn(entry.scope));
379
+ });
380
+ const closeImmediately = (entry, factoryMap, required) => Effect.gen(function* () {
381
+ if (!required)
382
+ return undefined;
383
+ entry.disposed = true;
384
+ const deferred = yield* Deferred.make();
385
+ entry.closing = deferred;
386
+ yield* disposeLeaseEntry(entry, factoryMap, deferred).pipe(Effect.forkDetach);
387
+ return deferred;
388
+ });
389
+ const releaseLease = (entry, factoryMap, leaseID, directory, isReleased, markReleased) => lock.withPermit(Effect.gen(function* () {
390
+ if (isReleased())
391
+ return undefined;
392
+ markReleased();
393
+ log(entry, "release");
394
+ entry.leases.delete(leaseID);
395
+ return yield* releaseAfterDelete(entry, factoryMap, directory);
396
+ }));
397
+ const releaseAfterDelete = (entry, factoryMap, directory) => entry.leases.size !== 0 || entry.disposed
398
+ ? Effect.succeed(undefined)
399
+ : releaseLastLease(entry, factoryMap, directory);
400
+ const releaseLastLease = (entry, factoryMap, directory) => Effect.gen(function* () {
401
+ const timeout = Number(entry.options.adapter.idleTimeoutMs ?? idleTimeoutMs);
402
+ const immediate = yield* closeImmediately(entry, factoryMap, entry.failure !== undefined || timeout <= 0);
403
+ if (immediate)
404
+ return immediate;
405
+ yield* scheduleIdle(entry, factoryMap, directory, timeout);
406
+ return undefined;
407
+ });
408
+ const reuseIdle = (entry) => Effect.gen(function* () {
409
+ if (!entry.idleFiber)
410
+ return;
411
+ yield* Fiber.interrupt(entry.idleFiber);
412
+ entry.idleFiber = undefined;
413
+ entry.idleUntil = undefined;
414
+ entry.disposed = false;
415
+ if (entry.status?.phase === "idle")
416
+ yield* publish(entry, { ...entry.status, phase: "ready", idleUntil: undefined });
417
+ log(entry, "reused");
418
+ });
220
419
  const acquireAttempt = (factory, options) => lock.withPermit(Effect.gen(function* () {
221
420
  const directory = yield* canonicalize(options.projectDirectory).pipe(Effect.mapError(() => new TypeError("project directory does not exist")));
222
421
  const factoryMap = factories.get(factory) ?? new Map();
223
422
  factories.set(factory, factoryMap);
423
+ factoryMaps.add(factoryMap);
224
424
  const entry = yield* getOrCreateEntry(factory, options, directory);
225
425
  if (entry.closing)
226
426
  return { waiting: entry.closing };
427
+ yield* reuseIdle(entry);
227
428
  const leaseID = Symbol();
228
429
  entry.leases.add(leaseID);
229
430
  if (!entry.startup && !entry.failure) {
@@ -239,19 +440,7 @@ export function makeBrokerRegistry(configuration = {}) {
239
440
  return yield* operation(entry.manager);
240
441
  });
241
442
  const release = Effect.gen(function* () {
242
- const close = yield* lock.withPermit(Effect.gen(function* () {
243
- if (released)
244
- return undefined;
245
- released = true;
246
- entry.leases.delete(leaseID);
247
- if (entry.leases.size !== 0 || entry.disposed)
248
- return undefined;
249
- entry.disposed = true;
250
- const deferred = yield* Deferred.make();
251
- entry.closing = deferred;
252
- yield* disposeLeaseEntry(entry, factoryMap, deferred).pipe(Effect.forkDetach);
253
- return deferred;
254
- }));
443
+ const close = yield* releaseLease(entry, factoryMap, leaseID, directory, () => released, () => (released = true));
255
444
  if (close)
256
445
  yield* Deferred.await(close);
257
446
  });
@@ -296,10 +485,30 @@ export function makeBrokerRegistry(configuration = {}) {
296
485
  const retryAttempt = (factory, options) => Effect.gen(function* () {
297
486
  const directory = yield* canonicalize(options.projectDirectory);
298
487
  const target = yield* lock.withPermit(Effect.gen(function* () {
299
- const entry = factories.get(factory)?.get(keyFor(directory, options));
488
+ const entry = factories
489
+ .get(factory)
490
+ ?.get((yield* keyFor(directory, options, schemaHash)).key);
491
+ if (entry?.retryBatch)
492
+ return entry.retryBatch;
493
+ if (entry) {
494
+ const initial = yield* retryEntry(entry);
495
+ if ("waiting" in initial || "pending" in initial)
496
+ return initial;
497
+ const batch = yield* Deferred.make();
498
+ entry.retryBatch = batch;
499
+ yield* Deferred.await(initial).pipe(Effect.matchEffect({
500
+ onSuccess: (result) => Deferred.succeed(batch, result),
501
+ onFailure: (error) => Deferred.fail(batch, error),
502
+ }),
503
+ // Keep the completed batch as the result of this explicit
504
+ // retry. A background retry clears it when its deadline is due
505
+ // and atomically establishes the next batch.
506
+ Effect.andThen(Effect.void), Effect.forkDetach);
507
+ return batch;
508
+ }
300
509
  return yield* retryEntry(entry);
301
510
  }));
302
- if ("waiting" in target)
511
+ if ("waiting" in target || "pending" in target)
303
512
  return target;
304
513
  return yield* Deferred.await(target);
305
514
  });
@@ -309,6 +518,12 @@ export function makeBrokerRegistry(configuration = {}) {
309
518
  yield* Deferred.await(result.waiting);
310
519
  return yield* retry(factory, options);
311
520
  }
521
+ if ("pending" in result) {
522
+ // This caller observed an attempt already in flight. Its result is the
523
+ // result of this retry operation; do not turn a shared failure into a
524
+ // fresh attempt when the pending attempt completes.
525
+ return yield* Deferred.await(result.pending);
526
+ }
312
527
  return result;
313
528
  });
314
529
  const retryEntry = (entry) => {
@@ -320,8 +535,9 @@ export function makeBrokerRegistry(configuration = {}) {
320
535
  }));
321
536
  if (entry.closing)
322
537
  return Effect.succeed({ waiting: entry.closing });
323
- if (entry.attempt && entry.attemptPending)
324
- return Effect.succeed(entry.attempt);
538
+ const pending = retryPending(entry);
539
+ if (pending)
540
+ return Effect.succeed(pending);
325
541
  if (!entry.failure)
326
542
  return Effect.fail(new VarlockError({
327
543
  code: "not_ready",
@@ -329,6 +545,10 @@ export function makeBrokerRegistry(configuration = {}) {
329
545
  retryable: false,
330
546
  }));
331
547
  return Effect.gen(function* () {
548
+ // The previous attempt is complete (attemptPending was checked above),
549
+ // so closing its scope cannot race a startup. This runs manager and
550
+ // certificate-directory finalizers before allocating the replacement.
551
+ yield* Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
332
552
  entry.failure = undefined;
333
553
  entry.disposed = false;
334
554
  entry.scope = yield* Scope.make();
@@ -341,7 +561,121 @@ export function makeBrokerRegistry(configuration = {}) {
341
561
  return entry.ready;
342
562
  });
343
563
  };
344
- return { acquire, retry };
564
+ const retryIfDue = (factory, options) => Effect.gen(function* () {
565
+ const directory = yield* canonicalize(options.projectDirectory);
566
+ const entry = factories
567
+ .get(factory)
568
+ ?.get((yield* keyFor(directory, options, schemaHash)).key);
569
+ if (!entry?.failure || (entry.nextRetryAt && now() < Date.parse(entry.nextRetryAt)))
570
+ return undefined;
571
+ entry.retryBatch = undefined;
572
+ return yield* retry(factory, options).pipe(Effect.catch((error) => error instanceof VarlockError && error.code === "not_ready"
573
+ ? Effect.succeed(undefined)
574
+ : Effect.fail(error)));
575
+ });
576
+ let unregisterOwnershipLogger = () => undefined;
577
+ const shutdown = Effect.uninterruptible(Effect.gen(function* () {
578
+ removeHooksWhenConfigured(configuration.hostShutdownHooks);
579
+ const current = [...entries];
580
+ yield* Effect.forEach(current, (entry) => disposeEntry(entry), { concurrency: "unbounded" });
581
+ for (const map of factoryMaps)
582
+ map.clear();
583
+ entries.clear();
584
+ unregisterOwnershipLogger();
585
+ }));
586
+ installHooksWhenConfigured(configuration.hostShutdownHooks, shutdown);
587
+ unregisterOwnershipLogger = setOwnershipLifecycleLogger((event) => configuration.logger?.({
588
+ ...event,
589
+ scope: "project",
590
+ directory: "",
591
+ keyHash: event.keyHash,
592
+ }));
593
+ return { acquire, retry, retryIfDue, shutdown };
594
+ }
595
+ let hostHooks;
596
+ let hostShutdown;
597
+ function removeHooksWhenConfigured(enabled) {
598
+ if (enabled)
599
+ removeHostShutdownHooks();
600
+ }
601
+ function installHooksWhenConfigured(enabled, shutdown) {
602
+ if (enabled)
603
+ installHostShutdownHooks(shutdown);
604
+ }
605
+ function signalListener(name, onSigInt, onSigTerm) {
606
+ return name === "SIGINT" ? onSigInt : onSigTerm;
607
+ }
608
+ function reraiseSignal(signalProcess, name, listener) {
609
+ signalProcess.removeListener(name, listener);
610
+ signalProcess.kill(signalProcess.pid, name);
611
+ }
612
+ function finishSignalCleanup(signalProcess, name, listener, active) {
613
+ if (!active && signalProcess.listeners(name).length > 0)
614
+ return;
615
+ const others = signalProcess.listeners(name).filter((item) => item !== listener);
616
+ signalProcess.removeListener(name, listener);
617
+ if (others.length === 0)
618
+ signalProcess.kill(signalProcess.pid, name);
619
+ }
620
+ export function removeHostShutdownHooks() {
621
+ hostHooks?.();
622
+ hostHooks = undefined;
623
+ hostShutdown = undefined;
624
+ }
625
+ export function installHostShutdownHooks(shutdown, signalProcess = process, options = {}) {
626
+ if (hostHooks)
627
+ return;
628
+ hostShutdown = shutdown;
629
+ let active = true;
630
+ const beforeExit = () => {
631
+ if (active)
632
+ void Effect.runPromise(hostShutdown).catch(() => undefined);
633
+ };
634
+ let cleaning = false;
635
+ // Node and Bun suppress their default signal termination while a signal
636
+ // listener is installed. Cleanup is bounded; `exit` cannot await work and
637
+ // is covered by the startup ownership reaper instead.
638
+ const boundedTimer = (resolve) => {
639
+ const timer = setTimeout(resolve, options.cleanupTimeoutMs ?? 3_000);
640
+ timer.unref?.();
641
+ return timer;
642
+ };
643
+ const signal = (name) => {
644
+ if (!active)
645
+ return;
646
+ const listener = signalListener(name, onSigInt, onSigTerm);
647
+ if (cleaning) {
648
+ reraiseSignal(signalProcess, name, listener);
649
+ return;
650
+ }
651
+ cleaning = true;
652
+ let timer;
653
+ const timeout = new Promise((resolve) => {
654
+ timer = boundedTimer(resolve);
655
+ });
656
+ void Promise.race([Effect.runPromise(hostShutdown).catch(() => undefined), timeout]).finally(() => {
657
+ if (timer !== undefined)
658
+ clearTimeout(timer);
659
+ // shutdown() removes the hooks at its start, so do not let that normal
660
+ // cleanup path suppress the signal re-raise requested by this handler.
661
+ finishSignalCleanup(signalProcess, name, listener, active);
662
+ });
663
+ };
664
+ const onSigInt = () => signal("SIGINT");
665
+ const onSigTerm = () => signal("SIGTERM");
666
+ signalProcess.on("beforeExit", beforeExit);
667
+ signalProcess.on("SIGINT", onSigInt);
668
+ signalProcess.on("SIGTERM", onSigTerm);
669
+ hostHooks = () => {
670
+ active = false;
671
+ signalProcess.removeListener("beforeExit", beforeExit);
672
+ signalProcess.removeListener("SIGINT", onSigInt);
673
+ signalProcess.removeListener("SIGTERM", onSigTerm);
674
+ };
675
+ return hostHooks;
345
676
  }
346
- export const brokerRegistry = makeBrokerRegistry();
677
+ export const brokerRegistry = makeBrokerRegistry({
678
+ idleTimeoutMs: 600_000,
679
+ hostShutdownHooks: true,
680
+ });
347
681
  //# sourceMappingURL=broker-registry.js.map