@arki/dot 0.1.4 → 0.3.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 (59) hide show
  1. package/README.md +57 -0
  2. package/dist/cli/index.d.ts +8 -0
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +46 -2
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/render-graph.d.ts +36 -0
  7. package/dist/cli/render-graph.d.ts.map +1 -0
  8. package/dist/cli/render-graph.js +70 -0
  9. package/dist/cli/render-graph.js.map +1 -0
  10. package/dist/cli/render-openapi.d.ts +38 -0
  11. package/dist/cli/render-openapi.d.ts.map +1 -0
  12. package/dist/cli/render-openapi.js +131 -0
  13. package/dist/cli/render-openapi.js.map +1 -0
  14. package/dist/define-app.d.ts +10 -0
  15. package/dist/define-app.d.ts.map +1 -1
  16. package/dist/define-app.js +2 -0
  17. package/dist/define-app.js.map +1 -1
  18. package/dist/index.d.ts +5 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/kernel/app-instance.d.ts +2 -0
  23. package/dist/kernel/app-instance.d.ts.map +1 -1
  24. package/dist/kernel/app-instance.js +67 -17
  25. package/dist/kernel/app-instance.js.map +1 -1
  26. package/dist/lifecycle.d.ts +2 -0
  27. package/dist/lifecycle.d.ts.map +1 -1
  28. package/dist/lifecycle.js +2 -0
  29. package/dist/lifecycle.js.map +1 -1
  30. package/dist/manifest.d.ts +19 -0
  31. package/dist/manifest.d.ts.map +1 -1
  32. package/dist/pip-contract.d.ts +24 -2
  33. package/dist/pip-contract.d.ts.map +1 -1
  34. package/dist/pip-contract.js +0 -25
  35. package/dist/pip-contract.js.map +1 -1
  36. package/dist/pip.d.ts +1 -1
  37. package/dist/pip.d.ts.map +1 -1
  38. package/dist/pip.js.map +1 -1
  39. package/dist/signals.d.ts +65 -0
  40. package/dist/signals.d.ts.map +1 -0
  41. package/dist/signals.js +97 -0
  42. package/dist/signals.js.map +1 -0
  43. package/dist/test-harness.d.ts +75 -1
  44. package/dist/test-harness.d.ts.map +1 -1
  45. package/dist/test-harness.js +71 -0
  46. package/dist/test-harness.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/cli/index.ts +68 -3
  49. package/src/cli/render-graph.ts +88 -0
  50. package/src/cli/render-openapi.ts +147 -0
  51. package/src/define-app.ts +13 -0
  52. package/src/index.ts +6 -2
  53. package/src/kernel/app-instance.ts +113 -51
  54. package/src/lifecycle.ts +2 -0
  55. package/src/manifest.ts +19 -0
  56. package/src/pip-contract.ts +31 -3
  57. package/src/pip.ts +1 -0
  58. package/src/signals.ts +134 -0
  59. package/src/test-harness.ts +140 -1
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Renderer for `dot explain --graph` / `dot doctor --graph`.
3
+ *
4
+ * Emits the app's pip graph as [Mermaid](https://mermaid.js.org) `flowchart`
5
+ * source — paste-able into GitHub markdown, docs, and mermaid.live.
6
+ *
7
+ * The nodes are the pips in declaration order (which IS boot order in v2 —
8
+ * the numbering makes that visible); the edges are the manifest's
9
+ * **observed** dependency edges, recorded by the kernel when a pip's need
10
+ * was satisfied during boot. `explain` never boots, so its graph shows
11
+ * declaration order with whatever edges configure-time metadata declared;
12
+ * `doctor` boots, so its graph shows the real wiring.
13
+ */
14
+
15
+ import type { DotAppManifest } from '../manifest.js';
16
+ import type { DotCliEnvelope, RenderOptions } from './render-explain.js';
17
+
18
+ /** Envelope payload for graph output. */
19
+ export type GraphData = {
20
+ format: 'mermaid';
21
+ /** Mermaid `flowchart` source. */
22
+ source: string;
23
+ };
24
+
25
+ /** Escape a pip name for use inside a quoted Mermaid node label. */
26
+ function escapeLabel(name: string): string {
27
+ return name.replaceAll('"', '#quot;');
28
+ }
29
+
30
+ /**
31
+ * Build Mermaid `flowchart` source from a manifest. Deterministic: node
32
+ * ids follow declaration order, edges follow manifest order.
33
+ */
34
+ export function buildMermaidGraph(manifest: DotAppManifest): string {
35
+ const lines: string[] = ['flowchart TD'];
36
+ const idByPip = new Map<string, string>();
37
+
38
+ for (const [index, pip] of manifest.pips.entries()) {
39
+ const id = `p${index.toString()}`;
40
+ idByPip.set(pip.name, id);
41
+ const order = (index + 1).toString();
42
+ const version = pip.version === undefined ? '' : `@${pip.version}`;
43
+ lines.push(` ${id}["${order} · ${escapeLabel(pip.name)}${escapeLabel(version)}"]`);
44
+ }
45
+
46
+ for (const edge of manifest.dependencies) {
47
+ const from = idByPip.get(edge.from);
48
+ const to = idByPip.get(edge.to);
49
+ // Edges referencing unknown pips would be a kernel bug — skip rather
50
+ // than emit invalid Mermaid.
51
+ if (from === undefined || to === undefined) continue;
52
+ lines.push(` ${from} -->|${edge.kind}| ${to}`);
53
+ }
54
+
55
+ return `${lines.join('\n')}\n`;
56
+ }
57
+
58
+ /**
59
+ * Render the graph output. Plain mode prints raw Mermaid source (pipe it
60
+ * straight into a markdown code fence); `--json` wraps it in the standard
61
+ * CLI envelope under `data.source`.
62
+ */
63
+ export function renderGraph(
64
+ source: { manifest: DotAppManifest; command: 'explain' | 'doctor' },
65
+ opts: RenderOptions,
66
+ ): DotCliEnvelope<GraphData> {
67
+ const mermaid = buildMermaidGraph(source.manifest);
68
+ const nowFactory = opts.now ?? (() => new Date());
69
+ const envelope: DotCliEnvelope<GraphData> = {
70
+ status: 'success',
71
+ command: source.command,
72
+ generatedAt: nowFactory().toISOString(),
73
+ data: { format: 'mermaid', source: mermaid },
74
+ errors: [],
75
+ };
76
+
77
+ const out =
78
+ opts.out ??
79
+ ((line: string) => {
80
+ process.stdout.write(line);
81
+ });
82
+ if (opts.json) {
83
+ out(`${JSON.stringify(envelope, null, 2)}\n`);
84
+ } else {
85
+ out(mermaid);
86
+ }
87
+ return envelope;
88
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Renderer for `dot explain --openapi`.
3
+ *
4
+ * Emits an OpenAPI 3.1 document straight from `manifest.routes` — no boot
5
+ * required. Adapters (e.g. `@arki/http`'s `registerRoutes`) convert their
6
+ * validation schemas to JSON Schema at `configure` time and register them
7
+ * via `ctx.registerRoute`, so the manifest already carries everything the
8
+ * document needs: methods, paths, per-location input schemas, output
9
+ * schemas, and the `streaming` flag for `text/event-stream` responses.
10
+ *
11
+ * Deterministic: paths and operations follow manifest registration order
12
+ * (which follows pip declaration order). Only `transport: 'http'` routes
13
+ * are rendered — rpc mounts have their own schema story.
14
+ */
15
+
16
+ import type { DotAppManifest, RouteManifest } from '../manifest.js';
17
+ import type { DotCliEnvelope, RenderOptions } from './render-explain.js';
18
+
19
+ /** Envelope payload for OpenAPI output. */
20
+ export type OpenApiData = {
21
+ format: 'openapi';
22
+ /** OpenAPI 3.1 document. */
23
+ document: Record<string, unknown>;
24
+ };
25
+
26
+ function openApiPath(path: string): string {
27
+ return path.replaceAll(/:(\w+)/g, '{$1}');
28
+ }
29
+
30
+ function pathParamNames(path: string): readonly string[] {
31
+ return [...path.matchAll(/:(\w+)/g)]
32
+ .map(match => match[1])
33
+ .filter((name): name is string => name !== undefined);
34
+ }
35
+
36
+ function buildOperation(route: RouteManifest): Record<string, unknown> {
37
+ const parameters: Record<string, unknown>[] = pathParamNames(route.path ?? '').map(name => ({
38
+ name,
39
+ in: 'path',
40
+ required: true,
41
+ schema: { type: 'string' },
42
+ }));
43
+
44
+ const querySchema = route.input?.query;
45
+ if (querySchema !== undefined) {
46
+ const properties = (querySchema['properties'] ?? {}) as Readonly<Record<string, unknown>>;
47
+ const required = new Set(querySchema['required'] as readonly string[] | undefined);
48
+ for (const [name, propertySchema] of Object.entries(properties)) {
49
+ parameters.push({ name, in: 'query', required: required.has(name), schema: propertySchema });
50
+ }
51
+ }
52
+
53
+ const operation: Record<string, unknown> = {
54
+ operationId: route.id,
55
+ tags: [route.pip],
56
+ };
57
+ if (route.description !== undefined) operation['summary'] = route.description;
58
+ if (parameters.length > 0) operation['parameters'] = parameters;
59
+
60
+ const bodySchema = route.input?.body;
61
+ if (bodySchema !== undefined) {
62
+ operation['requestBody'] = {
63
+ required: true,
64
+ content: { 'application/json': { schema: bodySchema } },
65
+ };
66
+ }
67
+
68
+ if (route.streaming === true) {
69
+ operation['responses'] = {
70
+ '200': {
71
+ description: 'event stream',
72
+ content: {
73
+ 'text/event-stream': route.output === undefined ? {} : { schema: route.output },
74
+ },
75
+ },
76
+ };
77
+ } else if (route.output === undefined) {
78
+ operation['responses'] = { '200': { description: 'success' } };
79
+ } else {
80
+ operation['responses'] = {
81
+ '200': {
82
+ description: 'success',
83
+ content: { 'application/json': { schema: route.output } },
84
+ },
85
+ };
86
+ }
87
+
88
+ return operation;
89
+ }
90
+
91
+ /**
92
+ * Build an OpenAPI 3.1 document from a manifest. First registration wins
93
+ * on a method+path collision — collisions are an app bug the serving
94
+ * adapter rejects at boot; the document renderer stays total.
95
+ */
96
+ export function buildOpenApiDocument(manifest: DotAppManifest): Record<string, unknown> {
97
+ const paths: Record<string, Record<string, unknown>> = {};
98
+ for (const route of manifest.routes) {
99
+ if (route.transport !== 'http') continue;
100
+ if (route.path === undefined) continue;
101
+ const path = openApiPath(route.path);
102
+ const method = (route.method ?? 'GET').toLowerCase();
103
+ const entry = (paths[path] ??= {});
104
+ if (entry[method] !== undefined) continue;
105
+ entry[method] = buildOperation(route);
106
+ }
107
+ return {
108
+ openapi: '3.1.0',
109
+ info: {
110
+ title: manifest.app.name,
111
+ version: manifest.app.version ?? '0.0.0',
112
+ },
113
+ paths,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Render the OpenAPI output. Plain mode prints the document as JSON
119
+ * (pipe it into a file or a swagger viewer); `--json` wraps it in the
120
+ * standard CLI envelope under `data.document`.
121
+ */
122
+ export function renderOpenApi(
123
+ source: { manifest: DotAppManifest; command: 'explain' },
124
+ opts: RenderOptions,
125
+ ): DotCliEnvelope<OpenApiData> {
126
+ const document = buildOpenApiDocument(source.manifest);
127
+ const nowFactory = opts.now ?? (() => new Date());
128
+ const envelope: DotCliEnvelope<OpenApiData> = {
129
+ status: 'success',
130
+ command: source.command,
131
+ generatedAt: nowFactory().toISOString(),
132
+ data: { format: 'openapi', document },
133
+ errors: [],
134
+ };
135
+
136
+ const out =
137
+ opts.out ??
138
+ ((line: string) => {
139
+ process.stdout.write(line);
140
+ });
141
+ if (opts.json) {
142
+ out(`${JSON.stringify(envelope, null, 2)}\n`);
143
+ } else {
144
+ out(`${JSON.stringify(document, null, 2)}\n`);
145
+ }
146
+ return envelope;
147
+ }
package/src/define-app.ts CHANGED
@@ -176,6 +176,7 @@ type BuilderState = {
176
176
  pips: AnyPip[];
177
177
  config?: Readonly<Record<string, unknown>>;
178
178
  observers?: readonly DotLifecycleObserver[];
179
+ hookTimeoutMs?: number;
179
180
  };
180
181
 
181
182
  /**
@@ -204,6 +205,16 @@ export function defineApp(
204
205
  * `configured.subscribe(...)` or `app.subscribe(...)`.
205
206
  */
206
207
  observers?: readonly DotLifecycleObserver[];
208
+ /**
209
+ * Watchdog budget (ms) for each async hook invocation (`boot`, `start`,
210
+ * `stop`, `dispose` — `configure` is sync). A hook exceeding the budget
211
+ * fails with `DOT_LIFECYCLE_E015` naming the pip and hook, and the
212
+ * kernel applies its normal failure rules (boot rollback, teardown
213
+ * aggregation). The hook's promise itself cannot be cancelled — the
214
+ * watchdog makes the hang *visible*, it does not kill it. Default:
215
+ * no watchdog.
216
+ */
217
+ hookTimeoutMs?: number;
207
218
  } = {},
208
219
  ): DotAppBuilder<EmptyShape> {
209
220
  const state: BuilderState = {
@@ -212,6 +223,7 @@ export function defineApp(
212
223
  pips: [],
213
224
  config: options.config,
214
225
  observers: options.observers,
226
+ hookTimeoutMs: options.hookTimeoutMs,
215
227
  };
216
228
  return makeBuilder<EmptyShape>(state);
217
229
  }
@@ -223,6 +235,7 @@ function buildImpl(state: BuilderState): DotAppImpl {
223
235
  pips: state.pips,
224
236
  config: state.config,
225
237
  observers: state.observers,
238
+ hookTimeoutMs: state.hookTimeoutMs,
226
239
  });
227
240
  }
228
241
 
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export type {
20
20
  CtxOf,
21
21
  DotConfigureContext,
22
22
  EmptyShape,
23
+ InferredProvides,
23
24
  KernelCtx,
24
25
  Lazy,
25
26
  LazyService,
@@ -80,8 +81,11 @@ export type {
80
81
  export { renderTimeline } from './timeline.js';
81
82
  export type { RenderTimelineOptions } from './timeline.js';
82
83
 
83
- export { testApp, bootTestApp } from './test-harness.js';
84
- export type { TestAppOptions } from './test-harness.js';
84
+ export { testApp, bootTestApp, testPip } from './test-harness.js';
85
+ export type { TestAppOptions, TestPipBuilder } from './test-harness.js';
86
+
87
+ export { hookSignals } from './signals.js';
88
+ export type { HookSignalsOptions, SignalTarget } from './signals.js';
85
89
 
86
90
  // Task 9b: CLI envelope type is exported so adapter packages can produce the
87
91
  // same shape from related tooling (release-tooling, pip scaffolds, etc.).
@@ -104,6 +104,8 @@ export type DotAppInternalConfig = {
104
104
  * happen before there's a public seam to call `subscribe()` on.
105
105
  */
106
106
  observers?: readonly DotLifecycleObserver[];
107
+ /** Per-hook watchdog budget in ms — see `defineApp`'s option of the same name. */
108
+ hookTimeoutMs?: number;
107
109
  };
108
110
 
109
111
  /**
@@ -122,6 +124,9 @@ export class DotAppImpl {
122
124
  /** Macro-state of the app. */
123
125
  #state: DotLifecycleState = 'defined';
124
126
 
127
+ /** Per-hook watchdog budget (ms); `undefined` = no watchdog. */
128
+ readonly #hookTimeoutMs: number | undefined;
129
+
125
130
  /** Manifest finalised after `configure`. */
126
131
  #manifest: DotAppManifest;
127
132
 
@@ -180,6 +185,7 @@ export class DotAppImpl {
180
185
  constructor(config: DotAppInternalConfig) {
181
186
  this.#appName = config.appName;
182
187
  this.#appVersion = config.appVersion;
188
+ this.#hookTimeoutMs = config.hookTimeoutMs;
183
189
  this.#config = Object.freeze({ ...config.config });
184
190
  this.#logger = new Logger('arki:dot:lifecycle', { 'dot.app.name': config.appName });
185
191
  this.#observers = new Set(config.observers);
@@ -675,6 +681,44 @@ export class DotAppImpl {
675
681
  return ctx;
676
682
  }
677
683
 
684
+ /**
685
+ * Race one hook invocation against the app's `hookTimeoutMs` watchdog.
686
+ * No budget configured → plain pass-through. On timeout the returned
687
+ * promise rejects with `DOT_LIFECYCLE_E015` naming the pip and hook,
688
+ * and the call site's existing catch path applies the phase's normal
689
+ * failure rules (boot rollback, start cascade, teardown aggregation).
690
+ * The hook's own promise cannot be cancelled — the watchdog makes a
691
+ * hang visible and bounded; it does not kill the hung work.
692
+ */
693
+ async #withHookBudget<T>(pipName: string, hook: DotLifecycleHook, run: () => Promise<T> | T): Promise<T> {
694
+ const budget = this.#hookTimeoutMs;
695
+ if (budget === undefined) return run();
696
+ let timer: ReturnType<typeof setTimeout> | undefined;
697
+ const watchdog = new Promise<never>((_resolve, reject) => {
698
+ timer = setTimeout(() => {
699
+ reject(
700
+ new DotLifecycleError({
701
+ code: DotLifecycleErrorCode.HookTimeout,
702
+ phase: hook,
703
+ pip: pipName,
704
+ message:
705
+ `${hook} hook of pip "${pipName}" exceeded the ${budget.toString()}ms hookTimeoutMs watchdog. ` +
706
+ `The kernel treats the hook as failed and applies its normal rollback/aggregation rules, but ` +
707
+ `cannot cancel the hook's promise — find the hang (a missing await? a connection that never ` +
708
+ `settles?) or raise the budget in defineApp(name, { hookTimeoutMs }).`,
709
+ }),
710
+ );
711
+ }, budget);
712
+ // A pending watchdog must never keep an otherwise-finished process alive.
713
+ timer.unref?.();
714
+ });
715
+ try {
716
+ return await Promise.race([Promise.resolve(run()), watchdog]);
717
+ } finally {
718
+ if (timer !== undefined) clearTimeout(timer);
719
+ }
720
+ }
721
+
678
722
  /**
679
723
  * Inner boot loop. Separated from `#runBoot` so the phase span wrapper
680
724
  * stays thin and the loop body — which orchestrates rollback on
@@ -770,26 +814,33 @@ export class DotAppImpl {
770
814
  this.#emitHook('boot', pip.name, record.order, 'starting');
771
815
  let result: ServiceRecord | void;
772
816
  try {
773
- result = await withPipHookSpan(
774
- {
775
- appName: this.#appName,
776
- pipName: pip.name,
777
- pipVersion: pip.version,
778
- hook: 'boot',
779
- order: record.order,
780
- logger: pipLogger,
781
- },
782
- // Erasure boundary: hooks are stored as `(ctx: never) => ...`;
783
- // the kernel is the one caller allowed to cross it.
784
- () => pip.hooks.boot!(ctx as never),
817
+ result = await this.#withHookBudget(pip.name, 'boot', () =>
818
+ withPipHookSpan(
819
+ {
820
+ appName: this.#appName,
821
+ pipName: pip.name,
822
+ pipVersion: pip.version,
823
+ hook: 'boot',
824
+ order: record.order,
825
+ logger: pipLogger,
826
+ },
827
+ // Erasure boundary: hooks are stored as `(ctx: never) => ...`;
828
+ // the kernel is the one caller allowed to cross it.
829
+ () => pip.hooks.boot!(ctx as never),
830
+ ),
785
831
  );
786
832
  } catch (error) {
833
+ const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
787
834
  return fail({
788
835
  record,
789
- code: DotLifecycleErrorCode.BootFailed,
790
- message: `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
791
- remediation: `Fix the error in the boot() hook of "${pip.name}". If boot opens partial resources before throwing, clean them up locally — DOT only disposes pips whose boot completed.`,
792
- docsAnchor: 'boot-failed',
836
+ code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.BootFailed,
837
+ message: timedOut
838
+ ? error.message
839
+ : `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
840
+ remediation: timedOut
841
+ ? `Find the hang in the boot() hook of "${pip.name}" — a missing await or a connection that never settles — or raise defineApp's hookTimeoutMs.`
842
+ : `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.`,
843
+ docsAnchor: timedOut ? 'hook-timeout' : 'boot-failed',
793
844
  durationMs: performance.now() - started,
794
845
  cause: error,
795
846
  rollback: bootedRecords,
@@ -917,25 +968,32 @@ export class DotAppImpl {
917
968
  const startedAt = performance.now();
918
969
  this.#emitHook('start', pip.name, record.order, 'starting');
919
970
  try {
920
- await withPipHookSpan(
921
- {
922
- appName: this.#appName,
923
- pipName: pip.name,
924
- pipVersion: pip.version,
925
- hook: 'start',
926
- order: record.order,
927
- logger: pipLogger,
928
- },
929
- () => pip.hooks.start!(ctx as never),
971
+ await this.#withHookBudget(pip.name, 'start', () =>
972
+ withPipHookSpan(
973
+ {
974
+ appName: this.#appName,
975
+ pipName: pip.name,
976
+ pipVersion: pip.version,
977
+ hook: 'start',
978
+ order: record.order,
979
+ logger: pipLogger,
980
+ },
981
+ () => pip.hooks.start!(ctx as never),
982
+ ),
930
983
  );
931
984
  } catch (error) {
932
985
  const durationMs = performance.now() - startedAt;
986
+ const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
933
987
  const issue = makeIssue({
934
- code: DotLifecycleErrorCode.StartFailed,
988
+ code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
935
989
  pip: pip.name,
936
- message: `start hook threw for pip "${pip.name}": ${stringifyError(error)}`,
937
- remediation: `Fix the error in the start() hook of "${pip.name}". DOT will stop all already-started pips and dispose all booted pips in reverse order.`,
938
- docsAnchor: 'start-failed',
990
+ message: timedOut
991
+ ? error.message
992
+ : `start hook threw for pip "${pip.name}": ${stringifyError(error)}`,
993
+ remediation: timedOut
994
+ ? `Find the hang in the start() hook of "${pip.name}" — or raise defineApp's hookTimeoutMs. DOT rolls back as for any start failure.`
995
+ : `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.`,
996
+ docsAnchor: timedOut ? 'hook-timeout' : 'start-failed',
939
997
  });
940
998
  record.issues.push(issue);
941
999
  record.lifecycleDiagnostics.push({
@@ -958,7 +1016,7 @@ export class DotAppImpl {
958
1016
  this.#manifest = this.#buildManifest();
959
1017
  const failures = [...stopFailures, ...disposeFailures];
960
1018
  throw new DotLifecycleError({
961
- code: DotLifecycleErrorCode.StartFailed,
1019
+ code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
962
1020
  phase: 'start',
963
1021
  pip: pip.name,
964
1022
  message: issue.message,
@@ -1053,16 +1111,18 @@ export class DotAppImpl {
1053
1111
  const startedAt = performance.now();
1054
1112
  this.#emitHook('stop', record.pip.name, record.order, 'starting');
1055
1113
  try {
1056
- await withPipHookSpan(
1057
- {
1058
- appName: this.#appName,
1059
- pipName: record.pip.name,
1060
- pipVersion: record.pip.version,
1061
- hook: 'stop',
1062
- order: record.order,
1063
- logger: pipLogger,
1064
- },
1065
- () => record.pip.hooks.stop!(ctx as never),
1114
+ await this.#withHookBudget(record.pip.name, 'stop', () =>
1115
+ withPipHookSpan(
1116
+ {
1117
+ appName: this.#appName,
1118
+ pipName: record.pip.name,
1119
+ pipVersion: record.pip.version,
1120
+ hook: 'stop',
1121
+ order: record.order,
1122
+ logger: pipLogger,
1123
+ },
1124
+ () => record.pip.hooks.stop!(ctx as never),
1125
+ ),
1066
1126
  );
1067
1127
  record.started = false;
1068
1128
  const durationMs = performance.now() - startedAt;
@@ -1265,16 +1325,18 @@ export class DotAppImpl {
1265
1325
  const startedAt = performance.now();
1266
1326
  this.#emitHook('dispose', record.pip.name, record.order, 'starting');
1267
1327
  try {
1268
- await withPipHookSpan(
1269
- {
1270
- appName: this.#appName,
1271
- pipName: record.pip.name,
1272
- pipVersion: record.pip.version,
1273
- hook: 'dispose',
1274
- order: record.order,
1275
- logger: pipLogger,
1276
- },
1277
- () => record.pip.hooks.dispose!(ctx as never),
1328
+ await this.#withHookBudget(record.pip.name, 'dispose', () =>
1329
+ withPipHookSpan(
1330
+ {
1331
+ appName: this.#appName,
1332
+ pipName: record.pip.name,
1333
+ pipVersion: record.pip.version,
1334
+ hook: 'dispose',
1335
+ order: record.order,
1336
+ logger: pipLogger,
1337
+ },
1338
+ () => record.pip.hooks.dispose!(ctx as never),
1339
+ ),
1278
1340
  );
1279
1341
  record.booted = false;
1280
1342
  const durationMs = performance.now() - startedAt;
package/src/lifecycle.ts CHANGED
@@ -76,6 +76,8 @@ export const DotLifecycleErrorCode = {
76
76
  ServiceCollision: 'DOT_LIFECYCLE_E013',
77
77
  /** A needs alias, publish key, or rename target uses the reserved `$` prefix. */
78
78
  ReservedServiceKey: 'DOT_LIFECYCLE_E014',
79
+ /** A lifecycle hook exceeded the app's `hookTimeoutMs` watchdog. */
80
+ HookTimeout: 'DOT_LIFECYCLE_E015',
79
81
  } as const;
80
82
 
81
83
  export type DotLifecycleErrorCodeValue = (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
package/src/manifest.ts CHANGED
@@ -71,6 +71,25 @@ export type RouteManifest = {
71
71
  method?: string;
72
72
  path?: string;
73
73
  transport: RouteTransport;
74
+ /** Human summary, rendered into docs/OpenAPI output. */
75
+ description?: string;
76
+ /**
77
+ * JSON Schema for the request inputs, keyed by location. Adapters
78
+ * convert their validation schemas (e.g. zod) at `configure` time so the
79
+ * manifest stays plain serializable data — `dot explain --openapi`
80
+ * renders straight from here without booting.
81
+ */
82
+ input?: {
83
+ readonly query?: Readonly<Record<string, unknown>>;
84
+ readonly body?: Readonly<Record<string, unknown>>;
85
+ };
86
+ /** JSON Schema for the success response (or per-event schema when `streaming`). */
87
+ output?: Readonly<Record<string, unknown>>;
88
+ /**
89
+ * The response is an open stream (e.g. `text/event-stream`) rather than
90
+ * a single JSON document.
91
+ */
92
+ streaming?: boolean;
74
93
  };
75
94
 
76
95
  /** Single service published by a pip. */
@@ -313,8 +313,25 @@ export type DotConfigureContext = {
313
313
  * is returned from the `boot` hook.
314
314
  */
315
315
  registerService(name: string, kind: ServiceKind): void;
316
- /** Register a route this pip exposes. */
317
- registerRoute(route: { id: string; method?: string; path?: string; transport: RouteTransport }): void;
316
+ /**
317
+ * Register a route this pip exposes. The optional `description`,
318
+ * `input`/`output` JSON Schemas, and `streaming` flag flow into
319
+ * `manifest.routes` untouched — `dot explain --openapi` renders from
320
+ * them without booting the app.
321
+ */
322
+ registerRoute(route: {
323
+ id: string;
324
+ method?: string;
325
+ path?: string;
326
+ transport: RouteTransport;
327
+ description?: string;
328
+ input?: {
329
+ readonly query?: Readonly<Record<string, unknown>>;
330
+ readonly body?: Readonly<Record<string, unknown>>;
331
+ };
332
+ output?: Readonly<Record<string, unknown>>;
333
+ streaming?: boolean;
334
+ }): void;
318
335
  /** Mark the pip as participating in a lifecycle hook. */
319
336
  registerLifecycleHook(hook: 'configure' | 'boot' | 'start' | 'stop' | 'dispose'): void;
320
337
  /** Append `provides` capability strings (informational, manifest-only). */
@@ -393,6 +410,17 @@ export type AnyPip = Pip<ServiceRecord, ServiceRecord>;
393
410
  * });
394
411
  * ```
395
412
  */
413
+ /**
414
+ * A `boot` that returns `void` gives `TProvides` no inference candidate,
415
+ * and TypeScript then substitutes the CONSTRAINT (`ServiceRecord & …`,
416
+ * whose `keyof` is `string`) rather than the declared default. That wide
417
+ * record would poison the app builder's collision check for every later
418
+ * `.use()` — `keyof TAvail & string` is never empty. Detect the fallback
419
+ * by its tell (a full string index signature — no `pip()`-authored
420
+ * provides record has one) and collapse it to "provides nothing".
421
+ */
422
+ export type InferredProvides<TP extends ServiceRecord> = string extends keyof TP ? EmptyShape : TP;
423
+
396
424
  export function pip<
397
425
  TShape extends NeedsShape & NoReservedKeys = EmptyShape,
398
426
  TProvides extends ServiceRecord & NoReservedKeys = EmptyShape,
@@ -405,7 +433,7 @@ export function pip<
405
433
  readonly start?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
406
434
  readonly stop?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
407
435
  readonly dispose?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
408
- }): Pip<WireNeeds<TShape>, TProvides> {
436
+ }): Pip<WireNeeds<TShape>, InferredProvides<TProvides>> {
409
437
  return {
410
438
  name: def.name,
411
439
  ...(def.version === undefined ? {} : { version: def.version }),
package/src/pip.ts CHANGED
@@ -21,6 +21,7 @@ export type {
21
21
  CtxOf,
22
22
  DotConfigureContext,
23
23
  EmptyShape,
24
+ InferredProvides,
24
25
  KernelCtx,
25
26
  Lazy,
26
27
  LazyService,