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