station-kit 1.0.8 → 1.0.9

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 (172) hide show
  1. package/.next/standalone/package.json +3 -1
  2. package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
  3. package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +76 -17
  4. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +11 -4
  5. package/.next/standalone/packages/station-kit/.next/build-manifest.json +3 -3
  6. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +105 -9
  7. package/.next/standalone/packages/station-kit/.next/routes-manifest.json +49 -0
  8. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page.js +2 -2
  9. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
  11. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +7 -7
  12. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page.js +2 -2
  13. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js +2 -0
  15. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js.nft.json +1 -0
  16. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -0
  17. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js +2 -0
  18. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js.nft.json +1 -0
  19. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -0
  20. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js +2 -0
  21. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js.nft.json +1 -0
  22. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -0
  23. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -0
  24. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.meta +7 -0
  25. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +25 -0
  26. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page.js +2 -2
  27. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
  28. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
  29. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +8 -8
  30. package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
  31. package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +8 -8
  32. package/.next/standalone/packages/station-kit/.next/server/app/page.js +2 -2
  33. package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js +2 -0
  35. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js.nft.json +1 -0
  36. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -0
  37. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -0
  38. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.meta +7 -0
  39. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +25 -0
  40. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page.js +2 -2
  41. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js +2 -0
  43. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js.nft.json +1 -0
  44. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -0
  45. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js +2 -0
  46. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js.nft.json +1 -0
  47. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -0
  48. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -0
  49. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.meta +7 -0
  50. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +25 -0
  51. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js +2 -0
  52. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js.nft.json +1 -0
  53. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
  54. package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -0
  55. package/.next/standalone/packages/station-kit/.next/server/app/schedules.meta +7 -0
  56. package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +25 -0
  57. package/.next/standalone/packages/station-kit/.next/server/app/settings/page.js +2 -2
  58. package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
  60. package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +8 -8
  61. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page.js +2 -2
  62. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/packages/station-kit/.next/server/app/signals/page.js +2 -2
  64. package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
  66. package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +8 -8
  67. package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +11 -4
  68. package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
  69. package/.next/standalone/packages/station-kit/.next/server/chunks/535.js +2 -0
  70. package/.next/standalone/packages/station-kit/.next/server/chunks/606.js +14 -14
  71. package/.next/standalone/packages/station-kit/.next/server/chunks/783.js +3 -3
  72. package/.next/standalone/packages/station-kit/.next/server/middleware-build-manifest.js +1 -1
  73. package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
  74. package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
  75. package/.next/standalone/packages/station-kit/.next/server/pages/_app.js +1 -1
  76. package/.next/standalone/packages/station-kit/.next/server/pages/_document.js +1 -1
  77. package/.next/standalone/packages/station-kit/.next/server/pages/_error.js +9 -9
  78. package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
  79. package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
  80. package/.next/standalone/packages/station-kit/.next/static/chunks/145-9e370afd2e5aba39.js +1 -0
  81. package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +1 -0
  82. package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +1 -0
  83. package/.next/standalone/packages/station-kit/.next/static/chunks/935-dff12960528de017.js +1 -0
  84. package/.next/standalone/packages/station-kit/.next/static/chunks/app/_not-found/{page-ce21b4ba9038a5a7.js → page-67ef312aee40cfeb.js} +1 -1
  85. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-fe2f5467a0c68fef.js +1 -0
  86. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/page-0d2505242014f51e.js +1 -0
  87. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +1 -0
  88. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/new/page-3d02707043d24dc7.js +1 -0
  89. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +1 -0
  90. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +1 -0
  91. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +1 -0
  92. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +1 -0
  93. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +1 -0
  94. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/[id]/page-435f67be180b8e4f.js +1 -0
  95. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/new/page-f697c289c813496a.js +1 -0
  96. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +1 -0
  97. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +1 -0
  98. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +1 -0
  99. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +1 -0
  100. package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-a3774a320f58a018.js +1 -0
  101. package/.next/standalone/packages/station-kit/.next/static/demLiQWDy62JuUkBw-ILG/_buildManifest.js +1 -0
  102. package/.next/standalone/packages/station-kit/package.json +5 -1
  103. package/dist/config/schema.d.ts +13 -0
  104. package/dist/config/schema.d.ts.map +1 -1
  105. package/dist/config/schema.js +1 -0
  106. package/dist/config/schema.js.map +1 -1
  107. package/dist/server/auth/keys.d.ts +56 -8
  108. package/dist/server/auth/keys.d.ts.map +1 -1
  109. package/dist/server/auth/keys.js +155 -53
  110. package/dist/server/auth/keys.js.map +1 -1
  111. package/dist/server/index.d.ts +2 -2
  112. package/dist/server/index.d.ts.map +1 -1
  113. package/dist/server/index.js +53 -6
  114. package/dist/server/index.js.map +1 -1
  115. package/dist/server/middleware/auth.js +1 -1
  116. package/dist/server/middleware/auth.js.map +1 -1
  117. package/dist/server/routes/v1/definitions.d.ts +21 -0
  118. package/dist/server/routes/v1/definitions.d.ts.map +1 -0
  119. package/dist/server/routes/v1/definitions.js +139 -0
  120. package/dist/server/routes/v1/definitions.js.map +1 -0
  121. package/dist/server/routes/v1/expressions.d.ts +3 -0
  122. package/dist/server/routes/v1/expressions.d.ts.map +1 -0
  123. package/dist/server/routes/v1/expressions.js +56 -0
  124. package/dist/server/routes/v1/expressions.js.map +1 -0
  125. package/dist/server/routes/v1/keys.js +3 -3
  126. package/dist/server/routes/v1/keys.js.map +1 -1
  127. package/dist/server/routes/v1/schedules.d.ts +10 -0
  128. package/dist/server/routes/v1/schedules.d.ts.map +1 -0
  129. package/dist/server/routes/v1/schedules.js +169 -0
  130. package/dist/server/routes/v1/schedules.js.map +1 -0
  131. package/dist/server/routes/v1/trigger.d.ts.map +1 -1
  132. package/dist/server/routes/v1/trigger.js +21 -0
  133. package/dist/server/routes/v1/trigger.js.map +1 -1
  134. package/package.json +11 -7
  135. package/src/app/broadcasts/components/broadcast-builder.tsx +535 -0
  136. package/src/app/broadcasts/components/dag-editor.tsx +510 -0
  137. package/src/app/broadcasts/dyn/[name]/dynamic-detail.tsx +243 -0
  138. package/src/app/broadcasts/dyn/[name]/page.tsx +10 -0
  139. package/src/app/broadcasts/dyn/[name]/v/[n]/page.tsx +10 -0
  140. package/src/app/broadcasts/dyn/[name]/v/[n]/version-view.tsx +285 -0
  141. package/src/app/broadcasts/new/page.tsx +102 -0
  142. package/src/app/broadcasts/page.tsx +176 -91
  143. package/src/app/components/api-panel.tsx +151 -0
  144. package/src/app/components/shell.tsx +23 -0
  145. package/src/app/hooks/use-api.ts +117 -0
  146. package/src/app/playground/expression/page.tsx +245 -0
  147. package/src/app/schedules/[id]/page.tsx +10 -0
  148. package/src/app/schedules/[id]/schedule-editor.tsx +195 -0
  149. package/src/app/schedules/components/schedule-form.tsx +140 -0
  150. package/src/app/schedules/new/page.tsx +166 -0
  151. package/src/app/schedules/page.tsx +126 -0
  152. package/src/config/schema.ts +14 -0
  153. package/src/server/auth/keys.ts +191 -56
  154. package/src/server/index.ts +72 -8
  155. package/src/server/middleware/auth.ts +1 -1
  156. package/src/server/routes/v1/definitions.ts +164 -0
  157. package/src/server/routes/v1/expressions.ts +76 -0
  158. package/src/server/routes/v1/keys.ts +3 -3
  159. package/src/server/routes/v1/schedules.ts +176 -0
  160. package/src/server/routes/v1/trigger.ts +27 -0
  161. package/.next/standalone/packages/station-kit/.next/static/chunks/580-f007f4d4c050db4e.js +0 -1
  162. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-a0a20cccda13a0e9.js +0 -1
  163. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-937eb876f9087bc9.js +0 -1
  164. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-68cd71116ba65cd8.js +0 -1
  165. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-70b0c0958c03459a.js +0 -1
  166. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-01f8040619fe56c5.js +0 -1
  167. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-beac11049f90da31.js +0 -1
  168. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-931e6a38a4a53d25.js +0 -1
  169. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-6a123a355d93fec5.js +0 -1
  170. package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-0a7b2e66ecbe3f0a.js +0 -1
  171. package/.next/standalone/packages/station-kit/.next/static/xYd6dn0Ox68DaamIrH_pB/_buildManifest.js +0 -1
  172. /package/.next/standalone/packages/station-kit/.next/static/{xYd6dn0Ox68DaamIrH_pB → demLiQWDy62JuUkBw-ILG}/_ssgManifest.js +0 -0
@@ -0,0 +1,535 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import type {
5
+ DynamicBroadcastSpec,
6
+ DynamicValidationResult,
7
+ SignalMeta,
8
+ } from "../../hooks/use-api";
9
+ import { useApi } from "../../hooks/use-api";
10
+ import { DagEditor } from "./dag-editor";
11
+ import { ApiPanel } from "../../components/api-panel";
12
+
13
+ export interface BroadcastBuilderProps {
14
+ json: string;
15
+ onChange: (next: string) => void;
16
+ validation: DynamicValidationResult | null;
17
+ /** Reset the validation state from the parent (e.g. on json change). */
18
+ onValidationStale?: () => void;
19
+ signals: SignalMeta[];
20
+ onValidate: () => void;
21
+ onSave: () => void;
22
+ saveLabel: string;
23
+ saving: boolean;
24
+ error: string | null;
25
+ /** Optional second action (e.g. "Trigger"). */
26
+ rightActions?: React.ReactNode;
27
+ /** Used for the API panel and import-from-existing-name. */
28
+ specName?: string;
29
+ }
30
+
31
+ /**
32
+ * The unified broadcast builder. Three views, switchable by tab; JSON is the
33
+ * truth — visual + dry-run keep `onChange` firing back into it.
34
+ *
35
+ * 1. Visual — drag-from-palette DAG editor with per-node form.
36
+ * 2. JSON — paste/edit the DynamicBroadcastSpec; live validation.
37
+ * 3. Dry-run — evaluates each node's input expression against a sample
38
+ * `{ input, upstream }` context and renders the trace.
39
+ */
40
+ export function BroadcastBuilder(props: BroadcastBuilderProps) {
41
+ const { json, onChange, validation, onValidationStale, signals, onValidate, onSave, saveLabel, saving, error, rightActions, specName } = props;
42
+
43
+ const [tab, setTab] = useState<"visual" | "json" | "dryrun">("visual");
44
+ const dagCommitRef = useRef<(() => Promise<void>) | null>(null);
45
+
46
+ // Any change to the JSON invalidates the previous validation result — the
47
+ // user shouldn't be blocked by stale errors after fixing them, and the Save
48
+ // button shouldn't stay disabled on already-corrected specs.
49
+ function emitChange(next: string) {
50
+ onChange(next);
51
+ onValidationStale?.();
52
+ }
53
+
54
+ // Wraps the parent's save so any in-flight expression edits in the DAG
55
+ // editor are flushed before the save fires.
56
+ async function handleSaveClick() {
57
+ try {
58
+ await dagCommitRef.current?.();
59
+ } catch {
60
+ // commit errors are surfaced inline in the inspector; don't block save
61
+ }
62
+ onSave();
63
+ }
64
+
65
+ // Try to parse the JSON to provide the visual + dry-run views with a
66
+ // structured spec. If parsing fails, those tabs surface a parse error.
67
+ const parsed = useMemo<{ spec: DynamicBroadcastSpec | null; error: string | null }>(() => {
68
+ if (!json.trim()) return { spec: null, error: null };
69
+ try {
70
+ return { spec: JSON.parse(json) as DynamicBroadcastSpec, error: null };
71
+ } catch (err) {
72
+ return { spec: null, error: err instanceof Error ? err.message : String(err) };
73
+ }
74
+ }, [json]);
75
+
76
+ function setSpec(next: DynamicBroadcastSpec) {
77
+ emitChange(JSON.stringify(next, null, 2));
78
+ }
79
+
80
+ function handleImport(file: File) {
81
+ const reader = new FileReader();
82
+ reader.onload = () => {
83
+ const text = String(reader.result ?? "");
84
+ try {
85
+ // Validate parseable; allow either spec or wrapping export object.
86
+ const parsed = JSON.parse(text);
87
+ const spec = parsed?.spec ?? parsed;
88
+ emitChange(JSON.stringify(spec, null, 2));
89
+ } catch (err) {
90
+ alert(`Import failed: ${err instanceof Error ? err.message : String(err)}`);
91
+ }
92
+ };
93
+ reader.readAsText(file);
94
+ }
95
+
96
+ function handleExport() {
97
+ const blob = new Blob([json], { type: "application/json" });
98
+ const url = URL.createObjectURL(blob);
99
+ const a = document.createElement("a");
100
+ a.href = url;
101
+ a.download = `${parsed.spec?.name ?? specName ?? "broadcast"}.json`;
102
+ document.body.appendChild(a);
103
+ a.click();
104
+ document.body.removeChild(a);
105
+ URL.revokeObjectURL(url);
106
+ }
107
+
108
+ return (
109
+ <div>
110
+ <div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", borderBottom: "1px solid var(--border)" }}>
111
+ <TabButton active={tab === "visual"} onClick={() => setTab("visual")}>Visual</TabButton>
112
+ <TabButton active={tab === "json"} onClick={() => setTab("json")}>JSON</TabButton>
113
+ <TabButton active={tab === "dryrun"} onClick={() => setTab("dryrun")}>Dry-run</TabButton>
114
+ <div style={{ marginLeft: "auto", display: "flex", gap: "0.25rem" }}>
115
+ <ImportButton onFile={handleImport} />
116
+ <button className="btn btn--sm" type="button" onClick={handleExport}>Export</button>
117
+ </div>
118
+ </div>
119
+
120
+ {tab === "visual" && (
121
+ parsed.spec ? (
122
+ <DagEditor
123
+ spec={parsed.spec}
124
+ signals={signals}
125
+ onChange={setSpec}
126
+ commitRef={dagCommitRef}
127
+ />
128
+ ) : (
129
+ <div style={{
130
+ padding: "0.75rem",
131
+ background: "var(--error-bg, #fee)",
132
+ color: "var(--error, #b00)",
133
+ borderRadius: "4px",
134
+ fontSize: "0.8125rem",
135
+ }}>
136
+ JSON parse error — switch to JSON tab to fix: {parsed.error}
137
+ </div>
138
+ )
139
+ )}
140
+
141
+ {tab === "json" && (
142
+ <JsonEditor
143
+ json={json}
144
+ onChange={emitChange}
145
+ signals={signals}
146
+ />
147
+ )}
148
+
149
+ {tab === "dryrun" && (
150
+ <DryRunPanel spec={parsed.spec} parseError={parsed.error} />
151
+ )}
152
+
153
+ <div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", alignItems: "center" }}>
154
+ <button className="btn" onClick={onValidate} disabled={saving}>Validate</button>
155
+ <button
156
+ className="btn btn--primary"
157
+ onClick={handleSaveClick}
158
+ disabled={saving || (validation !== null && !validation.ok)}
159
+ >
160
+ {saveLabel}
161
+ </button>
162
+ {rightActions}
163
+ </div>
164
+
165
+ {error && (
166
+ <div style={errorBlockStyle}>{error}</div>
167
+ )}
168
+
169
+ {validation && (
170
+ <div style={{
171
+ marginTop: "0.75rem",
172
+ padding: "0.625rem 0.75rem",
173
+ background: validation.ok ? "var(--success-bg, #efe)" : "var(--error-bg, #fee)",
174
+ color: validation.ok ? "var(--success, #060)" : "var(--error, #b00)",
175
+ border: `1px solid ${validation.ok ? "var(--success, #060)" : "var(--error, #b00)"}`,
176
+ borderRadius: "4px",
177
+ fontSize: "0.8125rem",
178
+ }}>
179
+ {validation.ok ? (
180
+ <span>Validation passed.</span>
181
+ ) : (
182
+ <div>
183
+ <div style={{ marginBottom: "0.5rem", fontWeight: 600 }}>
184
+ {validation.errors.length} validation error{validation.errors.length === 1 ? "" : "s"}:
185
+ </div>
186
+ <ul style={{ margin: 0, paddingLeft: "1rem" }}>
187
+ {validation.errors.map((e, i) => (
188
+ <li key={i} className="mono" style={{ fontSize: "0.75rem" }}>
189
+ <strong>{e.node}{e.field ? `.${e.field}` : ""}</strong>: {e.message}
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ </div>
194
+ )}
195
+ </div>
196
+ )}
197
+
198
+ <ApiPanel
199
+ title="Save this broadcast"
200
+ snippets={[
201
+ {
202
+ label: "Validate",
203
+ method: "POST",
204
+ path: "/api/v1/broadcast-definitions/validate",
205
+ body: parsed.spec ?? json,
206
+ },
207
+ {
208
+ label: "Save (creates a new version)",
209
+ method: "POST",
210
+ path: "/api/v1/broadcast-definitions",
211
+ body: parsed.spec ?? json,
212
+ },
213
+ ...(parsed.spec
214
+ ? [{
215
+ label: "Trigger",
216
+ method: "POST" as const,
217
+ path: "/api/v1/trigger-dynamic-broadcast",
218
+ body: { broadcastName: parsed.spec.name, input: {} },
219
+ }]
220
+ : []),
221
+ ]}
222
+ />
223
+ </div>
224
+ );
225
+ }
226
+
227
+ function ImportButton({ onFile }: { onFile: (file: File) => void }) {
228
+ const ref = useRef<HTMLInputElement | null>(null);
229
+ return (
230
+ <>
231
+ <input
232
+ ref={ref}
233
+ type="file"
234
+ accept="application/json,.json"
235
+ style={{ display: "none" }}
236
+ onChange={(e) => {
237
+ const f = e.target.files?.[0];
238
+ if (f) onFile(f);
239
+ if (ref.current) ref.current.value = "";
240
+ }}
241
+ />
242
+ <button className="btn btn--sm" type="button" onClick={() => ref.current?.click()}>
243
+ Import
244
+ </button>
245
+ </>
246
+ );
247
+ }
248
+
249
+ function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
250
+ return (
251
+ <button
252
+ type="button"
253
+ onClick={onClick}
254
+ style={{
255
+ padding: "0.375rem 0.75rem",
256
+ border: "none",
257
+ background: "transparent",
258
+ borderBottom: active ? "2px solid var(--text)" : "2px solid transparent",
259
+ color: active ? "var(--text)" : "var(--muted)",
260
+ cursor: "pointer",
261
+ fontSize: "0.875rem",
262
+ fontFamily: "var(--mono-font, monospace)",
263
+ marginBottom: "-1px",
264
+ }}
265
+ >
266
+ {children}
267
+ </button>
268
+ );
269
+ }
270
+
271
+ function JsonEditor({ json, onChange, signals }: { json: string; onChange: (s: string) => void; signals: SignalMeta[] }) {
272
+ return (
273
+ <div style={{ display: "grid", gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr)", gap: "1rem" }}>
274
+ <div>
275
+ <textarea
276
+ className="mono code-input"
277
+ value={json}
278
+ onChange={(e) => onChange(e.target.value)}
279
+ spellCheck={false}
280
+ style={{
281
+ width: "100%",
282
+ minHeight: "420px",
283
+ padding: "0.75rem",
284
+ border: "1px solid var(--border)",
285
+ borderRadius: "4px",
286
+ background: "var(--surface)",
287
+ color: "var(--text)",
288
+ fontSize: "0.8125rem",
289
+ lineHeight: 1.5,
290
+ fontFamily: "var(--mono-font, monospace)",
291
+ resize: "vertical",
292
+ }}
293
+ />
294
+ </div>
295
+
296
+ <aside>
297
+ <div className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)", marginBottom: "0.25rem" }}>
298
+ Available signals ({signals.length})
299
+ </div>
300
+ <div style={{
301
+ maxHeight: "180px",
302
+ overflowY: "auto",
303
+ border: "1px solid var(--border)",
304
+ borderRadius: "4px",
305
+ padding: "0.5rem",
306
+ background: "var(--surface)",
307
+ }}>
308
+ {signals.length === 0 ? (
309
+ <div style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>No signals registered.</div>
310
+ ) : (
311
+ <ul style={{ margin: 0, padding: 0, listStyle: "none" }}>
312
+ {signals.map((s) => (
313
+ <li key={s.name} className="mono" style={{ padding: "0.25rem 0", fontSize: "0.8125rem", borderBottom: "1px dashed var(--border)" }}>
314
+ {s.name}
315
+ </li>
316
+ ))}
317
+ </ul>
318
+ )}
319
+ </div>
320
+
321
+ <div className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)", marginTop: "1rem", marginBottom: "0.5rem" }}>
322
+ Expression refs
323
+ </div>
324
+ <ul style={{ margin: 0, paddingLeft: "1rem", fontSize: "0.75rem", color: "var(--muted)" }}>
325
+ <li className="mono">input.foo — broadcast trigger input</li>
326
+ <li className="mono">upstream.nodeName.field — output of an upstream node</li>
327
+ <li className="mono">nodeName.field — shorthand for upstream</li>
328
+ </ul>
329
+
330
+ <div className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)", marginTop: "1rem", marginBottom: "0.5rem" }}>
331
+ Escape hatch
332
+ </div>
333
+ <p style={{ fontSize: "0.75rem", color: "var(--muted)", lineHeight: 1.5, margin: 0 }}>
334
+ The expression language is intentionally minimal. If you can&apos;t express something here, write a code-defined signal that does the logic and reference it from this graph.
335
+ </p>
336
+ </aside>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ function DryRunPanel({ spec, parseError }: { spec: DynamicBroadcastSpec | null; parseError: string | null }) {
342
+ const api = useApi();
343
+ const [inputJson, setInputJson] = useState("{}");
344
+ const [trace, setTrace] = useState<DryRunTraceEntry[] | null>(null);
345
+ const [running, setRunning] = useState(false);
346
+ const [error, setError] = useState<string | null>(null);
347
+
348
+ if (parseError) {
349
+ return (
350
+ <div style={{
351
+ padding: "0.75rem",
352
+ background: "var(--error-bg, #fee)",
353
+ color: "var(--error, #b00)",
354
+ borderRadius: "4px",
355
+ fontSize: "0.8125rem",
356
+ }}>JSON parse error — fix the JSON tab first: {parseError}</div>
357
+ );
358
+ }
359
+
360
+ if (!spec) {
361
+ return <div style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>No spec to dry-run.</div>;
362
+ }
363
+
364
+ async function dryRun() {
365
+ setRunning(true);
366
+ setError(null);
367
+ setTrace(null);
368
+ let parsedInput: unknown;
369
+ try {
370
+ parsedInput = JSON.parse(inputJson);
371
+ } catch (err) {
372
+ setError(`Input JSON parse error: ${err instanceof Error ? err.message : String(err)}`);
373
+ setRunning(false);
374
+ return;
375
+ }
376
+ if (!spec) {
377
+ setRunning(false);
378
+ return;
379
+ }
380
+ try {
381
+ const result = await runTrace(api, spec, parsedInput);
382
+ setTrace(result);
383
+ } catch (err) {
384
+ setError(err instanceof Error ? err.message : String(err));
385
+ } finally {
386
+ setRunning(false);
387
+ }
388
+ }
389
+
390
+ return (
391
+ <div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1.5fr)", gap: "1rem" }}>
392
+ <section>
393
+ <div className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)", marginBottom: "0.25rem" }}>
394
+ Sample broadcast input (JSON)
395
+ </div>
396
+ <textarea
397
+ className="mono"
398
+ value={inputJson}
399
+ onChange={(e) => setInputJson(e.target.value)}
400
+ rows={10}
401
+ spellCheck={false}
402
+ style={{
403
+ width: "100%",
404
+ padding: "0.625rem",
405
+ border: "1px solid var(--border)",
406
+ borderRadius: "4px",
407
+ background: "var(--surface)",
408
+ color: "var(--text)",
409
+ fontSize: "0.8125rem",
410
+ fontFamily: "var(--mono-font, monospace)",
411
+ resize: "vertical",
412
+ }}
413
+ />
414
+ <div style={{ marginTop: "0.5rem" }}>
415
+ <button className="btn btn--primary btn--sm" onClick={dryRun} disabled={running}>
416
+ {running ? "Tracing..." : "Run dry-run"}
417
+ </button>
418
+ </div>
419
+ {error && <div style={errorBlockStyle}>{error}</div>}
420
+ <p className="mono" style={{ fontSize: "0.6875rem", color: "var(--muted)", marginTop: "0.5rem" }}>
421
+ Evaluates each node&apos;s input expression and when guard against synthetic upstream outputs (mocked from upstream signal output schemas where available, else &quot;&lt;mock-of-nodeName&gt;&quot;).
422
+ </p>
423
+ </section>
424
+
425
+ <section>
426
+ <div className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)", marginBottom: "0.25rem" }}>
427
+ Trace
428
+ </div>
429
+ {!trace ? (
430
+ <div style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>Run a trace to see results.</div>
431
+ ) : (
432
+ <ol style={{ margin: 0, padding: 0, listStyle: "none" }}>
433
+ {trace.map((t, i) => (
434
+ <li key={i} style={{ borderLeft: `3px solid ${t.skipped ? "var(--muted)" : t.error ? "var(--error, #b00)" : "var(--success, #060)"}`, paddingLeft: "0.5rem", marginBottom: "0.5rem" }}>
435
+ <div className="mono" style={{ fontSize: "0.8125rem", fontWeight: 600 }}>
436
+ {t.node}{t.skipped && <span style={{ color: "var(--muted)" }}> (skipped)</span>}{t.error && <span style={{ color: "var(--error, #b00)" }}> (error)</span>}
437
+ </div>
438
+ {t.error ? (
439
+ <pre className="mono" style={{ fontSize: "0.75rem", color: "var(--error, #b00)", margin: 0 }}>{t.error}</pre>
440
+ ) : t.skipped ? (
441
+ <div className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)" }}>{t.skipReason}</div>
442
+ ) : (
443
+ <pre className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)", margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}>{JSON.stringify(t.input, null, 2)}</pre>
444
+ )}
445
+ </li>
446
+ ))}
447
+ </ol>
448
+ )}
449
+ </section>
450
+ </div>
451
+ );
452
+ }
453
+
454
+ interface DryRunTraceEntry {
455
+ node: string;
456
+ input?: unknown;
457
+ skipped?: boolean;
458
+ skipReason?: string;
459
+ error?: string;
460
+ }
461
+
462
+ async function runTrace(
463
+ api: ReturnType<typeof useApi>,
464
+ spec: DynamicBroadcastSpec,
465
+ input: unknown,
466
+ ): Promise<DryRunTraceEntry[]> {
467
+ // Topo-sort by walking nodes; assume the spec is well-formed enough to dry-run
468
+ // (broken graphs surface their actual errors via /validate).
469
+ const trace: DryRunTraceEntry[] = [];
470
+ const upstream: Record<string, unknown> = {};
471
+ const visited = new Set<string>();
472
+ const order: typeof spec.nodes = [];
473
+ const remaining = [...spec.nodes];
474
+
475
+ let safety = spec.nodes.length * spec.nodes.length;
476
+ while (remaining.length > 0 && safety-- > 0) {
477
+ const idx = remaining.findIndex((n) => n.dependsOn.every((d) => visited.has(d)));
478
+ if (idx === -1) break;
479
+ const [n] = remaining.splice(idx, 1);
480
+ order.push(n);
481
+ visited.add(n.name);
482
+ }
483
+
484
+ for (const node of order) {
485
+ let runs = true;
486
+ if (node.when) {
487
+ try {
488
+ const res = await api.evaluateExpression(node.when, { input, upstream });
489
+ if (!res.data.value) {
490
+ trace.push({ node: node.name, skipped: true, skipReason: 'when guard returned false' });
491
+ runs = false;
492
+ }
493
+ } catch (err) {
494
+ trace.push({ node: node.name, error: err instanceof Error ? err.message : String(err) });
495
+ continue;
496
+ }
497
+ }
498
+ if (!runs) continue;
499
+
500
+ let nodeInput: unknown;
501
+ if (node.input) {
502
+ try {
503
+ const res = await api.evaluateExpression(node.input, { input, upstream });
504
+ nodeInput = res.data.value;
505
+ } catch (err) {
506
+ trace.push({ node: node.name, error: err instanceof Error ? err.message : String(err) });
507
+ continue;
508
+ }
509
+ } else if (node.dependsOn.length === 0) {
510
+ nodeInput = input;
511
+ } else if (node.dependsOn.length === 1) {
512
+ nodeInput = upstream[node.dependsOn[0]];
513
+ } else {
514
+ const slice: Record<string, unknown> = {};
515
+ for (const d of node.dependsOn) slice[d] = upstream[d];
516
+ nodeInput = slice;
517
+ }
518
+ trace.push({ node: node.name, input: nodeInput });
519
+ // Mock upstream for descendants — use the resolved input as a stand-in
520
+ // for the signal's output. Real outputs aren't available without running
521
+ // the signals; this is an explicit best-effort trace.
522
+ upstream[node.name] = `<mock-of-${node.name}>`;
523
+ }
524
+ return trace;
525
+ }
526
+
527
+ const errorBlockStyle: React.CSSProperties = {
528
+ marginTop: "0.75rem",
529
+ padding: "0.625rem 0.75rem",
530
+ background: "var(--error-bg, #fee)",
531
+ color: "var(--error, #b00)",
532
+ border: "1px solid var(--error, #b00)",
533
+ borderRadius: "4px",
534
+ fontSize: "0.8125rem",
535
+ };