@nwire/studio 0.12.1 → 0.13.1

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
package/vite.config.ts CHANGED
@@ -12,6 +12,12 @@ import {
12
12
  readSync,
13
13
  statSync,
14
14
  } from "node:fs";
15
+ import {
16
+ readManifest,
17
+ listTelemetryRuns,
18
+ readTelemetryRun,
19
+ type TelemetryRunMeta,
20
+ } from "./src/server/nwire-read";
15
21
  import { spawnSync } from "node:child_process";
16
22
  import { RunnerSupervisor, inspectHealthCheck } from "@nwire/supervisor";
17
23
  import type { IncomingMessage, ServerResponse } from "node:http";
@@ -220,9 +226,28 @@ function targetCwd(req: IncomingMessage): string | null {
220
226
  * Studio calls this on every `GET /__nwire/manifest.json` so a user
221
227
  * editing source never sees stale data — the cache rebuilds itself on
222
228
  * the next page load.
229
+ *
230
+ * The auto-rebuild is skipped when:
231
+ * - `NWIRE_NO_AUTOCACHE=1` is set in the environment (e.g. the e2e harness
232
+ * points Studio at a fixture directory that has a committed manifest but no
233
+ * real source — running `nwire cache` there would overwrite the fixture).
234
+ * - The cwd has no `package.json` (nothing to scan; serve whatever manifest
235
+ * is already on disk rather than producing an empty one).
236
+ * In both cases we fall through to serving the existing manifest, or 404 if
237
+ * it isn't there yet.
223
238
  */
224
239
  function ensureCacheBuilt(cwd: string = consumerCwd, opts: { force?: boolean } = {}): boolean {
225
240
  const manifestPath = resolve(cwd, ".nwire", "manifest.json");
241
+
242
+ // Guard: a fixture-only directory (no package.json) or an explicit opt-out
243
+ // from the caller env. Serve whatever is on disk; don't overwrite it.
244
+ if (process.env.NWIRE_NO_AUTOCACHE === "1" && !opts.force) {
245
+ return existsSync(manifestPath);
246
+ }
247
+ if (!existsSync(resolve(cwd, "package.json")) && !opts.force) {
248
+ return existsSync(manifestPath);
249
+ }
250
+
226
251
  const cliPath = resolve(here, "..", "nwire-cli", "dist", "cli.js");
227
252
  if (!existsSync(cliPath)) {
228
253
  return existsSync(manifestPath);
@@ -243,6 +268,63 @@ function ensureCacheBuilt(cwd: string = consumerCwd, opts: { force?: boolean } =
243
268
  return existsSync(manifestPath);
244
269
  }
245
270
 
271
+ // ── Telemetry run helpers ─────────────────────────────────────────────────
272
+ // listTelemetryRuns, readTelemetryRun, TelemetryRunMeta imported from
273
+ // ./src/server/nwire-read — shared with studio-host-api.ts.
274
+
275
+ /**
276
+ * Open a JSONL file tail: poll for new content and call `onLine` with each
277
+ * newly appended, parsed JSON record. Returns a stop handle. Same pattern as
278
+ * `openLogTail` but parses JSON (telemetry records) instead of raw text.
279
+ */
280
+ function openJsonlTail(path: string, onRecord: (rec: unknown) => void): { stop: () => void } {
281
+ if (!existsSync(path)) return { stop: () => {} };
282
+ let offset = 0;
283
+ let leftover = "";
284
+ try {
285
+ offset = statSync(path).size;
286
+ } catch {
287
+ offset = 0;
288
+ }
289
+ let stopped = false;
290
+ const poll = setInterval(() => {
291
+ if (stopped) return;
292
+ try {
293
+ const size = statSync(path).size;
294
+ if (size <= offset) return;
295
+ const fd = openSync(path, "r");
296
+ try {
297
+ const buf = Buffer.alloc(size - offset);
298
+ readSync(fd, buf, 0, buf.length, offset);
299
+ offset = size;
300
+ const text = leftover + buf.toString("utf8");
301
+ const lines = text.split(/\r?\n/);
302
+ // Last element may be an incomplete line — hold it over.
303
+ leftover = lines.pop() ?? "";
304
+ for (const line of lines) {
305
+ const trimmed = line.trim();
306
+ if (!trimmed) continue;
307
+ try {
308
+ onRecord(JSON.parse(trimmed));
309
+ } catch {
310
+ // malformed JSON line — skip
311
+ }
312
+ }
313
+ } finally {
314
+ closeSync(fd);
315
+ }
316
+ } catch {
317
+ // file removed or rotated — stop quietly
318
+ }
319
+ }, 250);
320
+ return {
321
+ stop: () => {
322
+ stopped = true;
323
+ clearInterval(poll);
324
+ },
325
+ };
326
+ }
327
+
246
328
  /**
247
329
  * Vite middleware that exposes the consumer's `.nwire/manifest.json` to the
248
330
  * Studio dev server. If the cache is missing, build it on the fly so the
@@ -390,7 +472,6 @@ function nwireDataPlugin() {
390
472
  res.end(JSON.stringify({ error: "unknown project" }));
391
473
  return;
392
474
  }
393
- const manifestPath = resolve(cwd, ".nwire", "manifest.json");
394
475
  // Always run the if-stale path. When the fingerprint matches,
395
476
  // the CLI exits in a few ms with no spawn cost beyond its own
396
477
  // bootstrap; when source moved, the manifest is rebuilt before
@@ -411,8 +492,14 @@ function nwireDataPlugin() {
411
492
  );
412
493
  return;
413
494
  }
495
+ const raw = readManifest(cwd);
496
+ if (!raw) {
497
+ res.statusCode = 404;
498
+ res.end(JSON.stringify({ error: "manifest.json missing after build" }));
499
+ return;
500
+ }
414
501
  res.setHeader("Content-Type", "application/json");
415
- res.end(readFileSync(manifestPath, "utf8"));
502
+ res.end(raw);
416
503
  });
417
504
 
418
505
  // Source viewer — reads a file from disk, returns { content, language }.
@@ -461,6 +548,137 @@ function nwireDataPlugin() {
461
548
  res.end(JSON.stringify({ error: (err as Error).message }));
462
549
  }
463
550
  });
551
+
552
+ /**
553
+ * Telemetry run list + single-run records.
554
+ *
555
+ * GET /__nwire/telemetry/runs → { runs: TelemetryRunMeta[] }
556
+ * GET /__nwire/telemetry/runs/:id → { id, records: unknown[] }
557
+ *
558
+ * Both honor `?project=<cwd>`.
559
+ */
560
+ server.middlewares.use("/__nwire/telemetry/runs", (req, res) => {
561
+ const cwd = targetCwd(req);
562
+ if (!cwd) {
563
+ res.statusCode = 400;
564
+ res.setHeader("Content-Type", "application/json");
565
+ res.end(JSON.stringify({ error: "unknown project" }));
566
+ return;
567
+ }
568
+ // connect strips the mount prefix, so req.url is "/" (or "") for the
569
+ // list and "/<id>" for a single run.
570
+ const sub = new URL(req.url ?? "/", "http://x").pathname;
571
+
572
+ // Exact: list all runs.
573
+ if (sub === "/" || sub === "") {
574
+ res.setHeader("Content-Type", "application/json");
575
+ res.end(JSON.stringify({ runs: listTelemetryRuns(cwd) }));
576
+ return;
577
+ }
578
+
579
+ // Sub-path: single run records.
580
+ const runIdMatch = /^\/([^/]+)$/.exec(sub);
581
+ if (runIdMatch) {
582
+ const runId = decodeURIComponent(runIdMatch[1]!);
583
+ const file = resolve(cwd, ".nwire", "telemetry", `${runId}.jsonl`);
584
+ if (!existsSync(file)) {
585
+ res.statusCode = 404;
586
+ res.setHeader("Content-Type", "application/json");
587
+ res.end(JSON.stringify({ error: "run not found", id: runId }));
588
+ return;
589
+ }
590
+ const records = readTelemetryRun(cwd, runId);
591
+ res.setHeader("Content-Type", "application/json");
592
+ res.end(JSON.stringify({ id: runId, records }));
593
+ return;
594
+ }
595
+
596
+ res.statusCode = 404;
597
+ res.setHeader("Content-Type", "application/json");
598
+ res.end(JSON.stringify({ error: "not found" }));
599
+ });
600
+
601
+ /**
602
+ * Live telemetry tail (SSE).
603
+ * GET /__nwire/telemetry/live[?project=<cwd>]
604
+ *
605
+ * Finds the current (newest) run file and tails it, streaming each new
606
+ * JSON record as an SSE `data:` event. Sends backfill (the entire current
607
+ * file at connection time) first, then polls for appended lines.
608
+ * Keepalive comments every 25 s to prevent proxy timeouts.
609
+ */
610
+ server.middlewares.use("/__nwire/telemetry/live", (req, res) => {
611
+ const cwd = targetCwd(req);
612
+ if (!cwd) {
613
+ res.statusCode = 400;
614
+ res.setHeader("Content-Type", "application/json");
615
+ res.end(JSON.stringify({ error: "unknown project" }));
616
+ return;
617
+ }
618
+ const runs = listTelemetryRuns(cwd);
619
+ const current = runs[0]; // newest, or undefined when no runs yet
620
+ const filePath = current
621
+ ? resolve(cwd, ".nwire", "telemetry", `${current.id}.jsonl`)
622
+ : null;
623
+
624
+ res.statusCode = 200;
625
+ res.setHeader("Content-Type", "text/event-stream");
626
+ res.setHeader("Cache-Control", "no-cache, no-transform");
627
+ res.setHeader("Connection", "keep-alive");
628
+
629
+ // Emit a meta event so the client knows which run it's tailing.
630
+ const runMeta = current ?? null;
631
+ try {
632
+ res.write(`event: run\ndata: ${JSON.stringify(runMeta)}\n\n`);
633
+ } catch {
634
+ return;
635
+ }
636
+
637
+ // Backfill: send all records already in the file.
638
+ if (filePath && existsSync(filePath)) {
639
+ const existing = readTelemetryRun(cwd, current!.id);
640
+ for (const rec of existing) {
641
+ try {
642
+ res.write(`data: ${JSON.stringify(rec)}\n\n`);
643
+ } catch {
644
+ return;
645
+ }
646
+ }
647
+ }
648
+
649
+ if (!filePath) {
650
+ // No run file yet — just keepalive and wait.
651
+ const keepalive = setInterval(() => {
652
+ try {
653
+ res.write(": keepalive\n\n");
654
+ } catch {
655
+ clearInterval(keepalive);
656
+ }
657
+ }, 25_000);
658
+ req.on("close", () => clearInterval(keepalive));
659
+ return;
660
+ }
661
+
662
+ // Tail for new lines appended after the backfill.
663
+ const tail = openJsonlTail(filePath, (rec) => {
664
+ try {
665
+ res.write(`data: ${JSON.stringify(rec)}\n\n`);
666
+ } catch {
667
+ // socket gone — tail.stop() will fire via req.close
668
+ }
669
+ });
670
+ const keepalive = setInterval(() => {
671
+ try {
672
+ res.write(": keepalive\n\n");
673
+ } catch {
674
+ clearInterval(keepalive);
675
+ }
676
+ }, 25_000);
677
+ req.on("close", () => {
678
+ tail.stop();
679
+ clearInterval(keepalive);
680
+ });
681
+ });
464
682
  },
465
683
  };
466
684
  }
@@ -1,105 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { normalizeCache } from "../normalize-cache";
3
-
4
- describe("normalizeCache", () => {
5
- it("fills every expected array when input is empty", () => {
6
- const { cache, missingFields } = normalizeCache({});
7
- expect(cache).not.toBeNull();
8
- expect(cache?.apps).toEqual([]);
9
- expect(cache?.actions).toEqual([]);
10
- expect(cache?.events).toEqual([]);
11
- expect(cache?.actors).toEqual([]);
12
- expect(cache?.projections).toEqual([]);
13
- expect(cache?.queries).toEqual([]);
14
- expect(cache?.resolvers).toEqual([]);
15
- expect(cache?.routes).toEqual([]);
16
- expect(cache?.workflows).toEqual([]);
17
- expect(cache?.graph).toEqual({ events: [] });
18
- expect(missingFields).toContain("resolvers");
19
- expect(missingFields).toContain("workflows");
20
- expect(missingFields).toContain("graph");
21
- });
22
-
23
- it("preserves arrays that ARE present", () => {
24
- const input = {
25
- apps: [{ name: "x", plugins: [] }],
26
- actions: [],
27
- events: [],
28
- actors: [],
29
- projections: [],
30
- queries: [],
31
- resolvers: [{ operation: "Op", version: 1 }],
32
- routes: [],
33
- workflows: [],
34
- externalCalls: [],
35
- inboundWebhooks: [],
36
- outboxes: [],
37
- inboxes: [],
38
- crons: [],
39
- hooks: [],
40
- plugins: [],
41
- sinks: [],
42
- bindings: [],
43
- graph: { events: [] },
44
- generatedAt: "2026-05-17T00:00:00Z",
45
- };
46
- const { cache, missingFields } = normalizeCache(input);
47
- expect(cache?.apps).toEqual(input.apps);
48
- expect(cache?.resolvers).toEqual(input.resolvers);
49
- expect(missingFields).toEqual([]);
50
- });
51
-
52
- it("reports the exact list of missing array fields", () => {
53
- const { missingFields } = normalizeCache({
54
- apps: [],
55
- actions: [],
56
- events: [],
57
- actors: [],
58
- projections: [],
59
- queries: [],
60
- // resolvers + workflows + sinks + graph missing
61
- routes: [],
62
- externalCalls: [],
63
- inboundWebhooks: [],
64
- outboxes: [],
65
- inboxes: [],
66
- crons: [],
67
- generatedAt: "2026-05-17T00:00:00Z",
68
- });
69
- expect([...missingFields].sort()).toEqual([
70
- "bindings",
71
- "graph",
72
- "hooks",
73
- "plugins",
74
- "resolvers",
75
- "sinks",
76
- "workflows",
77
- ]);
78
- });
79
-
80
- it("ignores non-array values masquerading as the field", () => {
81
- const { cache, missingFields } = normalizeCache({ resolvers: "not an array" });
82
- expect(cache?.resolvers).toEqual([]);
83
- expect(missingFields).toContain("resolvers");
84
- });
85
-
86
- it("returns null + fatal error when input is not an object", () => {
87
- expect(normalizeCache(null).cache).toBeNull();
88
- expect(normalizeCache(null).fatalError).toMatch(/not a JSON object/i);
89
- expect(normalizeCache([]).cache).toBeNull();
90
- expect(normalizeCache("oops").cache).toBeNull();
91
- expect(normalizeCache(42).cache).toBeNull();
92
- });
93
-
94
- it("synthesises generatedAt when missing", () => {
95
- const { cache, missingFields } = normalizeCache({});
96
- expect(cache?.generatedAt).toBeTypeOf("string");
97
- expect(missingFields).toContain("generatedAt");
98
- });
99
-
100
- it("handles graph.events missing while graph exists", () => {
101
- const { cache, missingFields } = normalizeCache({ graph: {} });
102
- expect(cache?.graph).toEqual({ events: [] });
103
- expect(missingFields).toContain("graph.events");
104
- });
105
- });
package/src/lib/cache.ts DELETED
@@ -1,312 +0,0 @@
1
- // Studio data layer — fetch + cache the `.nwire/manifest.json` (built by
2
- // `nwire cache`). The shape mirrors `@nwire/scan`'s Cache interface but we
3
- // duplicate it locally so the Studio package doesn't need to depend on
4
- // scan at runtime (the manifest is just JSON).
5
-
6
- import { ref, shallowRef, type Ref } from "vue";
7
-
8
- /**
9
- * `file:line:column` of a `defineX()` call. Studio uses it to render
10
- * IDE-open chips ("source" pill) next to every primitive.
11
- */
12
- export interface SourceLocationEntry {
13
- file: string;
14
- line: number;
15
- column?: number;
16
- }
17
-
18
- export interface ActionEntry {
19
- name: string;
20
- description?: string;
21
- app: string;
22
- /** Zod input schema serialized by the scanner. */
23
- inputSchema?: unknown;
24
- retry?: object;
25
- policy?: string | readonly string[];
26
- /** True when a handler was wired via defineAction({handler}). */
27
- hasInlineHandler: boolean;
28
- /** Event names this action emits, in declaration order. */
29
- emits: string[];
30
- /** True when the action def carries the `.public()` mark. */
31
- public?: boolean;
32
- persona?: string;
33
- journeyStep?: string;
34
- capability?: string;
35
- slo?: { p95LatencyMs?: number; successRate?: number };
36
- tags?: string[];
37
- source?: SourceLocationEntry;
38
- }
39
-
40
- export interface ExternalCallEntry {
41
- name: string;
42
- description?: string;
43
- app: string;
44
- target?: { provider: string; endpoint: string; region?: string };
45
- request?: object;
46
- response?: object;
47
- hasIdempotencyKey?: boolean;
48
- slo?: { p95LatencyMs?: number; successRate?: number };
49
- retry?: { max: number; backoff?: string };
50
- tags?: string[];
51
- source?: SourceLocationEntry;
52
- }
53
-
54
- export interface InboundWebhookEntry {
55
- name: string;
56
- description?: string;
57
- app: string;
58
- source?: SourceLocationEntry;
59
- path?: string;
60
- hasSignatureVerifier?: boolean;
61
- dedupe?: { window: string };
62
- discriminator?: string;
63
- routes?: Record<string, string>;
64
- tags?: string[];
65
- }
66
-
67
- export interface OutboxEntry {
68
- name: string;
69
- description?: string;
70
- app: string;
71
- publishes?: string[];
72
- flushIntervalMs?: number;
73
- maxBatch?: number;
74
- tags?: string[];
75
- source?: SourceLocationEntry;
76
- }
77
-
78
- export interface InboxEntry {
79
- name: string;
80
- description?: string;
81
- app: string;
82
- window?: string;
83
- on?: string[];
84
- tags?: string[];
85
- source?: SourceLocationEntry;
86
- }
87
-
88
- export interface CronEntry {
89
- name: string;
90
- description?: string;
91
- app: string;
92
- schedule: string;
93
- dispatches?: string;
94
- timezone?: string;
95
- tags?: string[];
96
- source?: SourceLocationEntry;
97
- }
98
-
99
- /**
100
- * Declared workflow (the unified primitive for reactions, translators, and
101
- * sagas). EventStorming renders each as a synthesized policy node fanning
102
- * out from its subscribed events to its declared dispatches.
103
- */
104
- export interface WorkflowEntry {
105
- name: string;
106
- app: string;
107
- description?: string;
108
- /** Event names this workflow listens to. */
109
- subscribesTo: string[];
110
- /** Action names this workflow dispatches inside its body. */
111
- dispatches: string[];
112
- /** True when the workflow def carries the `.public()` mark. */
113
- public?: boolean;
114
- source?: SourceLocationEntry;
115
- }
116
-
117
- export interface EventEntry {
118
- name: string;
119
- description?: string;
120
- app: string;
121
- /** True when the event def carries the `.public()` mark — reaches outbound sinks. */
122
- public?: boolean;
123
- version?: number;
124
- audience?: string[];
125
- source?: SourceLocationEntry;
126
- }
127
-
128
- export interface ActorEntry {
129
- name: string;
130
- app: string;
131
- /** State names declared in the actor's `states` map. */
132
- states: string[];
133
- source?: SourceLocationEntry;
134
- }
135
-
136
- export interface ProjectionEntry {
137
- name: string;
138
- app: string;
139
- description?: string;
140
- /** Event names this projection folds in. */
141
- listens: string[];
142
- freshness?: { p95MsBehindStream?: number };
143
- source?: SourceLocationEntry;
144
- }
145
-
146
- export interface QueryEntry {
147
- name: string;
148
- description?: string;
149
- app: string;
150
- /** Backing projection name (projection-form queries only). */
151
- projection?: string;
152
- /** True when the query def carries the `.public()` mark. */
153
- public?: boolean;
154
- slo?: { p95LatencyMs?: number };
155
- cacheable?: boolean;
156
- source?: SourceLocationEntry;
157
- }
158
-
159
- export interface AppEntry {
160
- name: string;
161
- description?: string;
162
- /** Plugin names installed on this app, in install order. */
163
- plugins: string[];
164
- tenantModel?: "single" | "per-org" | "per-account" | "per-workspace";
165
- tenantKey?: string;
166
- }
167
-
168
- export interface SinkEntry {
169
- name: string;
170
- app: string;
171
- /** Adapter kind tag — "bullmq", "nats", "capture", etc. */
172
- kind?: string;
173
- position: "early" | "middle" | "terminal";
174
- direction: "outbound";
175
- }
176
-
177
- export interface RouteEntry {
178
- method: string;
179
- path: string;
180
- target?: string;
181
- targetKind?: "action" | "query" | "resolver";
182
- app?: string;
183
- }
184
-
185
- export interface ResolverEntry {
186
- operation: string;
187
- version: number;
188
- status: "draft" | "active" | "deprecated" | "sunset";
189
- summary?: string;
190
- description?: string;
191
- tags?: string[];
192
- app: string;
193
- bindings: Array<{
194
- transport: "rest" | "graphql" | "cli";
195
- method?: string;
196
- path?: string;
197
- }>;
198
- params?: object;
199
- query?: object;
200
- body?: object;
201
- returns: Array<{ status: number; kind: "single" | "list" | "empty" }>;
202
- errors: Array<{ code: string; status: number }>;
203
- successor?: { operation: string; version: number };
204
- sunsetDate?: string;
205
- }
206
-
207
- /**
208
- * One directed edge in the static event graph as emitted by `@nwire/scan`.
209
- * `from` and `to` are name identifiers (action / event / workflow /
210
- * projection) per the `via` discriminator. Multiple edges share the
211
- * same source or target — e.g. one event consumed by N projections
212
- * shows up as N "folds" edges.
213
- */
214
- export interface EventGraphEdge {
215
- from: string;
216
- to: string;
217
- via: "emits" | "folds" | "subscribes" | "dispatches";
218
- }
219
-
220
- export interface HookEntry {
221
- id: string;
222
- name: string;
223
- chain: number;
224
- listeners: number;
225
- source?: SourceLocationEntry;
226
- }
227
-
228
- export interface PluginEntry {
229
- name: string;
230
- kind: "plugin" | "module";
231
- app: string;
232
- source?: SourceLocationEntry;
233
- }
234
-
235
- /**
236
- * One DI registration on an app's container — surfaced by
237
- * `container.list()` and emitted by `@nwire/scan` to `.nwire/di.json`.
238
- * Studio's DI page renders these as a "what's bound + who registered it"
239
- * table.
240
- */
241
- export interface DIBindingEntry {
242
- name: string;
243
- kind: "singleton" | "transient";
244
- app: string;
245
- source?: SourceLocationEntry;
246
- }
247
-
248
- export interface Cache {
249
- generatedAt: string;
250
- apps: AppEntry[];
251
- actions: ActionEntry[];
252
- events: EventEntry[];
253
- actors: ActorEntry[];
254
- projections: ProjectionEntry[];
255
- queries: QueryEntry[];
256
- resolvers: ResolverEntry[];
257
- routes: RouteEntry[];
258
- workflows: WorkflowEntry[];
259
- externalCalls: ExternalCallEntry[];
260
- inboundWebhooks: InboundWebhookEntry[];
261
- outboxes: OutboxEntry[];
262
- inboxes: InboxEntry[];
263
- crons: CronEntry[];
264
- hooks: HookEntry[];
265
- plugins: PluginEntry[];
266
- sinks: SinkEntry[];
267
- bindings: DIBindingEntry[];
268
- graph: { events: EventGraphEdge[] };
269
- }
270
-
271
- import { normalizeCache } from "./normalize-cache";
272
-
273
- const cache: Ref<Cache | null> = shallowRef(null);
274
- const loading = ref(false);
275
- const error: Ref<string | null> = ref(null);
276
- /**
277
- * Fields the cache was missing — non-fatal. Studio renders the affected
278
- * pages with empty states + shows a "rebuild cache" hint to the operator.
279
- */
280
- const missingFields: Ref<readonly string[]> = ref([]);
281
-
282
- export function useCache() {
283
- if (!cache.value && !loading.value) {
284
- void load();
285
- }
286
- return { cache, loading, error, missingFields, reload: load };
287
- }
288
-
289
- async function load(): Promise<void> {
290
- loading.value = true;
291
- error.value = null;
292
- missingFields.value = [];
293
- try {
294
- const res = await fetch("/__nwire/manifest.json");
295
- if (!res.ok) {
296
- error.value = `Cache fetch failed (${res.status}). Run \`nwire cache\` to build it.`;
297
- return;
298
- }
299
- const raw = await res.json();
300
- const result = normalizeCache(raw);
301
- if (result.fatalError) {
302
- error.value = result.fatalError;
303
- return;
304
- }
305
- cache.value = result.cache;
306
- missingFields.value = result.missingFields;
307
- } catch (e) {
308
- error.value = (e as Error).message;
309
- } finally {
310
- loading.value = false;
311
- }
312
- }