@nimbus-sh/fabric 0.2.0 → 0.3.0

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.
@@ -12,7 +12,7 @@
12
12
  * that is not a Durable Object implements `ProcessHost` against the same
13
13
  * `HostedProcess` and never imports this file.
14
14
  */
15
- import { type HostedProcess, type OneShotParams, type ProcessHostParams, type ResidentDiskReader, type ResidentSupervisorProps } from './process-fabric.js';
15
+ import { type HostedProcess, type OneShotParams, type ProcessHostParams, type ResidentBootSpec, type ResidentDiskReader, type ResidentSupervisorProps } from './process-fabric.js';
16
16
  /** Structural surface of a NimbusLoadedEntrypoint RPC stub. */
17
17
  export interface LoadedWorkerEntrypointStub {
18
18
  handleHttpRequest?: (request: Request) => Promise<Response>;
@@ -57,9 +57,14 @@ interface LoadedWorkerStub {
57
57
  * Durable Object class, so a resident process can be re-entered; `load` is
58
58
  * unkeyed and yields a stateless entrypoint, which is all a program that ends
59
59
  * with its call can ever need.
60
+ *
61
+ * `get` stays wide on purpose: the platform passes a null id for the unkeyed
62
+ * call and answers the callback with the code object or a promise of it, so a
63
+ * narrower declaration would refuse the real binding. The fabric itself always
64
+ * passes a string id and a promise callback.
60
65
  */
61
66
  interface WorkerLoaderBinding {
62
- get(id: string, code: () => Promise<unknown>): {
67
+ get(id: string | null, code: () => unknown): {
63
68
  getDurableObjectClass(name: string): unknown;
64
69
  };
65
70
  load(code: unknown): LoadedWorkerStub;
@@ -106,7 +111,7 @@ export declare function cloneStorage(ctx: DurableObjectState, clone: {
106
111
  populated(name: string): boolean | Promise<boolean>;
107
112
  }): Promise<void>;
108
113
  /**
109
- * The facet name for a slot. Reused, and that is the entire point.
114
+ * The facet name for an ephemeral slot. Reused, and that is the entire point.
110
115
  *
111
116
  * A Durable Object admits 65,536 facets over its LIFETIME: the IDs are
112
117
  * append-only and are never reclaimed, so the bound is on facets ever CREATED,
@@ -118,18 +123,36 @@ export declare function cloneStorage(ctx: DurableObjectState, clone: {
118
123
  * Reusing a NAME costs no new ID. So the name comes from a free list and the
119
124
  * pid stays what it always was: the process identity in the ProcessTable. The
120
125
  * two were only ever conflated because one of them happened to be handy.
126
+ *
127
+ * The book shares the facet-ID space with one other namespace: durable
128
+ * applications, which mint `app-slot-<n>` names of their own (one ID per app,
129
+ * ever). The prefixes are disjoint BY CONSTRUCTION, and that disjointness is
130
+ * load-bearing — a proc-slot name reissued onto a durable app's retained
131
+ * storage would boot the wrong process into someone else's disk.
121
132
  */
122
133
  export declare function residentFacetName(slot: number): string;
134
+ /** The prefix every durable application's facet name carries. */
135
+ export declare const DURABLE_FACET_NAME_PREFIX = "app-slot-";
136
+ /**
137
+ * Drop one facet's SQLite by name — the ONLY call site that may delete facet
138
+ * storage. `spawnResident` releases ephemeral processes with abort+delete
139
+ * (storage is slot-reuse hygiene) and durable ones with abort alone (the
140
+ * storage IS the durable application's state); explicit removal arrives here
141
+ * through the coordinator's durable-slot book, owner-checked.
142
+ */
143
+ export declare function deleteFacetStorage(ctx: DurableObjectState, name: string): void;
123
144
  /**
124
145
  * What `processes(ctx, env).spawn` hands back: a running process, minus its placement.
125
146
  *
126
- * `slot` rides along because the caller's `describe` needs the facet's real
127
- * name and the slot is not derivable from the pid — that indirection is the
128
- * whole point of the free list. Reading it back out of the book later would
129
- * also race the release that empties it.
147
+ * `name` is the facet's real name and `slot` its ephemeral book entry (absent
148
+ * for a durable spawn, whose name its coordinator allocated out of storage).
149
+ * Both ride along because the caller's `describe` needs them and neither is
150
+ * derivable from the pid — reading a slot back out of the book would race the
151
+ * release that empties it.
130
152
  */
131
153
  export type ResidentFacet = Omit<HostedProcess, 'describe'> & {
132
- slot: number;
154
+ name: string;
155
+ slot?: number;
133
156
  };
134
157
  /**
135
158
  * The process surface of one hosting actor: how a resident process comes
@@ -164,5 +187,18 @@ export declare class Processes {
164
187
  */
165
188
  run<T>(supervisor: ResidentSupervisorProps, params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T>;
166
189
  }
190
+ /**
191
+ * The WorkerCode the loader callback returns for one resident boot: the
192
+ * module map from {@link residentLoaderConfig} (or the staged assembler),
193
+ * plus the isolate's env and network posture.
194
+ *
195
+ * A `code` boot with an explicit `env` — defined, even as `{}` — is the
196
+ * embedder's whole statement about the isolate: the env rides through
197
+ * exactly as minted (loopback stubs by reference), and the composed supervisor
198
+ * entrypoint is not consulted at all, so no SUPERVISOR binding appears.
199
+ * Without one, the default holds: inherited network plus a SUPERVISOR
200
+ * minted from the composed entrypoint for the coordinator's identity.
201
+ */
202
+ export declare function residentWorkerConfig(env: ResidentFacetEnv, disk: () => ResidentDiskReader, supervisor: ResidentSupervisorProps, boot: ResidentBootSpec): Promise<Record<string, unknown>>;
167
203
  export {};
168
204
  //# sourceMappingURL=workerd-facet-host.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"workerd-facet-host.d.ts","sourceRoot":"","sources":["../src/workerd-facet-host.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"workerd-facet-host.d.ts","sourceRoot":"","sources":["../src/workerd-facet-host.ts"],"names":[],"mappings":"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"}
@@ -98,7 +98,7 @@ export async function cloneStorage(ctx, clone) {
98
98
  }
99
99
  }
100
100
  /**
101
- * The facet name for a slot. Reused, and that is the entire point.
101
+ * The facet name for an ephemeral slot. Reused, and that is the entire point.
102
102
  *
103
103
  * A Durable Object admits 65,536 facets over its LIFETIME: the IDs are
104
104
  * append-only and are never reclaimed, so the bound is on facets ever CREATED,
@@ -110,10 +110,18 @@ export async function cloneStorage(ctx, clone) {
110
110
  * Reusing a NAME costs no new ID. So the name comes from a free list and the
111
111
  * pid stays what it always was: the process identity in the ProcessTable. The
112
112
  * two were only ever conflated because one of them happened to be handy.
113
+ *
114
+ * The book shares the facet-ID space with one other namespace: durable
115
+ * applications, which mint `app-slot-<n>` names of their own (one ID per app,
116
+ * ever). The prefixes are disjoint BY CONSTRUCTION, and that disjointness is
117
+ * load-bearing — a proc-slot name reissued onto a durable app's retained
118
+ * storage would boot the wrong process into someone else's disk.
113
119
  */
114
120
  export function residentFacetName(slot) {
115
121
  return `proc-slot-${slot}`;
116
122
  }
123
+ /** The prefix every durable application's facet name carries. */
124
+ export const DURABLE_FACET_NAME_PREFIX = 'app-slot-';
117
125
  /**
118
126
  * Slot books, per hosting actor, because the facet index is per Durable
119
127
  * Object.
@@ -128,6 +136,11 @@ export function residentFacetName(slot) {
128
136
  * VFS epoch, in which case `invalidatedSince` can only answer poison and the
129
137
  * whole store is dropped. A process therefore cannot boot onto a previous
130
138
  * tenant's filesystem even when release never ran.
139
+ *
140
+ * The book names only the `proc-slot-` space. Durable `app-slot-` names are
141
+ * allocated against DO storage instead (their owner survives a reset), so a
142
+ * fresh incarnation's `next` starting at 0 can never collide with them even
143
+ * before the durable ledger is adopted.
131
144
  */
132
145
  const slotBooks = new WeakMap();
133
146
  function slotBook(ctx) {
@@ -163,6 +176,16 @@ function releaseSlot(ctx, pid) {
163
176
  book.free.push(slot);
164
177
  book.free.sort((a, b) => a - b);
165
178
  }
179
+ /**
180
+ * Drop one facet's SQLite by name — the ONLY call site that may delete facet
181
+ * storage. `spawnResident` releases ephemeral processes with abort+delete
182
+ * (storage is slot-reuse hygiene) and durable ones with abort alone (the
183
+ * storage IS the durable application's state); explicit removal arrives here
184
+ * through the coordinator's durable-slot book, owner-checked.
185
+ */
186
+ export function deleteFacetStorage(ctx, name) {
187
+ facetContainer(ctx).delete(name);
188
+ }
166
189
  /**
167
190
  * The process surface of one hosting actor: how a resident process comes
168
191
  * into existence on workerd, and how a one-shot program runs to completion.
@@ -207,8 +230,19 @@ export class Processes {
207
230
  }
208
231
  function spawnResident(ctx, env, disk, supervisor, params) {
209
232
  const facets = facetContainer(ctx);
210
- const slot = acquireSlot(ctx, params.pid);
211
- const name = residentFacetName(slot);
233
+ // An explicit name is the durable path: the caller allocated an
234
+ // `app-slot-<n>` identity out of DO storage and this facet keeps its SQLite
235
+ // across aborts. Anything else takes the in-memory book — and that book's
236
+ // `proc-slot-` names must never be minted for it, or an ephemeral release's
237
+ // delete would wipe the app's storage and a reused slot would land a new
238
+ // process on someone else's disk.
239
+ const explicit = params.facet;
240
+ if (explicit && !explicit.name.startsWith(DURABLE_FACET_NAME_PREFIX)) {
241
+ throw new Error(`Nimbus: an explicit facet name must carry the '${DURABLE_FACET_NAME_PREFIX}' `
242
+ + `prefix, got '${explicit.name}'`);
243
+ }
244
+ const slot = explicit ? undefined : acquireSlot(ctx, params.pid);
245
+ const name = explicit ? explicit.name : residentFacetName(slot);
212
246
  // The start callback is the ONLY way this facet is ever created, and it
213
247
  // fires AT MOST ONCE. Every later use goes through the stub below, so the
214
248
  // callback running a second time means the facet was released or died —
@@ -233,7 +267,8 @@ function spawnResident(ctx, env, disk, supervisor, params) {
233
267
  facet = facets.get(name, start);
234
268
  }
235
269
  catch (error) {
236
- releaseSlot(ctx, params.pid);
270
+ if (slot !== undefined)
271
+ releaseSlot(ctx, params.pid);
237
272
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
238
273
  }
239
274
  let disposed = false;
@@ -246,13 +281,21 @@ function spawnResident(ctx, env, disk, supervisor, params) {
246
281
  facets.abort(name, new Error('Nimbus: resident process released'));
247
282
  }
248
283
  catch { /* already gone */ }
249
- try {
250
- facets.delete(name);
284
+ // The two release classes: an ephemeral facet's SQLite is slot-reuse
285
+ // hygiene — the name is handed out again, so the store must not be — and
286
+ // a durable one's is the application itself: abort ends the process, the
287
+ // data stays for the next boot, and only removeDurableApp's explicit
288
+ // deleteFacetStorage call ever drops it.
289
+ if (!explicit?.durable) {
290
+ try {
291
+ facets.delete(name);
292
+ }
293
+ catch { /* already gone */ }
251
294
  }
252
- catch { /* already gone */ }
253
295
  // Only after the facet is gone. A slot handed out while its previous
254
296
  // tenant were still being torn down would have two processes on one name.
255
- releaseSlot(ctx, params.pid);
297
+ if (slot !== undefined)
298
+ releaseSlot(ctx, params.pid);
256
299
  };
257
300
  let started;
258
301
  try {
@@ -280,6 +323,7 @@ function spawnResident(ctx, env, disk, supervisor, params) {
280
323
  handleHttpRequest: (request) => facet.handleHttpRequest(request),
281
324
  handleWebSocketRequest: (request) => facet.fetch(request),
282
325
  release,
326
+ name,
283
327
  slot,
284
328
  };
285
329
  }
@@ -373,14 +417,40 @@ async function runOneShot(ctx, env, supervisor, params, consume) {
373
417
  disposeRpcResource(supervisorBinding);
374
418
  }
375
419
  }
376
- async function residentWorkerConfig(env, disk, supervisor, boot) {
420
+ /**
421
+ * The WorkerCode the loader callback returns for one resident boot: the
422
+ * module map from {@link residentLoaderConfig} (or the staged assembler),
423
+ * plus the isolate's env and network posture.
424
+ *
425
+ * A `code` boot with an explicit `env` — defined, even as `{}` — is the
426
+ * embedder's whole statement about the isolate: the env rides through
427
+ * exactly as minted (loopback stubs by reference), and the composed supervisor
428
+ * entrypoint is not consulted at all, so no SUPERVISOR binding appears.
429
+ * Without one, the default holds: inherited network plus a SUPERVISOR
430
+ * minted from the composed entrypoint for the coordinator's identity.
431
+ */
432
+ export async function residentWorkerConfig(env, disk, supervisor, boot) {
433
+ if (boot.kind === 'code' && boot.code.env !== undefined) {
434
+ const isolated = await residentLoaderConfig(boot.code, disk());
435
+ assertModuleMapWithinCodeLimit(configModules(isolated));
436
+ return isolated;
437
+ }
377
438
  const config = boot.kind === 'staged'
378
439
  ? await stagedBootAssembler()(env, boot.stage)
379
440
  : await residentLoaderConfig(boot.code, disk());
380
- assertModuleMapWithinCodeLimit(config.modules ?? {});
441
+ assertModuleMapWithinCodeLimit(configModules(config));
381
442
  const supervisorRpc = supervisorEntrypoint();
382
443
  if (!supervisorRpc) {
383
444
  throw new Error(`Nimbus: ctx.exports.${supervisorEntrypointName() ?? '<supervisor entrypoint>'} unavailable`);
384
445
  }
385
446
  return { ...config, env: { SUPERVISOR: supervisorRpc({ props: supervisor }) } };
386
447
  }
448
+ /** The module map a loader config assembled, or empty when it named none. */
449
+ function configModules(config) {
450
+ const modules = 'modules' in config ? config.modules : undefined;
451
+ if (typeof modules !== 'object' || modules === null)
452
+ return {};
453
+ // Loader configs only ever carry a module map under this key.
454
+ const map = modules;
455
+ return map;
456
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-sh/fabric",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The Cloudflare half of Nimbus — Durable Object facet hosting, dynamic-worker loader pools, the resident-process fabric, and DO alarm/hibernation machinery.",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -50,8 +50,8 @@
50
50
  "typecheck": "tsc --noEmit"
51
51
  },
52
52
  "dependencies": {
53
- "@nimbus-sh/core": "^0.6.0",
54
- "@nimbus-sh/platform": "^0.1.0",
53
+ "@nimbus-sh/core": "^0.7.0",
54
+ "@nimbus-sh/platform": "^0.2.0",
55
55
  "zod": "^4.4.3"
56
56
  },
57
57
  "devDependencies": {
@@ -1,127 +1,16 @@
1
- /**
2
- * composition.ts — the ONE seam an embedder wires the fabric through.
3
- *
4
- * The fabric mints supervisor bindings and assembles staged boots for the
5
- * programs it hosts, but the entrypoint class that answers those bindings and
6
- * the artifact sources a stage names both belong to the embedder. The
7
- * embedder states them once, in its composition root, with one call:
8
- *
9
- * composeFabric({
10
- * supervisorEntrypoint: 'SupervisorRPC',
11
- * stagedBootAssembler: (env, stage) => assembleConfig(env, stage),
12
- * });
13
- *
14
- * First-write-wins, like every holder in this module: the composition root's
15
- * module scope runs once per isolate, before any request.
16
- *
17
- * `ctx.exports` is runtime state, not composition: workerd mints it per
18
- * instance, so the embedder captures it where the platform hands it over —
19
- * the first fetch, or the DO constructor — with {@link adoptCtxExports}.
20
- *
21
- * This module stays a leaf (no fabric imports) so helpers (notably
22
- * isolate-pool.ts) can read `ctx.exports` without transitively importing the
23
- * Durable Object classes, which is what lets the pool be unit-tested in a
24
- * plain Node/Bun process.
25
- */
26
-
27
- /**
28
- * One entry of `ctx.exports`: a top-level entrypoint's loopback factory, which
29
- * mints a Service Binding stub for that entrypoint when called with props.
30
- *
31
- * The stub's RPC surface belongs to the entrypoint CLASS, which this leaf
32
- * cannot see — `Cloudflare.Exports` is derived from the embedder's own main
33
- * module, so for a library it evaluates to `{}`. A caller that knows the class
34
- * names the surface it expects (`factory<MySupervisorRpc>({ props })`); one
35
- * that does not gets `unknown` and has to narrow, same as
36
- * `DurableObjectNamespace<T>` and `RpcStub<T>` in @cloudflare/workers-types.
37
- */
38
- export type EntrypointLoopbackFactory = <Stub = unknown>(options: { props: object }) => Stub;
39
-
40
- /**
41
- * `ctx.exports` itself — one factory per top-level entrypoint export, keyed by
42
- * export name. Absent names read as undefined, which is how a caller finds out
43
- * the embedder's entry module does not re-export the class it needs.
44
- */
45
- export type CtxExports = Record<string, EntrypointLoopbackFactory | undefined>;
46
-
47
- /**
48
- * Assemble a complete Worker Loader config from a staged-artifact spec. The
49
- * embedder supplies this: a stage names artifact sources only the embedder
50
- * knows how to fetch (Nimbus's largest staged artifact is a ~23 MB module map
51
- * from ASSETS), and the assembler runs inside the loader's cache-miss callback
52
- * so those sources are materialized only while the facet actually loads.
53
- * `env` is whichever hosting actor's env the facet is opened with.
54
- */
55
- export type StagedBootAssembler = (
56
- env: unknown,
57
- stage: unknown,
58
- ) => Promise<object>;
59
-
60
- /** What an embedder states about itself, once, in its composition root. */
61
- export interface FabricComposition {
62
- /**
63
- * The ctx.exports name of the embedder's supervisor WorkerEntrypoint. The
64
- * fabric mints one supervisor binding per hosted program from it
65
- * (`env.SUPERVISOR` inside the facet).
66
- */
67
- supervisorEntrypoint: string;
68
- /** Only embedders that use 'staged' boot specs supply one. */
69
- stagedBootAssembler?: StagedBootAssembler;
70
- }
71
-
72
- let _composition: FabricComposition | null = null;
73
-
74
- /** Register the embedder's composition. First-write-wins. */
75
- export function composeFabric(composition: FabricComposition): void {
76
- if (_composition) return;
77
- _composition = composition;
78
- }
79
-
80
- let _ctxExports: CtxExports | null = null;
81
-
82
- /**
83
- * Capture `ctx.exports` for the helpers that mint loopback bindings. The
84
- * embedder calls this where the platform hands the bag over — the first
85
- * fetch, or the DO constructor. First-write-wins.
86
- */
87
- export function adoptCtxExports(value: CtxExports): void {
88
- if (_ctxExports) return;
89
- _ctxExports = value;
90
- }
91
-
92
- export function getCtxExports(): CtxExports | null {
93
- return _ctxExports;
94
- }
95
-
96
- /**
97
- * Resolve the composed supervisor entrypoint on an exports object —
98
- * `exportsObj` when given (a WorkerEntrypoint reads its own ctx.exports),
99
- * the adopted ctx.exports otherwise. Calling the result with props mints one
100
- * supervisor binding (`env.SUPERVISOR`) for one hosted program. Null when
101
- * either half is missing; the caller decides whether that degrades or throws.
102
- */
103
- export function supervisorEntrypoint(exportsObj?: unknown): EntrypointLoopbackFactory | null {
104
- const exports = exportsObj ?? _ctxExports;
105
- const name = _composition?.supervisorEntrypoint;
106
- if (!name) return null;
107
- if ((typeof exports !== 'object' && typeof exports !== 'function') || exports === null) return null;
108
- const factory = (exports as Record<string, unknown>)[name];
109
- return typeof factory === 'function' ? (factory as EntrypointLoopbackFactory) : null;
110
- }
111
-
112
- /** The composed name, for error messages that point at the missing export. */
113
- export function supervisorEntrypointName(): string | null {
114
- return _composition?.supervisorEntrypoint ?? null;
115
- }
116
-
117
- /** The composed assembler; a 'staged' boot spec cannot assemble without one. */
118
- export function stagedBootAssembler(): StagedBootAssembler {
119
- const assembler = _composition?.stagedBootAssembler;
120
- if (!assembler) {
121
- throw new Error(
122
- 'fabric: no staged-boot assembler composed; a \'staged\' boot spec '
123
- + 'cannot be assembled without one (composeFabric)',
124
- );
125
- }
126
- return assembler;
127
- }
1
+ // Core and fabric share one holder without introducing a dependency cycle.
2
+ export {
3
+ adoptCtxExports,
4
+ composeFabric,
5
+ getCtxExports,
6
+ stagedBootAssembler,
7
+ supervisorEntrypoint,
8
+ supervisorEntrypointName,
9
+ } from '@nimbus-sh/platform/composition.js';
10
+
11
+ export type {
12
+ CtxExports,
13
+ EntrypointLoopbackFactory,
14
+ FabricComposition,
15
+ StagedBootAssembler,
16
+ } from '@nimbus-sh/platform/composition.js';
@@ -133,6 +133,9 @@ export class FencedWork<R extends FencedWorkRecord> {
133
133
  * paying a storage delete for pids that never had a row.
134
134
  */
135
135
  private journalledPids = new Set<number>();
136
+ /** Re-drives in flight, by journal key — recovery's un-awaited ones and a
137
+ * caller-driven one share the same drive for the same row. */
138
+ private drives = new Map<string, Promise<boolean>>();
136
139
  /** Whether this instance has already read the journal a reset leaves behind. */
137
140
  private recovered = false;
138
141
 
@@ -143,8 +146,8 @@ export class FencedWork<R extends FencedWorkRecord> {
143
146
 
144
147
  /**
145
148
  * Record a launch as in flight, so an instance that replaces this one knows
146
- * it never finished. Best-effort: a launch that cannot be journalled still
147
- * runs, and a reset then costs exactly what it cost before the journal.
149
+ * it never finished. A launch that cannot be journalled does not start: the
150
+ * rejection reaches the caller, which reports it like any launch failure.
148
151
  *
149
152
  * Synced, not merely put: `await put()` resolves before durability, and the
150
153
  * reset this journal exists for destroys every write its turn still had
@@ -157,12 +160,12 @@ export class FencedWork<R extends FencedWorkRecord> {
157
160
  * losing its row costs a retype, not a recovery.
158
161
  */
159
162
  async journal(record: R): Promise<void> {
163
+ this.journalledPids.add(record.pid);
160
164
  try {
161
- this.journalledPids.add(record.pid);
162
165
  await this.storage.put(`${FENCED_WORK_KEY_PREFIX}${record.pid}`, record);
163
166
  await this.storage.sync();
164
- } catch (e: unknown) {
165
- console.warn('[nimbus] resident launch journal write failed:', errorMessage(e));
167
+ } catch (cause: unknown) {
168
+ throw new Error('resident launch journal write failed', { cause });
166
169
  }
167
170
  }
168
171
 
@@ -178,12 +181,68 @@ export class FencedWork<R extends FencedWorkRecord> {
178
181
  */
179
182
  async release(pid: number): Promise<void> {
180
183
  if (!this.journalledPids.delete(pid)) return;
181
- try {
182
- await this.storage.delete(`${FENCED_WORK_KEY_PREFIX}${pid}`);
183
- await this.storage.sync();
184
- } catch (e: unknown) {
185
- console.warn('[nimbus] resident launch journal delete failed:', errorMessage(e));
184
+ await this.storage.delete(`${FENCED_WORK_KEY_PREFIX}${pid}`);
185
+ await this.storage.sync();
186
+ }
187
+
188
+ /**
189
+ * Drop every row `predicate` claims, live-pid bookkeeping included, synced
190
+ * like {@link release}. The one bulk delete the journal admits: an owner
191
+ * that removes its durable application is owed no recovery, however many
192
+ * generations back its rows were written.
193
+ */
194
+ async purgeWhere(predicate: (record: R) => boolean): Promise<number> {
195
+ const rows = await this.storage.list<R>({ prefix: FENCED_WORK_KEY_PREFIX });
196
+ let purged = 0;
197
+ for (const [key, record] of rows) {
198
+ if (!predicate(record)) continue;
199
+ this.journalledPids.delete(record.pid);
200
+ await this.storage.delete(key);
201
+ purged += 1;
202
+ }
203
+ if (purged > 0) await this.storage.sync();
204
+ return purged;
205
+ }
206
+
207
+ /**
208
+ * Every journal row, storage-true — the rows this instance wrote and the
209
+ * ones a previous instance left behind. The one read surface a request-
210
+ * driven recovery needs to find the durable launch a port belongs to.
211
+ */
212
+ async rows(): Promise<Map<string, R>> {
213
+ return this.storage.list<R>({ prefix: FENCED_WORK_KEY_PREFIX });
214
+ }
215
+
216
+ /**
217
+ * Re-drive one journal row — the awaited sibling of recovery's un-awaited
218
+ * re-drives, for a caller that must know whether the launch actually came
219
+ * back. Single-flight per row: a request-driven drive and recovery's own
220
+ * never boot the same launch twice. Resolves true only when the re-drive
221
+ * itself FAILED and the failure was reported; a settled drive supersedes
222
+ * the row the same way recovery's does.
223
+ */
224
+ drive(key: string, record: R): Promise<boolean> {
225
+ let inflight = this.drives.get(key);
226
+ if (inflight === undefined) {
227
+ inflight = (async (): Promise<boolean> => {
228
+ let failed = false;
229
+ try {
230
+ await this.host.redrive(record, record.attempt + 1);
231
+ } catch (e: unknown) {
232
+ this.host.onRedriveFailed?.(record, e);
233
+ failed = true;
234
+ }
235
+ // A reported failure is settled business — supersede either way, so
236
+ // the row never re-surfaces on the next recovery.
237
+ await this.supersede(key);
238
+ return failed;
239
+ })();
240
+ this.drives.set(key, inflight);
241
+ inflight.finally(() => {
242
+ if (this.drives.get(key) === inflight) this.drives.delete(key);
243
+ });
186
244
  }
245
+ return inflight;
187
246
  }
188
247
 
189
248
  /**
@@ -195,8 +254,12 @@ export class FencedWork<R extends FencedWorkRecord> {
195
254
  * that replaces this one. So the first turn after a reset is already this
196
255
  * one.
197
256
  *
198
- * Runs once per instance: the journal only changes when a launch of THIS
199
- * instance starts or settles, and those are rows this instance wrote.
257
+ * Runs once per instance — re-calls in the same instance are no-ops — and
258
+ * re-drives every row whose pid is `> 0` and at or below `generationBase()`
259
+ * with `attempt < FENCED_WORK_MAX_ATTEMPT`; the rest are abandoned. What the
260
+ * re-drive resolver receives is the journalled recipe and nothing else:
261
+ * env and credentials are never written to storage, so the resolver's
262
+ * embedder re-resolves them rather than reading them back.
200
263
  */
201
264
  async recoverInterrupted(): Promise<void> {
202
265
  if (this.recovered) return;
@@ -233,14 +296,10 @@ export class FencedWork<R extends FencedWorkRecord> {
233
296
  // Not awaited: this call is running inside the alarm that granted the
234
297
  // turn, and the launch it starts asks for turns of its own through that
235
298
  // same alarm — awaiting it here would be waiting on an alarm that cannot
236
- // be scheduled until this one returns.
237
- this.host.waitUntil(
238
- this.host.redrive(record, record.attempt + 1)
239
- .catch((e: unknown) => {
240
- this.host.onRedriveFailed?.(record, e);
241
- })
242
- .then(() => this.supersede(key)),
243
- );
299
+ // be scheduled until this one returns. `drive` single-flights it: a
300
+ // request that arrives mid-launch waits on this same drive rather than
301
+ // booting a second process.
302
+ this.host.waitUntil(this.drive(key, record));
244
303
  }
245
304
  }
246
305
 
@@ -251,15 +310,7 @@ export class FencedWork<R extends FencedWorkRecord> {
251
310
  * a previous generation the terminal hook will never fire for.
252
311
  */
253
312
  private async supersede(key: string): Promise<void> {
254
- try {
255
- await this.storage.delete(key);
256
- await this.storage.sync();
257
- } catch (e: unknown) {
258
- console.warn('[nimbus] resident launch journal supersede failed:', errorMessage(e));
259
- }
313
+ await this.storage.delete(key);
314
+ await this.storage.sync();
260
315
  }
261
316
  }
262
-
263
- function errorMessage(error: unknown): string {
264
- return error instanceof Error ? error.message : String(error);
265
- }