@arki/dot 0.1.1 → 0.1.3

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.
@@ -0,0 +1,1452 @@
1
+ /**
2
+ * Internal DotApp implementation — the kernel's lifecycle scheduler.
3
+ *
4
+ * Not exported from the public surface. Tests reach it only through
5
+ * `defineApp(...)` and its returned `DotApp` interface.
6
+ */
7
+
8
+ import { Logger } from '@arki/log';
9
+ import { createDebugLogger } from '@arki/log/debug';
10
+
11
+ import type {
12
+ DiagnosticIssue,
13
+ DotDiagnosticsSnapshot,
14
+ LifecycleDiagnostic,
15
+ PipDiagnostic,
16
+ RouteDiagnostic,
17
+ ServiceDiagnostic,
18
+ } from '../diagnostics.js';
19
+ import type { DotLifecycleEvent, DotLifecycleObserver } from '../lifecycle-observer.js';
20
+ import type { DotLifecycleHook, DotLifecyclePipFailure, DotLifecycleState } from '../lifecycle.js';
21
+ import type {
22
+ DependencyEdge,
23
+ DotAppManifest,
24
+ LifecycleManifest,
25
+ PipManifest,
26
+ RouteManifest,
27
+ ServiceManifest,
28
+ } from '../manifest.js';
29
+ import type { AnyPip, DotConfigureContext, Lazy, ServiceRecord } from '../pip-contract.js';
30
+ import { DotLifecycleError, DotLifecycleErrorCode } from '../lifecycle.js';
31
+ import { isLazy, isLazyWitness, lazyOf } from '../pip-contract.js';
32
+ import { withPhaseSpan, withPipHookSpan } from './otel.js';
33
+
34
+ const debugKernel = createDebugLogger('arki:dot:kernel');
35
+
36
+ const DOCS_BASE = 'https://docs.arki.dev/dot/diagnostics';
37
+
38
+ /** Per-pip mutable bookkeeping. */
39
+ type PipRecord = {
40
+ pip: AnyPip;
41
+ /** Declaration order (0-based) — v2 boot order IS declaration order. */
42
+ order: number;
43
+ /** Routes registered during `configure`. */
44
+ routes: RouteManifest[];
45
+ /** Services declared during `configure`. */
46
+ services: ServiceManifest[];
47
+ /** Lifecycle hooks the pip participates in. */
48
+ hooks: Set<DotLifecycleHook>;
49
+ /** Provides capability strings declared during `configure`, joined with published wire keys. */
50
+ provides: Set<string>;
51
+ /**
52
+ * Services published by this pip, keyed by LOCAL publish keys (pre-rename).
53
+ * The pip's own stop/dispose contexts read these; the app-facing service
54
+ * map uses the renamed wire keys.
55
+ */
56
+ publishedServices: ServiceRecord;
57
+ /** Whether the pip's `boot` hook completed successfully. */
58
+ booted: boolean;
59
+ /** Whether the pip's `start` hook completed successfully. */
60
+ started: boolean;
61
+ /** Diagnostic issues collected for this pip. */
62
+ issues: DiagnosticIssue[];
63
+ /** Per-hook diagnostic entries. */
64
+ lifecycleDiagnostics: LifecycleDiagnostic[];
65
+ };
66
+
67
+ /** Resolve a needs-shape witness to its wire key. */
68
+ function wireKeyOf(witness: object, alias: string): string {
69
+ return 'key' in witness && typeof (witness as { key: unknown }).key === 'string'
70
+ ? (witness as { key: string }).key
71
+ : alias;
72
+ }
73
+
74
+ /** Helper for stable issue construction. */
75
+ function makeIssue(args: {
76
+ code: string;
77
+ severity?: 'info' | 'warning' | 'error';
78
+ pip?: string;
79
+ message: string;
80
+ remediation: string;
81
+ docsAnchor: string;
82
+ metadata?: Record<string, unknown>;
83
+ }): DiagnosticIssue {
84
+ return {
85
+ code: args.code,
86
+ severity: args.severity ?? 'error',
87
+ pip: args.pip,
88
+ message: args.message,
89
+ remediation: args.remediation,
90
+ docsUrl: `${DOCS_BASE}#${args.docsAnchor}`,
91
+ metadata: args.metadata,
92
+ };
93
+ }
94
+
95
+ export type DotAppInternalConfig = {
96
+ appName: string;
97
+ appVersion?: string;
98
+ pips: readonly AnyPip[];
99
+ /** Runtime config bag passed to every `boot` hook. */
100
+ config?: Readonly<Record<string, unknown>>;
101
+ /**
102
+ * Observers registered at construction time, before any phase fires.
103
+ * Required if a consumer wants to see `configure`-phase events — those
104
+ * happen before there's a public seam to call `subscribe()` on.
105
+ */
106
+ observers?: readonly DotLifecycleObserver[];
107
+ };
108
+
109
+ /**
110
+ * Internal app implementation. Public consumers see the `DotApp` interface
111
+ * from `../define-app.ts`.
112
+ */
113
+ export class DotAppImpl {
114
+ readonly #appName: string;
115
+ readonly #appVersion: string | undefined;
116
+ readonly #config: Readonly<Record<string, unknown>>;
117
+
118
+ /** Pips in declaration order — v2 boot order IS declaration order. */
119
+ readonly #ordered: readonly AnyPip[];
120
+ readonly #records: Map<string, PipRecord>;
121
+
122
+ /** Macro-state of the app. */
123
+ #state: DotLifecycleState = 'defined';
124
+
125
+ /** Manifest finalised after `configure`. */
126
+ #manifest: DotAppManifest;
127
+
128
+ /** Wire-keyed services map — populated as pips boot. */
129
+ readonly #serviceMap = new Map<string, unknown>();
130
+
131
+ /** Which pip provided each wire key — feeds dependency edges + collisions. */
132
+ readonly #providerByWireKey = new Map<string, string>();
133
+
134
+ /** Dependency edges observed during boot (consumer → provider). */
135
+ readonly #wiringEdges: DependencyEdge[] = [];
136
+
137
+ /** Merged wire-keyed services record exposed as `app.services`. */
138
+ #aggregatedServices: Record<string, unknown> = {};
139
+
140
+ /** Configure has already happened (idempotent). */
141
+ #configured = false;
142
+
143
+ /** In-flight boot promise — used for concurrent boot() coalescing. */
144
+ #bootInflight: Promise<void> | null = null;
145
+ /** In-flight start promise — used for concurrent start() coalescing. */
146
+ #startInflight: Promise<void> | null = null;
147
+ /** In-flight stop promise — used for concurrent stop() coalescing. */
148
+ #stopInflight: Promise<void> | null = null;
149
+ /** In-flight dispose promise — used for concurrent dispose() coalescing. */
150
+ #disposeInflight: Promise<void> | null = null;
151
+ /**
152
+ * Serializes lifecycle transitions. Each of boot/start/stop/dispose
153
+ * queues behind whatever transition is in flight and re-checks
154
+ * `#state` only once it reaches the head of the queue. Without this,
155
+ * phases interleave at await points — e.g. `dispose()` completes while
156
+ * `start()` awaits a hook, and the resuming start stamps `started`
157
+ * over `disposed`, resurrecting a dead app.
158
+ */
159
+ #transitionChain: Promise<unknown> = Promise.resolve();
160
+
161
+ /**
162
+ * Structured lifecycle logger. One per app instance; named so consumers
163
+ * can elevate it to DEBUG via `DEBUG=arki:dot:lifecycle` without
164
+ * touching unrelated namespaces. Every line carries the app name plus
165
+ * any phase/pip attributes from the call site. The span helpers
166
+ * (see `./otel.ts`) thread the active span's `traceId`+`spanId` onto
167
+ * forked instances of this logger, so every log record is groupable
168
+ * with its trace in any OTel-compatible backend.
169
+ */
170
+ readonly #logger: Logger;
171
+
172
+ /**
173
+ * In-process lifecycle observers. Fan-out is synchronous; observer
174
+ * exceptions are caught and dropped (DEBUG-logged). Mutable through
175
+ * `subscribe()` even after lifecycle starts — observers added later
176
+ * see only events from their subscription onwards.
177
+ */
178
+ readonly #observers: Set<DotLifecycleObserver>;
179
+
180
+ constructor(config: DotAppInternalConfig) {
181
+ this.#appName = config.appName;
182
+ this.#appVersion = config.appVersion;
183
+ this.#config = Object.freeze({ ...config.config });
184
+ this.#logger = new Logger('arki:dot:lifecycle', { 'dot.app.name': config.appName });
185
+ this.#observers = new Set(config.observers);
186
+
187
+ // Declaration order is boot order in v2. Duplicate names are surfaced at
188
+ // construction (the `configure` phase pseudo-time).
189
+ debugKernel('[%s] registering %d pip(s) in declaration order', this.#appName, config.pips.length);
190
+ this.#ordered = [...config.pips];
191
+
192
+ this.#records = new Map();
193
+ for (const [order, pip] of this.#ordered.entries()) {
194
+ if (this.#records.has(pip.name)) {
195
+ throw new DotLifecycleError({
196
+ code: DotLifecycleErrorCode.DuplicatePip,
197
+ phase: 'configure',
198
+ pip: pip.name,
199
+ message: `Pip "${pip.name}" is registered twice`,
200
+ });
201
+ }
202
+ // `$` is the kernel context namespace ($app/$pip/$config). The pip()
203
+ // constraint bans these at compile time; this is the runtime backstop
204
+ // for erased pips and rename() targets, which types cannot see.
205
+ for (const alias of Object.keys(pip.needs)) {
206
+ if (alias.startsWith('$')) {
207
+ throw new DotLifecycleError({
208
+ code: DotLifecycleErrorCode.ReservedServiceKey,
209
+ phase: 'configure',
210
+ pip: pip.name,
211
+ message:
212
+ `Pip "${pip.name}" declares needs alias "${alias}" — the "$" prefix is reserved ` +
213
+ `for kernel context keys ($app, $pip, $config). Rename the alias.`,
214
+ });
215
+ }
216
+ }
217
+ for (const [localKey, wireKey] of Object.entries(pip.renames)) {
218
+ if (wireKey.startsWith('$')) {
219
+ throw new DotLifecycleError({
220
+ code: DotLifecycleErrorCode.ReservedServiceKey,
221
+ phase: 'configure',
222
+ pip: pip.name,
223
+ message:
224
+ `Pip "${pip.name}" renames "${localKey}" to "${wireKey}" — the "$" prefix is ` +
225
+ `reserved for kernel context keys ($app, $pip, $config). Pick a different wire key.`,
226
+ });
227
+ }
228
+ }
229
+ this.#records.set(pip.name, {
230
+ pip,
231
+ order,
232
+ routes: [],
233
+ services: [],
234
+ hooks: new Set<DotLifecycleHook>(),
235
+ provides: new Set<string>(),
236
+ publishedServices: {},
237
+ booted: false,
238
+ started: false,
239
+ issues: [],
240
+ lifecycleDiagnostics: [],
241
+ });
242
+ }
243
+
244
+ // Initial empty manifest — filled in by `runConfigure`.
245
+ this.#manifest = this.#buildManifest();
246
+ }
247
+
248
+ /**
249
+ * Register a lifecycle observer. The returned function unregisters it.
250
+ * Observers added through `subscribe()` see events emitted *after*
251
+ * subscription only — to catch `configure` events, pass observers
252
+ * through `defineApp(name, { observers })` at construction time.
253
+ */
254
+ subscribe(observer: DotLifecycleObserver): () => void {
255
+ this.#observers.add(observer);
256
+ return () => {
257
+ this.#observers.delete(observer);
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Fan out one event to every registered observer. Observer exceptions
263
+ * are caught and DEBUG-logged so a misbehaving observer can never
264
+ * break the lifecycle or hide an event from siblings.
265
+ *
266
+ * Hot-path note: when `#observers` is empty, the for-loop body never
267
+ * runs — the per-event allocation in the caller (the event object) is
268
+ * the only cost. The kernel never builds an event when no observers
269
+ * are present; see {@link #emitIfObserved}.
270
+ */
271
+ #emit(event: DotLifecycleEvent): void {
272
+ for (const observer of this.#observers) {
273
+ try {
274
+ observer(event);
275
+ } catch (error) {
276
+ debugKernel('[%s] observer threw on %s/%s: %O', this.#appName, event.kind, event.status, error);
277
+ }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Guarded emit. The event factory is only invoked when at least one
283
+ * observer is registered — keeps the no-observer path allocation-free,
284
+ * matching the kernel's zero-cost-when-off discipline (principle 5).
285
+ */
286
+ #emitIfObserved(makeEvent: () => DotLifecycleEvent): void {
287
+ if (this.#observers.size === 0) return;
288
+ this.#emit(makeEvent());
289
+ }
290
+
291
+ /**
292
+ * Emit a single hook-level event. Skipped (zero allocation) when no
293
+ * observers are registered.
294
+ */
295
+ #emitHook(
296
+ phase: DotLifecycleHook,
297
+ pip: string,
298
+ order: number,
299
+ status: 'starting' | 'completed' | 'failed',
300
+ opts?: { durationMs?: number; error?: unknown },
301
+ ): void {
302
+ if (this.#observers.size === 0) return;
303
+ const event = {
304
+ kind: 'pip-hook' as const,
305
+ phase,
306
+ pip,
307
+ order,
308
+ status,
309
+ appName: this.#appName,
310
+ timestamp: Date.now(),
311
+ ...(opts?.durationMs === undefined ? {} : { durationMs: opts.durationMs }),
312
+ ...(opts?.error === undefined ? {} : { error: opts.error }),
313
+ };
314
+ this.#emit(event);
315
+ }
316
+
317
+ /**
318
+ * Wrap a sync phase body with starting/completed/failed observer events.
319
+ * `configure` is the only sync phase in the kernel today — keep the
320
+ * async variant separate (no monomorphisation cost on the hot path).
321
+ */
322
+ #withPhaseEmit(phase: DotLifecycleHook, fn: () => void): void {
323
+ const phaseStart = performance.now();
324
+ this.#emitIfObserved(() => ({
325
+ kind: 'phase',
326
+ phase,
327
+ status: 'starting',
328
+ appName: this.#appName,
329
+ timestamp: Date.now(),
330
+ }));
331
+ try {
332
+ fn();
333
+ this.#emitIfObserved(() => ({
334
+ kind: 'phase',
335
+ phase,
336
+ status: 'completed',
337
+ appName: this.#appName,
338
+ durationMs: performance.now() - phaseStart,
339
+ timestamp: Date.now(),
340
+ }));
341
+ } catch (error) {
342
+ this.#emitIfObserved(() => ({
343
+ kind: 'phase',
344
+ phase,
345
+ status: 'failed',
346
+ appName: this.#appName,
347
+ durationMs: performance.now() - phaseStart,
348
+ error,
349
+ timestamp: Date.now(),
350
+ }));
351
+ throw error;
352
+ }
353
+ }
354
+
355
+ /** Async variant of {@link #withPhaseEmit}. */
356
+ async #withPhaseEmitAsync(phase: DotLifecycleHook, fn: () => Promise<void>): Promise<void> {
357
+ const phaseStart = performance.now();
358
+ this.#emitIfObserved(() => ({
359
+ kind: 'phase',
360
+ phase,
361
+ status: 'starting',
362
+ appName: this.#appName,
363
+ timestamp: Date.now(),
364
+ }));
365
+ try {
366
+ await fn();
367
+ this.#emitIfObserved(() => ({
368
+ kind: 'phase',
369
+ phase,
370
+ status: 'completed',
371
+ appName: this.#appName,
372
+ durationMs: performance.now() - phaseStart,
373
+ timestamp: Date.now(),
374
+ }));
375
+ } catch (error) {
376
+ this.#emitIfObserved(() => ({
377
+ kind: 'phase',
378
+ phase,
379
+ status: 'failed',
380
+ appName: this.#appName,
381
+ durationMs: performance.now() - phaseStart,
382
+ error,
383
+ timestamp: Date.now(),
384
+ }));
385
+ throw error;
386
+ }
387
+ }
388
+
389
+ get name(): string {
390
+ return this.#appName;
391
+ }
392
+
393
+ get state(): DotLifecycleState {
394
+ return this.#state;
395
+ }
396
+
397
+ get services(): Record<string, unknown> {
398
+ return this.#aggregatedServices;
399
+ }
400
+
401
+ get manifest(): DotAppManifest {
402
+ return this.#manifest;
403
+ }
404
+
405
+ get diagnostics(): DotDiagnosticsSnapshot {
406
+ return this.#buildDiagnostics();
407
+ }
408
+
409
+ /**
410
+ * Run the `configure` phase synchronously. Idempotent.
411
+ *
412
+ * @throws {DotLifecycleError} if any configure hook throws or returns a Promise.
413
+ */
414
+ runConfigure(): void {
415
+ if (this.#configured) return;
416
+ if (this.#state === 'failed' || this.#state === 'disposed') {
417
+ throw this.#reuseError('configure');
418
+ }
419
+
420
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'configure');
421
+ phaseLogger.debug('configure: starting', { 'dot.app.pip.count': this.#ordered.length });
422
+
423
+ this.#withPhaseEmit('configure', () => {
424
+ withPhaseSpan(
425
+ {
426
+ appName: this.#appName,
427
+ phase: 'configure',
428
+ pipCount: this.#ordered.length,
429
+ logger: phaseLogger,
430
+ },
431
+ () => this.#runConfigureInner(phaseLogger),
432
+ );
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Inner configure loop. Separated from `runConfigure` so the phase span
438
+ * wrapper can stay thin and the loop body stays unindented — keeps the
439
+ * intricate error-handling readable.
440
+ */
441
+ #runConfigureInner(phaseLogger: Logger): void {
442
+ for (const pip of this.#ordered) {
443
+ const record = this.#records.get(pip.name)!;
444
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
445
+ if (!pip.hooks.configure) {
446
+ pipLogger.debug('configure: skipped (no hook)');
447
+ continue;
448
+ }
449
+ record.hooks.add('configure');
450
+
451
+ const ctx: DotConfigureContext = {
452
+ pipName: pip.name,
453
+ appName: this.#appName,
454
+ registerService: (name, kind) => {
455
+ record.services.push({ name, pip: pip.name, kind });
456
+ },
457
+ registerRoute: route => {
458
+ record.routes.push({ ...route, pip: pip.name });
459
+ },
460
+ registerLifecycleHook: hook => {
461
+ record.hooks.add(hook);
462
+ },
463
+ declareProvides: (...caps) => {
464
+ for (const cap of caps) record.provides.add(cap);
465
+ },
466
+ };
467
+
468
+ const started = performance.now();
469
+ this.#emitHook('configure', pip.name, record.order, 'starting');
470
+ let returned: unknown;
471
+ try {
472
+ returned = withPipHookSpan(
473
+ {
474
+ appName: this.#appName,
475
+ pipName: pip.name,
476
+ pipVersion: pip.version,
477
+ hook: 'configure',
478
+ order: record.order,
479
+ logger: pipLogger,
480
+ },
481
+ () => pip.hooks.configure!(ctx),
482
+ );
483
+ } catch (error) {
484
+ const durationMs = performance.now() - started;
485
+ const issue = makeIssue({
486
+ code: DotLifecycleErrorCode.ConfigureFailed,
487
+ pip: pip.name,
488
+ message: `configure hook threw for pip "${pip.name}": ${stringifyError(error)}`,
489
+ remediation: `Fix the error in the configure() hook of "${pip.name}". configure() is for synchronous registration only — avoid throwing on declarative work.`,
490
+ docsAnchor: 'configure-failed',
491
+ });
492
+ record.issues.push(issue);
493
+ record.lifecycleDiagnostics.push({
494
+ pip: pip.name,
495
+ hook: 'configure',
496
+ state: 'failed',
497
+ order: record.order,
498
+ durationMs,
499
+ issues: [issue],
500
+ });
501
+ this.#emitHook('configure', pip.name, record.order, 'failed', { durationMs, error });
502
+ this.#state = 'failed';
503
+ this.#manifest = this.#buildManifest();
504
+ throw new DotLifecycleError({
505
+ code: DotLifecycleErrorCode.ConfigureFailed,
506
+ phase: 'configure',
507
+ pip: pip.name,
508
+ message: issue.message,
509
+ cause: error,
510
+ });
511
+ }
512
+
513
+ if (isThenable(returned)) {
514
+ const durationMs = performance.now() - started;
515
+ const issue = makeIssue({
516
+ code: DotLifecycleErrorCode.ConfigureAsync,
517
+ pip: pip.name,
518
+ message: `configure hook of pip "${pip.name}" returned a Promise. configure must be synchronous.`,
519
+ remediation: 'Move async work to the boot() hook. configure() is for synchronous registration only.',
520
+ docsAnchor: 'configure-async',
521
+ });
522
+ record.issues.push(issue);
523
+ record.lifecycleDiagnostics.push({
524
+ pip: pip.name,
525
+ hook: 'configure',
526
+ state: 'failed',
527
+ order: record.order,
528
+ durationMs,
529
+ issues: [issue],
530
+ });
531
+ this.#emitHook('configure', pip.name, record.order, 'failed', {
532
+ durationMs,
533
+ error: new Error(issue.message),
534
+ });
535
+ this.#state = 'failed';
536
+ this.#manifest = this.#buildManifest();
537
+ throw new DotLifecycleError({
538
+ code: DotLifecycleErrorCode.ConfigureAsync,
539
+ phase: 'configure',
540
+ pip: pip.name,
541
+ message: issue.message,
542
+ });
543
+ }
544
+
545
+ const durationMs = performance.now() - started;
546
+ record.lifecycleDiagnostics.push({
547
+ pip: pip.name,
548
+ hook: 'configure',
549
+ state: 'configured',
550
+ order: record.order,
551
+ durationMs,
552
+ issues: [],
553
+ });
554
+ this.#emitHook('configure', pip.name, record.order, 'completed', { durationMs });
555
+ pipLogger.debug('configure: done', { 'dot.pip.duration.ms': durationMs });
556
+ }
557
+
558
+ // Also declare lifecycle-hook participation for non-configure hooks present
559
+ // on the pip object, so the manifest reflects them.
560
+ for (const pip of this.#ordered) {
561
+ const record = this.#records.get(pip.name)!;
562
+ if (pip.hooks.boot) record.hooks.add('boot');
563
+ if (pip.hooks.start) record.hooks.add('start');
564
+ if (pip.hooks.stop) record.hooks.add('stop');
565
+ if (pip.hooks.dispose) record.hooks.add('dispose');
566
+ }
567
+
568
+ this.#configured = true;
569
+ this.#state = 'configured';
570
+ this.#manifest = this.#buildManifest();
571
+ phaseLogger.debug('configure: complete', { 'dot.app.pip.count': this.#ordered.length });
572
+ }
573
+
574
+ /**
575
+ * Run one lifecycle transition after all previously enqueued ones.
576
+ * The chain itself never rejects — each link swallows its error for
577
+ * the *next* link only; the caller still receives the original
578
+ * rejection through the returned promise.
579
+ */
580
+ #enqueueTransition<T>(run: () => Promise<T>): Promise<T> {
581
+ const result = this.#transitionChain.then(run);
582
+ // The chain link settles even when the transition fails — the caller
583
+ // still sees the original rejection through `result`.
584
+ this.#transitionChain = result.catch(() => null);
585
+ return result;
586
+ }
587
+
588
+ /** Public boot() — idempotent + concurrent-safe. */
589
+ async boot(): Promise<void> {
590
+ if (this.#bootInflight) return this.#bootInflight;
591
+ this.#bootInflight = this.#enqueueTransition(() => this.#bootTransition()).finally(() => {
592
+ this.#bootInflight = null;
593
+ });
594
+ return this.#bootInflight;
595
+ }
596
+
597
+ /** State checks + boot body. Runs at the head of the transition queue. */
598
+ async #bootTransition(): Promise<void> {
599
+ if (this.#state === 'failed' || this.#state === 'disposed') {
600
+ throw this.#reuseError('boot');
601
+ }
602
+ if (this.#state === 'booted' || this.#state === 'started' || this.#state === 'stopped') {
603
+ return;
604
+ }
605
+ return this.#runBoot();
606
+ }
607
+
608
+ async #runBoot(): Promise<void> {
609
+ if (!this.#configured) {
610
+ this.runConfigure();
611
+ }
612
+
613
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'boot');
614
+ phaseLogger.debug('boot: starting', { 'dot.app.pip.count': this.#ordered.length });
615
+
616
+ return this.#withPhaseEmitAsync('boot', () =>
617
+ withPhaseSpan(
618
+ {
619
+ appName: this.#appName,
620
+ phase: 'boot',
621
+ pipCount: this.#ordered.length,
622
+ logger: phaseLogger,
623
+ },
624
+ () => this.#runBootInner(phaseLogger),
625
+ ),
626
+ );
627
+ }
628
+
629
+ /**
630
+ * Resolve a pip's needs into a hook context (alias-keyed), joined with
631
+ * the `$`-prefixed kernel keys and — for post-boot hooks — the pip's own
632
+ * published services (local keys).
633
+ *
634
+ * @throws {DotLifecycleError} `DOT_LIFECYCLE_E012` when a need has no
635
+ * provider among earlier-booted pips. Teardown hooks can never hit
636
+ * this: the service map only grows, and reverse-order teardown keeps
637
+ * providers alive until their consumers are done.
638
+ */
639
+ #buildHookCtx(record: PipRecord, phase: DotLifecycleHook, includeOwnProvides: boolean): Record<string, unknown> {
640
+ const ctx: Record<string, unknown> = {
641
+ $app: this.#appName,
642
+ $pip: record.pip.name,
643
+ $config: this.#config,
644
+ };
645
+ for (const [alias, witness] of Object.entries(record.pip.needs)) {
646
+ const wireKey = wireKeyOf(witness, alias);
647
+ if (!this.#serviceMap.has(wireKey)) {
648
+ throw new DotLifecycleError({
649
+ code: DotLifecycleErrorCode.UnsatisfiedNeed,
650
+ phase,
651
+ pip: record.pip.name,
652
+ message:
653
+ `Pip "${record.pip.name}" needs service "${wireKey}" but no earlier pip provides it. ` +
654
+ `Register a provider with .use() before this pip. Services flow strictly forward — ` +
655
+ `if a later pip provides "${wireKey}", move that provider earlier; if two pips need ` +
656
+ `each other's services, that is a cycle: merge them or extract the shared piece into ` +
657
+ `a third pip both consume.`,
658
+ });
659
+ }
660
+ const value = this.#serviceMap.get(wireKey);
661
+ // A lazy-lifting witness (`service.lazy<T>()`) always receives a
662
+ // handle: lazy provides pass through, eager provides are lifted into
663
+ // a pre-initialized wrapper. The wrapper is created per-injection and
664
+ // never published, so the kernel's auto-dispose does not touch it —
665
+ // the underlying value's lifecycle stays with its provider.
666
+ ctx[alias] = isLazyWitness(witness) && !isLazy(value) ? lazyOf(value) : value;
667
+ const provider = this.#providerByWireKey.get(wireKey);
668
+ if (provider !== undefined && !this.#wiringEdges.some(e => e.from === record.pip.name && e.to === provider)) {
669
+ this.#wiringEdges.push({ from: record.pip.name, to: provider, kind: 'requires' });
670
+ }
671
+ }
672
+ if (includeOwnProvides) {
673
+ Object.assign(ctx, record.publishedServices);
674
+ }
675
+ return ctx;
676
+ }
677
+
678
+ /**
679
+ * Inner boot loop. Separated from `#runBoot` so the phase span wrapper
680
+ * stays thin and the loop body — which orchestrates rollback on
681
+ * partial failure — stays unindented.
682
+ */
683
+ async #runBootInner(phaseLogger: Logger): Promise<void> {
684
+ const bootedRecords: PipRecord[] = [];
685
+
686
+ /** Shared failure path: mark diagnostics, roll back, throw. */
687
+ const fail = async (args: {
688
+ record: PipRecord;
689
+ code: (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
690
+ message: string;
691
+ remediation: string;
692
+ docsAnchor: string;
693
+ durationMs: number;
694
+ cause?: unknown;
695
+ /** Records to dispose (reverse order applied here). */
696
+ rollback: readonly PipRecord[];
697
+ }): Promise<never> => {
698
+ const issue = makeIssue({
699
+ code: args.code,
700
+ pip: args.record.pip.name,
701
+ message: args.message,
702
+ remediation: args.remediation,
703
+ docsAnchor: args.docsAnchor,
704
+ });
705
+ args.record.issues.push(issue);
706
+ args.record.lifecycleDiagnostics.push({
707
+ pip: args.record.pip.name,
708
+ hook: 'boot',
709
+ state: 'failed',
710
+ order: args.record.order,
711
+ durationMs: args.durationMs,
712
+ issues: [issue],
713
+ });
714
+ this.#emitHook('boot', args.record.pip.name, args.record.order, 'failed', {
715
+ durationMs: args.durationMs,
716
+ error: args.cause ?? new Error(args.message),
717
+ });
718
+ phaseLogger.error('boot: FAILED — rolling back already-booted pips', {
719
+ 'dot.pip.name': args.record.pip.name,
720
+ 'dot.app.rollback.count': args.rollback.length,
721
+ });
722
+ const disposeFailures = await this.#runDisposeForRecords(reverseRecords(args.rollback), phaseLogger);
723
+ this.#state = 'failed';
724
+ this.#manifest = this.#buildManifest();
725
+ throw new DotLifecycleError({
726
+ code: args.code,
727
+ phase: 'boot',
728
+ pip: args.record.pip.name,
729
+ message: args.message,
730
+ cause: args.cause,
731
+ failures: disposeFailures.length > 0 ? disposeFailures : undefined,
732
+ });
733
+ };
734
+
735
+ for (const pip of this.#ordered) {
736
+ const record = this.#records.get(pip.name)!;
737
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
738
+ const started = performance.now();
739
+
740
+ // Resolve needs BEFORE invoking the hook — a pip with needs but no
741
+ // boot hook still fails fast when its wiring is unsatisfied.
742
+ let ctx: Record<string, unknown>;
743
+ try {
744
+ ctx = this.#buildHookCtx(record, 'boot', false);
745
+ } catch (error) {
746
+ const message = error instanceof DotLifecycleError ? error.message : stringifyError(error);
747
+ return fail({
748
+ record,
749
+ code: DotLifecycleErrorCode.UnsatisfiedNeed,
750
+ message,
751
+ remediation:
752
+ `Add a provider for the missing service with .use() before "${pip.name}", or rename an ` +
753
+ `existing provider's keys to match. If a later pip provides it, reorder — services flow ` +
754
+ `strictly forward. Mutual needs between two pips are a cycle: merge them or extract the ` +
755
+ `shared piece into a third pip both consume.`,
756
+ docsAnchor: 'unsatisfied-need',
757
+ durationMs: performance.now() - started,
758
+ rollback: bootedRecords,
759
+ });
760
+ }
761
+
762
+ if (!pip.hooks.boot) {
763
+ record.booted = true;
764
+ bootedRecords.push(record);
765
+ pipLogger.debug('boot: skipped (no hook)');
766
+ continue;
767
+ }
768
+ record.hooks.add('boot');
769
+
770
+ this.#emitHook('boot', pip.name, record.order, 'starting');
771
+ let result: ServiceRecord | void;
772
+ try {
773
+ result = await withPipHookSpan(
774
+ {
775
+ appName: this.#appName,
776
+ pipName: pip.name,
777
+ pipVersion: pip.version,
778
+ hook: 'boot',
779
+ order: record.order,
780
+ logger: pipLogger,
781
+ },
782
+ // Erasure boundary: hooks are stored as `(ctx: never) => ...`;
783
+ // the kernel is the one caller allowed to cross it.
784
+ () => pip.hooks.boot!(ctx as never),
785
+ );
786
+ } catch (error) {
787
+ return fail({
788
+ record,
789
+ code: DotLifecycleErrorCode.BootFailed,
790
+ message: `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
791
+ remediation: `Fix the error in the boot() hook of "${pip.name}". If boot opens partial resources before throwing, clean them up locally — DOT only disposes pips whose boot completed.`,
792
+ docsAnchor: 'boot-failed',
793
+ durationMs: performance.now() - started,
794
+ cause: error,
795
+ rollback: bootedRecords,
796
+ });
797
+ }
798
+
799
+ const publishedServices: ServiceRecord = result ?? {};
800
+ record.publishedServices = publishedServices;
801
+ record.booted = true;
802
+
803
+ for (const [localKey, value] of Object.entries(publishedServices)) {
804
+ const wireKey = pip.renames[localKey] ?? localKey;
805
+ if (localKey.startsWith('$') || wireKey.startsWith('$')) {
806
+ // The pip() constraint bans this at compile time; erased pips can
807
+ // still reach here. A `$` publish would shadow $app/$pip/$config
808
+ // in the pip's own post-boot hook contexts.
809
+ return fail({
810
+ record,
811
+ code: DotLifecycleErrorCode.ReservedServiceKey,
812
+ message:
813
+ `Pip "${pip.name}" publishes service "${wireKey}" — the "$" prefix is reserved ` +
814
+ `for kernel context keys ($app, $pip, $config).`,
815
+ remediation: `Rename the "${wireKey}" key returned from the boot() hook of "${pip.name}" — "$"-prefixed keys would shadow the kernel context.`,
816
+ docsAnchor: 'reserved-service-key',
817
+ durationMs: performance.now() - started,
818
+ rollback: [...bootedRecords, record],
819
+ });
820
+ }
821
+ if (this.#serviceMap.has(wireKey)) {
822
+ const owner = this.#providerByWireKey.get(wireKey) ?? 'unknown';
823
+ // The current pip HAS booted — include it in the rollback.
824
+ return fail({
825
+ record,
826
+ code: DotLifecycleErrorCode.ServiceCollision,
827
+ message: `Pip "${pip.name}" publishes service "${wireKey}" which pip "${owner}" already provides.`,
828
+ remediation: `Mount one of the two with rename(pip, { '${wireKey}': '<newKey>' }) to keep both instances, or remove the duplicate provider.`,
829
+ docsAnchor: 'service-collision',
830
+ durationMs: performance.now() - started,
831
+ rollback: [...bootedRecords, record],
832
+ });
833
+ }
834
+ this.#serviceMap.set(wireKey, value);
835
+ this.#providerByWireKey.set(wireKey, pip.name);
836
+ this.#aggregatedServices[wireKey] = value;
837
+ record.provides.add(wireKey);
838
+ }
839
+ bootedRecords.push(record);
840
+
841
+ const durationMs = performance.now() - started;
842
+ record.lifecycleDiagnostics.push({
843
+ pip: pip.name,
844
+ hook: 'boot',
845
+ state: 'booted',
846
+ order: record.order,
847
+ durationMs,
848
+ issues: [],
849
+ });
850
+ this.#emitHook('boot', pip.name, record.order, 'completed', { durationMs });
851
+ pipLogger.debug('boot: done', {
852
+ 'dot.pip.duration.ms': durationMs,
853
+ 'dot.pip.services.published': Object.keys(publishedServices).length,
854
+ });
855
+ }
856
+
857
+ this.#state = 'booted';
858
+ this.#manifest = this.#buildManifest();
859
+ phaseLogger.debug('boot: complete', { 'dot.app.pip.count': this.#ordered.length });
860
+ }
861
+
862
+ /** Public start(). Boots first if needed. Idempotent + concurrent-safe. */
863
+ async start(): Promise<void> {
864
+ if (this.#startInflight) return this.#startInflight;
865
+ this.#startInflight = this.#enqueueTransition(() => this.#startTransition()).finally(() => {
866
+ this.#startInflight = null;
867
+ });
868
+ return this.#startInflight;
869
+ }
870
+
871
+ /** State checks + start body. Runs at the head of the transition queue. */
872
+ async #startTransition(): Promise<void> {
873
+ if (this.#state === 'failed' || this.#state === 'disposed') {
874
+ throw this.#reuseError('start');
875
+ }
876
+ if (this.#state === 'started') return;
877
+ if (this.#state === 'defined' || this.#state === 'configured') {
878
+ // Direct #runBoot — we already hold the head of the transition
879
+ // queue; going through public boot() would enqueue behind
880
+ // ourselves and deadlock.
881
+ await this.#runBoot();
882
+ }
883
+ // From booted or stopped, run start hooks.
884
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'start');
885
+ phaseLogger.debug('start: starting', { 'dot.app.pip.count': this.#ordered.length });
886
+
887
+ return this.#withPhaseEmitAsync('start', () =>
888
+ withPhaseSpan(
889
+ {
890
+ appName: this.#appName,
891
+ phase: 'start',
892
+ pipCount: this.#ordered.length,
893
+ logger: phaseLogger,
894
+ },
895
+ () => this.#runStartInner(phaseLogger),
896
+ ),
897
+ );
898
+ }
899
+
900
+ /**
901
+ * Inner start loop. Separated from `start()` so the phase span wrapper
902
+ * stays thin and the rollback-cascade error path stays readable.
903
+ */
904
+ async #runStartInner(phaseLogger: Logger): Promise<void> {
905
+ const startedRecords: PipRecord[] = [];
906
+ for (const pip of this.#ordered) {
907
+ const record = this.#records.get(pip.name)!;
908
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
909
+ if (!pip.hooks.start) {
910
+ pipLogger.debug('start: skipped (no hook)');
911
+ continue;
912
+ }
913
+ record.hooks.add('start');
914
+
915
+ const ctx = this.#buildHookCtx(record, 'start', true);
916
+
917
+ const startedAt = performance.now();
918
+ this.#emitHook('start', pip.name, record.order, 'starting');
919
+ try {
920
+ await withPipHookSpan(
921
+ {
922
+ appName: this.#appName,
923
+ pipName: pip.name,
924
+ pipVersion: pip.version,
925
+ hook: 'start',
926
+ order: record.order,
927
+ logger: pipLogger,
928
+ },
929
+ () => pip.hooks.start!(ctx as never),
930
+ );
931
+ } catch (error) {
932
+ const durationMs = performance.now() - startedAt;
933
+ const issue = makeIssue({
934
+ code: DotLifecycleErrorCode.StartFailed,
935
+ pip: pip.name,
936
+ message: `start hook threw for pip "${pip.name}": ${stringifyError(error)}`,
937
+ remediation: `Fix the error in the start() hook of "${pip.name}". DOT will stop all already-started pips and dispose all booted pips in reverse order.`,
938
+ docsAnchor: 'start-failed',
939
+ });
940
+ record.issues.push(issue);
941
+ record.lifecycleDiagnostics.push({
942
+ pip: pip.name,
943
+ hook: 'start',
944
+ state: 'failed',
945
+ order: record.order,
946
+ durationMs,
947
+ issues: [issue],
948
+ });
949
+ this.#emitHook('start', pip.name, record.order, 'failed', { durationMs, error });
950
+
951
+ pipLogger.error('start: FAILED — rolling back', {
952
+ 'dot.app.rollback.started.count': startedRecords.length,
953
+ });
954
+ const stopFailures = await this.#runStopForRecords(reverseRecords(startedRecords), phaseLogger);
955
+ const bootedForDispose = this.#ordered.map(p => this.#records.get(p.name)!).filter(r => r.booted);
956
+ const disposeFailures = await this.#runDisposeForRecords(reverseRecords(bootedForDispose), phaseLogger);
957
+ this.#state = 'failed';
958
+ this.#manifest = this.#buildManifest();
959
+ const failures = [...stopFailures, ...disposeFailures];
960
+ throw new DotLifecycleError({
961
+ code: DotLifecycleErrorCode.StartFailed,
962
+ phase: 'start',
963
+ pip: pip.name,
964
+ message: issue.message,
965
+ cause: error,
966
+ failures: failures.length > 0 ? failures : undefined,
967
+ });
968
+ }
969
+
970
+ record.started = true;
971
+ startedRecords.push(record);
972
+ const durationMs = performance.now() - startedAt;
973
+ record.lifecycleDiagnostics.push({
974
+ pip: pip.name,
975
+ hook: 'start',
976
+ state: 'started',
977
+ order: record.order,
978
+ durationMs,
979
+ issues: [],
980
+ });
981
+ this.#emitHook('start', pip.name, record.order, 'completed', { durationMs });
982
+ pipLogger.debug('start: done', { 'dot.pip.duration.ms': durationMs });
983
+ }
984
+
985
+ this.#state = 'started';
986
+ this.#manifest = this.#buildManifest();
987
+ phaseLogger.debug('start: complete', { 'dot.app.pip.count': this.#ordered.length });
988
+ }
989
+
990
+ /** Public stop(). Idempotent + concurrent-safe. */
991
+ async stop(): Promise<void> {
992
+ if (this.#stopInflight) return this.#stopInflight;
993
+ this.#stopInflight = this.#enqueueTransition(() => this.#stopTransition()).finally(() => {
994
+ this.#stopInflight = null;
995
+ });
996
+ return this.#stopInflight;
997
+ }
998
+
999
+ /** State checks + stop body. Runs at the head of the transition queue. */
1000
+ async #stopTransition(): Promise<void> {
1001
+ // stop() after dispose is a no-op (idempotent across terminal states only
1002
+ // for `disposed`; `failed` was already cleaned up).
1003
+ if (this.#state === 'disposed') return;
1004
+ if (this.#state === 'failed') throw this.#reuseError('stop');
1005
+ // For non-started states (defined/configured/booted/stopped): no-op.
1006
+ if (this.#state !== 'started') {
1007
+ // Mark booted as "stopped" for state-machine clarity.
1008
+ if (this.#state === 'booted') this.#state = 'stopped';
1009
+ return;
1010
+ }
1011
+ return this.#runStop();
1012
+ }
1013
+
1014
+ async #runStop(): Promise<void> {
1015
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'stop');
1016
+ phaseLogger.debug('stop: starting', { 'dot.app.pip.count': this.#ordered.length });
1017
+
1018
+ return this.#withPhaseEmitAsync('stop', () =>
1019
+ withPhaseSpan(
1020
+ {
1021
+ appName: this.#appName,
1022
+ phase: 'stop',
1023
+ pipCount: this.#ordered.length,
1024
+ logger: phaseLogger,
1025
+ },
1026
+ async () => {
1027
+ const startedRecords = this.#ordered.map(p => this.#records.get(p.name)!).filter(r => r.started);
1028
+ const failures = await this.#runStopForRecords(reverseRecords(startedRecords), phaseLogger);
1029
+ this.#state = 'stopped';
1030
+ this.#manifest = this.#buildManifest();
1031
+ if (failures.length > 0) {
1032
+ phaseLogger.warn('stop: complete with failures', { 'dot.app.failure.count': failures.length });
1033
+ throw new DotLifecycleError({
1034
+ code: DotLifecycleErrorCode.StopFailed,
1035
+ phase: 'stop',
1036
+ message: `${failures.length} pip(s) failed during stop`,
1037
+ failures,
1038
+ });
1039
+ }
1040
+ phaseLogger.debug('stop: complete', { 'dot.app.pip.count': this.#ordered.length });
1041
+ },
1042
+ ),
1043
+ );
1044
+ }
1045
+
1046
+ async #runStopForRecords(records: readonly PipRecord[], phaseLogger: Logger): Promise<DotLifecyclePipFailure[]> {
1047
+ const failures: DotLifecyclePipFailure[] = [];
1048
+ for (const record of records) {
1049
+ if (!record.pip.hooks.stop) continue;
1050
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', record.pip.name);
1051
+ record.hooks.add('stop');
1052
+ const ctx = this.#buildHookCtx(record, 'stop', true);
1053
+ const startedAt = performance.now();
1054
+ this.#emitHook('stop', record.pip.name, record.order, 'starting');
1055
+ try {
1056
+ await withPipHookSpan(
1057
+ {
1058
+ appName: this.#appName,
1059
+ pipName: record.pip.name,
1060
+ pipVersion: record.pip.version,
1061
+ hook: 'stop',
1062
+ order: record.order,
1063
+ logger: pipLogger,
1064
+ },
1065
+ () => record.pip.hooks.stop!(ctx as never),
1066
+ );
1067
+ record.started = false;
1068
+ const durationMs = performance.now() - startedAt;
1069
+ record.lifecycleDiagnostics.push({
1070
+ pip: record.pip.name,
1071
+ hook: 'stop',
1072
+ state: 'stopped',
1073
+ order: record.order,
1074
+ durationMs,
1075
+ issues: [],
1076
+ });
1077
+ this.#emitHook('stop', record.pip.name, record.order, 'completed', { durationMs });
1078
+ pipLogger.debug('stop: done', { 'dot.pip.duration.ms': durationMs });
1079
+ } catch (error) {
1080
+ const durationMs = performance.now() - startedAt;
1081
+ const issue = makeIssue({
1082
+ code: DotLifecycleErrorCode.StopFailed,
1083
+ pip: record.pip.name,
1084
+ message: `stop hook threw for pip "${record.pip.name}": ${stringifyError(error)}`,
1085
+ remediation: `Fix the error in the stop() hook of "${record.pip.name}". Stop continues through individual failures and reports an aggregate error.`,
1086
+ docsAnchor: 'stop-failed',
1087
+ });
1088
+ record.issues.push(issue);
1089
+ record.lifecycleDiagnostics.push({
1090
+ pip: record.pip.name,
1091
+ hook: 'stop',
1092
+ state: 'failed',
1093
+ order: record.order,
1094
+ durationMs,
1095
+ issues: [issue],
1096
+ });
1097
+ this.#emitHook('stop', record.pip.name, record.order, 'failed', { durationMs, error });
1098
+ failures.push({ pip: record.pip.name, phase: 'stop', error });
1099
+ pipLogger.error('stop: failed (continuing)', {
1100
+ 'dot.pip.error.message': stringifyError(error),
1101
+ });
1102
+ }
1103
+ }
1104
+ return failures;
1105
+ }
1106
+
1107
+ /** Public dispose(). Idempotent + concurrent-safe. */
1108
+ async dispose(): Promise<void> {
1109
+ if (this.#disposeInflight) return this.#disposeInflight;
1110
+ this.#disposeInflight = this.#enqueueTransition(() => this.#disposeTransition()).finally(() => {
1111
+ this.#disposeInflight = null;
1112
+ });
1113
+ return this.#disposeInflight;
1114
+ }
1115
+
1116
+ /** State check + dispose body. Runs at the head of the transition queue. */
1117
+ async #disposeTransition(): Promise<void> {
1118
+ if (this.#state === 'disposed') return;
1119
+ return this.#runDispose();
1120
+ }
1121
+
1122
+ async #runDispose(): Promise<void> {
1123
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'dispose');
1124
+ phaseLogger.debug('dispose: starting', { 'dot.app.pip.count': this.#ordered.length });
1125
+
1126
+ return this.#withPhaseEmitAsync('dispose', () =>
1127
+ withPhaseSpan(
1128
+ {
1129
+ appName: this.#appName,
1130
+ phase: 'dispose',
1131
+ pipCount: this.#ordered.length,
1132
+ logger: phaseLogger,
1133
+ },
1134
+ () => this.#runDisposeInner(phaseLogger),
1135
+ ),
1136
+ );
1137
+ }
1138
+
1139
+ /**
1140
+ * Inner dispose orchestration. Separated from `#runDispose` so the
1141
+ * phase span wrapper stays thin and the multi-state cascade (started
1142
+ * → stop+dispose, failed → no-op, default → dispose-only) stays
1143
+ * readable.
1144
+ */
1145
+ async #runDisposeInner(phaseLogger: Logger): Promise<void> {
1146
+ // From started, stop first.
1147
+ if (this.#state === 'started') {
1148
+ phaseLogger.debug('dispose: cascading from started — running stop first');
1149
+ // Inline stop without throwing — we want to dispose anyway, but capture failures.
1150
+ const startedRecords = this.#ordered.map(p => this.#records.get(p.name)!).filter(r => r.started);
1151
+ const stopFailures = await this.#runStopForRecords(reverseRecords(startedRecords), phaseLogger);
1152
+ this.#state = 'stopped';
1153
+
1154
+ const bootedRecords = this.#ordered.map(p => this.#records.get(p.name)!).filter(r => r.booted);
1155
+ const disposeFailures = await this.#runDisposeForRecords(reverseRecords(bootedRecords), phaseLogger);
1156
+ this.#state = 'disposed';
1157
+ this.#manifest = this.#buildManifest();
1158
+ const failures = [...stopFailures, ...disposeFailures];
1159
+ if (failures.length > 0) {
1160
+ phaseLogger.warn('dispose: complete with failures', { 'dot.app.failure.count': failures.length });
1161
+ throw new DotLifecycleError({
1162
+ code: DotLifecycleErrorCode.DisposeFailed,
1163
+ phase: 'dispose',
1164
+ message: `${failures.length} pip(s) failed during stop+dispose cascade`,
1165
+ failures,
1166
+ });
1167
+ }
1168
+ phaseLogger.debug('dispose: complete (cascaded from started)');
1169
+ return;
1170
+ }
1171
+
1172
+ // From booted/stopped/configured/defined: only dispose booted pips.
1173
+ if (this.#state === 'failed') {
1174
+ // Already cleaned up at the failure site — mark disposed.
1175
+ this.#state = 'disposed';
1176
+ this.#manifest = this.#buildManifest();
1177
+ phaseLogger.debug('dispose: complete (no-op; cleanup happened at failure site)');
1178
+ return;
1179
+ }
1180
+
1181
+ const bootedRecords = this.#ordered.map(p => this.#records.get(p.name)!).filter(r => r.booted);
1182
+ const failures = await this.#runDisposeForRecords(reverseRecords(bootedRecords), phaseLogger);
1183
+ this.#state = 'disposed';
1184
+ this.#manifest = this.#buildManifest();
1185
+ if (failures.length > 0) {
1186
+ phaseLogger.warn('dispose: complete with failures', { 'dot.app.failure.count': failures.length });
1187
+ throw new DotLifecycleError({
1188
+ code: DotLifecycleErrorCode.DisposeFailed,
1189
+ phase: 'dispose',
1190
+ message: `${failures.length} pip(s) failed during dispose`,
1191
+ failures,
1192
+ });
1193
+ }
1194
+ phaseLogger.debug('dispose: complete', { 'dot.app.pip.count': this.#ordered.length });
1195
+ }
1196
+
1197
+ async #runDisposeForRecords(records: readonly PipRecord[], phaseLogger: Logger): Promise<DotLifecyclePipFailure[]> {
1198
+ const failures: DotLifecyclePipFailure[] = [];
1199
+
1200
+ // A lazy handle can be published under several keys — a `service.lazy`
1201
+ // consumer republishing its provider's handle passes it through by
1202
+ // identity. Auto-dispose belongs to the handle's FIRST publisher
1203
+ // (declaration order): dispose runs in reverse, so cleaning the handle
1204
+ // with a republisher would kill it before the original provider's own
1205
+ // dispose hook gets to use it. `records` arrives reversed, so plain
1206
+ // overwrite leaves the earliest-declared pip as each handle's owner.
1207
+ const ownerOf = new Map<Lazy<unknown>, PipRecord>();
1208
+ for (const record of records) {
1209
+ for (const value of Object.values(record.publishedServices)) {
1210
+ if (isLazy(value)) ownerOf.set(value, record);
1211
+ }
1212
+ }
1213
+
1214
+ for (const record of records) {
1215
+ const seen = new Set<Lazy<unknown>>();
1216
+ const lazyPublishes = Object.entries(record.publishedServices).filter(
1217
+ (entry): entry is [string, Lazy<unknown>] => {
1218
+ const value = entry[1];
1219
+ if (!isLazy(value) || ownerOf.get(value) !== record || seen.has(value)) return false;
1220
+ seen.add(value);
1221
+ return true;
1222
+ },
1223
+ );
1224
+ const hasHook = record.pip.hooks.dispose !== undefined;
1225
+ if (!hasHook && lazyPublishes.length === 0) {
1226
+ record.booted = false;
1227
+ continue;
1228
+ }
1229
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', record.pip.name);
1230
+ if (hasHook) {
1231
+ await this.#runDisposeHook(record, pipLogger, failures);
1232
+ }
1233
+ // Auto-dispose lazy service handles AFTER the pip's own dispose hook
1234
+ // (the hook may still use them). Never-initialized handles no-op.
1235
+ for (const [serviceKey, handle] of lazyPublishes) {
1236
+ try {
1237
+ await handle.dispose();
1238
+ } catch (error) {
1239
+ const issue = makeIssue({
1240
+ code: DotLifecycleErrorCode.DisposeFailed,
1241
+ pip: record.pip.name,
1242
+ message: `lazy service "${serviceKey}" cleanup threw for pip "${record.pip.name}": ${stringifyError(error)}`,
1243
+ remediation: `Fix the error in the dispose callback passed to lazy(...) for service "${serviceKey}". Dispose continues through individual failures and reports an aggregate error.`,
1244
+ docsAnchor: 'dispose-failed',
1245
+ });
1246
+ record.issues.push(issue);
1247
+ failures.push({ pip: record.pip.name, phase: 'dispose', error });
1248
+ pipLogger.error('dispose: lazy service cleanup failed (continuing)', {
1249
+ 'dot.pip.service.key': serviceKey,
1250
+ 'dot.pip.error.message': stringifyError(error),
1251
+ });
1252
+ }
1253
+ }
1254
+ if (!hasHook) {
1255
+ record.booted = false;
1256
+ }
1257
+ }
1258
+ return failures;
1259
+ }
1260
+
1261
+ /** Run a single pip's dispose hook with spans/events/diagnostics. */
1262
+ async #runDisposeHook(record: PipRecord, pipLogger: Logger, failures: DotLifecyclePipFailure[]): Promise<void> {
1263
+ record.hooks.add('dispose');
1264
+ const ctx = this.#buildHookCtx(record, 'dispose', true);
1265
+ const startedAt = performance.now();
1266
+ this.#emitHook('dispose', record.pip.name, record.order, 'starting');
1267
+ try {
1268
+ await withPipHookSpan(
1269
+ {
1270
+ appName: this.#appName,
1271
+ pipName: record.pip.name,
1272
+ pipVersion: record.pip.version,
1273
+ hook: 'dispose',
1274
+ order: record.order,
1275
+ logger: pipLogger,
1276
+ },
1277
+ () => record.pip.hooks.dispose!(ctx as never),
1278
+ );
1279
+ record.booted = false;
1280
+ const durationMs = performance.now() - startedAt;
1281
+ record.lifecycleDiagnostics.push({
1282
+ pip: record.pip.name,
1283
+ hook: 'dispose',
1284
+ state: 'disposed',
1285
+ order: record.order,
1286
+ durationMs,
1287
+ issues: [],
1288
+ });
1289
+ this.#emitHook('dispose', record.pip.name, record.order, 'completed', { durationMs });
1290
+ pipLogger.debug('dispose: done', { 'dot.pip.duration.ms': durationMs });
1291
+ } catch (error) {
1292
+ const durationMs = performance.now() - startedAt;
1293
+ const issue = makeIssue({
1294
+ code: DotLifecycleErrorCode.DisposeFailed,
1295
+ pip: record.pip.name,
1296
+ message: `dispose hook threw for pip "${record.pip.name}": ${stringifyError(error)}`,
1297
+ remediation: `Fix the error in the dispose() hook of "${record.pip.name}". Dispose continues through individual failures and reports an aggregate error.`,
1298
+ docsAnchor: 'dispose-failed',
1299
+ });
1300
+ record.issues.push(issue);
1301
+ record.lifecycleDiagnostics.push({
1302
+ pip: record.pip.name,
1303
+ hook: 'dispose',
1304
+ state: 'failed',
1305
+ order: record.order,
1306
+ durationMs,
1307
+ issues: [issue],
1308
+ });
1309
+ this.#emitHook('dispose', record.pip.name, record.order, 'failed', { durationMs, error });
1310
+ failures.push({ pip: record.pip.name, phase: 'dispose', error });
1311
+ pipLogger.error('dispose: failed (continuing)', {
1312
+ 'dot.pip.error.message': stringifyError(error),
1313
+ });
1314
+ }
1315
+ }
1316
+
1317
+ #reuseError(phase: DotLifecycleHook): DotLifecycleError {
1318
+ const code =
1319
+ this.#state === 'disposed' ? DotLifecycleErrorCode.ReuseAfterDispose : DotLifecycleErrorCode.ReuseAfterFailure;
1320
+ const reason = this.#state === 'disposed' ? 'disposed' : 'failed';
1321
+ return new DotLifecycleError({
1322
+ code,
1323
+ phase,
1324
+ message: `Cannot ${phase}() — app "${this.#appName}" is ${reason}. Create a fresh app instance.`,
1325
+ });
1326
+ }
1327
+
1328
+ #buildManifest(): DotAppManifest {
1329
+ const pips: PipManifest[] = [];
1330
+ const routes: RouteManifest[] = [];
1331
+ const services: ServiceManifest[] = [];
1332
+ const lifecycle: LifecycleManifest[] = [];
1333
+ // Edges are observed at boot time: consumer pip → the pip whose
1334
+ // published wire key satisfied its need. Before boot, the per-pip
1335
+ // `dependencies` array (wire-key strings) is the declarative view.
1336
+ const dependencies: DependencyEdge[] = this.#wiringEdges.map(e => ({ ...e }));
1337
+
1338
+ for (const pip of this.#ordered) {
1339
+ const record = this.#records.get(pip.name)!;
1340
+ const needWireKeys = Object.entries(pip.needs).map(([alias, witness]) => wireKeyOf(witness, alias));
1341
+ pips.push({
1342
+ name: pip.name,
1343
+ version: pip.version,
1344
+ dependencies: needWireKeys,
1345
+ provides: [...record.provides],
1346
+ });
1347
+ routes.push(...record.routes);
1348
+ services.push(...record.services);
1349
+ lifecycle.push({
1350
+ pip: pip.name,
1351
+ hooks: [...record.hooks],
1352
+ });
1353
+ }
1354
+
1355
+ return {
1356
+ app: {
1357
+ name: this.#appName,
1358
+ version: this.#appVersion,
1359
+ },
1360
+ pips,
1361
+ routes,
1362
+ services,
1363
+ lifecycle,
1364
+ dependencies,
1365
+ };
1366
+ }
1367
+
1368
+ #buildDiagnostics(): DotDiagnosticsSnapshot {
1369
+ const pipDiagnostics: PipDiagnostic[] = [];
1370
+ const routeDiagnostics: RouteDiagnostic[] = [];
1371
+ const serviceDiagnostics: ServiceDiagnostic[] = [];
1372
+ const lifecycleDiagnostics: LifecycleDiagnostic[] = [];
1373
+ const issues: DiagnosticIssue[] = [];
1374
+
1375
+ for (const pip of this.#ordered) {
1376
+ const record = this.#records.get(pip.name)!;
1377
+ // Per-pip status: failed if any issue with severity error exists, ok otherwise.
1378
+ const hasError = record.issues.some(i => i.severity === 'error');
1379
+ const status: PipDiagnostic['status'] = hasError ? 'failed' : 'ok';
1380
+ pipDiagnostics.push({
1381
+ pip: pip.name,
1382
+ status,
1383
+ issues: [...record.issues],
1384
+ });
1385
+
1386
+ for (const route of record.routes) {
1387
+ routeDiagnostics.push({
1388
+ id: route.id,
1389
+ pip: pip.name,
1390
+ status: hasError ? 'failed' : 'ok',
1391
+ issues: [],
1392
+ });
1393
+ }
1394
+ for (const svc of record.services) {
1395
+ serviceDiagnostics.push({
1396
+ service: svc.name,
1397
+ pip: pip.name,
1398
+ status: hasError
1399
+ ? 'failed'
1400
+ : record.booted || this.#state === 'defined' || this.#state === 'configured'
1401
+ ? 'ok'
1402
+ : 'ok',
1403
+ issues: [],
1404
+ });
1405
+ }
1406
+ lifecycleDiagnostics.push(...record.lifecycleDiagnostics);
1407
+ issues.push(...record.issues);
1408
+ }
1409
+
1410
+ return {
1411
+ generatedAt: new Date().toISOString(),
1412
+ app: {
1413
+ name: this.#appName,
1414
+ state: this.#state,
1415
+ },
1416
+ pips: pipDiagnostics,
1417
+ routes: routeDiagnostics,
1418
+ services: serviceDiagnostics,
1419
+ lifecycle: lifecycleDiagnostics,
1420
+ issues,
1421
+ };
1422
+ }
1423
+ }
1424
+
1425
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
1426
+ return (
1427
+ typeof value === 'object' &&
1428
+ value !== null &&
1429
+ 'then' in value &&
1430
+ typeof (value as { then?: unknown }).then === 'function'
1431
+ );
1432
+ }
1433
+
1434
+ function stringifyError(error: unknown): string {
1435
+ if (error instanceof Error) return error.message;
1436
+ if (typeof error === 'string') return error;
1437
+ try {
1438
+ return JSON.stringify(error);
1439
+ } catch {
1440
+ return String(error);
1441
+ }
1442
+ }
1443
+
1444
+ function reverseRecords(records: readonly PipRecord[]): readonly PipRecord[] {
1445
+ // eslint-disable-next-line unicorn/no-array-reverse -- lib target is ES2022, toReversed is ES2023.
1446
+ return [...records].reverse();
1447
+ }
1448
+
1449
+ /** Re-export `ServiceKind` and `RouteTransport` for the kernel's internal use. */
1450
+
1451
+ export { type RouteTransport, type ServiceKind } from '../manifest.js';
1452
+ export { type Pip } from '../pip-contract.js';