yaml-flow 3.1.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +81 -20
  2. package/board-live-cards-cli.js +37 -0
  3. package/browser/board-livegraph-runtime.js +1453 -0
  4. package/browser/board-livegraph-runtime.js.map +1 -0
  5. package/browser/card-compute.js +153 -433
  6. package/browser/live-cards.js +868 -115
  7. package/browser/live-cards.schema.json +90 -83
  8. package/dist/board-livegraph-runtime/index.cjs +1448 -0
  9. package/dist/board-livegraph-runtime/index.cjs.map +1 -0
  10. package/dist/board-livegraph-runtime/index.d.cts +101 -0
  11. package/dist/board-livegraph-runtime/index.d.ts +101 -0
  12. package/dist/board-livegraph-runtime/index.js +1441 -0
  13. package/dist/board-livegraph-runtime/index.js.map +1 -0
  14. package/dist/card-compute/index.cjs +266 -431
  15. package/dist/card-compute/index.cjs.map +1 -1
  16. package/dist/card-compute/index.d.cts +77 -49
  17. package/dist/card-compute/index.d.ts +77 -49
  18. package/dist/card-compute/index.js +263 -432
  19. package/dist/card-compute/index.js.map +1 -1
  20. package/dist/cli/board-live-cards-cli.cjs +2750 -0
  21. package/dist/cli/board-live-cards-cli.cjs.map +1 -0
  22. package/dist/cli/board-live-cards-cli.d.cts +205 -0
  23. package/dist/cli/board-live-cards-cli.d.ts +205 -0
  24. package/dist/cli/board-live-cards-cli.js +2702 -0
  25. package/dist/cli/board-live-cards-cli.js.map +1 -0
  26. package/dist/{constants-B2zqu10b.d.ts → constants-DuzE5n03.d.ts} +2 -2
  27. package/dist/{constants-DJZU1pwJ.d.cts → constants-ozjf1Ejw.d.cts} +2 -2
  28. package/dist/continuous-event-graph/index.cjs +258 -464
  29. package/dist/continuous-event-graph/index.cjs.map +1 -1
  30. package/dist/continuous-event-graph/index.d.cts +18 -358
  31. package/dist/continuous-event-graph/index.d.ts +18 -358
  32. package/dist/continuous-event-graph/index.js +255 -464
  33. package/dist/continuous-event-graph/index.js.map +1 -1
  34. package/dist/event-graph/index.cjs +4 -4
  35. package/dist/event-graph/index.cjs.map +1 -1
  36. package/dist/event-graph/index.d.cts +5 -5
  37. package/dist/event-graph/index.d.ts +5 -5
  38. package/dist/event-graph/index.js +4 -4
  39. package/dist/event-graph/index.js.map +1 -1
  40. package/dist/index.cjs +1684 -555
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.d.cts +26 -7
  43. package/dist/index.d.ts +26 -7
  44. package/dist/index.js +1678 -555
  45. package/dist/index.js.map +1 -1
  46. package/dist/inference/index.cjs +138 -19
  47. package/dist/inference/index.cjs.map +1 -1
  48. package/dist/inference/index.d.cts +2 -2
  49. package/dist/inference/index.d.ts +2 -2
  50. package/dist/inference/index.js +138 -19
  51. package/dist/inference/index.js.map +1 -1
  52. package/dist/journal-DRfJiheM.d.cts +28 -0
  53. package/dist/journal-NLYuqege.d.ts +28 -0
  54. package/dist/live-cards-bridge-Or7fdEJV.d.ts +316 -0
  55. package/dist/live-cards-bridge-vGJ6tMzN.d.cts +316 -0
  56. package/dist/schedule-CMcZe5Ny.d.ts +21 -0
  57. package/dist/schedule-CiucyCan.d.cts +21 -0
  58. package/dist/step-machine/index.cjs +18 -1
  59. package/dist/step-machine/index.cjs.map +1 -1
  60. package/dist/step-machine/index.d.cts +2 -2
  61. package/dist/step-machine/index.d.ts +2 -2
  62. package/dist/step-machine/index.js +18 -1
  63. package/dist/step-machine/index.js.map +1 -1
  64. package/dist/stores/file.d.cts +1 -1
  65. package/dist/stores/file.d.ts +1 -1
  66. package/dist/stores/index.d.cts +1 -1
  67. package/dist/stores/index.d.ts +1 -1
  68. package/dist/stores/localStorage.d.cts +1 -1
  69. package/dist/stores/localStorage.d.ts +1 -1
  70. package/dist/stores/memory.d.cts +1 -1
  71. package/dist/stores/memory.d.ts +1 -1
  72. package/dist/{types-BwvgvlOO.d.cts → types-BzLD8bjb.d.cts} +1 -1
  73. package/dist/{types-ClRA8hzC.d.ts → types-C2eJ7DAV.d.ts} +1 -1
  74. package/dist/{types-DEj7OakX.d.cts → types-CMFSIjpc.d.cts} +39 -4
  75. package/dist/{types-DEj7OakX.d.ts → types-CMFSIjpc.d.ts} +39 -4
  76. package/dist/{types-FZ_eyErS.d.cts → types-ycun84cq.d.cts} +1 -0
  77. package/dist/{types-FZ_eyErS.d.ts → types-ycun84cq.d.ts} +1 -0
  78. package/dist/{validate-DEZ2Ymdb.d.ts → validate-DJQTQ6bP.d.ts} +1 -1
  79. package/dist/{validate-DqKTZg_o.d.cts → validate-ke92Cleg.d.cts} +1 -1
  80. package/examples/browser/boards/portfolio-tracker/cards/holdings-table.json +22 -0
  81. package/examples/browser/boards/portfolio-tracker/cards/portfolio-form.json +16 -0
  82. package/examples/browser/boards/portfolio-tracker/cards/portfolio-value.json +15 -0
  83. package/examples/browser/boards/portfolio-tracker/cards/price-fetch.json +15 -0
  84. package/examples/browser/boards/portfolio-tracker/fetch-prices.js +43 -0
  85. package/examples/browser/boards/portfolio-tracker/portfolio-tracker-task-executor.cjs +96 -0
  86. package/examples/browser/boards/portfolio-tracker/portfolio-tracker.bat +7 -0
  87. package/examples/browser/boards/portfolio-tracker/portfolio-tracker.js +217 -0
  88. package/examples/browser/livecards-browser/index.html +41 -0
  89. package/examples/browser/{index.html → step-machine-browser/index.html} +53 -53
  90. package/examples/cli/step-machine-cli/portfolio-tracker/cards/holdings-table.json +22 -0
  91. package/examples/cli/step-machine-cli/portfolio-tracker/cards/portfolio-form.json +43 -0
  92. package/examples/cli/step-machine-cli/portfolio-tracker/cards/portfolio-value.json +15 -0
  93. package/examples/cli/step-machine-cli/portfolio-tracker/cards/price-fetch.json +15 -0
  94. package/examples/cli/step-machine-cli/portfolio-tracker/fetch-prices.js +48 -0
  95. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/_board-cli.js +58 -0
  96. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +27 -0
  97. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/init-board-cli.js +25 -0
  98. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/reset-board-dir-cli.js +29 -0
  99. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/retrigger-cli.js +27 -0
  100. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/status-cli.js +25 -0
  101. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +37 -0
  102. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/wait-completed-cli.js +53 -0
  103. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/write-prices-cli.js +35 -0
  104. package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +227 -0
  105. package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker.input.json +38 -0
  106. package/examples/cli/step-machine-cli/portfolio-tracker/run-portfolio-tracker.bat +29 -0
  107. package/examples/cli/step-machine-demo/jsonata-init-board-cli.js +36 -0
  108. package/examples/cli/step-machine-demo/jsonata-init-board.flow.yaml +30 -0
  109. package/examples/cli/step-machine-demo/one-step-cli-only.flow.yaml +19 -0
  110. package/examples/cli/step-machine-demo/step-cli-echo-y.js +15 -0
  111. package/examples/cli/step-machine-demo/step2-double-cli.js +39 -0
  112. package/examples/cli/step-machine-demo/two-step-math-handlers.js +32 -0
  113. package/examples/cli/step-machine-demo/two-step-math.flow.yaml +31 -0
  114. package/examples/cli/step-machine-demo/two-step-mixed-handlers.js +24 -0
  115. package/examples/cli/step-machine-demo/two-step-mixed.flow.yaml +35 -0
  116. package/examples/example-board/board.yaml +23 -0
  117. package/examples/example-board/bootstrap_payload.json +1 -0
  118. package/examples/example-board/cards/card-chain-region-alert.json +39 -0
  119. package/examples/example-board/cards/card-chain-region-totals.json +26 -0
  120. package/examples/example-board/cards/card-chain-top-region.json +24 -0
  121. package/examples/example-board/cards/card-ex-actions.json +32 -0
  122. package/examples/example-board/cards/card-ex-chart.json +30 -0
  123. package/examples/example-board/cards/card-ex-filter.json +36 -0
  124. package/examples/example-board/cards/card-ex-filtered-by-preference.json +59 -0
  125. package/examples/example-board/cards/card-ex-form.json +91 -0
  126. package/examples/example-board/cards/card-ex-list.json +22 -0
  127. package/examples/example-board/cards/card-ex-markdown.json +17 -0
  128. package/examples/example-board/cards/card-ex-metric.json +19 -0
  129. package/examples/example-board/cards/card-ex-narrative.json +36 -0
  130. package/examples/example-board/cards/card-ex-source-http.json +28 -0
  131. package/examples/example-board/cards/card-ex-source.json +21 -0
  132. package/examples/example-board/cards/card-ex-status.json +35 -0
  133. package/examples/example-board/cards/card-ex-table.json +30 -0
  134. package/examples/example-board/cards/card-ex-todo.json +29 -0
  135. package/examples/example-board/demo-chat-handler.js +69 -0
  136. package/examples/example-board/demo-server.js +87 -0
  137. package/examples/example-board/demo-shell-browser.html +806 -0
  138. package/examples/example-board/demo-shell-with-server.html +280 -0
  139. package/examples/example-board/demo-shell.html +62 -0
  140. package/examples/example-board/demo-task-executor.js +255 -0
  141. package/examples/example-board/mock.db +15 -0
  142. package/examples/example-board/reusable-board-runtime-client.js +265 -0
  143. package/examples/example-board/reusable-runtime-artifacts-adapter.js +233 -0
  144. package/examples/example-board/reusable-server-runtime.js +1284 -0
  145. package/examples/index.html +799 -0
  146. package/examples/{batch → npm-libs/batch}/batch-step-machine.ts +1 -1
  147. package/examples/{continuous-event-graph → npm-libs/continuous-event-graph}/live-cards-board.ts +18 -18
  148. package/examples/{continuous-event-graph → npm-libs/continuous-event-graph}/live-portfolio-dashboard.ts +24 -24
  149. package/examples/{continuous-event-graph → npm-libs/continuous-event-graph}/portfolio-tracker.ts +1 -1
  150. package/examples/{continuous-event-graph → npm-libs/continuous-event-graph}/reactive-monitoring.ts +1 -1
  151. package/examples/{continuous-event-graph → npm-libs/continuous-event-graph}/reactive-pipeline.ts +1 -1
  152. package/examples/{continuous-event-graph → npm-libs/continuous-event-graph}/soc-incident-board.ts +1 -1
  153. package/examples/{continuous-event-graph → npm-libs/continuous-event-graph}/stock-dashboard.ts +1 -1
  154. package/examples/{event-graph → npm-libs/event-graph}/ci-cd-pipeline.ts +1 -1
  155. package/examples/{event-graph → npm-libs/event-graph}/executor-diamond.ts +1 -1
  156. package/examples/{event-graph → npm-libs/event-graph}/executor-pipeline.ts +1 -1
  157. package/examples/{event-graph → npm-libs/event-graph}/research-pipeline.ts +1 -1
  158. package/examples/{graph-of-graphs → npm-libs/graph-of-graphs}/multi-stage-etl.ts +1 -1
  159. package/examples/{graph-of-graphs → npm-libs/graph-of-graphs}/url-processing-pipeline.ts +1 -1
  160. package/examples/{inference → npm-libs/inference}/azure-deployment.ts +1 -1
  161. package/examples/{inference → npm-libs/inference}/copilot-cli.ts +1 -1
  162. package/examples/{inference → npm-libs/inference}/data-pipeline.ts +1 -1
  163. package/examples/{inference → npm-libs/inference}/pluggable-adapters.ts +1 -1
  164. package/examples/{node → npm-libs/node}/ai-conversation.ts +1 -1
  165. package/examples/{node → npm-libs/node}/simple-greeting.ts +2 -2
  166. package/examples/step-machine-cli/portfolio-tracker/cards/holdings-table.json +22 -0
  167. package/examples/step-machine-cli/portfolio-tracker/cards/portfolio-form.json +43 -0
  168. package/examples/step-machine-cli/portfolio-tracker/cards/portfolio-value.json +15 -0
  169. package/examples/step-machine-cli/portfolio-tracker/cards/price-fetch.json +15 -0
  170. package/examples/step-machine-cli/portfolio-tracker/fetch-prices.js +48 -0
  171. package/examples/step-machine-cli/portfolio-tracker/handlers/_board-cli.js +58 -0
  172. package/examples/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +27 -0
  173. package/examples/step-machine-cli/portfolio-tracker/handlers/init-board-cli.js +25 -0
  174. package/examples/step-machine-cli/portfolio-tracker/handlers/reset-board-dir-cli.js +29 -0
  175. package/examples/step-machine-cli/portfolio-tracker/handlers/retrigger-cli.js +27 -0
  176. package/examples/step-machine-cli/portfolio-tracker/handlers/status-cli.js +25 -0
  177. package/examples/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +37 -0
  178. package/examples/step-machine-cli/portfolio-tracker/handlers/wait-completed-cli.js +53 -0
  179. package/examples/step-machine-cli/portfolio-tracker/handlers/write-prices-cli.js +35 -0
  180. package/examples/step-machine-cli/portfolio-tracker/portfolio-tracker-task-executor.cjs +96 -0
  181. package/examples/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +227 -0
  182. package/examples/step-machine-cli/portfolio-tracker/portfolio-tracker.input.json +38 -0
  183. package/examples/step-machine-cli/portfolio-tracker/run-portfolio-tracker.bat +29 -0
  184. package/package.json +27 -2
  185. package/schema/board-status.schema.json +118 -0
  186. package/schema/card-runtime.schema.json +25 -0
  187. package/schema/flow.schema.json +5 -0
  188. package/schema/live-cards.schema.json +90 -83
  189. package/step-machine-cli.js +674 -0
  190. package/browser/ingest-board.js +0 -296
  191. package/examples/ingest.js +0 -733
  192. /package/examples/{flows → npm-libs/flows}/ai-conversation.yaml +0 -0
  193. /package/examples/{flows → npm-libs/flows}/order-processing.yaml +0 -0
  194. /package/examples/{flows → npm-libs/flows}/simple-greeting.yaml +0 -0
@@ -0,0 +1,1284 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+ import { execFileSync, spawn } from 'node:child_process';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+ const require = createRequire(import.meta.url);
11
+
12
+ const DEFAULT_CORS_HEADERS = {
13
+ 'Access-Control-Allow-Origin': '*',
14
+ 'Access-Control-Allow-Headers': 'content-type,x-file-name',
15
+ 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
16
+ };
17
+
18
+ const MAX_STORED_FILE_NAME_LEN = 32;
19
+
20
+ // Routes handled by the reusable runtime (demo-setup is excluded, handled by host)
21
+ export const RUNTIME_ROUTE_PATTERNS = [
22
+ /\/init-board$/,
23
+ /\/bootstrap-cards$/,
24
+ /\/bootstrap$/,
25
+ /\/sse$/,
26
+ /\/board-status$/,
27
+ /\/cards\/[^/]+$/,
28
+ /\/cards\/[^/]+\/actions$/,
29
+ /\/cards\/[^/]+\/chats$/,
30
+ /\/cards\/[^/]+\/files$/,
31
+ ];
32
+
33
+ export function isRuntimeRoute(pathname) {
34
+ return RUNTIME_ROUTE_PATTERNS.some((pattern) => pattern.test(pathname));
35
+ }
36
+
37
+ function parseUrl(urlString) {
38
+ return new URL(urlString, 'http://localhost');
39
+ }
40
+
41
+ export function createRuntimeRequestDispatcher(runtime) {
42
+ if (!runtime || typeof runtime !== 'object') {
43
+ throw new Error('runtime is required');
44
+ }
45
+
46
+ return async function dispatch(req, res, parsedUrl) {
47
+ const method = req.method || 'GET';
48
+ const url = parsedUrl || runtime.parseUrl(req.url || '/');
49
+
50
+ if (method === 'OPTIONS') {
51
+ res.writeHead(204, runtime.corsHeaders);
52
+ res.end();
53
+ return true;
54
+ }
55
+
56
+ // Multi-board runtime exposes handleApi; single-board exposes handleRuntimeApi.
57
+ if (typeof runtime.handleApi === 'function') {
58
+ if (await runtime.handleApi(req, res, url)) return true;
59
+ } else {
60
+ if (await runtime.handleRuntimeApi(req, res, url)) return true;
61
+ }
62
+
63
+ runtime.json(res, 404, { error: 'Not found' });
64
+ return true;
65
+ };
66
+ }
67
+
68
+ /**
69
+ * createMultiBoardServerRuntime
70
+ *
71
+ * Manages multiple boards under a single DEMO_SETUP_DIR.
72
+ * Directory layout:
73
+ * setupDir/
74
+ * boards-config.json ← board registry
75
+ * board-default/ ← built-in example board
76
+ * runtime/ ← board-graph.json, cards-inventory.jsonl
77
+ * surface/ ← tmp-cards/
78
+ * runtime-out/ ← computed artefacts
79
+ * board-<id>/ ← any additional board
80
+ * ...same layout...
81
+ *
82
+ * Routes:
83
+ * GET /api/boards list registered boards
84
+ * POST /api/boards {id, label?} register a new board
85
+ * GET /api/boards/:boardId/demo-setup (host-handled; runtime exposes performDemoSetup)
86
+ * GET /api/boards/:boardId/bootstrap
87
+ * GET /api/boards/:boardId/sse
88
+ * ... (all single-board routes, prefixed with /:boardId/)
89
+ */
90
+ export function createMultiBoardServerRuntime(options = {}) {
91
+ const setupDir = path.resolve(
92
+ options.setupDir ||
93
+ process.env.DEMO_SETUP_DIR ||
94
+ path.join(os.tmpdir(), 'board-live-cards-demo-setup')
95
+ );
96
+ const apiBasePath = String(options.apiBasePath || '/api/boards').replace(/\/$/, '');
97
+ const corsHeaders = { ...DEFAULT_CORS_HEADERS, ...(options.corsHeaders || {}) };
98
+
99
+ // Source card templates shared by all boards unless overridden per-board in config.
100
+ const defaultCardsDir = path.resolve(
101
+ options.defaultCardsDir || path.join(__dirname, 'cards')
102
+ );
103
+
104
+ const boardsConfigFile = path.join(setupDir, 'boards-config.json');
105
+ const boardServiceCache = new Map();
106
+
107
+ fs.mkdirSync(setupDir, { recursive: true });
108
+
109
+ function readBoardsConfig() {
110
+ if (!fs.existsSync(boardsConfigFile)) {
111
+ return { boards: [{ id: 'default', label: 'Default Board' }] };
112
+ }
113
+ try {
114
+ return JSON.parse(fs.readFileSync(boardsConfigFile, 'utf-8'));
115
+ } catch {
116
+ return { boards: [{ id: 'default', label: 'Default Board' }] };
117
+ }
118
+ }
119
+
120
+ function writeBoardsConfig(config) {
121
+ fs.writeFileSync(boardsConfigFile, JSON.stringify(config, null, 2));
122
+ }
123
+
124
+ function safeBoardId(raw) {
125
+ const sanitized = String(raw || '')
126
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
127
+ .replace(/^_+|_+$/g, '');
128
+ return sanitized.length > 0 && sanitized.length <= 64 ? sanitized : null;
129
+ }
130
+
131
+ function getBoardService(boardId) {
132
+ if (boardServiceCache.has(boardId)) return boardServiceCache.get(boardId);
133
+
134
+ const boardRoot = path.join(setupDir, `board-${boardId}`);
135
+ const config = readBoardsConfig();
136
+ const entry = config.boards.find((b) => b.id === boardId) || {};
137
+ const cardsDir = typeof entry.cardsDir === 'string' ? path.resolve(entry.cardsDir) : defaultCardsDir;
138
+ const defaultTaskExecutorPath = typeof entry.taskExecutorPath === 'string'
139
+ ? entry.taskExecutorPath
140
+ : options.defaultTaskExecutorPath;
141
+
142
+ const service = createExampleBoardServerRuntime({
143
+ apiBasePath: `${apiBasePath}/${boardId}`,
144
+ corsHeaders,
145
+ boardId,
146
+ boardDir: path.join(boardRoot, 'runtime'),
147
+ cardsDir,
148
+ tmpSurfaceDir: path.join(boardRoot, 'surface'),
149
+ runtimeOutDir: path.join(boardRoot, 'runtime-out'),
150
+ defaultTaskExecutorPath,
151
+ });
152
+
153
+ boardServiceCache.set(boardId, service);
154
+ return service;
155
+ }
156
+
157
+ function json(res, status, payload) {
158
+ const body = JSON.stringify(payload);
159
+ res.writeHead(status, {
160
+ ...corsHeaders,
161
+ 'Content-Type': 'application/json; charset=utf-8',
162
+ 'Content-Length': Buffer.byteLength(body),
163
+ });
164
+ res.end(body);
165
+ }
166
+
167
+ async function handleBoardsRegistryApi(req, res, parsedUrl) {
168
+ const method = req.method || 'GET';
169
+ const p = parsedUrl.pathname;
170
+
171
+ // GET /api/boards — list boards
172
+ if (method === 'GET' && p === apiBasePath) {
173
+ json(res, 200, { ok: true, boards: readBoardsConfig().boards });
174
+ return true;
175
+ }
176
+
177
+ // POST /api/boards {id, label?} — register new board
178
+ if (method === 'POST' && p === apiBasePath) {
179
+ const chunks = [];
180
+ for await (const c of req) chunks.push(c);
181
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
182
+ let body = {};
183
+ try { body = raw ? JSON.parse(raw) : {}; } catch { body = {}; }
184
+
185
+ const id = safeBoardId(body.id);
186
+ if (!id) {
187
+ json(res, 400, { error: 'board id must be 1-64 alphanumeric/dash/underscore characters' });
188
+ return true;
189
+ }
190
+
191
+ const config = readBoardsConfig();
192
+ if (config.boards.some((b) => b.id === id)) {
193
+ json(res, 409, { error: `Board "${id}" is already registered` });
194
+ return true;
195
+ }
196
+
197
+ const label = typeof body.label === 'string' && body.label.trim() ? body.label.trim() : id;
198
+ const entry = { id, label };
199
+ if (typeof body.cardsDir === 'string') entry.cardsDir = body.cardsDir;
200
+ config.boards.push(entry);
201
+ writeBoardsConfig(config);
202
+
203
+ // Pre-create board directory tree so the board is immediately usable.
204
+ const boardRoot = path.join(setupDir, `board-${id}`);
205
+ fs.mkdirSync(path.join(boardRoot, 'runtime'), { recursive: true });
206
+ fs.mkdirSync(path.join(boardRoot, 'surface'), { recursive: true });
207
+ fs.mkdirSync(path.join(boardRoot, 'runtime-out'), { recursive: true });
208
+
209
+ json(res, 200, { ok: true, board: entry });
210
+ return true;
211
+ }
212
+
213
+ return false;
214
+ }
215
+
216
+ async function handleBoardApi(req, res, parsedUrl) {
217
+ const p = parsedUrl.pathname;
218
+
219
+ // Extract boardId from /:boardId/... or /:boardId (exact)
220
+ const boardSegMatch = p.match(new RegExp(`^${apiBasePath}/([^/]+)(/|$)`));
221
+ if (!boardSegMatch) return false;
222
+
223
+ const boardId = safeBoardId(decodeURIComponent(boardSegMatch[1]));
224
+ if (!boardId) {
225
+ json(res, 400, { error: 'Invalid board id' });
226
+ return true;
227
+ }
228
+
229
+ const config = readBoardsConfig();
230
+ if (!config.boards.some((b) => b.id === boardId)) {
231
+ json(res, 404, {
232
+ error: `Board "${boardId}" not registered. POST ${apiBasePath} with {id} to register it first.`,
233
+ });
234
+ return true;
235
+ }
236
+
237
+ const service = getBoardService(boardId);
238
+ if (await service.handleRuntimeApi(req, res, parsedUrl)) return true;
239
+ return false;
240
+ }
241
+
242
+ async function handleApi(req, res, parsedUrl) {
243
+ if (await handleBoardsRegistryApi(req, res, parsedUrl)) return true;
244
+ if (await handleBoardApi(req, res, parsedUrl)) return true;
245
+ return false;
246
+ }
247
+
248
+ // Exposed for host transport layers to execute demo-setup for a specific board.
249
+ function performDemoSetup(boardId, reset = false) {
250
+ const config = readBoardsConfig();
251
+ if (!config.boards.some((b) => b.id === boardId)) {
252
+ const err = new Error(`Board "${boardId}" not registered`);
253
+ err.statusCode = 404;
254
+ throw err;
255
+ }
256
+
257
+ const service = getBoardService(boardId);
258
+ let setupPerformed = false;
259
+
260
+ if (reset) {
261
+ service.demoPrepSetup();
262
+ setupPerformed = true;
263
+ } else if (!fs.existsSync(service.tmpCardsDir)) {
264
+ service.ensureDemoSetup();
265
+ setupPerformed = true;
266
+ }
267
+
268
+ return { ok: true, setupPerformed, reset, tmpCardsDir: service.tmpCardsDir };
269
+ }
270
+
271
+ return {
272
+ apiBasePath,
273
+ corsHeaders,
274
+ setupDir,
275
+ parseUrl,
276
+ json,
277
+ handleBoardsRegistryApi,
278
+ handleBoardApi,
279
+ handleApi,
280
+ performDemoSetup,
281
+ };
282
+ }
283
+
284
+ export function createNodeHttpRuntimeHandler(runtime) {
285
+ const dispatch = createRuntimeRequestDispatcher(runtime);
286
+ return function nodeHttpHandler(req, res) {
287
+ void dispatch(req, res);
288
+ };
289
+ }
290
+
291
+ export function createExampleBoardServerRuntime(options = {}) {
292
+ const apiBasePath = String(options.apiBasePath || '/api/example-board/server').replace(/\/$/, '');
293
+ const corsHeaders = { ...DEFAULT_CORS_HEADERS, ...(options.corsHeaders || {}) };
294
+ const boardId = typeof options.boardId === 'string' && options.boardId ? options.boardId : '';
295
+
296
+ const boardDir = path.resolve(
297
+ options.boardDir || process.env.DEMO_BOARD_RUNTIME_DIR || path.join(os.tmpdir(), 'board-live-cards-demo-board')
298
+ );
299
+ const cardsDir = path.resolve(options.cardsDir || path.join(__dirname, 'cards'));
300
+ const tmpSurfaceDir = path.resolve(
301
+ options.tmpSurfaceDir || process.env.DEMO_SURFACE_DIR || path.join(os.tmpdir(), 'board-live-cards-demo-surface')
302
+ );
303
+ const tmpCardsDir = path.join(tmpSurfaceDir, 'tmp-cards');
304
+ const runtimeOutDir = path.resolve(
305
+ options.runtimeOutDir || process.env.DEMO_RUNTIME_OUT_DIR || path.join(os.tmpdir(), 'board-live-cards-demo-runtime-out')
306
+ );
307
+ const configuredTaskExecutorPath = typeof options.defaultTaskExecutorPath === 'string'
308
+ && options.defaultTaskExecutorPath.trim()
309
+ ? (path.isAbsolute(options.defaultTaskExecutorPath)
310
+ ? options.defaultTaskExecutorPath
311
+ : path.resolve(process.cwd(), options.defaultTaskExecutorPath))
312
+ : null;
313
+
314
+ const statusSnapshotFile = path.join(runtimeOutDir, 'board-livegraph-status.json');
315
+ const boardFile = path.join(boardDir, 'board-graph.json');
316
+ const inventoryFile = path.join(boardDir, 'cards-inventory.jsonl');
317
+
318
+ let didDemoSetup = false;
319
+
320
+ function resolveCliJsPath() {
321
+ const envOverride = process.env.BOARD_LIVE_CARDS_CLI_JS;
322
+ if (envOverride && fs.existsSync(envOverride)) return envOverride;
323
+
324
+ const repoDevPath = path.join(path.resolve(__dirname, '../..'), 'dist', 'cli', 'board-live-cards-cli.js');
325
+ if (fs.existsSync(repoDevPath)) return repoDevPath;
326
+
327
+ try {
328
+ const pkgJsonPath = require.resolve('yaml-flow/package.json', { paths: [process.cwd(), __dirname] });
329
+ const pkgRoot = path.dirname(pkgJsonPath);
330
+ const pkgCli = path.join(pkgRoot, 'board-live-cards-cli.js');
331
+ if (fs.existsSync(pkgCli)) return pkgCli;
332
+
333
+ const pkgDistCli = path.join(pkgRoot, 'dist', 'cli', 'board-live-cards-cli.js');
334
+ if (fs.existsSync(pkgDistCli)) return pkgDistCli;
335
+ } catch {
336
+ // fall through
337
+ }
338
+
339
+ return null;
340
+ }
341
+
342
+ const cliJs = resolveCliJsPath();
343
+
344
+ function ensureCardStorageDirs(cardId) {
345
+ const safeCardId = String(cardId || '').replace(/[^a-zA-Z0-9_-]/g, '_') || 'unknown-card';
346
+ const cardDir = path.join(tmpCardsDir, safeCardId);
347
+ const filesDir = path.join(cardDir, 'files');
348
+ const chatsDir = path.join(cardDir, 'chats');
349
+ fs.mkdirSync(filesDir, { recursive: true });
350
+ fs.mkdirSync(chatsDir, { recursive: true });
351
+ return { filesDir, chatsDir };
352
+ }
353
+
354
+ function normalizeDisplayFileName(name) {
355
+ const input = String(name || '').trim();
356
+ if (!input) return 'upload.bin';
357
+ const base = path.basename(input);
358
+ return base || 'upload.bin';
359
+ }
360
+
361
+ function normalizeStem(rawStem) {
362
+ const normalized = String(rawStem || '')
363
+ .toLowerCase()
364
+ .replace(/\s+/g, '_')
365
+ .replace(/[^a-z0-9_-]/g, '_')
366
+ .replace(/_+/g, '_')
367
+ .replace(/^_+|_+$/g, '');
368
+ return normalized || 'file';
369
+ }
370
+
371
+ function normalizeExt(rawExt) {
372
+ if (!rawExt || rawExt === '.') return '';
373
+ const extBody = String(rawExt).replace(/^\./, '').toLowerCase().replace(/[^a-z0-9]/g, '');
374
+ return extBody ? `.${extBody}` : '';
375
+ }
376
+
377
+ function parseLeadingSerial(fileName) {
378
+ const m = String(fileName || '').match(/^(\d+)[-_]/);
379
+ return m ? parseInt(m[1], 10) : 0;
380
+ }
381
+
382
+ function nextSerialFromNames(names) {
383
+ let maxSeen = 0;
384
+ for (const name of names) {
385
+ const n = parseLeadingSerial(name);
386
+ if (Number.isFinite(n) && n > maxSeen) maxSeen = n;
387
+ }
388
+ return maxSeen + 1;
389
+ }
390
+
391
+ function buildStoredFileName(displayName, serial) {
392
+ const base = normalizeDisplayFileName(displayName);
393
+ const ext = normalizeExt(path.extname(base));
394
+ const stemRaw = ext ? base.slice(0, -path.extname(base).length) : base;
395
+ const stemNorm = normalizeStem(stemRaw);
396
+ const prefix = `${String(serial).padStart(3, '0')}-`;
397
+
398
+ let keepExt = ext;
399
+ let stemBudget = MAX_STORED_FILE_NAME_LEN - prefix.length - keepExt.length;
400
+ if (stemBudget < 1) {
401
+ keepExt = '';
402
+ stemBudget = MAX_STORED_FILE_NAME_LEN - prefix.length;
403
+ }
404
+
405
+ const stem = stemNorm.slice(0, Math.max(1, stemBudget));
406
+ let out = `${prefix}${stem}${keepExt}`;
407
+ if (out.length > MAX_STORED_FILE_NAME_LEN) {
408
+ out = out.slice(0, MAX_STORED_FILE_NAME_LEN).replace(/\.$/, '');
409
+ }
410
+ return out;
411
+ }
412
+
413
+ function shellQuote(s) {
414
+ return '"' + String(s).replace(/"/g, '\\"') + '"';
415
+ }
416
+
417
+ function ensureBuilt() {
418
+ if (!cliJs || !fs.existsSync(cliJs)) {
419
+ throw new Error(
420
+ 'Unable to locate board-live-cards CLI. Set BOARD_LIVE_CARDS_CLI_JS or install yaml-flow in this project.'
421
+ );
422
+ }
423
+ }
424
+
425
+ function runCli(args) {
426
+ ensureBuilt();
427
+ return execFileSync(process.execPath, [cliJs, ...args], {
428
+ cwd: process.cwd(),
429
+ stdio: 'pipe',
430
+ encoding: 'utf-8',
431
+ });
432
+ }
433
+
434
+ function clearDirContents(dirPath) {
435
+ if (!fs.existsSync(dirPath)) return;
436
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
437
+ for (const entry of entries) {
438
+ const target = path.join(dirPath, entry.name);
439
+ fs.rmSync(target, { recursive: true, force: true });
440
+ }
441
+ }
442
+
443
+ function readJson(filePath) {
444
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
445
+ }
446
+
447
+ function readInventory() {
448
+ if (!fs.existsSync(inventoryFile)) return [];
449
+ return fs
450
+ .readFileSync(inventoryFile, 'utf-8')
451
+ .split('\n')
452
+ .map((l) => l.trim())
453
+ .filter(Boolean)
454
+ .map((l) => JSON.parse(l));
455
+ }
456
+
457
+ function readStatusSnapshot() {
458
+ if (!fs.existsSync(statusSnapshotFile)) return null;
459
+ return readJson(statusSnapshotFile);
460
+ }
461
+
462
+ function readCardDefinitions() {
463
+ const inv = readInventory();
464
+ const out = [];
465
+ for (const entry of inv) {
466
+ if (!entry || !entry.cardId || !entry.cardFilePath) continue;
467
+ if (!fs.existsSync(entry.cardFilePath)) continue;
468
+ out.push(readJson(entry.cardFilePath));
469
+ }
470
+ return out;
471
+ }
472
+
473
+ function readCardRuntimeArtifacts() {
474
+ const cardsOutDir = path.join(runtimeOutDir, 'cards');
475
+ if (!fs.existsSync(cardsOutDir)) return {};
476
+
477
+ const out = {};
478
+ for (const entry of fs.readdirSync(cardsOutDir, { withFileTypes: true })) {
479
+ if (!entry.isFile()) continue;
480
+ if (!entry.name.endsWith('.computed.json')) continue;
481
+ const cardId = entry.name.slice(0, -'.computed.json'.length);
482
+ out[cardId] = readJson(path.join(cardsOutDir, entry.name));
483
+ }
484
+ return out;
485
+ }
486
+
487
+ function readSourcePayloads(cardDefinition) {
488
+ const out = {};
489
+ if (!cardDefinition || !Array.isArray(cardDefinition.sources)) return out;
490
+
491
+ for (const sourceDef of cardDefinition.sources) {
492
+ if (!sourceDef || !sourceDef.bindTo || !sourceDef.outputFile) continue;
493
+ const filePath = path.join(boardDir, sourceDef.outputFile);
494
+ if (!fs.existsSync(filePath)) continue;
495
+
496
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
497
+ try {
498
+ out[sourceDef.bindTo] = JSON.parse(raw);
499
+ } catch {
500
+ out[sourceDef.bindTo] = raw;
501
+ }
502
+ }
503
+
504
+ return out;
505
+ }
506
+
507
+ function readDataObjectsByToken() {
508
+ const dirPath = path.join(runtimeOutDir, 'data-objects');
509
+ if (!fs.existsSync(dirPath)) return {};
510
+
511
+ const out = {};
512
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
513
+ if (!entry.isFile()) continue;
514
+ const token = entry.name;
515
+ const filePath = path.join(dirPath, entry.name);
516
+ try {
517
+ out[token] = readJson(filePath);
518
+ } catch {
519
+ // Ignore malformed token files and continue.
520
+ }
521
+ }
522
+
523
+ return out;
524
+ }
525
+
526
+ function readChatSignal(cardId) {
527
+ const chatsDir = path.join(tmpCardsDir, cardId, 'chats');
528
+ if (!fs.existsSync(chatsDir)) {
529
+ return { count: 0, latest_mtime_ms: 0 };
530
+ }
531
+
532
+ let count = 0;
533
+ let latestMtimeMs = 0;
534
+ for (const entry of fs.readdirSync(chatsDir, { withFileTypes: true })) {
535
+ if (!entry.isFile()) continue;
536
+ count += 1;
537
+ try {
538
+ const st = fs.statSync(path.join(chatsDir, entry.name));
539
+ const mtimeMs = Number(st.mtimeMs || 0);
540
+ if (mtimeMs > latestMtimeMs) latestMtimeMs = mtimeMs;
541
+ } catch {
542
+ // Ignore transient file stat/read errors.
543
+ }
544
+ }
545
+
546
+ return { count, latest_mtime_ms: latestMtimeMs };
547
+ }
548
+
549
+ function buildPublishedRuntimePayload() {
550
+ const cardDefinitions = readCardDefinitions();
551
+ const rawArtifacts = readCardRuntimeArtifacts();
552
+ const dataObjectsByToken = readDataObjectsByToken();
553
+ const cardRuntimeById = {};
554
+
555
+ for (const cardDefinition of cardDefinitions) {
556
+ if (!cardDefinition || !cardDefinition.id) continue;
557
+ const rawArtifact = rawArtifacts[cardDefinition.id] || {};
558
+ const sourcesFromFiles = readSourcePayloads(cardDefinition);
559
+ const chatSignal = readChatSignal(cardDefinition.id);
560
+ cardRuntimeById[cardDefinition.id] = {
561
+ schema_version: rawArtifact.schema_version || 'v1',
562
+ card_id: rawArtifact.card_id || cardDefinition.id,
563
+ card_data:
564
+ rawArtifact.card_data && typeof rawArtifact.card_data === 'object'
565
+ ? rawArtifact.card_data
566
+ : cardDefinition.card_data && typeof cardDefinition.card_data === 'object'
567
+ ? cardDefinition.card_data
568
+ : {},
569
+ computed_values:
570
+ rawArtifact.computed_values && typeof rawArtifact.computed_values === 'object'
571
+ ? rawArtifact.computed_values
572
+ : {},
573
+ fetched_sources: sourcesFromFiles,
574
+ requires:
575
+ rawArtifact.requires && typeof rawArtifact.requires === 'object'
576
+ ? rawArtifact.requires
577
+ : {},
578
+ };
579
+
580
+ if (!cardRuntimeById[cardDefinition.id].card_data || typeof cardRuntimeById[cardDefinition.id].card_data !== 'object') {
581
+ cardRuntimeById[cardDefinition.id].card_data = {};
582
+ }
583
+ cardRuntimeById[cardDefinition.id].card_data.__chat_signal = chatSignal;
584
+ }
585
+
586
+ return {
587
+ cardDefinitions,
588
+ statusSnapshot: readStatusSnapshot(),
589
+ dataObjectsByToken,
590
+ cardRuntimeById,
591
+ };
592
+ }
593
+
594
+ function demoPrepSetup() {
595
+ fs.mkdirSync(tmpSurfaceDir, { recursive: true });
596
+ fs.rmSync(tmpCardsDir, { recursive: true, force: true });
597
+ fs.mkdirSync(tmpCardsDir, { recursive: true });
598
+
599
+ const entries = fs.readdirSync(cardsDir, { withFileTypes: true });
600
+ for (const entry of entries) {
601
+ if (!entry.isFile()) continue;
602
+ if (!entry.name.toLowerCase().endsWith('.json')) continue;
603
+ const src = path.join(cardsDir, entry.name);
604
+ const dst = path.join(tmpCardsDir, entry.name);
605
+ fs.copyFileSync(src, dst);
606
+ }
607
+
608
+ didDemoSetup = true;
609
+ }
610
+
611
+ function ensureDemoSetup() {
612
+ if (didDemoSetup && fs.existsSync(tmpCardsDir)) return;
613
+ demoPrepSetup();
614
+ }
615
+
616
+ function resolveTaskExecutorPath(taskExecutorPathParam) {
617
+ const raw = typeof taskExecutorPathParam === 'string' ? taskExecutorPathParam.trim() : '';
618
+ const resolved = raw
619
+ ? (path.isAbsolute(raw) ? raw : path.resolve(__dirname, raw))
620
+ : configuredTaskExecutorPath;
621
+ if (!resolved) {
622
+ const err = new Error('taskExecutorPath is required (query param or runtime defaultTaskExecutorPath option)');
623
+ err.statusCode = 400;
624
+ throw err;
625
+ }
626
+ if (!fs.existsSync(resolved)) {
627
+ const err = new Error(`Task executor script not found: ${resolved}`);
628
+ err.statusCode = 400;
629
+ throw err;
630
+ }
631
+ return resolved;
632
+ }
633
+
634
+ function initBoard(taskExecutorPathParam) {
635
+ fs.mkdirSync(boardDir, { recursive: true });
636
+
637
+ const taskExecutorPath = resolveTaskExecutorPath(taskExecutorPathParam);
638
+ const taskExecutorCmd = `${shellQuote(process.execPath)} ${shellQuote(taskExecutorPath)}`;
639
+
640
+ try {
641
+ runCli(['init', boardDir, '--task-executor', taskExecutorCmd, '--runtime-out', runtimeOutDir]);
642
+ } catch (err) {
643
+ const msg = String((err && err.message) || err);
644
+ if (!msg.includes('no valid board-graph.json')) throw err;
645
+
646
+ clearDirContents(boardDir);
647
+ fs.mkdirSync(boardDir, { recursive: true });
648
+ runCli(['init', boardDir, '--task-executor', taskExecutorCmd, '--runtime-out', runtimeOutDir]);
649
+ }
650
+ }
651
+
652
+ function initBoardAndSetup(taskExecutorPathParam) {
653
+ ensureDemoSetup();
654
+
655
+ if (!fs.existsSync(boardFile)) {
656
+ initBoard(taskExecutorPathParam);
657
+ }
658
+
659
+ const expectedCardsRoot = path.resolve(tmpCardsDir);
660
+ const hasStaleMapping = readInventory().some((entry) => {
661
+ if (!entry || !entry.cardFilePath) return false;
662
+ const mapped = path.resolve(entry.cardFilePath);
663
+ return !mapped.startsWith(expectedCardsRoot + path.sep) && mapped !== expectedCardsRoot;
664
+ });
665
+
666
+ if (hasStaleMapping) {
667
+ clearDirContents(boardDir);
668
+ initBoard(taskExecutorPathParam);
669
+ }
670
+ }
671
+
672
+ function bootstrapCards() {
673
+ ensureDemoSetup();
674
+ runCli(['upsert-card', '--rg', boardDir, '--card-glob', path.join(tmpCardsDir, '*.json')]);
675
+ }
676
+
677
+ function bootstrapBoard() {
678
+ initBoardAndSetup();
679
+ bootstrapCards();
680
+ }
681
+
682
+ function findCardPath(cardId) {
683
+ const inv = readInventory();
684
+ const found = inv.find((e) => e.cardId === cardId);
685
+ return found ? found.cardFilePath : null;
686
+ }
687
+
688
+ function mutateCard(cardId, updateFn, opts) {
689
+ const options = opts && typeof opts === 'object' ? opts : {};
690
+ const syncBoard = options.syncBoard !== false;
691
+ const cardPath = findCardPath(cardId);
692
+ if (!cardPath || !fs.existsSync(cardPath)) {
693
+ const err = new Error(`Card not found: ${cardId}`);
694
+ err.statusCode = 404;
695
+ throw err;
696
+ }
697
+
698
+ const card = readJson(cardPath);
699
+ const nextCard = updateFn(card) || card;
700
+ fs.writeFileSync(cardPath, JSON.stringify(nextCard, null, 2));
701
+
702
+ if (syncBoard) {
703
+ runCli(['upsert-card', '--rg', boardDir, '--card', cardPath, '--restart']);
704
+ }
705
+ }
706
+
707
+ function updateCard(cardId, updateFn) {
708
+ mutateCard(cardId, updateFn, { syncBoard: true });
709
+ }
710
+
711
+ function updateCardLocalOnly(cardId, updateFn) {
712
+ mutateCard(cardId, updateFn, { syncBoard: false });
713
+ }
714
+
715
+ function patchCard(cardId, patch) {
716
+ updateCard(cardId, (card) => {
717
+ if (!patch || typeof patch !== 'object' || Object.keys(patch).length === 0) {
718
+ return card;
719
+ }
720
+
721
+ function deepSet(obj, dottedPath, value) {
722
+ const parts = String(dottedPath || '').split('.').filter(Boolean);
723
+ if (!parts.length) return;
724
+ let target = obj;
725
+ for (let i = 0; i < parts.length - 1; i++) {
726
+ const key = parts[i];
727
+ if (!target[key] || typeof target[key] !== 'object') target[key] = {};
728
+ target = target[key];
729
+ }
730
+ target[parts[parts.length - 1]] = value;
731
+ }
732
+
733
+ if (patch.fieldValues && typeof patch.fieldValues === 'object') {
734
+ let writeTo = null;
735
+ if (card.view && Array.isArray(card.view.elements)) {
736
+ for (const elem of card.view.elements) {
737
+ if (elem && elem.data && elem.data.writeTo) {
738
+ writeTo = elem.data.writeTo;
739
+ break;
740
+ }
741
+ }
742
+ }
743
+ if (writeTo) {
744
+ deepSet(card, writeTo, patch.fieldValues);
745
+ } else {
746
+ card.card_data = { ...(card.card_data || {}), ...patch.fieldValues };
747
+ }
748
+ } else if (Array.isArray(patch._stagedFiles) && patch._stagedFiles.length > 0) {
749
+ return card;
750
+ } else {
751
+ for (const [key, value] of Object.entries(patch)) {
752
+ if (key === '_stagedFiles') continue;
753
+ if (
754
+ value !== null &&
755
+ typeof value === 'object' &&
756
+ !Array.isArray(value) &&
757
+ card[key] !== null &&
758
+ typeof card[key] === 'object' &&
759
+ !Array.isArray(card[key])
760
+ ) {
761
+ card[key] = { ...card[key], ...value };
762
+ } else {
763
+ card[key] = value;
764
+ }
765
+ }
766
+ }
767
+
768
+ return card;
769
+ });
770
+ }
771
+
772
+ function clearChatRecords(cardId) {
773
+ const { chatsDir } = ensureCardStorageDirs(cardId);
774
+ clearDirContents(chatsDir);
775
+ }
776
+
777
+ function nextFileSerial(cardId) {
778
+ const names = [];
779
+
780
+ try {
781
+ const cardPath = findCardPath(cardId);
782
+ if (cardPath && fs.existsSync(cardPath)) {
783
+ const card = readJson(cardPath);
784
+ const files = card && card.card_data && Array.isArray(card.card_data.files) ? card.card_data.files : [];
785
+ for (const entry of files) {
786
+ if (entry && typeof entry.stored_name === 'string') names.push(entry.stored_name);
787
+ }
788
+ }
789
+ } catch {
790
+ // ignore malformed card file and fall back to dir scan
791
+ }
792
+
793
+ const { filesDir } = ensureCardStorageDirs(cardId);
794
+ if (fs.existsSync(filesDir)) {
795
+ for (const entry of fs.readdirSync(filesDir, { withFileTypes: true })) {
796
+ if (!entry.isFile()) continue;
797
+ names.push(entry.name);
798
+ }
799
+ }
800
+
801
+ return nextSerialFromNames(names);
802
+ }
803
+
804
+ function nextChatStoredName(cardId, role) {
805
+ const { chatsDir } = ensureCardStorageDirs(cardId);
806
+ const names = fs.existsSync(chatsDir)
807
+ ? fs.readdirSync(chatsDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name)
808
+ : [];
809
+ const serial = nextSerialFromNames(names);
810
+ const safeRole = String(role || 'system').toLowerCase().replace(/[^a-z0-9_-]/g, '_') || 'system';
811
+ return `${String(serial).padStart(3, '0')}_${safeRole}.txt`;
812
+ }
813
+
814
+ function writeChatRecord(cardId, role, text, files) {
815
+ const now = new Date().toISOString();
816
+ const { chatsDir } = ensureCardStorageDirs(cardId);
817
+ const outName = nextChatStoredName(cardId, role || 'system');
818
+ const outPath = path.join(chatsDir, outName);
819
+
820
+ const lines = [];
821
+ const msg = typeof text === 'string' ? text.trim() : '';
822
+ if (msg) lines.push(msg);
823
+
824
+ const fileList = Array.isArray(files) ? files : [];
825
+ if (fileList.length) {
826
+ if (lines.length) lines.push('');
827
+ lines.push('files:');
828
+ for (const file of fileList) {
829
+ if (!file || typeof file !== 'object') continue;
830
+ const display = typeof file.name === 'string' ? file.name : 'file';
831
+ const stored = typeof file.stored_name === 'string' ? file.stored_name : '';
832
+ lines.push(stored ? `- ${display} -> ${stored}` : `- ${display}`);
833
+ }
834
+ }
835
+
836
+ fs.writeFileSync(outPath, `${lines.join('\n')}\n`, 'utf-8');
837
+ return {
838
+ at: now,
839
+ role: role || 'system',
840
+ text: msg,
841
+ files: fileList,
842
+ path: `${cardId}/chats/${outName}`,
843
+ };
844
+ }
845
+
846
+ function readChatRecords(cardId) {
847
+ const { chatsDir } = ensureCardStorageDirs(cardId);
848
+ if (!fs.existsSync(chatsDir)) return [];
849
+
850
+ const out = [];
851
+ for (const entry of fs.readdirSync(chatsDir, { withFileTypes: true })) {
852
+ if (!entry.isFile()) continue;
853
+ const name = entry.name;
854
+ const parsed = String(name).match(/^(\d+)[-_]([a-z0-9_-]+)\.txt$/i);
855
+ const serial = parsed ? parseInt(parsed[1], 10) : 0;
856
+ const role = parsed ? parsed[2].toLowerCase() : 'system';
857
+ const filePath = path.join(chatsDir, name);
858
+ const text = fs.readFileSync(filePath, 'utf-8');
859
+ const stat = fs.statSync(filePath);
860
+ out.push({
861
+ serial,
862
+ role,
863
+ text,
864
+ path: `${cardId}/chats/${name}`,
865
+ stored_name: name,
866
+ updated_at: new Date(stat.mtimeMs).toISOString(),
867
+ });
868
+ }
869
+
870
+ out.sort((a, b) => a.serial - b.serial || a.stored_name.localeCompare(b.stored_name));
871
+ return out;
872
+ }
873
+
874
+ function persistUploadedFile(cardId, requestedName, contentType, buffer) {
875
+ const { filesDir } = ensureCardStorageDirs(cardId);
876
+ const displayName = normalizeDisplayFileName(requestedName);
877
+
878
+ let serial = nextFileSerial(cardId);
879
+ let storedName = buildStoredFileName(displayName, serial);
880
+ while (fs.existsSync(path.join(filesDir, storedName))) {
881
+ serial += 1;
882
+ storedName = buildStoredFileName(displayName, serial);
883
+ }
884
+
885
+ const targetPath = path.join(filesDir, storedName);
886
+ fs.writeFileSync(targetPath, buffer);
887
+
888
+ return {
889
+ name: displayName,
890
+ stored_name: storedName,
891
+ size: buffer.length,
892
+ mime_type: contentType || 'application/octet-stream',
893
+ path: `${cardId}/files/${storedName}`,
894
+ uploaded_at: new Date().toISOString(),
895
+ };
896
+ }
897
+
898
+ // Fire-and-forget invocation of .chat-handler after a user chat message is persisted.
899
+ // boardDir/.chat-handler must contain the handler command as a single-line string.
900
+ // Called with: --boardId <id> --cardId <id> --extra <json>
901
+ // extra: { chatDir: <abs path>, boardDir: <abs path>, lastChatFile: <filename> }
902
+ // Handler failures are logged and silently ignored — chat-send response is never affected.
903
+ function invokeChatHandler(cardId, chatsDir, lastChatFile) {
904
+ const handlerFile = path.join(boardDir, '.chat-handler');
905
+ if (!fs.existsSync(handlerFile)) return;
906
+ const handlerCmd = fs.readFileSync(handlerFile, 'utf-8').trim();
907
+ if (!handlerCmd) return;
908
+ const extra = Buffer.from(JSON.stringify({ chatDir: chatsDir, boardDir, lastChatFile })).toString('base64');
909
+ try {
910
+ const proc = spawn(handlerCmd, ['--boardId', boardId, '--cardId', String(cardId), '--extraEncJson', extra], {
911
+ shell: true,
912
+ stdio: 'ignore',
913
+ });
914
+ proc.unref();
915
+ console.log(`[chat-handler] invoked for card "${cardId}" (boardId: "${boardId}")`);
916
+ } catch (err) {
917
+ console.warn(`[chat-handler] spawn failed for card "${cardId}":`, (err && err.message) || String(err));
918
+ }
919
+ }
920
+
921
+ function applyCardAction(cardId, actionType, payload) {
922
+ const persistCard = actionType === 'chat-send' ? updateCardLocalOnly : updateCard;
923
+ let chatHandlerArgs = null;
924
+ persistCard(cardId, (card) => {
925
+ const now = new Date().toISOString();
926
+ const cardData = card.card_data && typeof card.card_data === 'object' ? card.card_data : {};
927
+ card.card_data = cardData;
928
+
929
+ if (actionType === 'chat-send') {
930
+ const text = payload && typeof payload.text === 'string' ? payload.text.trim() : '';
931
+ const files = Array.isArray(payload && payload.files)
932
+ ? payload.files
933
+ .map((f) => {
934
+ if (!f) return null;
935
+ if (typeof f === 'string') return { name: f };
936
+ if (typeof f === 'object' && typeof f.name === 'string') {
937
+ return {
938
+ name: f.name,
939
+ size: f.size || null,
940
+ mime_type: f.mime_type || null,
941
+ path: f.path || null,
942
+ uploaded_at: f.uploaded_at || null,
943
+ stored_name: f.stored_name || null,
944
+ };
945
+ }
946
+ return null;
947
+ })
948
+ .filter(Boolean)
949
+ : [];
950
+
951
+ if (text || files.length > 0) {
952
+ const { chatsDir } = ensureCardStorageDirs(cardId);
953
+ const userRecord = writeChatRecord(cardId, 'user', text, files);
954
+ chatHandlerArgs = { chatsDir, lastChatFile: path.basename(userRecord.path) };
955
+ for (const file of files) {
956
+ if (!file || typeof file !== 'object') continue;
957
+ const display = typeof file.name === 'string' ? file.name : 'file';
958
+ const stored = typeof file.stored_name === 'string' ? file.stored_name : null;
959
+ if (!stored) continue;
960
+ writeChatRecord(cardId, 'system', `File ${display} uploaded as ${stored}.`, []);
961
+ }
962
+ }
963
+
964
+ return card;
965
+ }
966
+
967
+ if (actionType === 'file-upload') {
968
+ const files = Array.isArray(payload && payload.files)
969
+ ? payload.files
970
+ .map((f) => {
971
+ if (!f || typeof f !== 'object') return null;
972
+ if (typeof f.stored_name !== 'string') return null;
973
+ return {
974
+ name: typeof f.name === 'string' ? f.name : f.stored_name,
975
+ stored_name: f.stored_name,
976
+ size: f.size || null,
977
+ mime_type: f.mime_type || null,
978
+ path: f.path || null,
979
+ uploaded_at: f.uploaded_at || now,
980
+ };
981
+ })
982
+ .filter(Boolean)
983
+ : [];
984
+
985
+ if (files.length > 0) {
986
+ const existing = Array.isArray(cardData.files) ? cardData.files.slice() : [];
987
+ const known = new Set(existing.map((f) => (f && f.stored_name ? f.stored_name : '')));
988
+ for (const f of files) {
989
+ if (known.has(f.stored_name)) continue;
990
+ existing.push(f);
991
+ known.add(f.stored_name);
992
+ }
993
+ cardData.files = existing;
994
+ }
995
+
996
+ return card;
997
+ }
998
+
999
+ if (actionType === 'action') {
1000
+ const buttonId = payload && typeof payload.buttonId === 'string' ? payload.buttonId : '';
1001
+ if (!buttonId) return card;
1002
+
1003
+ cardData.lastAction = { buttonId, at: now };
1004
+ cardData.lastActionText = `${buttonId} @ ${now}`;
1005
+ }
1006
+
1007
+ return card;
1008
+ });
1009
+
1010
+ if (chatHandlerArgs) {
1011
+ invokeChatHandler(cardId, chatHandlerArgs.chatsDir, chatHandlerArgs.lastChatFile);
1012
+ }
1013
+ }
1014
+
1015
+ function json(res, status, payload) {
1016
+ const body = JSON.stringify(payload);
1017
+ res.writeHead(status, {
1018
+ ...corsHeaders,
1019
+ 'Content-Type': 'application/json; charset=utf-8',
1020
+ 'Content-Length': Buffer.byteLength(body),
1021
+ });
1022
+ res.end(body);
1023
+ }
1024
+
1025
+ async function readJsonBody(req) {
1026
+ const chunks = [];
1027
+ for await (const c of req) chunks.push(c);
1028
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
1029
+ if (!raw) return {};
1030
+ return JSON.parse(raw);
1031
+ }
1032
+
1033
+ async function readRawBody(req) {
1034
+ const chunks = [];
1035
+ for await (const c of req) chunks.push(c);
1036
+ return Buffer.concat(chunks);
1037
+ }
1038
+
1039
+ function handleSse(req, res) {
1040
+ res.writeHead(200, {
1041
+ ...corsHeaders,
1042
+ 'Content-Type': 'text/event-stream',
1043
+ 'Cache-Control': 'no-cache',
1044
+ Connection: 'keep-alive',
1045
+ });
1046
+
1047
+ const stablePayloadString = (payload) =>
1048
+ JSON.stringify(payload, (key, value) => {
1049
+ if (key === 'status_age_ms') return undefined;
1050
+ return value;
1051
+ });
1052
+
1053
+ let lastPublishedHash = '';
1054
+
1055
+ const emitCards = (payload) => {
1056
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1057
+ };
1058
+
1059
+ const initialPayload = buildPublishedRuntimePayload();
1060
+ lastPublishedHash = stablePayloadString(initialPayload);
1061
+ emitCards(initialPayload);
1062
+
1063
+ const poll = setInterval(() => {
1064
+ try {
1065
+ runCli(['process-accumulated-events', '--rg', boardDir]);
1066
+
1067
+ const nextPayload = buildPublishedRuntimePayload();
1068
+ const nextHash = stablePayloadString(nextPayload);
1069
+ if (nextHash !== lastPublishedHash) {
1070
+ lastPublishedHash = nextHash;
1071
+ emitCards(nextPayload);
1072
+ } else {
1073
+ res.write(': keepalive\n\n');
1074
+ }
1075
+ } catch (err) {
1076
+ res.write(`data: ${JSON.stringify({ error: String((err && err.message) || err) })}\n\n`);
1077
+ }
1078
+ }, 800);
1079
+
1080
+ req.on('close', () => {
1081
+ clearInterval(poll);
1082
+ res.end();
1083
+ });
1084
+ }
1085
+
1086
+ async function handleDemoSetupApi(req, res, parsedUrl) {
1087
+ return false; // Demo-setup is handled by the host layer.
1088
+ }
1089
+
1090
+ async function handleRuntimeApi(req, res, parsedUrl) {
1091
+ const method = req.method || 'GET';
1092
+ const url = parsedUrl || parseUrl(req.url || '/');
1093
+ const p = url.pathname;
1094
+
1095
+ try {
1096
+ if (method === 'GET' && p === `${apiBasePath}/init-board`) {
1097
+ const taskExecutorPathParam = url.searchParams.get('taskExecutorPath') || '';
1098
+ initBoardAndSetup(taskExecutorPathParam);
1099
+ json(res, 200, buildPublishedRuntimePayload());
1100
+ return true;
1101
+ }
1102
+
1103
+ if (method === 'GET' && p === `${apiBasePath}/bootstrap-cards`) {
1104
+ bootstrapCards();
1105
+ json(res, 200, buildPublishedRuntimePayload());
1106
+ return true;
1107
+ }
1108
+
1109
+ if (method === 'GET' && p === `${apiBasePath}/bootstrap`) {
1110
+ bootstrapBoard();
1111
+ json(res, 200, buildPublishedRuntimePayload());
1112
+ return true;
1113
+ }
1114
+
1115
+ if (method === 'GET' && p === `${apiBasePath}/sse`) {
1116
+ bootstrapBoard();
1117
+ handleSse(req, res);
1118
+ return true;
1119
+ }
1120
+
1121
+ if (method === 'GET' && p === `${apiBasePath}/board-status`) {
1122
+ ensureDemoSetup();
1123
+ json(res, 200, buildPublishedRuntimePayload());
1124
+ return true;
1125
+ }
1126
+
1127
+ const cardMatch = p.match(new RegExp(`^${apiBasePath}/cards/([^/]+)$`));
1128
+ if (method === 'PATCH' && cardMatch) {
1129
+ bootstrapBoard();
1130
+ const cardId = decodeURIComponent(cardMatch[1]);
1131
+ const body = await readJsonBody(req);
1132
+ patchCard(cardId, body);
1133
+ json(res, 200, { ok: true });
1134
+ return true;
1135
+ }
1136
+
1137
+ const cardActionMatch = p.match(new RegExp(`^${apiBasePath}/cards/([^/]+)/actions$`));
1138
+ if (method === 'POST' && cardActionMatch) {
1139
+ bootstrapBoard();
1140
+ const cardId = decodeURIComponent(cardActionMatch[1]);
1141
+ const body = await readJsonBody(req);
1142
+ applyCardAction(cardId, body && body.actionType, body && body.payload);
1143
+ json(res, 200, { ok: true });
1144
+ return true;
1145
+ }
1146
+
1147
+ const cardChatsMatch = p.match(new RegExp(`^${apiBasePath}/cards/([^/]+)/chats$`));
1148
+ if (method === 'GET' && cardChatsMatch) {
1149
+ bootstrapBoard();
1150
+ const cardId = decodeURIComponent(cardChatsMatch[1]);
1151
+ json(res, 200, { ok: true, messages: readChatRecords(cardId) });
1152
+ return true;
1153
+ }
1154
+
1155
+ const cardFileMatch = p.match(new RegExp(`^${apiBasePath}/cards/([^/]+)/files$`));
1156
+ if (method === 'POST' && cardFileMatch) {
1157
+ bootstrapBoard();
1158
+ const cardId = decodeURIComponent(cardFileMatch[1]);
1159
+ const inChat = String(url.searchParams.get('inChat') || '').toLowerCase() === 'true';
1160
+ const encodedName = req.headers['x-file-name'];
1161
+ const contentType = String(req.headers['content-type'] || 'application/octet-stream');
1162
+ const rawName = Array.isArray(encodedName) ? encodedName[0] : encodedName;
1163
+ const requestedName = rawName ? decodeURIComponent(String(rawName)) : 'upload.bin';
1164
+ const body = await readRawBody(req);
1165
+ if (!body.length) {
1166
+ json(res, 400, { error: 'Empty upload body' });
1167
+ return true;
1168
+ }
1169
+
1170
+ const file = persistUploadedFile(cardId, requestedName, contentType, body);
1171
+ if (inChat) {
1172
+ updateCardLocalOnly(cardId, (card) => {
1173
+ const now = new Date().toISOString();
1174
+ const cardData = card.card_data && typeof card.card_data === 'object' ? card.card_data : {};
1175
+ card.card_data = cardData;
1176
+ const existing = Array.isArray(cardData.files) ? cardData.files.slice() : [];
1177
+ const known = new Set(existing.map((f) => (f && f.stored_name ? f.stored_name : '')));
1178
+ if (!known.has(file.stored_name)) {
1179
+ existing.push({
1180
+ name: typeof file.name === 'string' ? file.name : file.stored_name,
1181
+ stored_name: file.stored_name,
1182
+ size: file.size || null,
1183
+ mime_type: file.mime_type || null,
1184
+ path: file.path || null,
1185
+ uploaded_at: file.uploaded_at || now,
1186
+ });
1187
+ cardData.files = existing;
1188
+ }
1189
+ return card;
1190
+ });
1191
+ writeChatRecord(cardId, 'system', `file uploaded: ${file.name} as ${file.stored_name}`, []);
1192
+ }
1193
+ json(res, 200, { ok: true, file });
1194
+ return true;
1195
+ }
1196
+
1197
+ const cardFileDownloadMatch = p.match(new RegExp(`^${apiBasePath}/cards/([^/]+)/files/(\\d+)$`));
1198
+ if (method === 'GET' && cardFileDownloadMatch) {
1199
+ const cardId = decodeURIComponent(cardFileDownloadMatch[1]);
1200
+ const idx = parseInt(cardFileDownloadMatch[2], 10);
1201
+ const expectedStoredName = url.searchParams.get('sn');
1202
+
1203
+ const cardPath = path.join(tmpCardsDir, `${cardId}.json`);
1204
+ if (!fs.existsSync(cardPath)) {
1205
+ json(res, 404, { error: 'Card not found' });
1206
+ return true;
1207
+ }
1208
+
1209
+ let card;
1210
+ try {
1211
+ card = readJson(cardPath);
1212
+ } catch {
1213
+ json(res, 404, { error: 'Card not found' });
1214
+ return true;
1215
+ }
1216
+
1217
+ const files = card.card_data && Array.isArray(card.card_data.files) ? card.card_data.files : [];
1218
+ if (idx < 0 || idx >= files.length) {
1219
+ json(res, 404, { error: 'File not found' });
1220
+ return true;
1221
+ }
1222
+
1223
+ const fileRecord = files[idx];
1224
+ if (!fileRecord || !fileRecord.stored_name) {
1225
+ json(res, 404, { error: 'File not found' });
1226
+ return true;
1227
+ }
1228
+ if (expectedStoredName && expectedStoredName !== fileRecord.stored_name) {
1229
+ json(res, 409, { error: 'File reference is stale. Refresh and try again.' });
1230
+ return true;
1231
+ }
1232
+
1233
+ const { filesDir } = ensureCardStorageDirs(cardId);
1234
+ const filePath = path.join(filesDir, fileRecord.stored_name);
1235
+
1236
+ const realPath = path.resolve(filePath);
1237
+ const realFilesDir = path.resolve(filesDir);
1238
+ if (!realPath.startsWith(realFilesDir)) {
1239
+ json(res, 403, { error: 'Forbidden' });
1240
+ return true;
1241
+ }
1242
+
1243
+ if (!fs.existsSync(filePath)) {
1244
+ json(res, 404, { error: 'File not found' });
1245
+ return true;
1246
+ }
1247
+
1248
+ const buffer = fs.readFileSync(filePath);
1249
+ const filename = fileRecord.name || path.basename(filePath);
1250
+ const mimeType = fileRecord.mime_type || 'application/octet-stream';
1251
+ res.writeHead(200, {
1252
+ 'Content-Type': mimeType,
1253
+ 'Content-Disposition': `attachment; filename="${filename}"`,
1254
+ 'Content-Length': buffer.length,
1255
+ });
1256
+ res.end(buffer);
1257
+ return true;
1258
+ }
1259
+
1260
+ return false;
1261
+ } catch (err) {
1262
+ const statusCode = err && err.statusCode ? err.statusCode : 500;
1263
+ json(res, statusCode, { error: String((err && err.message) || err) });
1264
+ return true;
1265
+ }
1266
+ }
1267
+
1268
+ return {
1269
+ apiBasePath,
1270
+ corsHeaders,
1271
+ boardDir,
1272
+ tmpSurfaceDir,
1273
+ tmpCardsDir,
1274
+ runtimeOutDir,
1275
+ parseUrl,
1276
+ json,
1277
+ runCli,
1278
+ demoPrepSetup,
1279
+ ensureDemoSetup,
1280
+ buildPublishedRuntimePayload,
1281
+ handleRuntimeApi,
1282
+ clearChatRecords,
1283
+ };
1284
+ }