@arki/dot 0.1.0 → 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/README.md +36 -31
- package/dist/define-app.d.ts +58 -16
- package/dist/define-app.d.ts.map +1 -1
- package/dist/define-app.js +23 -13
- package/dist/define-app.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/kernel/app-instance.d.ts +3 -3
- package/dist/kernel/app-instance.d.ts.map +1 -1
- package/dist/kernel/app-instance.js +265 -159
- package/dist/kernel/app-instance.js.map +1 -1
- package/dist/lifecycle.d.ts +11 -9
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +15 -9
- package/dist/lifecycle.js.map +1 -1
- package/dist/pip-contract.d.ts +254 -149
- package/dist/pip-contract.d.ts.map +1 -1
- package/dist/pip-contract.js +185 -41
- package/dist/pip-contract.js.map +1 -1
- package/dist/pip.d.ts +9 -12
- package/dist/pip.d.ts.map +1 -1
- package/dist/pip.js +8 -11
- package/dist/pip.js.map +1 -1
- package/dist/test-harness.d.ts +13 -10
- package/dist/test-harness.d.ts.map +1 -1
- package/dist/test-harness.js +12 -12
- package/dist/test-harness.js.map +1 -1
- 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
- package/templates/app-minimal/AGENTS.md.tmpl +2 -2
|
@@ -6,12 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Logger } from '@arki/log';
|
|
8
8
|
import { createDebugLogger } from '@arki/log/debug';
|
|
9
|
-
import { buildDependencyEdges, topologicalSort } from '../dependency-graph.js';
|
|
10
9
|
import { DotLifecycleError, DotLifecycleErrorCode } from '../lifecycle.js';
|
|
11
|
-
import {
|
|
10
|
+
import { isLazy, isLazyWitness, lazyOf } from '../pip-contract.js';
|
|
12
11
|
import { withPhaseSpan, withPipHookSpan } from './otel.js';
|
|
13
12
|
const debugKernel = createDebugLogger('arki:dot:kernel');
|
|
14
13
|
const DOCS_BASE = 'https://docs.arki.dev/dot/diagnostics';
|
|
14
|
+
/** Resolve a needs-shape witness to its wire key. */
|
|
15
|
+
function wireKeyOf(witness, alias) {
|
|
16
|
+
return 'key' in witness && typeof witness.key === 'string'
|
|
17
|
+
? witness.key
|
|
18
|
+
: alias;
|
|
19
|
+
}
|
|
15
20
|
/** Helper for stable issue construction. */
|
|
16
21
|
function makeIssue(args) {
|
|
17
22
|
return {
|
|
@@ -32,16 +37,20 @@ export class DotAppImpl {
|
|
|
32
37
|
#appName;
|
|
33
38
|
#appVersion;
|
|
34
39
|
#config;
|
|
35
|
-
/** Pips in
|
|
40
|
+
/** Pips in declaration order — v2 boot order IS declaration order. */
|
|
36
41
|
#ordered;
|
|
37
42
|
#records;
|
|
38
43
|
/** Macro-state of the app. */
|
|
39
44
|
#state = 'defined';
|
|
40
45
|
/** Manifest finalised after `configure`. */
|
|
41
46
|
#manifest;
|
|
42
|
-
/**
|
|
47
|
+
/** Wire-keyed services map — populated as pips boot. */
|
|
43
48
|
#serviceMap = new Map();
|
|
44
|
-
/**
|
|
49
|
+
/** Which pip provided each wire key — feeds dependency edges + collisions. */
|
|
50
|
+
#providerByWireKey = new Map();
|
|
51
|
+
/** Dependency edges observed during boot (consumer → provider). */
|
|
52
|
+
#wiringEdges = [];
|
|
53
|
+
/** Merged wire-keyed services record exposed as `app.services`. */
|
|
45
54
|
#aggregatedServices = {};
|
|
46
55
|
/** Configure has already happened (idempotent). */
|
|
47
56
|
#configured = false;
|
|
@@ -74,20 +83,27 @@ export class DotAppImpl {
|
|
|
74
83
|
this.#config = Object.freeze({ ...config.config });
|
|
75
84
|
this.#logger = new Logger('arki:dot:lifecycle', { 'dot.app.name': config.appName });
|
|
76
85
|
this.#observers = new Set(config.observers);
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
debugKernel('[%s]
|
|
80
|
-
this.#ordered =
|
|
86
|
+
// Declaration order is boot order in v2. Duplicate names are surfaced at
|
|
87
|
+
// construction (the `configure` phase pseudo-time).
|
|
88
|
+
debugKernel('[%s] registering %d pip(s) in declaration order', this.#appName, config.pips.length);
|
|
89
|
+
this.#ordered = [...config.pips];
|
|
81
90
|
this.#records = new Map();
|
|
82
91
|
for (const [order, pip] of this.#ordered.entries()) {
|
|
92
|
+
if (this.#records.has(pip.name)) {
|
|
93
|
+
throw new DotLifecycleError({
|
|
94
|
+
code: DotLifecycleErrorCode.DuplicatePip,
|
|
95
|
+
phase: 'configure',
|
|
96
|
+
pip: pip.name,
|
|
97
|
+
message: `Pip "${pip.name}" is registered twice`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
83
100
|
this.#records.set(pip.name, {
|
|
84
101
|
pip,
|
|
85
102
|
order,
|
|
86
103
|
routes: [],
|
|
87
104
|
services: [],
|
|
88
105
|
hooks: new Set(),
|
|
89
|
-
provides: new Set(
|
|
90
|
-
extraDependencies: [],
|
|
106
|
+
provides: new Set(),
|
|
91
107
|
publishedServices: {},
|
|
92
108
|
booted: false,
|
|
93
109
|
started: false,
|
|
@@ -278,7 +294,7 @@ export class DotAppImpl {
|
|
|
278
294
|
for (const pip of this.#ordered) {
|
|
279
295
|
const record = this.#records.get(pip.name);
|
|
280
296
|
const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
|
|
281
|
-
if (!pip.configure) {
|
|
297
|
+
if (!pip.hooks.configure) {
|
|
282
298
|
pipLogger.debug('configure: skipped (no hook)');
|
|
283
299
|
continue;
|
|
284
300
|
}
|
|
@@ -299,9 +315,6 @@ export class DotAppImpl {
|
|
|
299
315
|
for (const cap of caps)
|
|
300
316
|
record.provides.add(cap);
|
|
301
317
|
},
|
|
302
|
-
declareDependency: (to, kind) => {
|
|
303
|
-
record.extraDependencies.push({ to, kind: kind ?? 'uses' });
|
|
304
|
-
},
|
|
305
318
|
};
|
|
306
319
|
const started = performance.now();
|
|
307
320
|
this.#emitHook('configure', pip.name, record.order, 'starting');
|
|
@@ -314,7 +327,7 @@ export class DotAppImpl {
|
|
|
314
327
|
hook: 'configure',
|
|
315
328
|
order: record.order,
|
|
316
329
|
logger: pipLogger,
|
|
317
|
-
}, () => pip.configure(ctx));
|
|
330
|
+
}, () => pip.hooks.configure(ctx));
|
|
318
331
|
}
|
|
319
332
|
catch (error) {
|
|
320
333
|
const durationMs = performance.now() - started;
|
|
@@ -392,13 +405,13 @@ export class DotAppImpl {
|
|
|
392
405
|
// on the pip object, so the manifest reflects them.
|
|
393
406
|
for (const pip of this.#ordered) {
|
|
394
407
|
const record = this.#records.get(pip.name);
|
|
395
|
-
if (pip.boot)
|
|
408
|
+
if (pip.hooks.boot)
|
|
396
409
|
record.hooks.add('boot');
|
|
397
|
-
if (pip.start)
|
|
410
|
+
if (pip.hooks.start)
|
|
398
411
|
record.hooks.add('start');
|
|
399
|
-
if (pip.stop)
|
|
412
|
+
if (pip.hooks.stop)
|
|
400
413
|
record.hooks.add('stop');
|
|
401
|
-
if (pip.dispose)
|
|
414
|
+
if (pip.hooks.dispose)
|
|
402
415
|
record.hooks.add('dispose');
|
|
403
416
|
}
|
|
404
417
|
this.#configured = true;
|
|
@@ -434,6 +447,53 @@ export class DotAppImpl {
|
|
|
434
447
|
logger: phaseLogger,
|
|
435
448
|
}, () => this.#runBootInner(phaseLogger)));
|
|
436
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* Resolve a pip's needs into a hook context (alias-keyed), joined with
|
|
452
|
+
* the `$`-prefixed kernel keys and — for post-boot hooks — the pip's own
|
|
453
|
+
* published services (local keys).
|
|
454
|
+
*
|
|
455
|
+
* @throws {DotLifecycleError} `DOT_LIFECYCLE_E012` when a need has no
|
|
456
|
+
* provider among earlier-booted pips. Teardown hooks can never hit
|
|
457
|
+
* this: the service map only grows, and reverse-order teardown keeps
|
|
458
|
+
* providers alive until their consumers are done.
|
|
459
|
+
*/
|
|
460
|
+
#buildHookCtx(record, phase, includeOwnProvides) {
|
|
461
|
+
const ctx = {
|
|
462
|
+
$app: this.#appName,
|
|
463
|
+
$pip: record.pip.name,
|
|
464
|
+
$config: this.#config,
|
|
465
|
+
};
|
|
466
|
+
for (const [alias, witness] of Object.entries(record.pip.needs)) {
|
|
467
|
+
const wireKey = wireKeyOf(witness, alias);
|
|
468
|
+
if (!this.#serviceMap.has(wireKey)) {
|
|
469
|
+
throw new DotLifecycleError({
|
|
470
|
+
code: DotLifecycleErrorCode.UnsatisfiedNeed,
|
|
471
|
+
phase,
|
|
472
|
+
pip: record.pip.name,
|
|
473
|
+
message: `Pip "${record.pip.name}" needs service "${wireKey}" but no earlier pip provides it. ` +
|
|
474
|
+
`Register a provider with .use() before this pip. Services flow strictly forward — ` +
|
|
475
|
+
`if a later pip provides "${wireKey}", move that provider earlier; if two pips need ` +
|
|
476
|
+
`each other's services, that is a cycle: merge them or extract the shared piece into ` +
|
|
477
|
+
`a third pip both consume.`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
const value = this.#serviceMap.get(wireKey);
|
|
481
|
+
// A lazy-lifting witness (`service.lazy<T>()`) always receives a
|
|
482
|
+
// handle: lazy provides pass through, eager provides are lifted into
|
|
483
|
+
// a pre-initialized wrapper. The wrapper is created per-injection and
|
|
484
|
+
// never published, so the kernel's auto-dispose does not touch it —
|
|
485
|
+
// the underlying value's lifecycle stays with its provider.
|
|
486
|
+
ctx[alias] = isLazyWitness(witness) && !isLazy(value) ? lazyOf(value) : value;
|
|
487
|
+
const provider = this.#providerByWireKey.get(wireKey);
|
|
488
|
+
if (provider !== undefined && !this.#wiringEdges.some(e => e.from === record.pip.name && e.to === provider)) {
|
|
489
|
+
this.#wiringEdges.push({ from: record.pip.name, to: provider, kind: 'requires' });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (includeOwnProvides) {
|
|
493
|
+
Object.assign(ctx, record.publishedServices);
|
|
494
|
+
}
|
|
495
|
+
return ctx;
|
|
496
|
+
}
|
|
437
497
|
/**
|
|
438
498
|
* Inner boot loop. Separated from `#runBoot` so the phase span wrapper
|
|
439
499
|
* stays thin and the loop body — which orchestrates rollback on
|
|
@@ -441,78 +501,126 @@ export class DotAppImpl {
|
|
|
441
501
|
*/
|
|
442
502
|
async #runBootInner(phaseLogger) {
|
|
443
503
|
const bootedRecords = [];
|
|
504
|
+
/** Shared failure path: mark diagnostics, roll back, throw. */
|
|
505
|
+
const fail = async (args) => {
|
|
506
|
+
const issue = makeIssue({
|
|
507
|
+
code: args.code,
|
|
508
|
+
pip: args.record.pip.name,
|
|
509
|
+
message: args.message,
|
|
510
|
+
remediation: args.remediation,
|
|
511
|
+
docsAnchor: args.docsAnchor,
|
|
512
|
+
});
|
|
513
|
+
args.record.issues.push(issue);
|
|
514
|
+
args.record.lifecycleDiagnostics.push({
|
|
515
|
+
pip: args.record.pip.name,
|
|
516
|
+
hook: 'boot',
|
|
517
|
+
state: 'failed',
|
|
518
|
+
order: args.record.order,
|
|
519
|
+
durationMs: args.durationMs,
|
|
520
|
+
issues: [issue],
|
|
521
|
+
});
|
|
522
|
+
this.#emitHook('boot', args.record.pip.name, args.record.order, 'failed', {
|
|
523
|
+
durationMs: args.durationMs,
|
|
524
|
+
error: args.cause ?? new Error(args.message),
|
|
525
|
+
});
|
|
526
|
+
phaseLogger.error('boot: FAILED — rolling back already-booted pips', {
|
|
527
|
+
'dot.pip.name': args.record.pip.name,
|
|
528
|
+
'dot.app.rollback.count': args.rollback.length,
|
|
529
|
+
});
|
|
530
|
+
const disposeFailures = await this.#runDisposeForRecords(reverseRecords(args.rollback), phaseLogger);
|
|
531
|
+
this.#state = 'failed';
|
|
532
|
+
this.#manifest = this.#buildManifest();
|
|
533
|
+
throw new DotLifecycleError({
|
|
534
|
+
code: args.code,
|
|
535
|
+
phase: 'boot',
|
|
536
|
+
pip: args.record.pip.name,
|
|
537
|
+
message: args.message,
|
|
538
|
+
cause: args.cause,
|
|
539
|
+
failures: disposeFailures.length > 0 ? disposeFailures : undefined,
|
|
540
|
+
});
|
|
541
|
+
};
|
|
444
542
|
for (const pip of this.#ordered) {
|
|
445
543
|
const record = this.#records.get(pip.name);
|
|
446
544
|
const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
|
|
447
|
-
|
|
545
|
+
const started = performance.now();
|
|
546
|
+
// Resolve needs BEFORE invoking the hook — a pip with needs but no
|
|
547
|
+
// boot hook still fails fast when its wiring is unsatisfied.
|
|
548
|
+
let ctx;
|
|
549
|
+
try {
|
|
550
|
+
ctx = this.#buildHookCtx(record, 'boot', false);
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
const message = error instanceof DotLifecycleError ? error.message : stringifyError(error);
|
|
554
|
+
return fail({
|
|
555
|
+
record,
|
|
556
|
+
code: DotLifecycleErrorCode.UnsatisfiedNeed,
|
|
557
|
+
message,
|
|
558
|
+
remediation: `Add a provider for the missing service with .use() before "${pip.name}", or rename an ` +
|
|
559
|
+
`existing provider's keys to match. If a later pip provides it, reorder — services flow ` +
|
|
560
|
+
`strictly forward. Mutual needs between two pips are a cycle: merge them or extract the ` +
|
|
561
|
+
`shared piece into a third pip both consume.`,
|
|
562
|
+
docsAnchor: 'unsatisfied-need',
|
|
563
|
+
durationMs: performance.now() - started,
|
|
564
|
+
rollback: bootedRecords,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
if (!pip.hooks.boot) {
|
|
448
568
|
record.booted = true;
|
|
449
569
|
bootedRecords.push(record);
|
|
450
570
|
pipLogger.debug('boot: skipped (no hook)');
|
|
451
571
|
continue;
|
|
452
572
|
}
|
|
453
573
|
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
574
|
this.#emitHook('boot', pip.name, record.order, 'starting');
|
|
462
575
|
let result;
|
|
463
576
|
try {
|
|
464
|
-
|
|
577
|
+
result = await withPipHookSpan({
|
|
465
578
|
appName: this.#appName,
|
|
466
579
|
pipName: pip.name,
|
|
467
580
|
pipVersion: pip.version,
|
|
468
581
|
hook: 'boot',
|
|
469
582
|
order: record.order,
|
|
470
583
|
logger: pipLogger,
|
|
471
|
-
},
|
|
472
|
-
|
|
584
|
+
},
|
|
585
|
+
// Erasure boundary: hooks are stored as `(ctx: never) => ...`;
|
|
586
|
+
// the kernel is the one caller allowed to cross it.
|
|
587
|
+
() => pip.hooks.boot(ctx));
|
|
473
588
|
}
|
|
474
589
|
catch (error) {
|
|
475
|
-
|
|
476
|
-
|
|
590
|
+
return fail({
|
|
591
|
+
record,
|
|
477
592
|
code: DotLifecycleErrorCode.BootFailed,
|
|
478
|
-
pip: pip.name,
|
|
479
593
|
message: `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
|
|
480
594
|
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
595
|
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,
|
|
596
|
+
durationMs: performance.now() - started,
|
|
505
597
|
cause: error,
|
|
506
|
-
|
|
598
|
+
rollback: bootedRecords,
|
|
507
599
|
});
|
|
508
600
|
}
|
|
509
|
-
const publishedServices = result
|
|
601
|
+
const publishedServices = result ?? {};
|
|
510
602
|
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
603
|
record.booted = true;
|
|
604
|
+
for (const [localKey, value] of Object.entries(publishedServices)) {
|
|
605
|
+
const wireKey = pip.renames[localKey] ?? localKey;
|
|
606
|
+
if (this.#serviceMap.has(wireKey)) {
|
|
607
|
+
const owner = this.#providerByWireKey.get(wireKey) ?? 'unknown';
|
|
608
|
+
// The current pip HAS booted — include it in the rollback.
|
|
609
|
+
return fail({
|
|
610
|
+
record,
|
|
611
|
+
code: DotLifecycleErrorCode.ServiceCollision,
|
|
612
|
+
message: `Pip "${pip.name}" publishes service "${wireKey}" which pip "${owner}" already provides.`,
|
|
613
|
+
remediation: `Mount one of the two with rename(pip, { '${wireKey}': '<newKey>' }) to keep both instances, or remove the duplicate provider.`,
|
|
614
|
+
docsAnchor: 'service-collision',
|
|
615
|
+
durationMs: performance.now() - started,
|
|
616
|
+
rollback: [...bootedRecords, record],
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
this.#serviceMap.set(wireKey, value);
|
|
620
|
+
this.#providerByWireKey.set(wireKey, pip.name);
|
|
621
|
+
this.#aggregatedServices[wireKey] = value;
|
|
622
|
+
record.provides.add(wireKey);
|
|
623
|
+
}
|
|
516
624
|
bootedRecords.push(record);
|
|
517
625
|
const durationMs = performance.now() - started;
|
|
518
626
|
record.lifecycleDiagnostics.push({
|
|
@@ -562,16 +670,12 @@ export class DotAppImpl {
|
|
|
562
670
|
for (const pip of this.#ordered) {
|
|
563
671
|
const record = this.#records.get(pip.name);
|
|
564
672
|
const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
|
|
565
|
-
if (!pip.start) {
|
|
673
|
+
if (!pip.hooks.start) {
|
|
566
674
|
pipLogger.debug('start: skipped (no hook)');
|
|
567
675
|
continue;
|
|
568
676
|
}
|
|
569
677
|
record.hooks.add('start');
|
|
570
|
-
const ctx =
|
|
571
|
-
pipName: pip.name,
|
|
572
|
-
appName: this.#appName,
|
|
573
|
-
services: this.#aggregatedServices,
|
|
574
|
-
};
|
|
678
|
+
const ctx = this.#buildHookCtx(record, 'start', true);
|
|
575
679
|
const startedAt = performance.now();
|
|
576
680
|
this.#emitHook('start', pip.name, record.order, 'starting');
|
|
577
681
|
try {
|
|
@@ -582,7 +686,7 @@ export class DotAppImpl {
|
|
|
582
686
|
hook: 'start',
|
|
583
687
|
order: record.order,
|
|
584
688
|
logger: pipLogger,
|
|
585
|
-
}, () => pip.start(ctx));
|
|
689
|
+
}, () => pip.hooks.start(ctx));
|
|
586
690
|
}
|
|
587
691
|
catch (error) {
|
|
588
692
|
const durationMs = performance.now() - startedAt;
|
|
@@ -690,15 +794,11 @@ export class DotAppImpl {
|
|
|
690
794
|
async #runStopForRecords(records, phaseLogger) {
|
|
691
795
|
const failures = [];
|
|
692
796
|
for (const record of records) {
|
|
693
|
-
if (!record.pip.stop)
|
|
797
|
+
if (!record.pip.hooks.stop)
|
|
694
798
|
continue;
|
|
695
799
|
const pipLogger = phaseLogger.withAttribute('dot.pip.name', record.pip.name);
|
|
696
800
|
record.hooks.add('stop');
|
|
697
|
-
const ctx =
|
|
698
|
-
pipName: record.pip.name,
|
|
699
|
-
appName: this.#appName,
|
|
700
|
-
services: this.#aggregatedServices,
|
|
701
|
-
};
|
|
801
|
+
const ctx = this.#buildHookCtx(record, 'stop', true);
|
|
702
802
|
const startedAt = performance.now();
|
|
703
803
|
this.#emitHook('stop', record.pip.name, record.order, 'starting');
|
|
704
804
|
try {
|
|
@@ -709,7 +809,7 @@ export class DotAppImpl {
|
|
|
709
809
|
hook: 'stop',
|
|
710
810
|
order: record.order,
|
|
711
811
|
logger: pipLogger,
|
|
712
|
-
}, () => record.pip.stop(ctx));
|
|
812
|
+
}, () => record.pip.hooks.stop(ctx));
|
|
713
813
|
record.started = false;
|
|
714
814
|
const durationMs = performance.now() - startedAt;
|
|
715
815
|
record.lifecycleDiagnostics.push({
|
|
@@ -828,68 +928,97 @@ export class DotAppImpl {
|
|
|
828
928
|
async #runDisposeForRecords(records, phaseLogger) {
|
|
829
929
|
const failures = [];
|
|
830
930
|
for (const record of records) {
|
|
831
|
-
|
|
931
|
+
const lazyPublishes = Object.entries(record.publishedServices).filter((entry) => isLazy(entry[1]));
|
|
932
|
+
const hasHook = record.pip.hooks.dispose !== undefined;
|
|
933
|
+
if (!hasHook && lazyPublishes.length === 0) {
|
|
832
934
|
record.booted = false;
|
|
833
935
|
continue;
|
|
834
936
|
}
|
|
835
937
|
const pipLogger = phaseLogger.withAttribute('dot.pip.name', record.pip.name);
|
|
836
|
-
|
|
837
|
-
|
|
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 });
|
|
938
|
+
if (hasHook) {
|
|
939
|
+
await this.#runDisposeHook(record, pipLogger, failures);
|
|
865
940
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
941
|
+
// Auto-dispose lazy service handles AFTER the pip's own dispose hook
|
|
942
|
+
// (the hook may still use them). Never-initialized handles no-op.
|
|
943
|
+
for (const [serviceKey, handle] of lazyPublishes) {
|
|
944
|
+
try {
|
|
945
|
+
await handle.dispose();
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
const issue = makeIssue({
|
|
949
|
+
code: DotLifecycleErrorCode.DisposeFailed,
|
|
950
|
+
pip: record.pip.name,
|
|
951
|
+
message: `lazy service "${serviceKey}" cleanup threw for pip "${record.pip.name}": ${stringifyError(error)}`,
|
|
952
|
+
remediation: `Fix the error in the dispose callback passed to lazy(...) for service "${serviceKey}". Dispose continues through individual failures and reports an aggregate error.`,
|
|
953
|
+
docsAnchor: 'dispose-failed',
|
|
954
|
+
});
|
|
955
|
+
record.issues.push(issue);
|
|
956
|
+
failures.push({ pip: record.pip.name, phase: 'dispose', error });
|
|
957
|
+
pipLogger.error('dispose: lazy service cleanup failed (continuing)', {
|
|
958
|
+
'dot.pip.service.key': serviceKey,
|
|
959
|
+
'dot.pip.error.message': stringifyError(error),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (!hasHook) {
|
|
964
|
+
record.booted = false;
|
|
889
965
|
}
|
|
890
966
|
}
|
|
891
967
|
return failures;
|
|
892
968
|
}
|
|
969
|
+
/** Run a single pip's dispose hook with spans/events/diagnostics. */
|
|
970
|
+
async #runDisposeHook(record, pipLogger, failures) {
|
|
971
|
+
record.hooks.add('dispose');
|
|
972
|
+
const ctx = this.#buildHookCtx(record, 'dispose', true);
|
|
973
|
+
const startedAt = performance.now();
|
|
974
|
+
this.#emitHook('dispose', record.pip.name, record.order, 'starting');
|
|
975
|
+
try {
|
|
976
|
+
await withPipHookSpan({
|
|
977
|
+
appName: this.#appName,
|
|
978
|
+
pipName: record.pip.name,
|
|
979
|
+
pipVersion: record.pip.version,
|
|
980
|
+
hook: 'dispose',
|
|
981
|
+
order: record.order,
|
|
982
|
+
logger: pipLogger,
|
|
983
|
+
}, () => record.pip.hooks.dispose(ctx));
|
|
984
|
+
record.booted = false;
|
|
985
|
+
const durationMs = performance.now() - startedAt;
|
|
986
|
+
record.lifecycleDiagnostics.push({
|
|
987
|
+
pip: record.pip.name,
|
|
988
|
+
hook: 'dispose',
|
|
989
|
+
state: 'disposed',
|
|
990
|
+
order: record.order,
|
|
991
|
+
durationMs,
|
|
992
|
+
issues: [],
|
|
993
|
+
});
|
|
994
|
+
this.#emitHook('dispose', record.pip.name, record.order, 'completed', { durationMs });
|
|
995
|
+
pipLogger.debug('dispose: done', { 'dot.pip.duration.ms': durationMs });
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
const durationMs = performance.now() - startedAt;
|
|
999
|
+
const issue = makeIssue({
|
|
1000
|
+
code: DotLifecycleErrorCode.DisposeFailed,
|
|
1001
|
+
pip: record.pip.name,
|
|
1002
|
+
message: `dispose hook threw for pip "${record.pip.name}": ${stringifyError(error)}`,
|
|
1003
|
+
remediation: `Fix the error in the dispose() hook of "${record.pip.name}". Dispose continues through individual failures and reports an aggregate error.`,
|
|
1004
|
+
docsAnchor: 'dispose-failed',
|
|
1005
|
+
});
|
|
1006
|
+
record.issues.push(issue);
|
|
1007
|
+
record.lifecycleDiagnostics.push({
|
|
1008
|
+
pip: record.pip.name,
|
|
1009
|
+
hook: 'dispose',
|
|
1010
|
+
state: 'failed',
|
|
1011
|
+
order: record.order,
|
|
1012
|
+
durationMs,
|
|
1013
|
+
issues: [issue],
|
|
1014
|
+
});
|
|
1015
|
+
this.#emitHook('dispose', record.pip.name, record.order, 'failed', { durationMs, error });
|
|
1016
|
+
failures.push({ pip: record.pip.name, phase: 'dispose', error });
|
|
1017
|
+
pipLogger.error('dispose: failed (continuing)', {
|
|
1018
|
+
'dot.pip.error.message': stringifyError(error),
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
893
1022
|
#reuseError(phase) {
|
|
894
1023
|
const code = this.#state === 'disposed' ? DotLifecycleErrorCode.ReuseAfterDispose : DotLifecycleErrorCode.ReuseAfterFailure;
|
|
895
1024
|
const reason = this.#state === 'disposed' ? 'disposed' : 'failed';
|
|
@@ -904,13 +1033,17 @@ export class DotAppImpl {
|
|
|
904
1033
|
const routes = [];
|
|
905
1034
|
const services = [];
|
|
906
1035
|
const lifecycle = [];
|
|
907
|
-
|
|
1036
|
+
// Edges are observed at boot time: consumer pip → the pip whose
|
|
1037
|
+
// published wire key satisfied its need. Before boot, the per-pip
|
|
1038
|
+
// `dependencies` array (wire-key strings) is the declarative view.
|
|
1039
|
+
const dependencies = this.#wiringEdges.map(e => ({ ...e }));
|
|
908
1040
|
for (const pip of this.#ordered) {
|
|
909
1041
|
const record = this.#records.get(pip.name);
|
|
1042
|
+
const needWireKeys = Object.entries(pip.needs).map(([alias, witness]) => wireKeyOf(witness, alias));
|
|
910
1043
|
pips.push({
|
|
911
1044
|
name: pip.name,
|
|
912
1045
|
version: pip.version,
|
|
913
|
-
dependencies:
|
|
1046
|
+
dependencies: needWireKeys,
|
|
914
1047
|
provides: [...record.provides],
|
|
915
1048
|
});
|
|
916
1049
|
routes.push(...record.routes);
|
|
@@ -919,33 +1052,6 @@ export class DotAppImpl {
|
|
|
919
1052
|
pip: pip.name,
|
|
920
1053
|
hooks: [...record.hooks],
|
|
921
1054
|
});
|
|
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
1055
|
}
|
|
950
1056
|
return {
|
|
951
1057
|
app: {
|