@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.
- package/package.json +10 -4
- package/src/cli/discover.ts +223 -0
- package/src/cli/error-codes.ts +74 -0
- package/src/cli/files.ts +120 -0
- package/src/cli/index.ts +539 -0
- package/src/cli/json.ts +49 -0
- package/src/cli/new.ts +420 -0
- package/src/cli/observability-probe.ts +51 -0
- package/src/cli/render-doctor.ts +199 -0
- package/src/cli/render-explain.ts +161 -0
- package/src/define-app.ts +310 -0
- package/src/diagnostics.ts +91 -0
- package/src/index.ts +89 -0
- package/src/kernel/app-instance.ts +1341 -0
- package/src/kernel/otel.ts +265 -0
- package/src/lifecycle-observer.ts +100 -0
- package/src/lifecycle.ts +121 -0
- package/src/manifest.ts +94 -0
- package/src/pip-contract.ts +477 -0
- package/src/pip.ts +84 -0
- package/src/test-harness.ts +72 -0
- package/src/timeline.ts +137 -0
|
@@ -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';
|