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,510 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import type { DynamicBroadcastSpec, DynamicNodeSpec, SignalMeta } from "../../hooks/use-api";
5
+ import { DAGView, type DagNode } from "../../components/dag-view";
6
+ import { useApi } from "../../hooks/use-api";
7
+
8
+ export interface DagEditorProps {
9
+ spec: DynamicBroadcastSpec;
10
+ onChange: (next: DynamicBroadcastSpec) => void;
11
+ signals: SignalMeta[];
12
+ /**
13
+ * Optional: when provided, the dry-run panel becomes available — the
14
+ * editor calls back so the parent can render trace results.
15
+ */
16
+ onDryRun?: (spec: DynamicBroadcastSpec) => void;
17
+ /**
18
+ * Parent-supplied ref that exposes a `commit()` callback. The parent should
19
+ * `await ref.current?.()` before triggering a save so any in-flight
20
+ * expression edits land in the spec.
21
+ */
22
+ commitRef?: React.MutableRefObject<(() => Promise<void>) | null>;
23
+ }
24
+
25
+ /**
26
+ * Visual DAG editor for DynamicBroadcastSpec. Sits alongside the JSON editor
27
+ * — the spec it produces flows back through `onChange` to keep the JSON view
28
+ * in sync. Supports:
29
+ * - signal palette with click-to-add
30
+ * - per-node inline form (signalName, dependsOn, input expr, when expr)
31
+ * - dependency picker dropdown sourced from existing node names
32
+ * - inline expression validation via /v1/expressions/validate
33
+ */
34
+ export function DagEditor({ spec, onChange, signals, commitRef }: DagEditorProps) {
35
+ const [selectedNode, setSelectedNode] = useState<string | null>(
36
+ spec.nodes[0]?.name ?? null,
37
+ );
38
+ const api = useApi();
39
+ const inspectorCommitRef = useRef<(() => Promise<void>) | null>(null);
40
+
41
+ // Expose a commit() to the parent so save flows can await pending edits.
42
+ useEffect(() => {
43
+ if (!commitRef) return;
44
+ commitRef.current = async () => {
45
+ await inspectorCommitRef.current?.();
46
+ };
47
+ return () => {
48
+ if (commitRef.current && commitRef === commitRef) commitRef.current = null;
49
+ };
50
+ }, [commitRef]);
51
+
52
+ const dagNodes: DagNode[] = useMemo(
53
+ () =>
54
+ spec.nodes.map((n) => ({
55
+ name: n.name,
56
+ signalName: n.signalName,
57
+ dependsOn: n.dependsOn,
58
+ })),
59
+ [spec.nodes],
60
+ );
61
+
62
+ const selected = spec.nodes.find((n) => n.name === selectedNode) ?? null;
63
+
64
+ function setNodes(updater: (prev: DynamicNodeSpec[]) => DynamicNodeSpec[]) {
65
+ onChange({ ...spec, nodes: updater(spec.nodes) });
66
+ }
67
+
68
+ function addNodeForSignal(signal: SignalMeta) {
69
+ const baseName = signal.name;
70
+ let name = baseName;
71
+ let suffix = 1;
72
+ const existing = new Set(spec.nodes.map((n) => n.name));
73
+ while (existing.has(name)) {
74
+ name = `${baseName}_${++suffix}`;
75
+ }
76
+ const newNode: DynamicNodeSpec = {
77
+ name,
78
+ signalName: signal.name,
79
+ dependsOn: spec.nodes.length > 0 ? [spec.nodes[spec.nodes.length - 1].name] : [],
80
+ };
81
+ setNodes((prev) => [...prev, newNode]);
82
+ setSelectedNode(name);
83
+ }
84
+
85
+ function removeNode(name: string) {
86
+ setNodes((prev) =>
87
+ prev
88
+ .filter((n) => n.name !== name)
89
+ .map((n) => ({ ...n, dependsOn: n.dependsOn.filter((d) => d !== name) })),
90
+ );
91
+ if (selectedNode === name) setSelectedNode(null);
92
+ }
93
+
94
+ function updateNode(name: string, patch: Partial<DynamicNodeSpec>) {
95
+ const renamed = "name" in patch && typeof patch.name === "string" && patch.name !== name;
96
+ const newName = renamed ? (patch.name as string) : name;
97
+
98
+ // Apply the patch and rewire dependencies referencing the old name in a
99
+ // *single* updater pass — two consecutive setNodes calls would each close
100
+ // over the pre-update spec and the second would clobber the first.
101
+ setNodes((prev) =>
102
+ prev.map((n) => {
103
+ const dependsOn = renamed
104
+ ? n.dependsOn.map((d) => (d === name ? newName : d))
105
+ : n.dependsOn;
106
+ if (n.name !== name) {
107
+ return dependsOn === n.dependsOn ? n : { ...n, dependsOn };
108
+ }
109
+ return { ...n, ...patch, dependsOn };
110
+ }),
111
+ );
112
+ if (renamed) setSelectedNode(newName);
113
+ }
114
+
115
+ return (
116
+ <div style={{ display: "grid", gridTemplateColumns: "240px 1fr 320px", gap: "1rem" }}>
117
+ <SignalPalette signals={signals} onAdd={addNodeForSignal} />
118
+
119
+ <div>
120
+ <div className="mono" style={labelStyle}>DAG ({spec.nodes.length} nodes)</div>
121
+ <div style={{
122
+ minHeight: "320px",
123
+ padding: "0.5rem",
124
+ border: "1px solid var(--border)",
125
+ borderRadius: "4px",
126
+ background: "var(--surface)",
127
+ overflowX: "auto",
128
+ }}>
129
+ {spec.nodes.length === 0 ? (
130
+ signals.length === 0 ? (
131
+ <div style={{ color: "var(--muted)", fontSize: "0.875rem", textAlign: "center", padding: "2rem", lineHeight: 1.6 }}>
132
+ No signals are registered.<br />
133
+ Define one in your <span className="mono">signals/</span> directory or visit{" "}
134
+ <a href="/signals" style={{ color: "var(--text)", textDecoration: "underline" }}>/signals</a>.
135
+ </div>
136
+ ) : (
137
+ <div style={{ color: "var(--muted)", fontSize: "0.875rem", textAlign: "center", padding: "2rem" }}>
138
+ Click a signal in the palette to add the first node.
139
+ </div>
140
+ )
141
+ ) : (
142
+ <DAGView
143
+ nodes={dagNodes}
144
+ selectedNode={selectedNode ?? undefined}
145
+ onNodeClick={(name) => setSelectedNode(name)}
146
+ compact
147
+ />
148
+ )}
149
+ </div>
150
+ <p className="mono" style={{ fontSize: "0.6875rem", color: "var(--muted)", marginTop: "0.375rem" }}>
151
+ Click a node to edit it. Click a palette entry to add another node.
152
+ </p>
153
+ </div>
154
+
155
+ <NodeInspector
156
+ node={selected}
157
+ allNodes={spec.nodes}
158
+ onChange={(patch) => selected && updateNode(selected.name, patch)}
159
+ onRemove={(name) => removeNode(name)}
160
+ api={api}
161
+ commitRef={inspectorCommitRef}
162
+ />
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function SignalPalette({ signals, onAdd }: { signals: SignalMeta[]; onAdd: (s: SignalMeta) => void }) {
168
+ const [filter, setFilter] = useState("");
169
+ const visible = signals.filter((s) =>
170
+ filter ? s.name.toLowerCase().includes(filter.toLowerCase()) : true,
171
+ );
172
+ return (
173
+ <aside>
174
+ <div className="mono" style={labelStyle}>Signals ({signals.length})</div>
175
+ <input
176
+ className="mono"
177
+ placeholder="Filter…"
178
+ value={filter}
179
+ onChange={(e) => setFilter(e.target.value)}
180
+ style={{
181
+ ...inputStyle,
182
+ marginBottom: "0.375rem",
183
+ fontSize: "0.75rem",
184
+ }}
185
+ />
186
+ <div style={{
187
+ maxHeight: "360px",
188
+ overflowY: "auto",
189
+ border: "1px solid var(--border)",
190
+ borderRadius: "4px",
191
+ background: "var(--surface)",
192
+ }}>
193
+ {visible.length === 0 ? (
194
+ <div style={{ padding: "0.5rem", fontSize: "0.75rem", color: "var(--muted)" }}>
195
+ {signals.length === 0 ? "No signals registered." : "No matches."}
196
+ </div>
197
+ ) : (
198
+ <ul style={{ margin: 0, padding: 0, listStyle: "none" }}>
199
+ {visible.map((s) => (
200
+ <li key={s.name}>
201
+ <button
202
+ className="mono"
203
+ type="button"
204
+ onClick={() => onAdd(s)}
205
+ title={s.filePath}
206
+ style={{
207
+ width: "100%",
208
+ textAlign: "left",
209
+ padding: "0.375rem 0.5rem",
210
+ background: "transparent",
211
+ border: "none",
212
+ borderBottom: "1px dashed var(--border)",
213
+ color: "var(--text)",
214
+ fontSize: "0.8125rem",
215
+ cursor: "pointer",
216
+ }}
217
+ onMouseEnter={(e) => {
218
+ (e.currentTarget as HTMLButtonElement).style.background = "var(--surface-hover, rgba(0,0,0,0.04))";
219
+ }}
220
+ onMouseLeave={(e) => {
221
+ (e.currentTarget as HTMLButtonElement).style.background = "transparent";
222
+ }}
223
+ >
224
+ + {s.name}
225
+ </button>
226
+ </li>
227
+ ))}
228
+ </ul>
229
+ )}
230
+ </div>
231
+ </aside>
232
+ );
233
+ }
234
+
235
+ interface NodeInspectorProps {
236
+ node: DynamicNodeSpec | null;
237
+ allNodes: DynamicNodeSpec[];
238
+ onChange: (patch: Partial<DynamicNodeSpec>) => void;
239
+ onRemove: (name: string) => void;
240
+ api: ReturnType<typeof useApi>;
241
+ /**
242
+ * Parent-supplied ref. The inspector populates it with a `commit()` that
243
+ * flushes any uncommitted text-area edits into the spec — used by the
244
+ * top-level Save flow so racing the click against blur doesn't lose edits.
245
+ */
246
+ commitRef?: React.MutableRefObject<(() => Promise<void>) | null>;
247
+ }
248
+
249
+ function NodeInspector({ node, allNodes, onChange, onRemove, api, commitRef }: NodeInspectorProps) {
250
+ const [inputSrc, setInputSrc] = useState<string>("");
251
+ const [whenSrc, setWhenSrc] = useState<string>("");
252
+ const [inputErr, setInputErr] = useState<string | null>(null);
253
+ const [whenErr, setWhenErr] = useState<string | null>(null);
254
+ // Latest source values readable from the imperative commit() — refs avoid
255
+ // the stale-closure problem when commit fires from outside React's lifecycle.
256
+ const inputSrcRef = useRef("");
257
+ const whenSrcRef = useRef("");
258
+
259
+ useEffect(() => { inputSrcRef.current = inputSrc; }, [inputSrc]);
260
+ useEffect(() => { whenSrcRef.current = whenSrc; }, [whenSrc]);
261
+
262
+ // Keep editor source in sync when the selected node changes.
263
+ useEffect(() => {
264
+ if (!node) {
265
+ setInputSrc("");
266
+ setWhenSrc("");
267
+ return;
268
+ }
269
+ setInputSrc(node.input ? JSON.stringify(node.input, null, 2) : "");
270
+ setWhenSrc(node.when ? JSON.stringify(node.when, null, 2) : "");
271
+ setInputErr(null);
272
+ setWhenErr(null);
273
+ }, [node?.name]); // eslint-disable-line react-hooks/exhaustive-deps
274
+
275
+ if (!node) {
276
+ return (
277
+ <aside>
278
+ <div className="mono" style={labelStyle}>Inspector</div>
279
+ <div style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>Select a node to edit.</div>
280
+ </aside>
281
+ );
282
+ }
283
+
284
+ const candidateDeps = allNodes.filter((n) => n.name !== node.name);
285
+
286
+ async function tryCommitInput() {
287
+ const src = inputSrcRef.current;
288
+ if (!src.trim()) {
289
+ onChange({ input: undefined });
290
+ setInputErr(null);
291
+ return;
292
+ }
293
+ // Accept either JSON AST (preferred) or a parser source.
294
+ try {
295
+ const ast = JSON.parse(src);
296
+ onChange({ input: ast });
297
+ setInputErr(null);
298
+ return;
299
+ } catch {
300
+ // fall through — try the parser
301
+ }
302
+ try {
303
+ const res = await api.parseExpression(src);
304
+ onChange({ input: res.data.node });
305
+ setInputErr(null);
306
+ } catch (err) {
307
+ setInputErr(err instanceof Error ? err.message : String(err));
308
+ }
309
+ }
310
+
311
+ async function tryCommitWhen() {
312
+ const src = whenSrcRef.current;
313
+ if (!src.trim()) {
314
+ onChange({ when: undefined });
315
+ setWhenErr(null);
316
+ return;
317
+ }
318
+ try {
319
+ const ast = JSON.parse(src);
320
+ onChange({ when: ast });
321
+ setWhenErr(null);
322
+ return;
323
+ } catch {
324
+ // fall through
325
+ }
326
+ try {
327
+ const res = await api.parseExpression(src);
328
+ onChange({ when: res.data.node });
329
+ setWhenErr(null);
330
+ } catch (err) {
331
+ setWhenErr(err instanceof Error ? err.message : String(err));
332
+ }
333
+ }
334
+
335
+ // Expose commit() to the parent so its save flow can flush pending edits.
336
+ useEffect(() => {
337
+ if (!commitRef) return;
338
+ commitRef.current = async () => {
339
+ await Promise.all([tryCommitInput(), tryCommitWhen()]);
340
+ };
341
+ return () => {
342
+ if (commitRef) commitRef.current = null;
343
+ };
344
+ }, [commitRef]); // eslint-disable-line react-hooks/exhaustive-deps
345
+
346
+ return (
347
+ <aside style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
348
+ <div className="mono" style={labelStyle}>Node: {node.name}</div>
349
+
350
+ <div>
351
+ <Label>Node name</Label>
352
+ <input
353
+ className="mono"
354
+ value={node.name}
355
+ onChange={(e) => onChange({ name: e.target.value })}
356
+ style={inputStyle}
357
+ />
358
+ </div>
359
+
360
+ <div>
361
+ <Label>Signal</Label>
362
+ <div className="mono" style={{ fontSize: "0.8125rem" }}>{node.signalName}</div>
363
+ </div>
364
+
365
+ <div>
366
+ <Label>Depends on</Label>
367
+ <div style={{
368
+ padding: "0.375rem",
369
+ border: "1px solid var(--border)",
370
+ borderRadius: "4px",
371
+ background: "var(--surface)",
372
+ minHeight: "40px",
373
+ display: "flex",
374
+ flexWrap: "wrap",
375
+ gap: "0.25rem",
376
+ }}>
377
+ {node.dependsOn.length === 0 ? (
378
+ <span style={{ color: "var(--muted)", fontSize: "0.75rem", padding: "0.125rem" }}>
379
+ (root node)
380
+ </span>
381
+ ) : (
382
+ node.dependsOn.map((dep) => (
383
+ <span
384
+ key={dep}
385
+ className="mono"
386
+ style={{
387
+ fontSize: "0.75rem",
388
+ padding: "0.125rem 0.375rem",
389
+ background: "var(--text)",
390
+ color: "var(--surface)",
391
+ borderRadius: "3px",
392
+ display: "inline-flex",
393
+ alignItems: "center",
394
+ gap: "0.25rem",
395
+ }}
396
+ >
397
+ {dep}
398
+ <button
399
+ type="button"
400
+ onClick={() => onChange({ dependsOn: node.dependsOn.filter((d) => d !== dep) })}
401
+ style={{ background: "none", border: "none", color: "inherit", cursor: "pointer", padding: 0, fontSize: "0.875rem", lineHeight: 1 }}
402
+ aria-label={`Remove ${dep}`}
403
+ >×</button>
404
+ </span>
405
+ ))
406
+ )}
407
+ </div>
408
+ <select
409
+ className="mono"
410
+ value=""
411
+ onChange={(e) => {
412
+ const dep = e.target.value;
413
+ if (!dep) return;
414
+ if (node.dependsOn.includes(dep)) return;
415
+ onChange({ dependsOn: [...node.dependsOn, dep] });
416
+ (e.target as HTMLSelectElement).value = "";
417
+ }}
418
+ style={{ ...inputStyle, marginTop: "0.375rem", fontSize: "0.75rem" }}
419
+ >
420
+ <option value="">+ Add dependency…</option>
421
+ {candidateDeps
422
+ .filter((c) => !node.dependsOn.includes(c.name))
423
+ .map((c) => (
424
+ <option key={c.name} value={c.name}>{c.name}</option>
425
+ ))}
426
+ </select>
427
+ </div>
428
+
429
+ <div>
430
+ <Label>input mapper (expression source or AST JSON)</Label>
431
+ <textarea
432
+ className="mono"
433
+ value={inputSrc}
434
+ onChange={(e) => setInputSrc(e.target.value)}
435
+ onBlur={tryCommitInput}
436
+ rows={4}
437
+ spellCheck={false}
438
+ placeholder={`e.g. {"to": input.email}\nor: input.email`}
439
+ style={{ ...inputStyle, fontSize: "0.75rem", fontFamily: "var(--mono-font, monospace)" }}
440
+ />
441
+ {inputErr && <ErrorBlurb>{inputErr}</ErrorBlurb>}
442
+ </div>
443
+
444
+ <div>
445
+ <Label>when guard (boolean expression)</Label>
446
+ <textarea
447
+ className="mono"
448
+ value={whenSrc}
449
+ onChange={(e) => setWhenSrc(e.target.value)}
450
+ onBlur={tryCommitWhen}
451
+ rows={3}
452
+ spellCheck={false}
453
+ placeholder={`e.g. input.amount > 100`}
454
+ style={{ ...inputStyle, fontSize: "0.75rem", fontFamily: "var(--mono-font, monospace)" }}
455
+ />
456
+ {whenErr && <ErrorBlurb>{whenErr}</ErrorBlurb>}
457
+ </div>
458
+
459
+ <button
460
+ type="button"
461
+ className="btn btn--danger btn--sm"
462
+ onClick={() => onRemove(node.name)}
463
+ >
464
+ Remove node
465
+ </button>
466
+ </aside>
467
+ );
468
+ }
469
+
470
+ function Label({ children }: { children: React.ReactNode }) {
471
+ return (
472
+ <label className="mono" style={{
473
+ display: "block",
474
+ fontSize: "0.6875rem",
475
+ color: "var(--muted)",
476
+ textTransform: "uppercase",
477
+ letterSpacing: "0.05em",
478
+ marginBottom: "0.25rem",
479
+ }}>{children}</label>
480
+ );
481
+ }
482
+
483
+ function ErrorBlurb({ children }: { children: React.ReactNode }) {
484
+ return (
485
+ <div style={{
486
+ marginTop: "0.25rem",
487
+ fontSize: "0.6875rem",
488
+ color: "var(--error, #b00)",
489
+ }}>{children}</div>
490
+ );
491
+ }
492
+
493
+ const labelStyle: React.CSSProperties = {
494
+ display: "block",
495
+ fontSize: "0.75rem",
496
+ color: "var(--muted)",
497
+ textTransform: "uppercase",
498
+ letterSpacing: "0.05em",
499
+ marginBottom: "0.375rem",
500
+ };
501
+
502
+ const inputStyle: React.CSSProperties = {
503
+ width: "100%",
504
+ padding: "0.375rem 0.5rem",
505
+ border: "1px solid var(--border)",
506
+ borderRadius: "4px",
507
+ background: "var(--surface)",
508
+ color: "var(--text)",
509
+ fontSize: "0.8125rem",
510
+ };