@nwire/studio 0.12.0 → 0.13.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 (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildBcGraph, nodeDetail, toInvariants, layoutBcGraph, type BcGraph } from "../bc-graph";
3
+ import type { Manifest } from "../manifest";
4
+
5
+ /** A flat (older) manifest — per-kind arrays + `graph.events`, no `model`. */
6
+ const flat = {
7
+ apps: [{ name: "orders" }, { name: "billing" }],
8
+ actions: [
9
+ { name: "orders.place", app: "orders", public: true },
10
+ { name: "billing.charge", app: "billing" },
11
+ ],
12
+ events: [
13
+ { name: "orders.placed", app: "orders", public: true },
14
+ { name: "billing.charged", app: "billing" },
15
+ ],
16
+ queries: [{ name: "orders.list", app: "orders" }],
17
+ actors: [],
18
+ workflows: [{ name: "orders.fulfil", app: "orders" }],
19
+ projections: [{ name: "orders.dashboard", app: "orders" }],
20
+ graph: {
21
+ events: [
22
+ // orders.placed (orders) → billing.charge (billing): cross-BC.
23
+ { from: "orders.placed", to: "billing.charge", via: "delivers" },
24
+ // intra-BC — must not produce an edge.
25
+ { from: "orders.place", to: "orders.placed", via: "emits" },
26
+ ],
27
+ },
28
+ } as unknown as Manifest;
29
+
30
+ /** A deep manifest — carries `model.edges` over `${kind}:${name}` ids. */
31
+ const deep = {
32
+ apps: [{ name: "orders" }, { name: "billing" }],
33
+ actions: [
34
+ { name: "orders.place", app: "orders" },
35
+ { name: "billing.charge", app: "billing" },
36
+ ],
37
+ events: [{ name: "orders.placed", app: "orders" }],
38
+ queries: [],
39
+ actors: [],
40
+ workflows: [],
41
+ projections: [],
42
+ model: {
43
+ nodes: [],
44
+ edges: [{ from: "event:orders.placed", to: "action:billing.charge", type: "delivers" }],
45
+ },
46
+ // a flat graph that DISAGREES — proves `model.edges` wins when present.
47
+ graph: { events: [{ from: "orders.placed", to: "orders.place", via: "emits" }] },
48
+ } as unknown as Manifest;
49
+
50
+ describe("buildBcGraph", () => {
51
+ it("returns empty graph for null/undefined", () => {
52
+ expect(buildBcGraph(null)).toEqual({ bcs: [], edges: [] });
53
+ expect(buildBcGraph(undefined)).toEqual({ bcs: [], edges: [] });
54
+ });
55
+
56
+ it("groups members into BC cards by app, sorted", () => {
57
+ const g = buildBcGraph(flat);
58
+ expect(g.bcs.map((b) => b.name)).toEqual(["billing", "orders"]);
59
+ const orders = g.bcs.find((b) => b.name === "orders")!;
60
+ // 1 action + 1 event + 1 query + 1 workflow + 1 projection
61
+ expect(orders.rows).toHaveLength(5);
62
+ expect(orders.rows.map((r) => r.kind).sort()).toEqual([
63
+ "action",
64
+ "event",
65
+ "projection",
66
+ "query",
67
+ "workflow",
68
+ ]);
69
+ });
70
+
71
+ it("carries public flag and stable ids onto rows", () => {
72
+ const g = buildBcGraph(flat);
73
+ const place = g.bcs.flatMap((b) => b.rows).find((r) => r.name === "orders.place")!;
74
+ expect(place.id).toBe("action:orders.place");
75
+ expect(place.public).toBe(true);
76
+ });
77
+
78
+ it("derives cross-BC edges from graph.events (flat path), skipping intra-BC", () => {
79
+ const g = buildBcGraph(flat);
80
+ expect(g.edges).toEqual([{ from: "orders", to: "billing", label: "delivers" }]);
81
+ });
82
+
83
+ it("prefers model.edges over graph.events when a deep model is present", () => {
84
+ const g = buildBcGraph(deep);
85
+ expect(g.edges).toEqual([{ from: "orders", to: "billing", label: "delivers" }]);
86
+ });
87
+
88
+ it("buckets members with no app into (unscoped)", () => {
89
+ const g = buildBcGraph({
90
+ apps: [],
91
+ actions: [{ name: "loose.thing" }],
92
+ } as unknown as Manifest);
93
+ expect(g.bcs.map((b) => b.name)).toContain("(unscoped)");
94
+ });
95
+ });
96
+
97
+ describe("nodeDetail", () => {
98
+ const m = {
99
+ actions: [
100
+ {
101
+ name: "orders.place",
102
+ app: "orders",
103
+ description: "Place an order.",
104
+ public: true,
105
+ inputSchema: { type: "object" },
106
+ emits: ["orders.placed"],
107
+ retry: 3,
108
+ source: { file: "/app/actions.ts", line: 10, column: 2 },
109
+ },
110
+ ],
111
+ } as unknown as Manifest;
112
+
113
+ it("resolves a row id to full detail", () => {
114
+ const d = nodeDetail(m, "action:orders.place")!;
115
+ expect(d.name).toBe("orders.place");
116
+ expect(d.app).toBe("orders");
117
+ expect(d.description).toBe("Place an order.");
118
+ expect(d.public).toBe(true);
119
+ expect(d.schema).toEqual({ type: "object" });
120
+ expect(d.emits).toEqual(["orders.placed"]);
121
+ expect(d.source).toEqual({ file: "/app/actions.ts", line: 10, column: 2 });
122
+ });
123
+
124
+ it("puts unknown fields into extra, not the typed surface", () => {
125
+ const d = nodeDetail(m, "action:orders.place")!;
126
+ expect(d.extra.retry).toBe(3);
127
+ expect(d.extra.name).toBeUndefined();
128
+ expect(d.extra.inputSchema).toBeUndefined();
129
+ });
130
+
131
+ it("returns null for unknown kind, missing entry, bad id, or no manifest", () => {
132
+ expect(nodeDetail(m, "nope:orders.place")).toBeNull();
133
+ expect(nodeDetail(m, "action:does.not.exist")).toBeNull();
134
+ expect(nodeDetail(m, "noseparator")).toBeNull();
135
+ expect(nodeDetail(null, "action:orders.place")).toBeNull();
136
+ expect(nodeDetail(m, null)).toBeNull();
137
+ });
138
+
139
+ it("surfaces invariants as a typed field, not in extra", () => {
140
+ const withRules = {
141
+ actions: [
142
+ {
143
+ name: "mod.approve",
144
+ app: "mod",
145
+ invariants: [
146
+ { rule: '(i) => i.by.length > 0 || "Needs a moderator"', message: "Needs a moderator" },
147
+ { rule: "(i) => i.ok" },
148
+ ],
149
+ },
150
+ ],
151
+ } as unknown as Manifest;
152
+ const d = nodeDetail(withRules, "action:mod.approve")!;
153
+ expect(d.invariants).toEqual([
154
+ { rule: '(i) => i.by.length > 0 || "Needs a moderator"', message: "Needs a moderator" },
155
+ { rule: "(i) => i.ok" },
156
+ ]);
157
+ expect(d.extra.invariants).toBeUndefined();
158
+ });
159
+ });
160
+
161
+ describe("toInvariants", () => {
162
+ it("normalizes well-formed entries and drops junk", () => {
163
+ expect(
164
+ toInvariants([{ rule: "a", message: "A" }, { rule: "b" }, { rule: 123 }, "nope", null]),
165
+ ).toEqual([{ rule: "a", message: "A" }, { rule: "b" }]);
166
+ });
167
+
168
+ it("returns undefined for non-arrays and empty results", () => {
169
+ expect(toInvariants(undefined)).toBeUndefined();
170
+ expect(toInvariants("x")).toBeUndefined();
171
+ expect(toInvariants([])).toBeUndefined();
172
+ expect(toInvariants([{ message: "no rule" }])).toBeUndefined();
173
+ });
174
+ });
175
+
176
+ describe("layoutBcGraph", () => {
177
+ const graph: BcGraph = {
178
+ bcs: [
179
+ {
180
+ name: "a",
181
+ rows: [
182
+ { id: "action:a1", kind: "action", name: "a1" },
183
+ { id: "action:a2", kind: "action", name: "a2" },
184
+ { id: "action:a3", kind: "action", name: "a3" },
185
+ ],
186
+ },
187
+ { name: "b", rows: [] },
188
+ { name: "c", rows: [] },
189
+ { name: "d", rows: [] },
190
+ ],
191
+ edges: [],
192
+ };
193
+
194
+ it("positions cards in a grid by column count", () => {
195
+ const nodes = layoutBcGraph(graph, { columns: 2, width: 100, gapX: 20 });
196
+ expect(nodes).toHaveLength(4);
197
+ // row 0
198
+ expect(nodes[0]!.x).toBe(0);
199
+ expect(nodes[1]!.x).toBe(120);
200
+ expect(nodes[0]!.y).toBe(nodes[1]!.y);
201
+ // row 1 wraps below row 0
202
+ expect(nodes[2]!.x).toBe(0);
203
+ expect(nodes[2]!.y).toBeGreaterThan(nodes[0]!.y);
204
+ });
205
+
206
+ it("scales card height with member count", () => {
207
+ const nodes = layoutBcGraph(graph, { columns: 4 });
208
+ const tall = nodes.find((n) => n.name === "a")!;
209
+ const short = nodes.find((n) => n.name === "b")!;
210
+ expect(tall.height).toBeGreaterThan(short.height);
211
+ });
212
+
213
+ it("never overlaps rows — next row clears the tallest card above", () => {
214
+ const nodes = layoutBcGraph(graph, { columns: 2 });
215
+ const rowZeroBottom = Math.max(nodes[0]!.y + nodes[0]!.height, nodes[1]!.y + nodes[1]!.height);
216
+ expect(nodes[2]!.y).toBeGreaterThanOrEqual(rowZeroBottom);
217
+ });
218
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { Manifest } from "../manifest";
3
+ import {
4
+ dispatchTargets,
5
+ filterTargets,
6
+ schemaFields,
7
+ scaffoldInput,
8
+ isInlineRenderable,
9
+ allInlineRenderable,
10
+ } from "../dispatch-form";
11
+
12
+ const objectSchema = {
13
+ type: "object",
14
+ properties: {
15
+ authorId: { type: "string" },
16
+ body: { type: "string" },
17
+ flagged: { type: "boolean", default: false },
18
+ severity: { type: "string", enum: ["low", "high"] },
19
+ },
20
+ required: ["authorId", "body"],
21
+ };
22
+
23
+ const manifest = {
24
+ actions: [
25
+ {
26
+ name: "moderation.submit-post",
27
+ app: "moderation",
28
+ description: "post",
29
+ inputSchema: objectSchema,
30
+ },
31
+ { name: "moderation.flag", app: "moderation" },
32
+ ],
33
+ queries: [{ name: "moderation.queue", app: "moderation", projection: "queue" }],
34
+ } as unknown as Manifest;
35
+
36
+ describe("dispatchTargets", () => {
37
+ it("reads actions then queries from the flat manifest arrays", () => {
38
+ const t = dispatchTargets(manifest);
39
+ expect(t.map((x) => x.name)).toEqual([
40
+ "moderation.submit-post",
41
+ "moderation.flag",
42
+ "moderation.queue",
43
+ ]);
44
+ expect(t[0].kind).toBe("action");
45
+ expect(t[2].kind).toBe("query");
46
+ expect(t[0].inputSchema).toBe(objectSchema);
47
+ });
48
+
49
+ it("is tolerant of a null / partial manifest", () => {
50
+ expect(dispatchTargets(null)).toEqual([]);
51
+ expect(dispatchTargets({} as Manifest)).toEqual([]);
52
+ expect(dispatchTargets({ actions: "nope" } as unknown as Manifest)).toEqual([]);
53
+ });
54
+
55
+ it("skips entries without a name", () => {
56
+ const m = {
57
+ actions: [{ app: "x" }, { name: "ok", app: "x" }],
58
+ } as unknown as Manifest;
59
+ expect(dispatchTargets(m).map((t) => t.name)).toEqual(["ok"]);
60
+ });
61
+ });
62
+
63
+ describe("filterTargets", () => {
64
+ const targets = dispatchTargets(manifest);
65
+ it("returns all on an empty query", () => {
66
+ expect(filterTargets(targets, " ")).toHaveLength(3);
67
+ });
68
+ it("matches name, app, and description case-insensitively", () => {
69
+ expect(filterTargets(targets, "FLAG").map((t) => t.name)).toEqual(["moderation.flag"]);
70
+ expect(filterTargets(targets, "post").map((t) => t.name)).toEqual(["moderation.submit-post"]);
71
+ expect(filterTargets(targets, "queue").map((t) => t.name)).toEqual(["moderation.queue"]);
72
+ });
73
+ });
74
+
75
+ describe("schemaFields", () => {
76
+ it("flattens top-level properties with required/default/enum", () => {
77
+ const f = schemaFields(objectSchema);
78
+ expect(f.map((x) => x.name)).toEqual(["authorId", "body", "flagged", "severity"]);
79
+ expect(f.find((x) => x.name === "authorId")?.required).toBe(true);
80
+ expect(f.find((x) => x.name === "flagged")?.default).toBe(false);
81
+ expect(f.find((x) => x.name === "severity")?.enum).toEqual(["low", "high"]);
82
+ });
83
+ it("returns [] for a non-object / schemaless input", () => {
84
+ expect(schemaFields(undefined)).toEqual([]);
85
+ expect(schemaFields({ type: "string" })).toEqual([]);
86
+ });
87
+ });
88
+
89
+ describe("scaffoldInput", () => {
90
+ it("seeds type-appropriate values and honors defaults", () => {
91
+ expect(scaffoldInput(objectSchema)).toEqual({
92
+ authorId: "",
93
+ body: "",
94
+ flagged: false,
95
+ severity: "",
96
+ });
97
+ });
98
+ it("returns {} for a schemaless input", () => {
99
+ expect(scaffoldInput(null)).toEqual({});
100
+ });
101
+ });
102
+
103
+ describe("inline renderability", () => {
104
+ it("treats primitives as inline-renderable", () => {
105
+ expect(isInlineRenderable({ name: "a", type: "string", required: true })).toBe(true);
106
+ expect(isInlineRenderable({ name: "a", type: "object", required: false })).toBe(false);
107
+ });
108
+ it("allInlineRenderable is true only when all fields are primitive", () => {
109
+ expect(allInlineRenderable(schemaFields(objectSchema))).toBe(true);
110
+ expect(allInlineRenderable([{ name: "x", type: "object", required: false }])).toBe(false);
111
+ expect(allInlineRenderable([])).toBe(false);
112
+ });
113
+ });
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ explainError,
4
+ categorize,
5
+ severityOf,
6
+ subjectOf,
7
+ attemptOf,
8
+ retryNarrative,
9
+ maxSeverity,
10
+ } from "../error-friendly";
11
+ import type { TelemetryRecord } from "../telemetry";
12
+
13
+ const rec = (over: Partial<TelemetryRecord> & { kind: string }): TelemetryRecord => over;
14
+
15
+ describe("subjectOf", () => {
16
+ it("reads the failing unit off whichever id field is present", () => {
17
+ expect(subjectOf(rec({ kind: "action.failed", action: "orders.charge" }))).toEqual({
18
+ kind: "action",
19
+ name: "orders.charge",
20
+ });
21
+ expect(subjectOf(rec({ kind: "projection.failed", projection: "balances" }))).toEqual({
22
+ kind: "projection",
23
+ name: "balances",
24
+ });
25
+ expect(subjectOf(rec({ kind: "external.call.failed", call: "stripe.charge" }))).toEqual({
26
+ kind: "external call",
27
+ name: "stripe.charge",
28
+ });
29
+ });
30
+ it("falls back to the source event, then a generic label", () => {
31
+ expect(subjectOf(rec({ kind: "reaction.failed", sourceEvent: "OrderPlaced" }))).toEqual({
32
+ kind: "operation",
33
+ name: "OrderPlaced",
34
+ });
35
+ expect(subjectOf(rec({ kind: "x" })).name).toBe("an operation");
36
+ });
37
+ });
38
+
39
+ describe("categorize", () => {
40
+ it("classifies by error name / message / code first", () => {
41
+ expect(
42
+ categorize(rec({ kind: "action.failed", error: { name: "ZodError", message: "x" } })),
43
+ ).toBe("validation");
44
+ expect(
45
+ categorize(
46
+ rec({ kind: "action.failed", error: { name: "E", message: "field is required" } }),
47
+ ),
48
+ ).toBe("validation");
49
+ expect(
50
+ categorize(
51
+ rec({ kind: "action.failed", error: { name: "E", message: "card declined", code: 402 } }),
52
+ ),
53
+ ).toBe("declined");
54
+ expect(
55
+ categorize(
56
+ rec({ kind: "action.failed", error: { name: "Forbidden", message: "x", code: 403 } }),
57
+ ),
58
+ ).toBe("unauthorized");
59
+ expect(
60
+ categorize(
61
+ rec({ kind: "action.failed", error: { name: "E", message: "user not found", code: 404 } }),
62
+ ),
63
+ ).toBe("not-found");
64
+ expect(
65
+ categorize(
66
+ rec({ kind: "action.failed", error: { name: "E", message: "already exists", code: 409 } }),
67
+ ),
68
+ ).toBe("conflict");
69
+ expect(
70
+ categorize(
71
+ rec({ kind: "action.failed", error: { name: "TimeoutError", message: "timed out" } }),
72
+ ),
73
+ ).toBe("timeout");
74
+ });
75
+ it("falls back to the record kind for the forge terminal shapes", () => {
76
+ expect(categorize(rec({ kind: "dlq.recorded", error: { name: "E", message: "boom" } }))).toBe(
77
+ "dead-letter",
78
+ );
79
+ expect(
80
+ categorize(rec({ kind: "reaction.exhausted", error: { name: "E", message: "boom" } })),
81
+ ).toBe("exhausted");
82
+ expect(
83
+ categorize(rec({ kind: "external.call.failed", error: { name: "E", message: "boom" } })),
84
+ ).toBe("external");
85
+ expect(
86
+ categorize(rec({ kind: "projection.failed", error: { name: "E", message: "boom" } })),
87
+ ).toBe("downstream");
88
+ });
89
+ it("is 'unknown' when nothing matches", () => {
90
+ expect(
91
+ categorize(rec({ kind: "action.failed", error: { name: "WeirdError", message: "?" } })),
92
+ ).toBe("unknown");
93
+ });
94
+ });
95
+
96
+ describe("attemptOf / retryNarrative", () => {
97
+ it("normalises the retrying shape", () => {
98
+ const r = rec({
99
+ kind: "action.failed",
100
+ attempt: 2,
101
+ maxAttempts: 3,
102
+ willRetry: true,
103
+ error: { name: "E", message: "x" },
104
+ });
105
+ expect(attemptOf(r)).toMatchObject({
106
+ attempt: 2,
107
+ maxAttempts: 3,
108
+ willRetry: true,
109
+ terminal: false,
110
+ });
111
+ expect(retryNarrative(r)).toBe("attempt 2 of 3, retrying");
112
+ });
113
+ it("marks terminal shapes and reports total attempts", () => {
114
+ const r = rec({ kind: "dlq.recorded", attempts: 3, error: { name: "E", message: "x" } });
115
+ expect(attemptOf(r)).toMatchObject({ attempts: 3, willRetry: false, terminal: true });
116
+ expect(retryNarrative(r)).toBe("3 attempts — gave up");
117
+ });
118
+ it("returns null when there's nothing to say", () => {
119
+ expect(retryNarrative(rec({ kind: "action.failed", attempt: 1, maxAttempts: 1 }))).toBe(
120
+ "attempt 1 of 1",
121
+ );
122
+ expect(retryNarrative(rec({ kind: "projection.failed" }))).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe("severityOf", () => {
127
+ it("terminal shapes are critical", () => {
128
+ expect(severityOf(rec({ kind: "dlq.recorded" }))).toBe("critical");
129
+ expect(severityOf(rec({ kind: "reaction.exhausted" }))).toBe("critical");
130
+ });
131
+ it("a failure that will retry is only a warning", () => {
132
+ expect(
133
+ severityOf(
134
+ rec({ kind: "action.failed", willRetry: true, error: { name: "E", message: "x" } }),
135
+ ),
136
+ ).toBe("warning");
137
+ });
138
+ it("validation / declined are warnings (expected control-flow)", () => {
139
+ expect(
140
+ severityOf(rec({ kind: "action.failed", error: { name: "ZodError", message: "x" } })),
141
+ ).toBe("warning");
142
+ expect(
143
+ severityOf(rec({ kind: "action.failed", error: { name: "E", message: "declined" } })),
144
+ ).toBe("warning");
145
+ });
146
+ it("an unrecoverable runtime failure is high", () => {
147
+ expect(
148
+ severityOf(
149
+ rec({
150
+ kind: "action.failed",
151
+ willRetry: false,
152
+ error: { name: "TypeError", message: "boom" },
153
+ }),
154
+ ),
155
+ ).toBe("high");
156
+ });
157
+ });
158
+
159
+ describe("maxSeverity", () => {
160
+ it("picks the louder", () => {
161
+ expect(maxSeverity("warning", "critical")).toBe("critical");
162
+ expect(maxSeverity("high", "warning")).toBe("high");
163
+ expect(maxSeverity("info", "info")).toBe("info");
164
+ });
165
+ });
166
+
167
+ describe("explainError", () => {
168
+ it("produces a friendly title/summary/suggestion naming the subject", () => {
169
+ const f = explainError(
170
+ rec({
171
+ kind: "action.failed",
172
+ action: "orders.charge",
173
+ willRetry: false,
174
+ error: { name: "PaymentDeclined", message: "card declined", code: 402 },
175
+ }),
176
+ );
177
+ expect(f.category).toBe("declined");
178
+ expect(f.title).toBe("Declined");
179
+ expect(f.summary).toContain("orders.charge");
180
+ expect(f.suggestion.length).toBeGreaterThan(0);
181
+ expect(f.severity).toBe("warning");
182
+ expect(f.rawMessage).toBe("card declined");
183
+ });
184
+ it("explains a dead-lettered action loudly", () => {
185
+ const f = explainError(
186
+ rec({
187
+ kind: "dlq.recorded",
188
+ action: "orders.ship",
189
+ attempts: 5,
190
+ error: { name: "TypeError", message: "boom" },
191
+ }),
192
+ );
193
+ expect(f.severity).toBe("critical");
194
+ expect(f.title).toBe("Dead-lettered");
195
+ expect(f.summary).toContain("orders.ship");
196
+ expect(f.attempt.terminal).toBe(true);
197
+ });
198
+ });