@arki/dot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/cli/discover.d.ts +65 -0
  4. package/dist/cli/discover.d.ts.map +1 -0
  5. package/dist/cli/discover.js +184 -0
  6. package/dist/cli/discover.js.map +1 -0
  7. package/dist/cli/error-codes.d.ts +49 -0
  8. package/dist/cli/error-codes.d.ts.map +1 -0
  9. package/dist/cli/error-codes.js +61 -0
  10. package/dist/cli/error-codes.js.map +1 -0
  11. package/dist/cli/files.d.ts +67 -0
  12. package/dist/cli/files.d.ts.map +1 -0
  13. package/dist/cli/files.js +80 -0
  14. package/dist/cli/files.js.map +1 -0
  15. package/dist/cli/index.d.ts +95 -0
  16. package/dist/cli/index.d.ts.map +1 -0
  17. package/dist/cli/index.js +456 -0
  18. package/dist/cli/index.js.map +1 -0
  19. package/dist/cli/json.d.ts +38 -0
  20. package/dist/cli/json.d.ts.map +1 -0
  21. package/dist/cli/json.js +18 -0
  22. package/dist/cli/json.js.map +1 -0
  23. package/dist/cli/new.d.ts +105 -0
  24. package/dist/cli/new.d.ts.map +1 -0
  25. package/dist/cli/new.js +322 -0
  26. package/dist/cli/new.js.map +1 -0
  27. package/dist/cli/observability-probe.d.ts +28 -0
  28. package/dist/cli/observability-probe.d.ts.map +1 -0
  29. package/dist/cli/observability-probe.js +44 -0
  30. package/dist/cli/observability-probe.js.map +1 -0
  31. package/dist/cli/render-doctor.d.ts +26 -0
  32. package/dist/cli/render-doctor.d.ts.map +1 -0
  33. package/dist/cli/render-doctor.js +183 -0
  34. package/dist/cli/render-doctor.js.map +1 -0
  35. package/dist/cli/render-explain.d.ts +43 -0
  36. package/dist/cli/render-explain.d.ts.map +1 -0
  37. package/dist/cli/render-explain.js +127 -0
  38. package/dist/cli/render-explain.js.map +1 -0
  39. package/dist/define-app.d.ts +125 -0
  40. package/dist/define-app.d.ts.map +1 -0
  41. package/dist/define-app.js +126 -0
  42. package/dist/define-app.js.map +1 -0
  43. package/dist/dependency-graph.d.ts +25 -0
  44. package/dist/dependency-graph.d.ts.map +1 -0
  45. package/dist/dependency-graph.js +86 -0
  46. package/dist/dependency-graph.js.map +1 -0
  47. package/dist/diagnostics.d.ts +83 -0
  48. package/dist/diagnostics.d.ts.map +1 -0
  49. package/dist/diagnostics.js +14 -0
  50. package/dist/diagnostics.js.map +1 -0
  51. package/dist/index.d.ts +24 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +17 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/kernel/app-instance.d.ts +62 -0
  56. package/dist/kernel/app-instance.d.ts.map +1 -0
  57. package/dist/kernel/app-instance.js +1040 -0
  58. package/dist/kernel/app-instance.js.map +1 -0
  59. package/dist/kernel/otel.d.ts +105 -0
  60. package/dist/kernel/otel.d.ts.map +1 -0
  61. package/dist/kernel/otel.js +218 -0
  62. package/dist/kernel/otel.js.map +1 -0
  63. package/dist/lifecycle-observer.d.ts +95 -0
  64. package/dist/lifecycle-observer.d.ts.map +1 -0
  65. package/dist/lifecycle-observer.js +19 -0
  66. package/dist/lifecycle-observer.js.map +1 -0
  67. package/dist/lifecycle.d.ts +94 -0
  68. package/dist/lifecycle.d.ts.map +1 -0
  69. package/dist/lifecycle.js +78 -0
  70. package/dist/lifecycle.js.map +1 -0
  71. package/dist/manifest.d.ts +75 -0
  72. package/dist/manifest.d.ts.map +1 -0
  73. package/dist/manifest.js +14 -0
  74. package/dist/manifest.js.map +1 -0
  75. package/dist/pip-contract.d.ts +219 -0
  76. package/dist/pip-contract.d.ts.map +1 -0
  77. package/dist/pip-contract.js +90 -0
  78. package/dist/pip-contract.js.map +1 -0
  79. package/dist/pip.d.ts +33 -0
  80. package/dist/pip.d.ts.map +1 -0
  81. package/dist/pip.js +25 -0
  82. package/dist/pip.js.map +1 -0
  83. package/dist/plugin-contract.d.ts +175 -0
  84. package/dist/plugin-contract.d.ts.map +1 -0
  85. package/dist/plugin-contract.js +45 -0
  86. package/dist/plugin-contract.js.map +1 -0
  87. package/dist/plugin.d.ts +30 -0
  88. package/dist/plugin.d.ts.map +1 -0
  89. package/dist/plugin.js +24 -0
  90. package/dist/plugin.js.map +1 -0
  91. package/dist/test-harness.d.ts +42 -0
  92. package/dist/test-harness.d.ts.map +1 -0
  93. package/dist/test-harness.js +46 -0
  94. package/dist/test-harness.js.map +1 -0
  95. package/dist/timeline.d.ts +43 -0
  96. package/dist/timeline.d.ts.map +1 -0
  97. package/dist/timeline.js +121 -0
  98. package/dist/timeline.js.map +1 -0
  99. package/package.json +68 -0
  100. package/templates/app-minimal/.gitignore.tmpl +6 -0
  101. package/templates/app-minimal/AGENTS.md.tmpl +66 -0
  102. package/templates/app-minimal/README.md.tmpl +24 -0
  103. package/templates/app-minimal/package.json.tmpl +21 -0
  104. package/templates/app-minimal/src/app.ts.tmpl +20 -0
  105. package/templates/app-minimal/src/env.ts.tmpl +12 -0
  106. package/templates/app-minimal/tests/boot.test.ts.tmpl +25 -0
  107. package/templates/app-minimal/tsconfig.json.tmpl +22 -0
@@ -0,0 +1,1040 @@
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
+ import { Logger } from '@arki/log';
8
+ import { createDebugLogger } from '@arki/log/debug';
9
+ import { buildDependencyEdges, topologicalSort } from '../dependency-graph.js';
10
+ import { DotLifecycleError, DotLifecycleErrorCode } from '../lifecycle.js';
11
+ import { pipProvides } from '../pip-contract.js';
12
+ import { withPhaseSpan, withPipHookSpan } from './otel.js';
13
+ const debugKernel = createDebugLogger('arki:dot:kernel');
14
+ const DOCS_BASE = 'https://docs.arki.dev/dot/diagnostics';
15
+ /** Helper for stable issue construction. */
16
+ function makeIssue(args) {
17
+ return {
18
+ code: args.code,
19
+ severity: args.severity ?? 'error',
20
+ pip: args.pip,
21
+ message: args.message,
22
+ remediation: args.remediation,
23
+ docsUrl: `${DOCS_BASE}#${args.docsAnchor}`,
24
+ metadata: args.metadata,
25
+ };
26
+ }
27
+ /**
28
+ * Internal app implementation. Public consumers see the `DotApp` interface
29
+ * from `../define-app.ts`.
30
+ */
31
+ export class DotAppImpl {
32
+ #appName;
33
+ #appVersion;
34
+ #config;
35
+ /** Pips in topological order (set after the constructor sorts them). */
36
+ #ordered;
37
+ #records;
38
+ /** Macro-state of the app. */
39
+ #state = 'defined';
40
+ /** Manifest finalised after `configure`. */
41
+ #manifest;
42
+ /** Cached services map — populated as pips boot. */
43
+ #serviceMap = new Map();
44
+ /** Cached merged services record for `start`/`stop`/`dispose` contexts. */
45
+ #aggregatedServices = {};
46
+ /** Configure has already happened (idempotent). */
47
+ #configured = false;
48
+ /** In-flight boot promise — used for concurrent boot() coalescing. */
49
+ #bootInflight = null;
50
+ /** In-flight stop promise — used for concurrent stop() coalescing. */
51
+ #stopInflight = null;
52
+ /** In-flight dispose promise — used for concurrent dispose() coalescing. */
53
+ #disposeInflight = null;
54
+ /**
55
+ * Structured lifecycle logger. One per app instance; named so consumers
56
+ * can elevate it to DEBUG via `DEBUG=arki:dot:lifecycle` without
57
+ * touching unrelated namespaces. Every line carries the app name plus
58
+ * any phase/pip attributes from the call site. The span helpers
59
+ * (see `./otel.ts`) thread the active span's `traceId`+`spanId` onto
60
+ * forked instances of this logger, so every log record is groupable
61
+ * with its trace in any OTel-compatible backend.
62
+ */
63
+ #logger;
64
+ /**
65
+ * In-process lifecycle observers. Fan-out is synchronous; observer
66
+ * exceptions are caught and dropped (DEBUG-logged). Mutable through
67
+ * `subscribe()` even after lifecycle starts — observers added later
68
+ * see only events from their subscription onwards.
69
+ */
70
+ #observers;
71
+ constructor(config) {
72
+ this.#appName = config.appName;
73
+ this.#appVersion = config.appVersion;
74
+ this.#config = Object.freeze({ ...config.config });
75
+ this.#logger = new Logger('arki:dot:lifecycle', { 'dot.app.name': config.appName });
76
+ this.#observers = new Set(config.observers);
77
+ // Topological sort happens up-front — surfaces cycle/dup/missing-dep errors
78
+ // at construction (the `configure` phase pseudo-time).
79
+ debugKernel('[%s] topologically sorting %d pip(s)', this.#appName, config.pips.length);
80
+ this.#ordered = topologicalSort(config.pips);
81
+ this.#records = new Map();
82
+ for (const [order, pip] of this.#ordered.entries()) {
83
+ this.#records.set(pip.name, {
84
+ pip,
85
+ order,
86
+ routes: [],
87
+ services: [],
88
+ hooks: new Set(),
89
+ provides: new Set(pipProvides(pip)),
90
+ extraDependencies: [],
91
+ publishedServices: {},
92
+ booted: false,
93
+ started: false,
94
+ issues: [],
95
+ lifecycleDiagnostics: [],
96
+ });
97
+ }
98
+ // Initial empty manifest — filled in by `runConfigure`.
99
+ this.#manifest = this.#buildManifest();
100
+ }
101
+ /**
102
+ * Register a lifecycle observer. The returned function unregisters it.
103
+ * Observers added through `subscribe()` see events emitted *after*
104
+ * subscription only — to catch `configure` events, pass observers
105
+ * through `defineApp(name, { observers })` at construction time.
106
+ */
107
+ subscribe(observer) {
108
+ this.#observers.add(observer);
109
+ return () => {
110
+ this.#observers.delete(observer);
111
+ };
112
+ }
113
+ /**
114
+ * Fan out one event to every registered observer. Observer exceptions
115
+ * are caught and DEBUG-logged so a misbehaving observer can never
116
+ * break the lifecycle or hide an event from siblings.
117
+ *
118
+ * Hot-path note: when `#observers` is empty, the for-loop body never
119
+ * runs — the per-event allocation in the caller (the event object) is
120
+ * the only cost. The kernel never builds an event when no observers
121
+ * are present; see {@link #emitIfObserved}.
122
+ */
123
+ #emit(event) {
124
+ for (const observer of this.#observers) {
125
+ try {
126
+ observer(event);
127
+ }
128
+ catch (error) {
129
+ debugKernel('[%s] observer threw on %s/%s: %O', this.#appName, event.kind, event.status, error);
130
+ }
131
+ }
132
+ }
133
+ /**
134
+ * Guarded emit. The event factory is only invoked when at least one
135
+ * observer is registered — keeps the no-observer path allocation-free,
136
+ * matching the kernel's zero-cost-when-off discipline (principle 5).
137
+ */
138
+ #emitIfObserved(makeEvent) {
139
+ if (this.#observers.size === 0)
140
+ return;
141
+ this.#emit(makeEvent());
142
+ }
143
+ /**
144
+ * Emit a single hook-level event. Skipped (zero allocation) when no
145
+ * observers are registered.
146
+ */
147
+ #emitHook(phase, pip, order, status, opts) {
148
+ if (this.#observers.size === 0)
149
+ return;
150
+ const event = {
151
+ kind: 'pip-hook',
152
+ phase,
153
+ pip,
154
+ order,
155
+ status,
156
+ appName: this.#appName,
157
+ timestamp: Date.now(),
158
+ ...(opts?.durationMs === undefined ? {} : { durationMs: opts.durationMs }),
159
+ ...(opts?.error === undefined ? {} : { error: opts.error }),
160
+ };
161
+ this.#emit(event);
162
+ }
163
+ /**
164
+ * Wrap a sync phase body with starting/completed/failed observer events.
165
+ * `configure` is the only sync phase in the kernel today — keep the
166
+ * async variant separate (no monomorphisation cost on the hot path).
167
+ */
168
+ #withPhaseEmit(phase, fn) {
169
+ const phaseStart = performance.now();
170
+ this.#emitIfObserved(() => ({
171
+ kind: 'phase',
172
+ phase,
173
+ status: 'starting',
174
+ appName: this.#appName,
175
+ timestamp: Date.now(),
176
+ }));
177
+ try {
178
+ fn();
179
+ this.#emitIfObserved(() => ({
180
+ kind: 'phase',
181
+ phase,
182
+ status: 'completed',
183
+ appName: this.#appName,
184
+ durationMs: performance.now() - phaseStart,
185
+ timestamp: Date.now(),
186
+ }));
187
+ }
188
+ catch (error) {
189
+ this.#emitIfObserved(() => ({
190
+ kind: 'phase',
191
+ phase,
192
+ status: 'failed',
193
+ appName: this.#appName,
194
+ durationMs: performance.now() - phaseStart,
195
+ error,
196
+ timestamp: Date.now(),
197
+ }));
198
+ throw error;
199
+ }
200
+ }
201
+ /** Async variant of {@link #withPhaseEmit}. */
202
+ async #withPhaseEmitAsync(phase, fn) {
203
+ const phaseStart = performance.now();
204
+ this.#emitIfObserved(() => ({
205
+ kind: 'phase',
206
+ phase,
207
+ status: 'starting',
208
+ appName: this.#appName,
209
+ timestamp: Date.now(),
210
+ }));
211
+ try {
212
+ await fn();
213
+ this.#emitIfObserved(() => ({
214
+ kind: 'phase',
215
+ phase,
216
+ status: 'completed',
217
+ appName: this.#appName,
218
+ durationMs: performance.now() - phaseStart,
219
+ timestamp: Date.now(),
220
+ }));
221
+ }
222
+ catch (error) {
223
+ this.#emitIfObserved(() => ({
224
+ kind: 'phase',
225
+ phase,
226
+ status: 'failed',
227
+ appName: this.#appName,
228
+ durationMs: performance.now() - phaseStart,
229
+ error,
230
+ timestamp: Date.now(),
231
+ }));
232
+ throw error;
233
+ }
234
+ }
235
+ get name() {
236
+ return this.#appName;
237
+ }
238
+ get state() {
239
+ return this.#state;
240
+ }
241
+ get services() {
242
+ return this.#aggregatedServices;
243
+ }
244
+ get manifest() {
245
+ return this.#manifest;
246
+ }
247
+ get diagnostics() {
248
+ return this.#buildDiagnostics();
249
+ }
250
+ /**
251
+ * Run the `configure` phase synchronously. Idempotent.
252
+ *
253
+ * @throws {DotLifecycleError} if any configure hook throws or returns a Promise.
254
+ */
255
+ runConfigure() {
256
+ if (this.#configured)
257
+ return;
258
+ if (this.#state === 'failed' || this.#state === 'disposed') {
259
+ throw this.#reuseError('configure');
260
+ }
261
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'configure');
262
+ phaseLogger.debug('configure: starting', { 'dot.app.pip.count': this.#ordered.length });
263
+ this.#withPhaseEmit('configure', () => {
264
+ withPhaseSpan({
265
+ appName: this.#appName,
266
+ phase: 'configure',
267
+ pipCount: this.#ordered.length,
268
+ logger: phaseLogger,
269
+ }, () => this.#runConfigureInner(phaseLogger));
270
+ });
271
+ }
272
+ /**
273
+ * Inner configure loop. Separated from `runConfigure` so the phase span
274
+ * wrapper can stay thin and the loop body stays unindented — keeps the
275
+ * intricate error-handling readable.
276
+ */
277
+ #runConfigureInner(phaseLogger) {
278
+ for (const pip of this.#ordered) {
279
+ const record = this.#records.get(pip.name);
280
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
281
+ if (!pip.configure) {
282
+ pipLogger.debug('configure: skipped (no hook)');
283
+ continue;
284
+ }
285
+ record.hooks.add('configure');
286
+ const ctx = {
287
+ pipName: pip.name,
288
+ appName: this.#appName,
289
+ registerService: (name, kind) => {
290
+ record.services.push({ name, pip: pip.name, kind });
291
+ },
292
+ registerRoute: route => {
293
+ record.routes.push({ ...route, pip: pip.name });
294
+ },
295
+ registerLifecycleHook: hook => {
296
+ record.hooks.add(hook);
297
+ },
298
+ declareProvides: (...caps) => {
299
+ for (const cap of caps)
300
+ record.provides.add(cap);
301
+ },
302
+ declareDependency: (to, kind) => {
303
+ record.extraDependencies.push({ to, kind: kind ?? 'uses' });
304
+ },
305
+ };
306
+ const started = performance.now();
307
+ this.#emitHook('configure', pip.name, record.order, 'starting');
308
+ let returned;
309
+ try {
310
+ returned = withPipHookSpan({
311
+ appName: this.#appName,
312
+ pipName: pip.name,
313
+ pipVersion: pip.version,
314
+ hook: 'configure',
315
+ order: record.order,
316
+ logger: pipLogger,
317
+ }, () => pip.configure(ctx));
318
+ }
319
+ catch (error) {
320
+ const durationMs = performance.now() - started;
321
+ const issue = makeIssue({
322
+ code: DotLifecycleErrorCode.ConfigureFailed,
323
+ pip: pip.name,
324
+ message: `configure hook threw for pip "${pip.name}": ${stringifyError(error)}`,
325
+ remediation: `Fix the error in the configure() hook of "${pip.name}". configure() is for synchronous registration only — avoid throwing on declarative work.`,
326
+ docsAnchor: 'configure-failed',
327
+ });
328
+ record.issues.push(issue);
329
+ record.lifecycleDiagnostics.push({
330
+ pip: pip.name,
331
+ hook: 'configure',
332
+ state: 'failed',
333
+ order: record.order,
334
+ durationMs,
335
+ issues: [issue],
336
+ });
337
+ this.#emitHook('configure', pip.name, record.order, 'failed', { durationMs, error });
338
+ this.#state = 'failed';
339
+ this.#manifest = this.#buildManifest();
340
+ throw new DotLifecycleError({
341
+ code: DotLifecycleErrorCode.ConfigureFailed,
342
+ phase: 'configure',
343
+ pip: pip.name,
344
+ message: issue.message,
345
+ cause: error,
346
+ });
347
+ }
348
+ if (isThenable(returned)) {
349
+ const durationMs = performance.now() - started;
350
+ const issue = makeIssue({
351
+ code: DotLifecycleErrorCode.ConfigureAsync,
352
+ pip: pip.name,
353
+ message: `configure hook of pip "${pip.name}" returned a Promise. configure must be synchronous.`,
354
+ remediation: 'Move async work to the boot() hook. configure() is for synchronous registration only.',
355
+ docsAnchor: 'configure-async',
356
+ });
357
+ record.issues.push(issue);
358
+ record.lifecycleDiagnostics.push({
359
+ pip: pip.name,
360
+ hook: 'configure',
361
+ state: 'failed',
362
+ order: record.order,
363
+ durationMs,
364
+ issues: [issue],
365
+ });
366
+ this.#emitHook('configure', pip.name, record.order, 'failed', {
367
+ durationMs,
368
+ error: new Error(issue.message),
369
+ });
370
+ this.#state = 'failed';
371
+ this.#manifest = this.#buildManifest();
372
+ throw new DotLifecycleError({
373
+ code: DotLifecycleErrorCode.ConfigureAsync,
374
+ phase: 'configure',
375
+ pip: pip.name,
376
+ message: issue.message,
377
+ });
378
+ }
379
+ const durationMs = performance.now() - started;
380
+ record.lifecycleDiagnostics.push({
381
+ pip: pip.name,
382
+ hook: 'configure',
383
+ state: 'configured',
384
+ order: record.order,
385
+ durationMs,
386
+ issues: [],
387
+ });
388
+ this.#emitHook('configure', pip.name, record.order, 'completed', { durationMs });
389
+ pipLogger.debug('configure: done', { 'dot.pip.duration.ms': durationMs });
390
+ }
391
+ // Also declare lifecycle-hook participation for non-configure hooks present
392
+ // on the pip object, so the manifest reflects them.
393
+ for (const pip of this.#ordered) {
394
+ const record = this.#records.get(pip.name);
395
+ if (pip.boot)
396
+ record.hooks.add('boot');
397
+ if (pip.start)
398
+ record.hooks.add('start');
399
+ if (pip.stop)
400
+ record.hooks.add('stop');
401
+ if (pip.dispose)
402
+ record.hooks.add('dispose');
403
+ }
404
+ this.#configured = true;
405
+ this.#state = 'configured';
406
+ this.#manifest = this.#buildManifest();
407
+ phaseLogger.debug('configure: complete', { 'dot.app.pip.count': this.#ordered.length });
408
+ }
409
+ /** Public boot() — idempotent + concurrent-safe. */
410
+ async boot() {
411
+ if (this.#state === 'failed' || this.#state === 'disposed') {
412
+ throw this.#reuseError('boot');
413
+ }
414
+ if (this.#state === 'booted' || this.#state === 'started' || this.#state === 'stopped') {
415
+ return;
416
+ }
417
+ if (this.#bootInflight)
418
+ return this.#bootInflight;
419
+ this.#bootInflight = this.#runBoot().finally(() => {
420
+ this.#bootInflight = null;
421
+ });
422
+ return this.#bootInflight;
423
+ }
424
+ async #runBoot() {
425
+ if (!this.#configured) {
426
+ this.runConfigure();
427
+ }
428
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'boot');
429
+ phaseLogger.debug('boot: starting', { 'dot.app.pip.count': this.#ordered.length });
430
+ return this.#withPhaseEmitAsync('boot', () => withPhaseSpan({
431
+ appName: this.#appName,
432
+ phase: 'boot',
433
+ pipCount: this.#ordered.length,
434
+ logger: phaseLogger,
435
+ }, () => this.#runBootInner(phaseLogger)));
436
+ }
437
+ /**
438
+ * Inner boot loop. Separated from `#runBoot` so the phase span wrapper
439
+ * stays thin and the loop body — which orchestrates rollback on
440
+ * partial failure — stays unindented.
441
+ */
442
+ async #runBootInner(phaseLogger) {
443
+ const bootedRecords = [];
444
+ for (const pip of this.#ordered) {
445
+ const record = this.#records.get(pip.name);
446
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
447
+ if (!pip.boot) {
448
+ record.booted = true;
449
+ bootedRecords.push(record);
450
+ pipLogger.debug('boot: skipped (no hook)');
451
+ continue;
452
+ }
453
+ record.hooks.add('boot');
454
+ const ctx = {
455
+ pipName: pip.name,
456
+ appName: this.#appName,
457
+ services: this.#serviceMap,
458
+ config: this.#config,
459
+ };
460
+ const started = performance.now();
461
+ this.#emitHook('boot', pip.name, record.order, 'starting');
462
+ let result;
463
+ try {
464
+ const maybeResult = await withPipHookSpan({
465
+ appName: this.#appName,
466
+ pipName: pip.name,
467
+ pipVersion: pip.version,
468
+ hook: 'boot',
469
+ order: record.order,
470
+ logger: pipLogger,
471
+ }, () => pip.boot(ctx));
472
+ result = maybeResult ?? {};
473
+ }
474
+ catch (error) {
475
+ const durationMs = performance.now() - started;
476
+ const issue = makeIssue({
477
+ code: DotLifecycleErrorCode.BootFailed,
478
+ pip: pip.name,
479
+ message: `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
480
+ 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.`,
481
+ docsAnchor: 'boot-failed',
482
+ });
483
+ record.issues.push(issue);
484
+ record.lifecycleDiagnostics.push({
485
+ pip: pip.name,
486
+ hook: 'boot',
487
+ state: 'failed',
488
+ order: record.order,
489
+ durationMs,
490
+ issues: [issue],
491
+ });
492
+ this.#emitHook('boot', pip.name, record.order, 'failed', { durationMs, error });
493
+ pipLogger.error('boot: FAILED — rolling back already-booted pips', {
494
+ 'dot.app.rollback.count': bootedRecords.length,
495
+ });
496
+ // Dispose already-booted pips in reverse-topological order.
497
+ const disposeFailures = await this.#runDisposeForRecords(reverseRecords(bootedRecords), phaseLogger);
498
+ this.#state = 'failed';
499
+ this.#manifest = this.#buildManifest();
500
+ throw new DotLifecycleError({
501
+ code: DotLifecycleErrorCode.BootFailed,
502
+ phase: 'boot',
503
+ pip: pip.name,
504
+ message: issue.message,
505
+ cause: error,
506
+ failures: disposeFailures.length > 0 ? disposeFailures : undefined,
507
+ });
508
+ }
509
+ const publishedServices = result.services ?? {};
510
+ record.publishedServices = publishedServices;
511
+ for (const [name, value] of Object.entries(publishedServices)) {
512
+ this.#serviceMap.set(name, value);
513
+ }
514
+ Object.assign(this.#aggregatedServices, publishedServices);
515
+ record.booted = true;
516
+ bootedRecords.push(record);
517
+ const durationMs = performance.now() - started;
518
+ record.lifecycleDiagnostics.push({
519
+ pip: pip.name,
520
+ hook: 'boot',
521
+ state: 'booted',
522
+ order: record.order,
523
+ durationMs,
524
+ issues: [],
525
+ });
526
+ this.#emitHook('boot', pip.name, record.order, 'completed', { durationMs });
527
+ pipLogger.debug('boot: done', {
528
+ 'dot.pip.duration.ms': durationMs,
529
+ 'dot.pip.services.published': Object.keys(publishedServices).length,
530
+ });
531
+ }
532
+ this.#state = 'booted';
533
+ this.#manifest = this.#buildManifest();
534
+ phaseLogger.debug('boot: complete', { 'dot.app.pip.count': this.#ordered.length });
535
+ }
536
+ /** Public start(). Boots first if needed. Idempotent. */
537
+ async start() {
538
+ if (this.#state === 'failed' || this.#state === 'disposed') {
539
+ throw this.#reuseError('start');
540
+ }
541
+ if (this.#state === 'started')
542
+ return;
543
+ if (this.#state === 'defined' || this.#state === 'configured') {
544
+ await this.boot();
545
+ }
546
+ // From booted or stopped, run start hooks.
547
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'start');
548
+ phaseLogger.debug('start: starting', { 'dot.app.pip.count': this.#ordered.length });
549
+ return this.#withPhaseEmitAsync('start', () => withPhaseSpan({
550
+ appName: this.#appName,
551
+ phase: 'start',
552
+ pipCount: this.#ordered.length,
553
+ logger: phaseLogger,
554
+ }, () => this.#runStartInner(phaseLogger)));
555
+ }
556
+ /**
557
+ * Inner start loop. Separated from `start()` so the phase span wrapper
558
+ * stays thin and the rollback-cascade error path stays readable.
559
+ */
560
+ async #runStartInner(phaseLogger) {
561
+ const startedRecords = [];
562
+ for (const pip of this.#ordered) {
563
+ const record = this.#records.get(pip.name);
564
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
565
+ if (!pip.start) {
566
+ pipLogger.debug('start: skipped (no hook)');
567
+ continue;
568
+ }
569
+ record.hooks.add('start');
570
+ const ctx = {
571
+ pipName: pip.name,
572
+ appName: this.#appName,
573
+ services: this.#aggregatedServices,
574
+ };
575
+ const startedAt = performance.now();
576
+ this.#emitHook('start', pip.name, record.order, 'starting');
577
+ try {
578
+ await withPipHookSpan({
579
+ appName: this.#appName,
580
+ pipName: pip.name,
581
+ pipVersion: pip.version,
582
+ hook: 'start',
583
+ order: record.order,
584
+ logger: pipLogger,
585
+ }, () => pip.start(ctx));
586
+ }
587
+ catch (error) {
588
+ const durationMs = performance.now() - startedAt;
589
+ const issue = makeIssue({
590
+ code: DotLifecycleErrorCode.StartFailed,
591
+ pip: pip.name,
592
+ message: `start hook threw for pip "${pip.name}": ${stringifyError(error)}`,
593
+ 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.`,
594
+ docsAnchor: 'start-failed',
595
+ });
596
+ record.issues.push(issue);
597
+ record.lifecycleDiagnostics.push({
598
+ pip: pip.name,
599
+ hook: 'start',
600
+ state: 'failed',
601
+ order: record.order,
602
+ durationMs,
603
+ issues: [issue],
604
+ });
605
+ this.#emitHook('start', pip.name, record.order, 'failed', { durationMs, error });
606
+ pipLogger.error('start: FAILED — rolling back', {
607
+ 'dot.app.rollback.started.count': startedRecords.length,
608
+ });
609
+ const stopFailures = await this.#runStopForRecords(reverseRecords(startedRecords), phaseLogger);
610
+ const bootedForDispose = this.#ordered.map(p => this.#records.get(p.name)).filter(r => r.booted);
611
+ const disposeFailures = await this.#runDisposeForRecords(reverseRecords(bootedForDispose), phaseLogger);
612
+ this.#state = 'failed';
613
+ this.#manifest = this.#buildManifest();
614
+ const failures = [...stopFailures, ...disposeFailures];
615
+ throw new DotLifecycleError({
616
+ code: DotLifecycleErrorCode.StartFailed,
617
+ phase: 'start',
618
+ pip: pip.name,
619
+ message: issue.message,
620
+ cause: error,
621
+ failures: failures.length > 0 ? failures : undefined,
622
+ });
623
+ }
624
+ record.started = true;
625
+ startedRecords.push(record);
626
+ const durationMs = performance.now() - startedAt;
627
+ record.lifecycleDiagnostics.push({
628
+ pip: pip.name,
629
+ hook: 'start',
630
+ state: 'started',
631
+ order: record.order,
632
+ durationMs,
633
+ issues: [],
634
+ });
635
+ this.#emitHook('start', pip.name, record.order, 'completed', { durationMs });
636
+ pipLogger.debug('start: done', { 'dot.pip.duration.ms': durationMs });
637
+ }
638
+ this.#state = 'started';
639
+ this.#manifest = this.#buildManifest();
640
+ phaseLogger.debug('start: complete', { 'dot.app.pip.count': this.#ordered.length });
641
+ }
642
+ /** Public stop(). Idempotent + concurrent-safe. */
643
+ async stop() {
644
+ if (this.#state === 'failed' || this.#state === 'disposed') {
645
+ // stop() after dispose is a no-op (idempotent across terminal states only
646
+ // for `disposed`; `failed` was already cleaned up).
647
+ if (this.#state === 'disposed')
648
+ return;
649
+ throw this.#reuseError('stop');
650
+ }
651
+ if (this.#stopInflight)
652
+ return this.#stopInflight;
653
+ // For non-started states (defined/configured/booted/stopped): no-op.
654
+ if (this.#state !== 'started') {
655
+ // Mark booted as "stopped" for state-machine clarity.
656
+ if (this.#state === 'booted')
657
+ this.#state = 'stopped';
658
+ return;
659
+ }
660
+ this.#stopInflight = this.#runStop().finally(() => {
661
+ this.#stopInflight = null;
662
+ });
663
+ return this.#stopInflight;
664
+ }
665
+ async #runStop() {
666
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'stop');
667
+ phaseLogger.debug('stop: starting', { 'dot.app.pip.count': this.#ordered.length });
668
+ return this.#withPhaseEmitAsync('stop', () => withPhaseSpan({
669
+ appName: this.#appName,
670
+ phase: 'stop',
671
+ pipCount: this.#ordered.length,
672
+ logger: phaseLogger,
673
+ }, async () => {
674
+ const startedRecords = this.#ordered.map(p => this.#records.get(p.name)).filter(r => r.started);
675
+ const failures = await this.#runStopForRecords(reverseRecords(startedRecords), phaseLogger);
676
+ this.#state = 'stopped';
677
+ this.#manifest = this.#buildManifest();
678
+ if (failures.length > 0) {
679
+ phaseLogger.warn('stop: complete with failures', { 'dot.app.failure.count': failures.length });
680
+ throw new DotLifecycleError({
681
+ code: DotLifecycleErrorCode.StopFailed,
682
+ phase: 'stop',
683
+ message: `${failures.length} pip(s) failed during stop`,
684
+ failures,
685
+ });
686
+ }
687
+ phaseLogger.debug('stop: complete', { 'dot.app.pip.count': this.#ordered.length });
688
+ }));
689
+ }
690
+ async #runStopForRecords(records, phaseLogger) {
691
+ const failures = [];
692
+ for (const record of records) {
693
+ if (!record.pip.stop)
694
+ continue;
695
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', record.pip.name);
696
+ record.hooks.add('stop');
697
+ const ctx = {
698
+ pipName: record.pip.name,
699
+ appName: this.#appName,
700
+ services: this.#aggregatedServices,
701
+ };
702
+ const startedAt = performance.now();
703
+ this.#emitHook('stop', record.pip.name, record.order, 'starting');
704
+ try {
705
+ await withPipHookSpan({
706
+ appName: this.#appName,
707
+ pipName: record.pip.name,
708
+ pipVersion: record.pip.version,
709
+ hook: 'stop',
710
+ order: record.order,
711
+ logger: pipLogger,
712
+ }, () => record.pip.stop(ctx));
713
+ record.started = false;
714
+ const durationMs = performance.now() - startedAt;
715
+ record.lifecycleDiagnostics.push({
716
+ pip: record.pip.name,
717
+ hook: 'stop',
718
+ state: 'stopped',
719
+ order: record.order,
720
+ durationMs,
721
+ issues: [],
722
+ });
723
+ this.#emitHook('stop', record.pip.name, record.order, 'completed', { durationMs });
724
+ pipLogger.debug('stop: done', { 'dot.pip.duration.ms': durationMs });
725
+ }
726
+ catch (error) {
727
+ const durationMs = performance.now() - startedAt;
728
+ const issue = makeIssue({
729
+ code: DotLifecycleErrorCode.StopFailed,
730
+ pip: record.pip.name,
731
+ message: `stop hook threw for pip "${record.pip.name}": ${stringifyError(error)}`,
732
+ remediation: `Fix the error in the stop() hook of "${record.pip.name}". Stop continues through individual failures and reports an aggregate error.`,
733
+ docsAnchor: 'stop-failed',
734
+ });
735
+ record.issues.push(issue);
736
+ record.lifecycleDiagnostics.push({
737
+ pip: record.pip.name,
738
+ hook: 'stop',
739
+ state: 'failed',
740
+ order: record.order,
741
+ durationMs,
742
+ issues: [issue],
743
+ });
744
+ this.#emitHook('stop', record.pip.name, record.order, 'failed', { durationMs, error });
745
+ failures.push({ pip: record.pip.name, phase: 'stop', error });
746
+ pipLogger.error('stop: failed (continuing)', {
747
+ 'dot.pip.error.message': stringifyError(error),
748
+ });
749
+ }
750
+ }
751
+ return failures;
752
+ }
753
+ /** Public dispose(). Idempotent + concurrent-safe. */
754
+ async dispose() {
755
+ if (this.#state === 'disposed')
756
+ return;
757
+ if (this.#disposeInflight)
758
+ return this.#disposeInflight;
759
+ this.#disposeInflight = this.#runDispose().finally(() => {
760
+ this.#disposeInflight = null;
761
+ });
762
+ return this.#disposeInflight;
763
+ }
764
+ async #runDispose() {
765
+ const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'dispose');
766
+ phaseLogger.debug('dispose: starting', { 'dot.app.pip.count': this.#ordered.length });
767
+ return this.#withPhaseEmitAsync('dispose', () => withPhaseSpan({
768
+ appName: this.#appName,
769
+ phase: 'dispose',
770
+ pipCount: this.#ordered.length,
771
+ logger: phaseLogger,
772
+ }, () => this.#runDisposeInner(phaseLogger)));
773
+ }
774
+ /**
775
+ * Inner dispose orchestration. Separated from `#runDispose` so the
776
+ * phase span wrapper stays thin and the multi-state cascade (started
777
+ * → stop+dispose, failed → no-op, default → dispose-only) stays
778
+ * readable.
779
+ */
780
+ async #runDisposeInner(phaseLogger) {
781
+ // From started, stop first.
782
+ if (this.#state === 'started') {
783
+ phaseLogger.debug('dispose: cascading from started — running stop first');
784
+ // Inline stop without throwing — we want to dispose anyway, but capture failures.
785
+ const startedRecords = this.#ordered.map(p => this.#records.get(p.name)).filter(r => r.started);
786
+ const stopFailures = await this.#runStopForRecords(reverseRecords(startedRecords), phaseLogger);
787
+ this.#state = 'stopped';
788
+ const bootedRecords = this.#ordered.map(p => this.#records.get(p.name)).filter(r => r.booted);
789
+ const disposeFailures = await this.#runDisposeForRecords(reverseRecords(bootedRecords), phaseLogger);
790
+ this.#state = 'disposed';
791
+ this.#manifest = this.#buildManifest();
792
+ const failures = [...stopFailures, ...disposeFailures];
793
+ if (failures.length > 0) {
794
+ phaseLogger.warn('dispose: complete with failures', { 'dot.app.failure.count': failures.length });
795
+ throw new DotLifecycleError({
796
+ code: DotLifecycleErrorCode.DisposeFailed,
797
+ phase: 'dispose',
798
+ message: `${failures.length} pip(s) failed during stop+dispose cascade`,
799
+ failures,
800
+ });
801
+ }
802
+ phaseLogger.debug('dispose: complete (cascaded from started)');
803
+ return;
804
+ }
805
+ // From booted/stopped/configured/defined: only dispose booted pips.
806
+ if (this.#state === 'failed') {
807
+ // Already cleaned up at the failure site — mark disposed.
808
+ this.#state = 'disposed';
809
+ this.#manifest = this.#buildManifest();
810
+ phaseLogger.debug('dispose: complete (no-op; cleanup happened at failure site)');
811
+ return;
812
+ }
813
+ const bootedRecords = this.#ordered.map(p => this.#records.get(p.name)).filter(r => r.booted);
814
+ const failures = await this.#runDisposeForRecords(reverseRecords(bootedRecords), phaseLogger);
815
+ this.#state = 'disposed';
816
+ this.#manifest = this.#buildManifest();
817
+ if (failures.length > 0) {
818
+ phaseLogger.warn('dispose: complete with failures', { 'dot.app.failure.count': failures.length });
819
+ throw new DotLifecycleError({
820
+ code: DotLifecycleErrorCode.DisposeFailed,
821
+ phase: 'dispose',
822
+ message: `${failures.length} pip(s) failed during dispose`,
823
+ failures,
824
+ });
825
+ }
826
+ phaseLogger.debug('dispose: complete', { 'dot.app.pip.count': this.#ordered.length });
827
+ }
828
+ async #runDisposeForRecords(records, phaseLogger) {
829
+ const failures = [];
830
+ for (const record of records) {
831
+ if (!record.pip.dispose) {
832
+ record.booted = false;
833
+ continue;
834
+ }
835
+ const pipLogger = phaseLogger.withAttribute('dot.pip.name', record.pip.name);
836
+ record.hooks.add('dispose');
837
+ const ctx = {
838
+ pipName: record.pip.name,
839
+ appName: this.#appName,
840
+ services: this.#aggregatedServices,
841
+ };
842
+ const startedAt = performance.now();
843
+ this.#emitHook('dispose', record.pip.name, record.order, 'starting');
844
+ try {
845
+ await withPipHookSpan({
846
+ appName: this.#appName,
847
+ pipName: record.pip.name,
848
+ pipVersion: record.pip.version,
849
+ hook: 'dispose',
850
+ order: record.order,
851
+ logger: pipLogger,
852
+ }, () => record.pip.dispose(ctx));
853
+ record.booted = false;
854
+ const durationMs = performance.now() - startedAt;
855
+ record.lifecycleDiagnostics.push({
856
+ pip: record.pip.name,
857
+ hook: 'dispose',
858
+ state: 'disposed',
859
+ order: record.order,
860
+ durationMs,
861
+ issues: [],
862
+ });
863
+ this.#emitHook('dispose', record.pip.name, record.order, 'completed', { durationMs });
864
+ pipLogger.debug('dispose: done', { 'dot.pip.duration.ms': durationMs });
865
+ }
866
+ catch (error) {
867
+ const durationMs = performance.now() - startedAt;
868
+ const issue = makeIssue({
869
+ code: DotLifecycleErrorCode.DisposeFailed,
870
+ pip: record.pip.name,
871
+ message: `dispose hook threw for pip "${record.pip.name}": ${stringifyError(error)}`,
872
+ remediation: `Fix the error in the dispose() hook of "${record.pip.name}". Dispose continues through individual failures and reports an aggregate error.`,
873
+ docsAnchor: 'dispose-failed',
874
+ });
875
+ record.issues.push(issue);
876
+ record.lifecycleDiagnostics.push({
877
+ pip: record.pip.name,
878
+ hook: 'dispose',
879
+ state: 'failed',
880
+ order: record.order,
881
+ durationMs,
882
+ issues: [issue],
883
+ });
884
+ this.#emitHook('dispose', record.pip.name, record.order, 'failed', { durationMs, error });
885
+ failures.push({ pip: record.pip.name, phase: 'dispose', error });
886
+ pipLogger.error('dispose: failed (continuing)', {
887
+ 'dot.pip.error.message': stringifyError(error),
888
+ });
889
+ }
890
+ }
891
+ return failures;
892
+ }
893
+ #reuseError(phase) {
894
+ const code = this.#state === 'disposed' ? DotLifecycleErrorCode.ReuseAfterDispose : DotLifecycleErrorCode.ReuseAfterFailure;
895
+ const reason = this.#state === 'disposed' ? 'disposed' : 'failed';
896
+ return new DotLifecycleError({
897
+ code,
898
+ phase,
899
+ message: `Cannot ${phase}() — app "${this.#appName}" is ${reason}. Create a fresh app instance.`,
900
+ });
901
+ }
902
+ #buildManifest() {
903
+ const pips = [];
904
+ const routes = [];
905
+ const services = [];
906
+ const lifecycle = [];
907
+ const dependencies = buildDependencyEdges(this.#ordered).map(e => ({ ...e }));
908
+ for (const pip of this.#ordered) {
909
+ const record = this.#records.get(pip.name);
910
+ pips.push({
911
+ name: pip.name,
912
+ version: pip.version,
913
+ dependencies: pip.dependencies ?? [],
914
+ provides: [...record.provides],
915
+ });
916
+ routes.push(...record.routes);
917
+ services.push(...record.services);
918
+ lifecycle.push({
919
+ pip: pip.name,
920
+ hooks: [...record.hooks],
921
+ });
922
+ // Append extra dependency edges declared during configure.
923
+ for (const dep of record.extraDependencies) {
924
+ dependencies.push({ from: pip.name, to: dep.to, kind: dep.kind });
925
+ }
926
+ // Optional manifest contribution from the pip itself.
927
+ const contribution = typeof pip.manifest === 'function'
928
+ ? pip.manifest({
929
+ pipName: pip.name,
930
+ services: this.#aggregatedServices,
931
+ })
932
+ : pip.manifest;
933
+ if (contribution) {
934
+ for (const svc of contribution.services ?? []) {
935
+ services.push({ name: svc.name, pip: pip.name, kind: svc.kind });
936
+ }
937
+ for (const route of contribution.routes ?? []) {
938
+ routes.push({ ...route, pip: pip.name });
939
+ }
940
+ for (const cap of contribution.provides ?? []) {
941
+ if (!pips.at(-1).provides.includes(cap)) {
942
+ pips.at(-1).provides.push(cap);
943
+ }
944
+ }
945
+ for (const dep of contribution.dependencies ?? []) {
946
+ dependencies.push({ from: pip.name, to: dep.to, kind: dep.kind ?? 'uses' });
947
+ }
948
+ }
949
+ }
950
+ return {
951
+ app: {
952
+ name: this.#appName,
953
+ version: this.#appVersion,
954
+ },
955
+ pips,
956
+ routes,
957
+ services,
958
+ lifecycle,
959
+ dependencies,
960
+ };
961
+ }
962
+ #buildDiagnostics() {
963
+ const pipDiagnostics = [];
964
+ const routeDiagnostics = [];
965
+ const serviceDiagnostics = [];
966
+ const lifecycleDiagnostics = [];
967
+ const issues = [];
968
+ for (const pip of this.#ordered) {
969
+ const record = this.#records.get(pip.name);
970
+ // Per-pip status: failed if any issue with severity error exists, ok otherwise.
971
+ const hasError = record.issues.some(i => i.severity === 'error');
972
+ const status = hasError ? 'failed' : 'ok';
973
+ pipDiagnostics.push({
974
+ pip: pip.name,
975
+ status,
976
+ issues: [...record.issues],
977
+ });
978
+ for (const route of record.routes) {
979
+ routeDiagnostics.push({
980
+ id: route.id,
981
+ pip: pip.name,
982
+ status: hasError ? 'failed' : 'ok',
983
+ issues: [],
984
+ });
985
+ }
986
+ for (const svc of record.services) {
987
+ serviceDiagnostics.push({
988
+ service: svc.name,
989
+ pip: pip.name,
990
+ status: hasError
991
+ ? 'failed'
992
+ : record.booted || this.#state === 'defined' || this.#state === 'configured'
993
+ ? 'ok'
994
+ : 'ok',
995
+ issues: [],
996
+ });
997
+ }
998
+ lifecycleDiagnostics.push(...record.lifecycleDiagnostics);
999
+ issues.push(...record.issues);
1000
+ }
1001
+ return {
1002
+ generatedAt: new Date().toISOString(),
1003
+ app: {
1004
+ name: this.#appName,
1005
+ state: this.#state,
1006
+ },
1007
+ pips: pipDiagnostics,
1008
+ routes: routeDiagnostics,
1009
+ services: serviceDiagnostics,
1010
+ lifecycle: lifecycleDiagnostics,
1011
+ issues,
1012
+ };
1013
+ }
1014
+ }
1015
+ function isThenable(value) {
1016
+ return (typeof value === 'object' &&
1017
+ value !== null &&
1018
+ 'then' in value &&
1019
+ typeof value.then === 'function');
1020
+ }
1021
+ function stringifyError(error) {
1022
+ if (error instanceof Error)
1023
+ return error.message;
1024
+ if (typeof error === 'string')
1025
+ return error;
1026
+ try {
1027
+ return JSON.stringify(error);
1028
+ }
1029
+ catch {
1030
+ return String(error);
1031
+ }
1032
+ }
1033
+ function reverseRecords(records) {
1034
+ // eslint-disable-next-line unicorn/no-array-reverse -- lib target is ES2022, toReversed is ES2023.
1035
+ return [...records].reverse();
1036
+ }
1037
+ /** Re-export `ServiceKind` and `RouteTransport` for the kernel's internal use. */
1038
+ export {} from '../manifest.js';
1039
+ export {} from '../pip-contract.js';
1040
+ //# sourceMappingURL=app-instance.js.map