@arki/dot 0.1.1 → 0.1.2

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