@openprd/cli 0.1.18 → 0.1.19
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/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-generated-change/design.md +50 -0
- package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
- package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
- package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
- package/.openprd/engagements/active/prd.md +99 -77
- package/README.md +20 -0
- package/README_EN.md +25 -0
- package/package.json +1 -1
- package/src/agent-canonical-content.js +2 -0
- package/src/canvas-app-client-app.js +838 -0
- package/src/canvas-app-client-helpers.js +1138 -0
- package/src/canvas-app-styles.js +898 -0
- package/src/canvas-app.html.js +6 -2781
- package/src/canvas-binding.js +414 -0
- package/src/canvas-codex-relay.js +430 -0
- package/src/canvas-i18n.js +8 -0
- package/src/canvas-scene.js +377 -0
- package/src/canvas-session-store.js +148 -0
- package/src/canvas-workspace.js +59 -1066
- package/src/codex-hook-runner-template.mjs +22 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Core purpose
|
|
3
|
+
* Scene-level domain logic for the OpenPrd canvas: scene/library
|
|
4
|
+
* normalization, structured selection payloads, size-contract handling,
|
|
5
|
+
* anchored image placement math, and asset materialization (data URL, local
|
|
6
|
+
* path, or remote URL into the session assets directory).
|
|
7
|
+
*
|
|
8
|
+
* Positioning
|
|
9
|
+
* Used by canvas-workspace.js request handlers. Keep HTTP concerns out of
|
|
10
|
+
* this module; it only deals with scene data and session files.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { cjoin, readJson, writeJson } from './fs-utils.js';
|
|
15
|
+
import { timestamp } from './time.js';
|
|
16
|
+
import {
|
|
17
|
+
CANVAS_SURFACE_BACKGROUND,
|
|
18
|
+
LEGACY_CANVAS_BACKGROUNDS,
|
|
19
|
+
appendOperation,
|
|
20
|
+
firstPresent,
|
|
21
|
+
isPlainObject,
|
|
22
|
+
shortHash,
|
|
23
|
+
trimString,
|
|
24
|
+
} from './canvas-session-store.js';
|
|
25
|
+
|
|
26
|
+
const MIME_TYPES = {
|
|
27
|
+
'.html': 'text/html; charset=utf-8',
|
|
28
|
+
'.json': 'application/json; charset=utf-8',
|
|
29
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
30
|
+
'.css': 'text/css; charset=utf-8',
|
|
31
|
+
'.png': 'image/png',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.webp': 'image/webp',
|
|
35
|
+
'.gif': 'image/gif',
|
|
36
|
+
'.svg': 'image/svg+xml',
|
|
37
|
+
'.excalidraw': 'application/json; charset=utf-8',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function normalizeCanvasSceneAppState(appState) {
|
|
41
|
+
const nextAppState = isPlainObject(appState) ? { ...appState } : {};
|
|
42
|
+
const current = String(nextAppState.viewBackgroundColor ?? '').trim().toLowerCase();
|
|
43
|
+
if (!current || LEGACY_CANVAS_BACKGROUNDS.has(current)) {
|
|
44
|
+
nextAppState.viewBackgroundColor = CANVAS_SURFACE_BACKGROUND;
|
|
45
|
+
}
|
|
46
|
+
return nextAppState;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeCanvasScene(scene = {}) {
|
|
50
|
+
const input = isPlainObject(scene) ? scene : {};
|
|
51
|
+
return {
|
|
52
|
+
type: input.type ?? 'openprd.canvas.scene.v1',
|
|
53
|
+
elements: Array.isArray(input.elements) ? input.elements : [],
|
|
54
|
+
appState: normalizeCanvasSceneAppState(input.appState),
|
|
55
|
+
files: isPlainObject(input.files) ? input.files : {},
|
|
56
|
+
savedAt: input.savedAt ?? timestamp(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeCanvasLibrary(input = {}) {
|
|
61
|
+
const body = isPlainObject(input) ? input : {};
|
|
62
|
+
const rawItems = Array.isArray(body.libraryItems)
|
|
63
|
+
? body.libraryItems
|
|
64
|
+
: (Array.isArray(body.items) ? body.items : []);
|
|
65
|
+
return {
|
|
66
|
+
type: 'excalidrawlib',
|
|
67
|
+
version: Number.isFinite(Number(body.version)) ? Number(body.version) : 2,
|
|
68
|
+
source: body.source ?? 'https://openprd.local/canvas',
|
|
69
|
+
libraryItems: rawItems.map(normalizeLibraryItem).filter(Boolean),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeLibraryItem(item) {
|
|
74
|
+
if (!isPlainObject(item) || !Array.isArray(item.elements) || item.elements.length === 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const id = String(item.id || `lib-${Date.now()}-${shortHash(JSON.stringify(item.elements))}`);
|
|
78
|
+
const status = item.status === 'published' ? 'published' : 'unpublished';
|
|
79
|
+
const created = Number.isFinite(Number(item.created)) ? Number(item.created) : Date.now();
|
|
80
|
+
const nextItem = {
|
|
81
|
+
id,
|
|
82
|
+
status,
|
|
83
|
+
elements: item.elements,
|
|
84
|
+
created,
|
|
85
|
+
};
|
|
86
|
+
if (typeof item.name === 'string' && item.name.trim()) {
|
|
87
|
+
nextItem.name = trimString(item.name, 120);
|
|
88
|
+
}
|
|
89
|
+
if (typeof item.error === 'string' && item.error.trim()) {
|
|
90
|
+
nextItem.error = trimString(item.error, 500);
|
|
91
|
+
}
|
|
92
|
+
return nextItem;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readLibrary(paths) {
|
|
96
|
+
return normalizeCanvasLibrary(await readJson(paths.libraryFile).catch(() => ({
|
|
97
|
+
type: 'openprd.canvas.library.v1',
|
|
98
|
+
items: [],
|
|
99
|
+
})));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function writeLibrary(paths, library) {
|
|
103
|
+
const nextLibrary = normalizeCanvasLibrary(library);
|
|
104
|
+
await writeJson(paths.libraryFile, nextLibrary);
|
|
105
|
+
return nextLibrary;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function appendLibraryItem(session, input) {
|
|
109
|
+
const elements = Array.isArray(input.elements) ? input.elements : [];
|
|
110
|
+
if (elements.length === 0) {
|
|
111
|
+
const error = new Error('Expected at least one element for a library item.');
|
|
112
|
+
error.statusCode = 400;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
const library = await readLibrary(session.paths);
|
|
116
|
+
const id = `lib-${Date.now()}-${shortHash(JSON.stringify(elements))}`;
|
|
117
|
+
const item = {
|
|
118
|
+
id,
|
|
119
|
+
status: input.status === 'published' ? 'published' : 'unpublished',
|
|
120
|
+
created: Date.now(),
|
|
121
|
+
elements,
|
|
122
|
+
};
|
|
123
|
+
if (input.name) {
|
|
124
|
+
item.name = trimString(input.name, 120);
|
|
125
|
+
}
|
|
126
|
+
const nextItems = [item, ...library.libraryItems].slice(0, 80);
|
|
127
|
+
const nextLibrary = await writeLibrary(session.paths, {
|
|
128
|
+
...library,
|
|
129
|
+
libraryItems: nextItems,
|
|
130
|
+
});
|
|
131
|
+
return { item, library: nextLibrary };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function dataUrlToBuffer(dataUrl) {
|
|
135
|
+
const match = /^data:([^;,]+)?(;base64)?,([\s\S]*)$/i.exec(String(dataUrl ?? ''));
|
|
136
|
+
if (!match) {
|
|
137
|
+
throw new Error('Expected a data URL.');
|
|
138
|
+
}
|
|
139
|
+
const mimeType = match[1] || 'application/octet-stream';
|
|
140
|
+
const isBase64 = Boolean(match[2]);
|
|
141
|
+
const raw = match[3] ?? '';
|
|
142
|
+
const buffer = isBase64 ? Buffer.from(raw, 'base64') : Buffer.from(decodeURIComponent(raw), 'utf8');
|
|
143
|
+
return { buffer, mimeType };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extensionFromMime(mimeType, fallback = '.bin') {
|
|
147
|
+
const normalized = String(mimeType ?? '').split(';')[0].trim().toLowerCase();
|
|
148
|
+
if (normalized === 'image/png') return '.png';
|
|
149
|
+
if (normalized === 'image/jpeg' || normalized === 'image/jpg') return '.jpg';
|
|
150
|
+
if (normalized === 'image/webp') return '.webp';
|
|
151
|
+
if (normalized === 'image/gif') return '.gif';
|
|
152
|
+
if (normalized === 'image/svg+xml') return '.svg';
|
|
153
|
+
if (normalized === 'application/json') return '.json';
|
|
154
|
+
return fallback;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mimeFromPath(filePath) {
|
|
158
|
+
return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const CANVAS_ANCHOR_PLACEMENTS = new Set(['right', 'left', 'below', 'above']);
|
|
162
|
+
|
|
163
|
+
function summarizeCanvasElement(element) {
|
|
164
|
+
const summary = {
|
|
165
|
+
id: element.id,
|
|
166
|
+
type: element.type ?? null,
|
|
167
|
+
x: Math.round(Number(element.x ?? 0)),
|
|
168
|
+
y: Math.round(Number(element.y ?? 0)),
|
|
169
|
+
width: Math.round(Number(element.width ?? 0)),
|
|
170
|
+
height: Math.round(Number(element.height ?? 0)),
|
|
171
|
+
};
|
|
172
|
+
if (Number(element.angle ?? 0) !== 0) {
|
|
173
|
+
summary.angle = Number(element.angle);
|
|
174
|
+
}
|
|
175
|
+
if (typeof element.text === 'string' && element.text.trim()) {
|
|
176
|
+
summary.text = trimString(element.text, 500);
|
|
177
|
+
}
|
|
178
|
+
if (element.fileId) {
|
|
179
|
+
summary.fileId = element.fileId;
|
|
180
|
+
}
|
|
181
|
+
const role = element.customData?.openprdRole;
|
|
182
|
+
if (role) {
|
|
183
|
+
summary.openprdRole = role;
|
|
184
|
+
}
|
|
185
|
+
const groupIds = Array.isArray(element.groupIds) ? element.groupIds.filter(Boolean) : [];
|
|
186
|
+
if (groupIds.length > 0) {
|
|
187
|
+
summary.groupIds = groupIds;
|
|
188
|
+
}
|
|
189
|
+
return summary;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildCanvasSelectionPayload(scene) {
|
|
193
|
+
const elements = Array.isArray(scene?.elements) ? scene.elements : [];
|
|
194
|
+
const selectedIds = isPlainObject(scene?.appState?.selectedElementIds) ? scene.appState.selectedElementIds : {};
|
|
195
|
+
const selected = elements.filter((element) => element && !element.isDeleted && selectedIds[element.id]);
|
|
196
|
+
const payload = {
|
|
197
|
+
ok: true,
|
|
198
|
+
savedAt: scene?.savedAt ?? null,
|
|
199
|
+
count: selected.length,
|
|
200
|
+
elements: selected.map(summarizeCanvasElement),
|
|
201
|
+
};
|
|
202
|
+
if (selected.length > 0) {
|
|
203
|
+
const minX = Math.min(...selected.map((element) => Number(element.x ?? 0)));
|
|
204
|
+
const minY = Math.min(...selected.map((element) => Number(element.y ?? 0)));
|
|
205
|
+
const maxX = Math.max(...selected.map((element) => Number(element.x ?? 0) + Number(element.width ?? 0)));
|
|
206
|
+
const maxY = Math.max(...selected.map((element) => Number(element.y ?? 0) + Number(element.height ?? 0)));
|
|
207
|
+
payload.bounds = {
|
|
208
|
+
x: Math.round(minX),
|
|
209
|
+
y: Math.round(minY),
|
|
210
|
+
width: Math.round(maxX - minX),
|
|
211
|
+
height: Math.round(maxY - minY),
|
|
212
|
+
};
|
|
213
|
+
} else {
|
|
214
|
+
payload.hint = 'No canvas selection right now. Ask the user to select elements on the canvas, or read /api/scene for the full scene.';
|
|
215
|
+
}
|
|
216
|
+
return payload;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeCanvasSizeContract(input) {
|
|
220
|
+
if (!isPlainObject(input)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const contract = {};
|
|
224
|
+
const width = Number(input.width);
|
|
225
|
+
const height = Number(input.height);
|
|
226
|
+
const aspectRatio = Number(input.aspectRatio);
|
|
227
|
+
if (Number.isFinite(width) && width > 0) {
|
|
228
|
+
contract.width = Math.round(width);
|
|
229
|
+
}
|
|
230
|
+
if (Number.isFinite(height) && height > 0) {
|
|
231
|
+
contract.height = Math.round(height);
|
|
232
|
+
}
|
|
233
|
+
if (Number.isFinite(aspectRatio) && aspectRatio > 0) {
|
|
234
|
+
contract.aspectRatio = aspectRatio;
|
|
235
|
+
}
|
|
236
|
+
return Object.keys(contract).length > 0 ? contract : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function computeAnchoredCanvasBox(anchor, placement, input = {}) {
|
|
240
|
+
const gap = Number.isFinite(Number(input.gap)) ? Number(input.gap) : 48;
|
|
241
|
+
const anchorX = Number(anchor.x ?? 0);
|
|
242
|
+
const anchorY = Number(anchor.y ?? 0);
|
|
243
|
+
const anchorWidth = Math.max(1, Number(anchor.width ?? 320));
|
|
244
|
+
const anchorHeight = Math.max(1, Number(anchor.height ?? 240));
|
|
245
|
+
const ratio = Number(input.aspectRatio);
|
|
246
|
+
const width = Number.isFinite(Number(input.width)) && Number(input.width) > 0
|
|
247
|
+
? Number(input.width)
|
|
248
|
+
: anchorWidth;
|
|
249
|
+
const height = Number.isFinite(Number(input.height)) && Number(input.height) > 0
|
|
250
|
+
? Number(input.height)
|
|
251
|
+
: (Number.isFinite(ratio) && ratio > 0 ? width / ratio : anchorHeight);
|
|
252
|
+
let x = anchorX;
|
|
253
|
+
let y = anchorY;
|
|
254
|
+
if (placement === 'right') {
|
|
255
|
+
x = anchorX + anchorWidth + gap;
|
|
256
|
+
} else if (placement === 'left') {
|
|
257
|
+
x = anchorX - gap - width;
|
|
258
|
+
} else if (placement === 'below') {
|
|
259
|
+
y = anchorY + anchorHeight + gap;
|
|
260
|
+
} else if (placement === 'above') {
|
|
261
|
+
y = anchorY - gap - height;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
x: Math.round(x),
|
|
265
|
+
y: Math.round(y),
|
|
266
|
+
width: Math.round(width),
|
|
267
|
+
height: Math.round(height),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function insertAnchoredCanvasImage(session, input) {
|
|
272
|
+
const anchorElementId = firstPresent(input.anchorElementId, input.anchorShapeId);
|
|
273
|
+
let anchor = null;
|
|
274
|
+
let placement = null;
|
|
275
|
+
let box = null;
|
|
276
|
+
if (anchorElementId) {
|
|
277
|
+
const scene = normalizeCanvasScene(await readJson(session.paths.sceneFile).catch(() => null));
|
|
278
|
+
anchor = scene.elements.find((element) => element && element.id === anchorElementId && !element.isDeleted) ?? null;
|
|
279
|
+
if (!anchor) {
|
|
280
|
+
const error = new Error(`Anchor element not found on the canvas: ${anchorElementId}`);
|
|
281
|
+
error.statusCode = 404;
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
placement = CANVAS_ANCHOR_PLACEMENTS.has(input.placement) ? input.placement : 'right';
|
|
285
|
+
box = computeAnchoredCanvasBox(anchor, placement, input);
|
|
286
|
+
}
|
|
287
|
+
const asset = await materializeImage(session, input);
|
|
288
|
+
const operation = await appendOperation(session.paths, {
|
|
289
|
+
type: 'image',
|
|
290
|
+
mode: input.mode === 'replace' ? 'replace' : 'append',
|
|
291
|
+
aspectRatio: input.aspectRatio ?? null,
|
|
292
|
+
placeholderId: input.placeholderId ?? null,
|
|
293
|
+
anchorElementId: anchor?.id ?? null,
|
|
294
|
+
placement,
|
|
295
|
+
box,
|
|
296
|
+
sizeContract: normalizeCanvasSizeContract(input.sizeContract),
|
|
297
|
+
note: input.note ?? 'insert-image',
|
|
298
|
+
assetUrl: asset.assetUrl,
|
|
299
|
+
assetPath: asset.assetPath,
|
|
300
|
+
sourcePath: asset.sourcePath,
|
|
301
|
+
sourceUrl: asset.sourceUrl,
|
|
302
|
+
mimeType: asset.mimeType,
|
|
303
|
+
fileId: `openprd-${asset.digest}`,
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
operation,
|
|
307
|
+
asset,
|
|
308
|
+
anchor: anchor ? summarizeCanvasElement(anchor) : null,
|
|
309
|
+
box,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function materializeImage(session, input) {
|
|
314
|
+
const paths = session.paths;
|
|
315
|
+
let buffer = null;
|
|
316
|
+
let mimeType = null;
|
|
317
|
+
let sourcePath = null;
|
|
318
|
+
let sourceUrl = null;
|
|
319
|
+
let extension = '.png';
|
|
320
|
+
|
|
321
|
+
if (input.dataUrl) {
|
|
322
|
+
const parsed = dataUrlToBuffer(input.dataUrl);
|
|
323
|
+
buffer = parsed.buffer;
|
|
324
|
+
mimeType = parsed.mimeType;
|
|
325
|
+
extension = extensionFromMime(mimeType, '.png');
|
|
326
|
+
} else if (input.path) {
|
|
327
|
+
sourcePath = path.isAbsolute(input.path) ? input.path : path.resolve(session.projectRoot, input.path);
|
|
328
|
+
buffer = await fs.readFile(sourcePath);
|
|
329
|
+
extension = path.extname(sourcePath) || '.png';
|
|
330
|
+
mimeType = mimeFromPath(sourcePath);
|
|
331
|
+
} else if (input.url) {
|
|
332
|
+
sourceUrl = input.url;
|
|
333
|
+
const response = await fetch(input.url);
|
|
334
|
+
if (!response.ok) {
|
|
335
|
+
throw new Error(`Unable to fetch image URL: ${response.status} ${response.statusText}`);
|
|
336
|
+
}
|
|
337
|
+
buffer = Buffer.from(await response.arrayBuffer());
|
|
338
|
+
mimeType = response.headers.get('content-type') ?? 'application/octet-stream';
|
|
339
|
+
extension = extensionFromMime(mimeType, path.extname(decodeURIComponent(new URL(input.url).pathname)) || '.png');
|
|
340
|
+
} else {
|
|
341
|
+
throw new Error('Expected one of: dataUrl, path, url.');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const safeExt = extension.startsWith('.') ? extension : `.${extension}`;
|
|
345
|
+
const digest = shortHash(buffer);
|
|
346
|
+
const fileName = `image-${Date.now()}-${digest}${safeExt.toLowerCase()}`;
|
|
347
|
+
const assetPath = cjoin(paths.assetsDir, fileName);
|
|
348
|
+
await fs.writeFile(assetPath, buffer);
|
|
349
|
+
return {
|
|
350
|
+
fileName,
|
|
351
|
+
assetPath,
|
|
352
|
+
assetUrl: `/assets/${encodeURIComponent(fileName)}`,
|
|
353
|
+
mimeType,
|
|
354
|
+
sourcePath,
|
|
355
|
+
sourceUrl,
|
|
356
|
+
digest,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export {
|
|
361
|
+
CANVAS_ANCHOR_PLACEMENTS,
|
|
362
|
+
MIME_TYPES,
|
|
363
|
+
appendLibraryItem,
|
|
364
|
+
buildCanvasSelectionPayload,
|
|
365
|
+
computeAnchoredCanvasBox,
|
|
366
|
+
dataUrlToBuffer,
|
|
367
|
+
extensionFromMime,
|
|
368
|
+
insertAnchoredCanvasImage,
|
|
369
|
+
materializeImage,
|
|
370
|
+
mimeFromPath,
|
|
371
|
+
normalizeCanvasLibrary,
|
|
372
|
+
normalizeCanvasScene,
|
|
373
|
+
normalizeCanvasSizeContract,
|
|
374
|
+
readLibrary,
|
|
375
|
+
summarizeCanvasElement,
|
|
376
|
+
writeLibrary,
|
|
377
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Core purpose
|
|
3
|
+
* Shared low-level primitives for the OpenPrd canvas subsystem: session key
|
|
4
|
+
* hashing, string/JSON normalization helpers, and the file-backed ops ledger
|
|
5
|
+
* (read/append/update operations in ops.json).
|
|
6
|
+
*
|
|
7
|
+
* Positioning
|
|
8
|
+
* Imported by canvas-workspace.js, canvas-binding.js, and
|
|
9
|
+
* canvas-codex-relay.js. Keep this module dependency-light (no HTTP, no
|
|
10
|
+
* process spawning) so every canvas module can safely depend on it.
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { readJson, writeJson } from './fs-utils.js';
|
|
14
|
+
import { timestamp } from './time.js';
|
|
15
|
+
|
|
16
|
+
const SESSION_KEY_MAX = 84;
|
|
17
|
+
const HANDOFF_TEXT_MAX = 24000;
|
|
18
|
+
const CANVAS_SURFACE_BACKGROUND = '#ffffff';
|
|
19
|
+
const LEGACY_CANVAS_BACKGROUNDS = new Set(['#fbfaf7', '#fffdf9', '#f8f6f1']);
|
|
20
|
+
|
|
21
|
+
function isPlainObject(value) {
|
|
22
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shortHash(value) {
|
|
26
|
+
return crypto.createHash('sha256').update(String(value ?? '')).digest('hex').slice(0, 10);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sanitizeSessionSegment(value, fallback = 'workspace') {
|
|
30
|
+
const normalized = String(value ?? '')
|
|
31
|
+
.trim()
|
|
32
|
+
.replace(/[/\\:]+/g, '-')
|
|
33
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
34
|
+
.replace(/^-+|-+$/g, '')
|
|
35
|
+
.slice(0, SESSION_KEY_MAX);
|
|
36
|
+
return normalized || fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildSessionKey(kind, id) {
|
|
40
|
+
const safeKind = sanitizeSessionSegment(kind, 'session');
|
|
41
|
+
const safeId = sanitizeSessionSegment(id, 'workspace');
|
|
42
|
+
return `${safeKind}-${safeId}-${shortHash(`${kind}:${id}`)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function firstPresent(...values) {
|
|
46
|
+
for (const value of values) {
|
|
47
|
+
if (typeof value === 'string' && value.trim()) {
|
|
48
|
+
return value.trim();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeBindingTitle(...values) {
|
|
55
|
+
const title = firstPresent(...values);
|
|
56
|
+
if (!title) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return title.length > 160 ? `${title.slice(0, 157)}...` : title;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readOps(paths) {
|
|
63
|
+
const ops = await readJson(paths.opsFile).catch(() => null);
|
|
64
|
+
if (!ops || !Array.isArray(ops.ops)) {
|
|
65
|
+
return { type: 'openprd.canvas.ops.v1', nextSeq: 0, ops: [] };
|
|
66
|
+
}
|
|
67
|
+
return ops;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function appendOperation(paths, operation) {
|
|
71
|
+
const ops = await readOps(paths);
|
|
72
|
+
const seq = Number(ops.nextSeq ?? ops.ops.length) + 1;
|
|
73
|
+
const nextOperation = {
|
|
74
|
+
id: operation.id ?? `op-${seq}-${shortHash(JSON.stringify(operation))}`,
|
|
75
|
+
seq,
|
|
76
|
+
createdAt: timestamp(),
|
|
77
|
+
...operation,
|
|
78
|
+
};
|
|
79
|
+
const nextOps = {
|
|
80
|
+
...ops,
|
|
81
|
+
nextSeq: seq,
|
|
82
|
+
ops: [...ops.ops, nextOperation],
|
|
83
|
+
};
|
|
84
|
+
await writeJson(paths.opsFile, nextOps);
|
|
85
|
+
return nextOperation;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function updateOperation(paths, operationId, patch) {
|
|
89
|
+
const ops = await readOps(paths);
|
|
90
|
+
let updatedOperation = null;
|
|
91
|
+
const nextOperations = ops.ops.map((op) => {
|
|
92
|
+
if (op.id !== operationId) {
|
|
93
|
+
return op;
|
|
94
|
+
}
|
|
95
|
+
updatedOperation = {
|
|
96
|
+
...op,
|
|
97
|
+
...patch,
|
|
98
|
+
updatedAt: timestamp(),
|
|
99
|
+
};
|
|
100
|
+
return updatedOperation;
|
|
101
|
+
});
|
|
102
|
+
if (!updatedOperation) {
|
|
103
|
+
const error = new Error(`Canvas operation not found: ${operationId}`);
|
|
104
|
+
error.statusCode = 404;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
await writeJson(paths.opsFile, {
|
|
108
|
+
...ops,
|
|
109
|
+
ops: nextOperations,
|
|
110
|
+
});
|
|
111
|
+
return updatedOperation;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function trimString(value, maxLength = HANDOFF_TEXT_MAX) {
|
|
115
|
+
const text = String(value ?? '');
|
|
116
|
+
return text.length > maxLength ? `${text.slice(0, maxLength)}\n...[truncated]` : text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parsePositiveInteger(value, fallback = 0) {
|
|
120
|
+
const parsed = Number(value);
|
|
121
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeStringArray(value) {
|
|
125
|
+
if (!Array.isArray(value)) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
return value.map((item) => String(item)).filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
HANDOFF_TEXT_MAX,
|
|
133
|
+
SESSION_KEY_MAX,
|
|
134
|
+
CANVAS_SURFACE_BACKGROUND,
|
|
135
|
+
LEGACY_CANVAS_BACKGROUNDS,
|
|
136
|
+
appendOperation,
|
|
137
|
+
buildSessionKey,
|
|
138
|
+
firstPresent,
|
|
139
|
+
isPlainObject,
|
|
140
|
+
normalizeBindingTitle,
|
|
141
|
+
normalizeStringArray,
|
|
142
|
+
parsePositiveInteger,
|
|
143
|
+
readOps,
|
|
144
|
+
sanitizeSessionSegment,
|
|
145
|
+
shortHash,
|
|
146
|
+
trimString,
|
|
147
|
+
updateOperation,
|
|
148
|
+
};
|