@khanglvm/relay 0.2.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/src/spec.js ADDED
@@ -0,0 +1,435 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CliError } from './util.js';
4
+
5
+ export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale'];
6
+
7
+ const ALIASES = {
8
+ radio: 'single',
9
+ choice: 'single',
10
+ select: 'single',
11
+ checkbox: 'multi',
12
+ checkboxes: 'multi',
13
+ boolean: 'yesno',
14
+ bool: 'yesno',
15
+ yn: 'yesno',
16
+ input: 'text',
17
+ string: 'text',
18
+ longtext: 'textarea',
19
+ long: 'textarea',
20
+ rating: 'scale',
21
+ likert: 'scale',
22
+ };
23
+
24
+ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 360 };
25
+
26
+ // Block heights clamp to the same window; defaults vary per block type.
27
+ const BLOCK_HEIGHT = { min: 100, max: 2400 };
28
+ export const BLOCK_TYPES = ['markdown', 'mermaid', 'chart', 'table', 'code', 'html'];
29
+ const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
30
+
31
+ const asStr = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
32
+
33
+ function clampInt(v, min, max, def) {
34
+ const n = Number.parseInt(v, 10);
35
+ if (!Number.isFinite(n)) return def;
36
+ return Math.min(max, Math.max(min, n));
37
+ }
38
+
39
+ function readHtml(obj, cwd, where) {
40
+ if (typeof obj.html === 'string' && obj.html.trim()) return obj.html;
41
+ if (typeof obj.htmlFile === 'string' && obj.htmlFile.trim()) {
42
+ const p = path.resolve(cwd, obj.htmlFile);
43
+ try {
44
+ return fs.readFileSync(p, 'utf8');
45
+ } catch {
46
+ throw new CliError(`${where}: cannot read htmlFile "${obj.htmlFile}" (resolved: ${p})`);
47
+ }
48
+ }
49
+ return '';
50
+ }
51
+
52
+ // Reads an html-block body from either an inline string or a file path.
53
+ function readBlockHtml(block, cwd, where) {
54
+ if (typeof block.html === 'string' && block.html) return block.html;
55
+ if (typeof block.htmlFile === 'string' && block.htmlFile.trim()) {
56
+ const p = path.resolve(cwd, block.htmlFile);
57
+ try {
58
+ return fs.readFileSync(p, 'utf8');
59
+ } catch {
60
+ throw new CliError(`${where}: cannot read htmlFile "${block.htmlFile}" (resolved: ${p})`);
61
+ }
62
+ }
63
+ return '';
64
+ }
65
+
66
+ // Normalizes one block object. `id` is the already-assigned block id.
67
+ // Returns the normalized block (with a guaranteed string `type` + `id`).
68
+ function normalizeBlock(rawBlock, id, cwd, where) {
69
+ if (rawBlock === null || typeof rawBlock !== 'object' || Array.isArray(rawBlock)) {
70
+ throw new CliError(`${where}: must be an object with a "type".`);
71
+ }
72
+ let type = asStr(rawBlock.type).trim().toLowerCase();
73
+ if (!type) throw new CliError(`${where}: missing "type". Valid: ${BLOCK_TYPES.join(', ')}.`);
74
+ if (!BLOCK_TYPES.includes(type)) {
75
+ throw new CliError(`${where}: unknown block type "${rawBlock.type}". Valid: ${BLOCK_TYPES.join(', ')}.`);
76
+ }
77
+
78
+ const hasHeight = rawBlock.height !== undefined && rawBlock.height !== null && rawBlock.height !== '';
79
+
80
+ if (type === 'markdown') {
81
+ const md = asStr(rawBlock.md);
82
+ if (!md.trim()) throw new CliError(`${where}: markdown block needs a non-empty "md" string.`);
83
+ const block = { id, type: 'markdown', md };
84
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
85
+ return block;
86
+ }
87
+
88
+ if (type === 'mermaid') {
89
+ const code = asStr(rawBlock.code);
90
+ if (!code.trim()) throw new CliError(`${where}: mermaid block needs a non-empty "code" string.`);
91
+ const block = { id, type: 'mermaid', code };
92
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
93
+ return block;
94
+ }
95
+
96
+ if (type === 'code') {
97
+ const code = asStr(rawBlock.code);
98
+ if (!code) throw new CliError(`${where}: code block needs a "code" string.`);
99
+ const block = { id, type: 'code', code };
100
+ if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
101
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
102
+ return block;
103
+ }
104
+
105
+ if (type === 'chart') {
106
+ const hasConfig = rawBlock.config && typeof rawBlock.config === 'object' && !Array.isArray(rawBlock.config);
107
+ const hasShorthand =
108
+ typeof rawBlock.kind === 'string' ||
109
+ Array.isArray(rawBlock.labels) ||
110
+ Array.isArray(rawBlock.series);
111
+ if (!hasConfig && !hasShorthand) {
112
+ throw new CliError(`${where}: chart needs "config" (full Chart.js config) or "kind"+"labels"+"series".`);
113
+ }
114
+ const block = { id, type: 'chart' };
115
+ block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, 320);
116
+ if (hasConfig) {
117
+ block.config = rawBlock.config;
118
+ return block;
119
+ }
120
+ // Shorthand form: validate kind/labels/series.
121
+ const kind = asStr(rawBlock.kind).trim().toLowerCase();
122
+ if (!kind) throw new CliError(`${where}: chart shorthand needs a "kind" (${CHART_KINDS.join('|')}).`);
123
+ if (!CHART_KINDS.includes(kind)) {
124
+ throw new CliError(`${where}: unknown chart kind "${rawBlock.kind}". Valid: ${CHART_KINDS.join(', ')}.`);
125
+ }
126
+ if (!Array.isArray(rawBlock.labels)) {
127
+ throw new CliError(`${where}: chart shorthand needs a "labels" array.`);
128
+ }
129
+ if (!Array.isArray(rawBlock.series) || rawBlock.series.length < 1) {
130
+ throw new CliError(`${where}: chart shorthand needs a non-empty "series" array of {label, data[]}.`);
131
+ }
132
+ block.kind = kind;
133
+ block.labels = rawBlock.labels.map((l) => asStr(l));
134
+ block.series = rawBlock.series.map((s, k) => {
135
+ if (s === null || typeof s !== 'object' || Array.isArray(s)) {
136
+ throw new CliError(`${where}.series[${k}]: must be an object {label, data[]}.`);
137
+ }
138
+ if (!Array.isArray(s.data)) {
139
+ throw new CliError(`${where}.series[${k}]: needs a "data" array.`);
140
+ }
141
+ const out = { label: asStr(s.label), data: s.data };
142
+ if (s.color !== undefined) out.color = asStr(s.color);
143
+ return out;
144
+ });
145
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
146
+ return block;
147
+ }
148
+
149
+ if (type === 'table') {
150
+ if (!Array.isArray(rawBlock.columns) || rawBlock.columns.length < 1) {
151
+ throw new CliError(`${where}: table needs a non-empty "columns" array (strings or {key,label,align?}).`);
152
+ }
153
+ if (!Array.isArray(rawBlock.rows)) {
154
+ throw new CliError(`${where}: table needs a "rows" array.`);
155
+ }
156
+ const columns = rawBlock.columns.map((c, k) => {
157
+ if (typeof c === 'string' || typeof c === 'number') {
158
+ const key = String(c);
159
+ return { key, label: key };
160
+ }
161
+ if (c && typeof c === 'object' && !Array.isArray(c)) {
162
+ const key = asStr(c.key ?? c.label).trim();
163
+ if (!key) throw new CliError(`${where}.columns[${k}]: needs "key" or "label".`);
164
+ const col = { key, label: asStr(c.label ?? c.key) || key };
165
+ if (c.align !== undefined) col.align = asStr(c.align);
166
+ return col;
167
+ }
168
+ throw new CliError(`${where}.columns[${k}]: must be a string or {key, label, align?}.`);
169
+ });
170
+ // Normalize array rows into objects keyed by column key, so the client
171
+ // (and table-cell annotation values) always index rows the same way.
172
+ const rows = rawBlock.rows.map((r, ri) => {
173
+ if (Array.isArray(r)) {
174
+ const obj = {};
175
+ columns.forEach((col, ci) => {
176
+ obj[col.key] = r[ci];
177
+ });
178
+ return obj;
179
+ }
180
+ if (r && typeof r === 'object') return r;
181
+ throw new CliError(`${where}.rows[${ri}]: must be an array or an object.`);
182
+ });
183
+ const block = { id, type: 'table', columns, rows };
184
+ if (rawBlock.sortable === true) block.sortable = true;
185
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
186
+ return block;
187
+ }
188
+
189
+ // type === 'html'
190
+ const html = readBlockHtml(rawBlock, cwd, where);
191
+ if (!html) {
192
+ throw new CliError(`${where}: html block needs an "html" string or readable "htmlFile".`);
193
+ }
194
+ return {
195
+ id,
196
+ type: 'html',
197
+ html,
198
+ height: clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, 360),
199
+ };
200
+ }
201
+
202
+ // Builds the normalized block list for a scope (board or question).
203
+ // Legacy html/htmlFile become a PREPENDED html block; ids are assigned in
204
+ // final order with `prefix` ('' for board → b1,b2…; '<qid>-' for questions).
205
+ function buildBlocks(rawObj, cwd, where, prefix) {
206
+ const blocks = [];
207
+ let n = 0;
208
+ const nextId = () => `${prefix}b${++n}`;
209
+
210
+ // Legacy html/htmlFile → a single prepended html block. htmlHeight applies.
211
+ const legacyHtml = readHtml(rawObj, cwd, where);
212
+ if (legacyHtml) {
213
+ const def = prefix ? HTML_HEIGHT.questionDefault : HTML_HEIGHT.boardDefault;
214
+ blocks.push({
215
+ id: nextId(),
216
+ type: 'html',
217
+ html: legacyHtml,
218
+ height: clampInt(rawObj.htmlHeight, HTML_HEIGHT.min, HTML_HEIGHT.max, def),
219
+ });
220
+ }
221
+
222
+ if (rawObj.blocks !== undefined && rawObj.blocks !== null) {
223
+ if (!Array.isArray(rawObj.blocks)) throw new CliError(`${where}.blocks: must be an array.`);
224
+ rawObj.blocks.forEach((b, i) => {
225
+ blocks.push(normalizeBlock(b, nextId(), cwd, `${where}.blocks[${i}]`));
226
+ });
227
+ }
228
+ return blocks;
229
+ }
230
+
231
+ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
232
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
233
+ throw new CliError('Spec must be a JSON object. Run `rly agent` for the schema and examples.');
234
+ }
235
+ const spec = {
236
+ title: asStr(raw.title).trim() || 'Relay',
237
+ intro: asStr(raw.intro),
238
+ blocks: buildBlocks(raw, cwd, 'board', ''),
239
+ allowPartial: raw.allowPartial !== false,
240
+ note: raw.note !== false,
241
+ autoClose: raw.autoClose !== false,
242
+ questions: [],
243
+ submitLabel: '',
244
+ };
245
+
246
+ const rawQs = raw.questions == null ? [] : raw.questions;
247
+ if (!Array.isArray(rawQs)) throw new CliError('"questions" must be an array.');
248
+
249
+ const seen = new Set();
250
+ rawQs.forEach((rq, i) => {
251
+ const where = `questions[${i}]`;
252
+ if (rq === null || typeof rq !== 'object' || Array.isArray(rq)) {
253
+ throw new CliError(`${where}: must be an object.`);
254
+ }
255
+ const label = asStr(rq.label ?? rq.question ?? rq.text).trim();
256
+ if (!label) throw new CliError(`${where}: missing "label".`);
257
+
258
+ let type = asStr(rq.type).trim().toLowerCase() || 'text';
259
+ type = ALIASES[type] || type;
260
+ if (!TYPES.includes(type)) {
261
+ throw new CliError(`${where}: unknown type "${rq.type}". Valid: ${TYPES.join(', ')} (plus aliases like radio/checkbox/boolean/rating).`);
262
+ }
263
+
264
+ const id = asStr(rq.id).trim() || `q${i + 1}`;
265
+ if (seen.has(id)) throw new CliError(`${where}: duplicate question id "${id}".`);
266
+ seen.add(id);
267
+
268
+ const q = {
269
+ id,
270
+ type,
271
+ label,
272
+ description: asStr(rq.description),
273
+ required: rq.required === true,
274
+ note: rq.note === true,
275
+ blocks: buildBlocks(rq, cwd, where, `${id}-`),
276
+ placeholder: asStr(rq.placeholder),
277
+ };
278
+
279
+ if (type === 'single' || type === 'multi') {
280
+ const opts = Array.isArray(rq.options) ? rq.options : [];
281
+ q.options = opts.map((o, j) => {
282
+ if (typeof o === 'string' || typeof o === 'number') {
283
+ return { value: String(o), label: String(o) };
284
+ }
285
+ if (o && typeof o === 'object') {
286
+ const value = asStr(o.value ?? o.label).trim();
287
+ const olabel = asStr(o.label ?? o.value).trim();
288
+ if (!value) throw new CliError(`${where}.options[${j}]: needs "value" or "label".`);
289
+ const out = { value, label: olabel || value };
290
+ if (o.description) out.description = asStr(o.description);
291
+ return out;
292
+ }
293
+ throw new CliError(`${where}.options[${j}]: must be a string or {value, label, description?}.`);
294
+ });
295
+ if (q.options.length < 1) {
296
+ throw new CliError(`${where}: type "${type}" needs at least 1 option.`);
297
+ }
298
+ q.other = rq.other === true;
299
+ }
300
+
301
+ if (type === 'scale') {
302
+ q.min = clampInt(rq.min, 0, 9, 1);
303
+ q.max = clampInt(rq.max, q.min + 1, 10, Math.max(5, q.min + 1));
304
+ q.minLabel = asStr(rq.minLabel);
305
+ q.maxLabel = asStr(rq.maxLabel);
306
+ }
307
+
308
+ if (rq.default !== undefined) q.default = rq.default;
309
+ spec.questions.push(q);
310
+ });
311
+
312
+ if (!spec.questions.length && !spec.blocks.length) {
313
+ throw new CliError('Spec needs "questions" and/or "blocks"/"html" — nothing to show.');
314
+ }
315
+ spec.submitLabel = asStr(raw.submitLabel).trim() || (spec.questions.length ? 'Submit' : 'Acknowledge');
316
+ return spec;
317
+ }
318
+
319
+ // Inline question syntax for `rly ask -q`: "[!]label::type::opt1,opt2"
320
+ // Leading "!" marks the question required. type defaults to "text".
321
+ export function questionFromInline(s, i) {
322
+ let body = asStr(s).trim();
323
+ let required = false;
324
+ if (body.startsWith('!')) {
325
+ required = true;
326
+ body = body.slice(1).trim();
327
+ }
328
+ const [label, type, options] = body.split('::').map((p) => p.trim());
329
+ if (!label) throw new CliError(`-q #${i + 1}: empty question text.`);
330
+ const q = { label, required };
331
+ if (type) q.type = type;
332
+ if (options) q.options = options.split(',').map((o) => o.trim()).filter(Boolean);
333
+ return q;
334
+ }
335
+
336
+ const BLOCK_SCHEMA = {
337
+ type: 'array',
338
+ description:
339
+ 'Rich content blocks rendered in order. Board-level blocks show above the questions; per-question blocks show above the control. Each is annotatable (element-level comments returned in result.annotations).',
340
+ items: {
341
+ type: 'object',
342
+ required: ['type'],
343
+ properties: {
344
+ type: { type: 'string', enum: BLOCK_TYPES },
345
+ md: { type: 'string', description: 'markdown: built-in mini renderer (no external library). Text selections are commentable.' },
346
+ code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); code: the source to display.' },
347
+ lang: { type: 'string', description: 'code block: language hint for display.' },
348
+ config: { type: 'object', description: 'chart: a full Chart.js config object.' },
349
+ kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
350
+ labels: { type: 'array', description: 'chart shorthand: x-axis / category labels.' },
351
+ series: {
352
+ type: 'array',
353
+ description: 'chart shorthand: [{label, data:[...], color?}]. Chart data points are individually commentable.',
354
+ items: { type: 'object', properties: { label: { type: 'string' }, data: { type: 'array' }, color: { type: 'string' } } },
355
+ },
356
+ title: { type: 'string', description: 'chart shorthand: chart title.' },
357
+ columns: {
358
+ type: 'array',
359
+ description: 'table: strings, or {key, label, align?}. Cells are commentable.',
360
+ items: { anyOf: [{ type: 'string' }, { type: 'object' }] },
361
+ },
362
+ rows: { type: 'array', description: 'table: array of arrays (positional) or array of objects (keyed by column key).' },
363
+ sortable: { type: 'boolean', description: 'table: enable click-to-sort headers.' },
364
+ html: { type: 'string', description: 'html: custom markup rendered in a sandboxed iframe.' },
365
+ htmlFile: { type: 'string', description: 'html: path to an HTML file (alternative to "html").' },
366
+ height: { type: 'integer', minimum: BLOCK_HEIGHT.min, maximum: BLOCK_HEIGHT.max, description: 'Block height in px. Defaults: chart 320, html 360; markdown/table/code flow naturally; mermaid natural (max 1200, scrolls).' },
367
+ },
368
+ },
369
+ };
370
+
371
+ export const SPEC_SCHEMA = {
372
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
373
+ title: 'relay board spec',
374
+ type: 'object',
375
+ description:
376
+ 'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes", and "annotations" (element-level comments the user attached to any block — see the annotation shape below).',
377
+ properties: {
378
+ title: { type: 'string', description: 'Board title (default "Relay")' },
379
+ intro: { type: 'string', description: 'Intro text shown under the title. Newlines preserved.' },
380
+ blocks: BLOCK_SCHEMA,
381
+ html: { type: 'string', description: 'Legacy: board-level custom HTML. Normalized into a single html block PREPENDED to "blocks".' },
382
+ htmlFile: { type: 'string', description: 'Legacy: path to an HTML file (alternative to "html"). Resolved against the CWD. Normalized into a prepended html block.' },
383
+ htmlHeight: { type: 'integer', minimum: HTML_HEIGHT.min, maximum: HTML_HEIGHT.max, default: HTML_HEIGHT.boardDefault, description: 'Legacy: iframe height for the html/htmlFile block.' },
384
+ allowPartial: { type: 'boolean', default: true, description: 'When true, users may submit with unanswered questions (returned in "skipped").' },
385
+ note: { type: 'boolean', default: true, description: 'Show an optional free-text note box ("Anything else?") returned as "comment".' },
386
+ autoClose: { type: 'boolean', default: true, description: 'Try to close the browser tab automatically after submit.' },
387
+ submitLabel: { type: 'string', description: 'Submit button label. Defaults: "Submit", or "Acknowledge" when there are no questions.' },
388
+ questions: {
389
+ type: 'array',
390
+ items: {
391
+ type: 'object',
392
+ required: ['label'],
393
+ properties: {
394
+ id: { type: 'string', description: 'Answer key in the result JSON. Defaults to q1, q2, …' },
395
+ type: { type: 'string', enum: ['single', 'multi', 'yesno', 'text', 'textarea', 'scale'], default: 'text' },
396
+ label: { type: 'string' },
397
+ description: { type: 'string' },
398
+ required: { type: 'boolean', default: false },
399
+ options: {
400
+ type: 'array',
401
+ description: 'For single/multi. Strings, or {value, label, description}.',
402
+ items: {
403
+ anyOf: [
404
+ { type: 'string' },
405
+ {
406
+ type: 'object',
407
+ properties: { value: { type: 'string' }, label: { type: 'string' }, description: { type: 'string' } },
408
+ },
409
+ ],
410
+ },
411
+ },
412
+ other: { type: 'boolean', default: false, description: 'single/multi: add a free-text "Other" option. Its text is returned verbatim as the value.' },
413
+ note: { type: 'boolean', default: false, description: 'Add a small optional free-text field under the question (e.g. to qualify a choice). Returned separately as result.notes[questionId].' },
414
+ placeholder: { type: 'string', description: 'For text/textarea.' },
415
+ default: { description: 'Pre-selected value. Shape matches the answer shape for the type.' },
416
+ min: { type: 'integer', default: 1, description: 'scale only' },
417
+ max: { type: 'integer', default: 5, maximum: 10, description: 'scale only' },
418
+ minLabel: { type: 'string', description: 'scale only' },
419
+ maxLabel: { type: 'string', description: 'scale only' },
420
+ blocks: BLOCK_SCHEMA,
421
+ html: { type: 'string', description: 'Legacy: per-question custom HTML. Normalized into an html block prepended to this question\'s "blocks".' },
422
+ htmlFile: { type: 'string', description: 'Legacy: path to an HTML file (alternative to "html").' },
423
+ htmlHeight: { type: 'integer', minimum: HTML_HEIGHT.min, maximum: HTML_HEIGHT.max, default: HTML_HEIGHT.questionDefault, description: 'Legacy: iframe height for the html/htmlFile block.' },
424
+ },
425
+ },
426
+ },
427
+ annotations: {
428
+ type: 'array',
429
+ readOnly: true,
430
+ description:
431
+ 'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"table-cell"|"text"|"html-element", …}, text, createdAt}.',
432
+ },
433
+ },
434
+ anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
435
+ };
package/src/store.js ADDED
@@ -0,0 +1,147 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ // All state lives under one dir so multiple boards/instances never collide.
6
+ // RLY_HOME override exists for tests and sandboxed agents.
7
+ export const HOME = process.env.RLY_HOME || path.join(os.homedir(), '.relay');
8
+ export const BOARDS_DIR = path.join(HOME, 'boards');
9
+ export const RUNNING_DIR = path.join(HOME, 'running');
10
+
11
+ export function ensureDirs() {
12
+ fs.mkdirSync(BOARDS_DIR, { recursive: true });
13
+ fs.mkdirSync(RUNNING_DIR, { recursive: true });
14
+ }
15
+
16
+ export function newId() {
17
+ const t = Date.now().toString(36).slice(-5);
18
+ const r = Math.random().toString(36).slice(2, 5);
19
+ return `b-${t}${r}`;
20
+ }
21
+
22
+ const boardPath = (id) => path.join(BOARDS_DIR, `${id}.json`);
23
+ const runningPath = (id) => path.join(RUNNING_DIR, `${id}.json`);
24
+
25
+ export function createBoard(spec) {
26
+ ensureDirs();
27
+ const record = {
28
+ id: newId(),
29
+ createdAt: new Date().toISOString(),
30
+ title: spec.title,
31
+ spec,
32
+ draft: null,
33
+ result: null,
34
+ };
35
+ saveBoard(record);
36
+ return record;
37
+ }
38
+
39
+ export function saveBoard(record) {
40
+ ensureDirs();
41
+ fs.writeFileSync(boardPath(record.id), JSON.stringify(record, null, 2));
42
+ }
43
+
44
+ export function loadBoard(id) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(boardPath(id), 'utf8'));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function deleteBoard(id) {
53
+ try {
54
+ fs.unlinkSync(boardPath(id));
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ export function listBoards(limit = 20) {
62
+ ensureDirs();
63
+ const records = [];
64
+ for (const f of fs.readdirSync(BOARDS_DIR)) {
65
+ if (!f.endsWith('.json')) continue;
66
+ try {
67
+ records.push(JSON.parse(fs.readFileSync(path.join(BOARDS_DIR, f), 'utf8')));
68
+ } catch {
69
+ // ignore corrupt entries
70
+ }
71
+ }
72
+ records.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
73
+ return limit > 0 ? records.slice(0, limit) : records;
74
+ }
75
+
76
+ export function saveRunning(info) {
77
+ ensureDirs();
78
+ fs.writeFileSync(runningPath(info.id), JSON.stringify(info, null, 2));
79
+ }
80
+
81
+ export function loadRunning(id) {
82
+ try {
83
+ return JSON.parse(fs.readFileSync(runningPath(id), 'utf8'));
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function removeRunning(id) {
90
+ try {
91
+ fs.unlinkSync(runningPath(id));
92
+ } catch {
93
+ // already gone
94
+ }
95
+ }
96
+
97
+ // Cross-board UI preferences (e.g. theme). Boards run on random ports, so
98
+ // localStorage alone can't persist choices across boards — this file can.
99
+ const prefPath = () => path.join(HOME, 'ui-pref.json');
100
+
101
+ export function loadPref() {
102
+ try {
103
+ return JSON.parse(fs.readFileSync(prefPath(), 'utf8'));
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ export function savePref(patch) {
110
+ ensureDirs();
111
+ fs.writeFileSync(prefPath(), JSON.stringify({ ...loadPref(), ...patch }, null, 2));
112
+ }
113
+
114
+ export function isAlive(pid) {
115
+ try {
116
+ process.kill(pid, 0);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ export function listRunning() {
124
+ ensureDirs();
125
+ const out = [];
126
+ for (const f of fs.readdirSync(RUNNING_DIR)) {
127
+ if (!f.endsWith('.json')) continue;
128
+ let info = null;
129
+ try {
130
+ info = JSON.parse(fs.readFileSync(path.join(RUNNING_DIR, f), 'utf8'));
131
+ } catch {
132
+ continue;
133
+ }
134
+ if (info && isAlive(info.pid)) {
135
+ out.push(info);
136
+ } else {
137
+ // stale entry from a killed process
138
+ try {
139
+ fs.unlinkSync(path.join(RUNNING_DIR, f));
140
+ } catch {
141
+ // ignore
142
+ }
143
+ }
144
+ }
145
+ out.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
146
+ return out;
147
+ }