@tuongaz/seeflow 0.1.3
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/README.md +95 -0
- package/bin/seeflow +32 -0
- package/bin/seeflow-mcp +23 -0
- package/dist/web/assets/html2canvas.esm-CBrSDip1.js +22 -0
- package/dist/web/assets/index-BlhIMoXf.js +8005 -0
- package/dist/web/assets/index-CIpouxGY.css +1 -0
- package/dist/web/assets/index.es-D6Hswegt.js +18 -0
- package/dist/web/assets/purify.es-CLGrRn1w.js +3 -0
- package/dist/web/index.html +13 -0
- package/examples/ecommerce-platform/.seeflow/scripts/play.ts +2 -0
- package/examples/ecommerce-platform/.seeflow/seeflow.json +250 -0
- package/examples/order-pipeline/.seeflow/scripts/play.ts +18 -0
- package/examples/order-pipeline/.seeflow/seeflow.json +86 -0
- package/examples/order-pipeline/README.md +11 -0
- package/examples/order-pipeline/package.json +6 -0
- package/package.json +55 -0
- package/public/runtime/tailwind.js +24394 -0
- package/src/api.ts +1093 -0
- package/src/cli.ts +329 -0
- package/src/demo.ts +65 -0
- package/src/diagram.ts +432 -0
- package/src/events.ts +70 -0
- package/src/mcp-shim.ts +93 -0
- package/src/mcp.ts +540 -0
- package/src/operations.ts +1192 -0
- package/src/process-spawner.ts +75 -0
- package/src/proxy.ts +393 -0
- package/src/registry.ts +139 -0
- package/src/runtime.ts +78 -0
- package/src/schema.ts +441 -0
- package/src/sdk-template.ts +37 -0
- package/src/sdk-writer.ts +37 -0
- package/src/server.ts +211 -0
- package/src/shellout.ts +30 -0
- package/src/status-runner.ts +374 -0
- package/src/watcher.ts +383 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { streamSSE } from 'hono/streaming';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import {
|
|
7
|
+
AssembleRequestSchema,
|
|
8
|
+
ProposeScopeRequestSchema,
|
|
9
|
+
ValidateRequestSchema,
|
|
10
|
+
assembleDemo,
|
|
11
|
+
proposeScope,
|
|
12
|
+
validateDemo,
|
|
13
|
+
} from './diagram.ts';
|
|
14
|
+
import type { EventBus } from './events.ts';
|
|
15
|
+
import {
|
|
16
|
+
ConnectorPatchBodySchema,
|
|
17
|
+
CreateProjectBodySchema,
|
|
18
|
+
NodePatchBodySchema,
|
|
19
|
+
PositionBodySchema,
|
|
20
|
+
RegisterBodySchema,
|
|
21
|
+
ReorderBodySchema,
|
|
22
|
+
addConnectorImpl,
|
|
23
|
+
addNodeImpl,
|
|
24
|
+
createProjectImpl,
|
|
25
|
+
deleteConnectorImpl,
|
|
26
|
+
deleteDemoImpl,
|
|
27
|
+
deleteNodeImpl,
|
|
28
|
+
getDemoImpl,
|
|
29
|
+
listDemosImpl,
|
|
30
|
+
moveNodeImpl,
|
|
31
|
+
patchConnectorImpl,
|
|
32
|
+
patchNodeImpl,
|
|
33
|
+
registerDemoImpl,
|
|
34
|
+
reorderNodeImpl,
|
|
35
|
+
resolveDemoPath,
|
|
36
|
+
} from './operations.ts';
|
|
37
|
+
import type { ProcessSpawner } from './process-spawner.ts';
|
|
38
|
+
import {
|
|
39
|
+
type PlayResult,
|
|
40
|
+
type ResetResult,
|
|
41
|
+
type RunPlayOptions,
|
|
42
|
+
type RunResetOptions,
|
|
43
|
+
runPlay as defaultRunPlay,
|
|
44
|
+
runReset as defaultRunReset,
|
|
45
|
+
stopAllPlays as defaultStopAllPlays,
|
|
46
|
+
} from './proxy.ts';
|
|
47
|
+
import type { Registry } from './registry.ts';
|
|
48
|
+
import { DemoSchema } from './schema.ts';
|
|
49
|
+
import { type Spawner, defaultSpawner } from './shellout.ts';
|
|
50
|
+
import type { StatusRunner } from './status-runner.ts';
|
|
51
|
+
import type { DemoWatcher } from './watcher.ts';
|
|
52
|
+
|
|
53
|
+
const EmitBodySchema = z.object({
|
|
54
|
+
demoId: z.string().min(1),
|
|
55
|
+
nodeId: z.string().min(1),
|
|
56
|
+
status: z.enum(['running', 'done', 'error']),
|
|
57
|
+
runId: z.string().optional(),
|
|
58
|
+
payload: z.unknown().optional(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
type RelativePathCheck = { kind: 'ok' } | { kind: 'invalid'; reason: string };
|
|
62
|
+
|
|
63
|
+
// Reject absolute paths and `..` traversal before any filesystem touch.
|
|
64
|
+
// Realpath verification is layered on top by the caller for symlink defense.
|
|
65
|
+
const validateRelativePath = (path: string): RelativePathCheck => {
|
|
66
|
+
if (path.length === 0) return { kind: 'invalid', reason: 'path is empty' };
|
|
67
|
+
if (isAbsolute(path) || path.startsWith('/') || path.startsWith('\\')) {
|
|
68
|
+
return { kind: 'invalid', reason: 'absolute paths are not allowed' };
|
|
69
|
+
}
|
|
70
|
+
const segments = path.split(/[\\/]/);
|
|
71
|
+
if (segments.some((s) => s === '..')) {
|
|
72
|
+
return { kind: 'invalid', reason: 'path traversal is not allowed' };
|
|
73
|
+
}
|
|
74
|
+
return { kind: 'ok' };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const EMIT_STATUS_TO_EVENT = {
|
|
78
|
+
running: 'node:running',
|
|
79
|
+
done: 'node:done',
|
|
80
|
+
error: 'node:error',
|
|
81
|
+
} as const;
|
|
82
|
+
|
|
83
|
+
const FilePathBodySchema = z.object({ path: z.string() });
|
|
84
|
+
|
|
85
|
+
type ResolvedProjectFile =
|
|
86
|
+
| { kind: 'ok'; absPath: string; seeflowRoot: string }
|
|
87
|
+
| { kind: 'unknownProject' }
|
|
88
|
+
| { kind: 'invalidPath'; reason: string }
|
|
89
|
+
| { kind: 'fileMissing'; absPath: string };
|
|
90
|
+
|
|
91
|
+
// Shared path-safety + filesystem resolution for project-scoped file routes.
|
|
92
|
+
// Performs textual rejection of absolute paths / `..` traversal, then layered
|
|
93
|
+
// realpath verification that the resolved file stays inside `<project>/.seeflow/`
|
|
94
|
+
// (defense against symlink escapes). Returns the realpath of an existing file
|
|
95
|
+
// on success, or `fileMissing` with the would-be absolute path so callers can
|
|
96
|
+
// soft-fail with that path included for clipboard fallback.
|
|
97
|
+
function resolveProjectFile(
|
|
98
|
+
registry: Registry,
|
|
99
|
+
projectId: string,
|
|
100
|
+
relPath: string,
|
|
101
|
+
): ResolvedProjectFile {
|
|
102
|
+
const entry = registry.getById(projectId);
|
|
103
|
+
if (!entry) return { kind: 'unknownProject' };
|
|
104
|
+
|
|
105
|
+
const guard = validateRelativePath(relPath);
|
|
106
|
+
if (guard.kind === 'invalid') return { kind: 'invalidPath', reason: guard.reason };
|
|
107
|
+
|
|
108
|
+
const seeflowRoot = join(entry.repoPath, '.seeflow');
|
|
109
|
+
let realRoot: string;
|
|
110
|
+
try {
|
|
111
|
+
realRoot = realpathSync(seeflowRoot);
|
|
112
|
+
} catch {
|
|
113
|
+
return { kind: 'fileMissing', absPath: resolve(seeflowRoot, relPath) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const target = resolve(seeflowRoot, relPath);
|
|
117
|
+
let realTarget: string;
|
|
118
|
+
try {
|
|
119
|
+
realTarget = realpathSync(target);
|
|
120
|
+
} catch {
|
|
121
|
+
return { kind: 'fileMissing', absPath: target };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rootWithSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
|
|
125
|
+
if (realTarget !== realRoot && !realTarget.startsWith(rootWithSep)) {
|
|
126
|
+
return { kind: 'invalidPath', reason: 'path escapes project root' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { kind: 'ok', absPath: realTarget, seeflowRoot: realRoot };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Allowed extensions for /files/upload. Lowercased; matched after dropping the
|
|
133
|
+
// leading `.`. Stored as a Set so future expansion (PDF, video) is one-edit.
|
|
134
|
+
const UPLOAD_ALLOWED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
135
|
+
const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
|
|
136
|
+
|
|
137
|
+
// Turn a user-supplied filename into a `<slug>.<ext>` pair. Returns null when
|
|
138
|
+
// the extension isn't on the allowlist or the slug is empty after sanitization.
|
|
139
|
+
function sanitizeUploadFilename(name: string): { base: string; ext: string } | null {
|
|
140
|
+
const last = name.split(/[\\/]/).pop() ?? name;
|
|
141
|
+
const dotIdx = last.lastIndexOf('.');
|
|
142
|
+
if (dotIdx <= 0 || dotIdx === last.length - 1) return null;
|
|
143
|
+
const ext = last.slice(dotIdx).toLowerCase();
|
|
144
|
+
if (!UPLOAD_ALLOWED_EXTS.has(ext)) return null;
|
|
145
|
+
const slug = last
|
|
146
|
+
.slice(0, dotIdx)
|
|
147
|
+
.toLowerCase()
|
|
148
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
149
|
+
.replace(/^-+|-+$/g, '');
|
|
150
|
+
if (slug.length === 0) return null;
|
|
151
|
+
return { base: slug, ext };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Find the first unused `<base>.<ext>` (then `<base>-2.<ext>`, `<base>-3.<ext>`,
|
|
155
|
+
// …) inside `assetsDir`. Caps at 999 attempts to avoid an unbounded loop on a
|
|
156
|
+
// pathologically full directory.
|
|
157
|
+
function pickUploadFilename(assetsDir: string, base: string, ext: string): string {
|
|
158
|
+
const first = `${base}${ext}`;
|
|
159
|
+
if (!existsSync(join(assetsDir, first))) return first;
|
|
160
|
+
for (let i = 2; i < 1000; i++) {
|
|
161
|
+
const candidate = `${base}-${i}${ext}`;
|
|
162
|
+
if (!existsSync(join(assetsDir, candidate))) return candidate;
|
|
163
|
+
}
|
|
164
|
+
return `${base}-${Date.now()}${ext}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ApiOptions {
|
|
168
|
+
registry: Registry;
|
|
169
|
+
events?: EventBus;
|
|
170
|
+
watcher?: DemoWatcher;
|
|
171
|
+
/** Injectable shellout for tests; defaults to Bun.spawn fire-and-forget. */
|
|
172
|
+
spawner?: Spawner;
|
|
173
|
+
/** Override `process.platform` for tests covering darwin/win32/linux branches. */
|
|
174
|
+
platform?: NodeJS.Platform;
|
|
175
|
+
/** Long-running statusAction runner; fanned out on each /play click. */
|
|
176
|
+
statusRunner?: StatusRunner;
|
|
177
|
+
/** Injectable ProcessSpawner threaded into runPlay; tests use this to avoid
|
|
178
|
+
* launching real child processes for the play-action script. */
|
|
179
|
+
processSpawner?: ProcessSpawner;
|
|
180
|
+
/** Injectable proxy facade — defaults wrap the proxy.ts module exports.
|
|
181
|
+
* Tests use this to record call order across runPlay / runReset /
|
|
182
|
+
* stopAllPlays and to drive each in isolation. */
|
|
183
|
+
proxy?: ProxyFacade;
|
|
184
|
+
/** Override base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
|
|
185
|
+
projectBaseDir?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Thin call-through wrapper around the proxy.ts module exports. Lets tests
|
|
190
|
+
* inject a recording fake to assert call order across runPlay, runReset, and
|
|
191
|
+
* stopAllPlays — none of which can be observed via the underlying
|
|
192
|
+
* ProcessSpawner alone because the play-run map and event broadcasts are
|
|
193
|
+
* encapsulated inside proxy.ts.
|
|
194
|
+
*/
|
|
195
|
+
export interface ProxyFacade {
|
|
196
|
+
runPlay(options: RunPlayOptions): Promise<PlayResult>;
|
|
197
|
+
runReset(options: RunResetOptions): Promise<ResetResult>;
|
|
198
|
+
stopAllPlays(demoId: string): Promise<void>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const defaultProxyFacade: ProxyFacade = {
|
|
202
|
+
runPlay: defaultRunPlay,
|
|
203
|
+
runReset: defaultRunReset,
|
|
204
|
+
stopAllPlays: defaultStopAllPlays,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export function createApi(options: ApiOptions): Hono {
|
|
208
|
+
const { registry, events, watcher, statusRunner } = options;
|
|
209
|
+
const spawner = options.spawner ?? defaultSpawner;
|
|
210
|
+
const platform = options.platform ?? process.platform;
|
|
211
|
+
const processSpawner = options.processSpawner;
|
|
212
|
+
const proxy = options.proxy ?? defaultProxyFacade;
|
|
213
|
+
const projectBaseDir = options.projectBaseDir;
|
|
214
|
+
const api = new Hono();
|
|
215
|
+
|
|
216
|
+
api.post('/demos/register', async (c) => {
|
|
217
|
+
let body: unknown;
|
|
218
|
+
try {
|
|
219
|
+
body = await c.req.json();
|
|
220
|
+
} catch {
|
|
221
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const parsed = RegisterBodySchema.safeParse(body);
|
|
225
|
+
if (!parsed.success) {
|
|
226
|
+
return c.json({ error: 'Invalid register body', issues: parsed.error.issues }, 400);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const result = await registerDemoImpl({ registry, watcher }, parsed.data);
|
|
230
|
+
switch (result.kind) {
|
|
231
|
+
case 'ok':
|
|
232
|
+
return c.json(result.data);
|
|
233
|
+
case 'fileNotFound':
|
|
234
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 400);
|
|
235
|
+
case 'badJson':
|
|
236
|
+
return c.json({ error: 'Demo file is not valid JSON', detail: result.detail }, 400);
|
|
237
|
+
case 'badSchema':
|
|
238
|
+
return c.json({ error: 'Demo file failed schema validation', issues: result.issues }, 400);
|
|
239
|
+
case 'sdkWriteFailed':
|
|
240
|
+
return c.json(
|
|
241
|
+
{
|
|
242
|
+
error: `Failed to write SDK helper: ${result.message}`,
|
|
243
|
+
id: result.id,
|
|
244
|
+
slug: result.slug,
|
|
245
|
+
},
|
|
246
|
+
500,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// POST /api/demos/validate — dry-run validation. The skill's diagram
|
|
252
|
+
// pipeline calls this between assemble and register to decide whether to
|
|
253
|
+
// rewire. Runs the Zod schema, the soft node cap, and the tier playability
|
|
254
|
+
// check. Filesystem-bound checks (harness coverage, event emitter index)
|
|
255
|
+
// stay in the skill since the studio doesn't see the user's $TARGET.
|
|
256
|
+
api.post('/demos/validate', async (c) => {
|
|
257
|
+
let body: unknown;
|
|
258
|
+
try {
|
|
259
|
+
body = await c.req.json();
|
|
260
|
+
} catch {
|
|
261
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
262
|
+
}
|
|
263
|
+
const parsed = ValidateRequestSchema.safeParse(body);
|
|
264
|
+
if (!parsed.success) {
|
|
265
|
+
return c.json({ error: 'Invalid validate body', issues: parsed.error.issues }, 400);
|
|
266
|
+
}
|
|
267
|
+
return c.json(validateDemo(parsed.data));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// POST /api/diagram/propose-scope — Phase 2 helper. The skill POSTs the
|
|
271
|
+
// scan-result.json shape and gets back ranked entry-point candidates.
|
|
272
|
+
// Pure compute; skill writes the response to intermediate/entry-candidates.json.
|
|
273
|
+
api.post('/diagram/propose-scope', async (c) => {
|
|
274
|
+
let body: unknown;
|
|
275
|
+
try {
|
|
276
|
+
body = await c.req.json();
|
|
277
|
+
} catch {
|
|
278
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
279
|
+
}
|
|
280
|
+
const parsed = ProposeScopeRequestSchema.safeParse(body);
|
|
281
|
+
if (!parsed.success) {
|
|
282
|
+
return c.json({ error: 'Invalid propose-scope body', issues: parsed.error.issues }, 400);
|
|
283
|
+
}
|
|
284
|
+
return c.json(proposeScope(parsed.data));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// POST /api/diagram/assemble — Phase 7a. The skill POSTs wiring + layout
|
|
288
|
+
// and gets back the assembled demo (IDs normalized, dupes dropped, dangling
|
|
289
|
+
// connectors removed, positions snapped to a 24px grid). Pure compute; the
|
|
290
|
+
// skill writes the response to $TARGET/.seeflow/seeflow.json. No schema
|
|
291
|
+
// validation here — call /demos/validate for that.
|
|
292
|
+
api.post('/diagram/assemble', async (c) => {
|
|
293
|
+
let body: unknown;
|
|
294
|
+
try {
|
|
295
|
+
body = await c.req.json();
|
|
296
|
+
} catch {
|
|
297
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
298
|
+
}
|
|
299
|
+
const parsed = AssembleRequestSchema.safeParse(body);
|
|
300
|
+
if (!parsed.success) {
|
|
301
|
+
return c.json({ error: 'Invalid assemble body', issues: parsed.error.issues }, 400);
|
|
302
|
+
}
|
|
303
|
+
return c.json(assembleDemo(parsed.data));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// POST /api/projects — UI-driven "Create new project" flow (US-020). Two
|
|
307
|
+
// branches based on whether the target folder already has a SeeFlow
|
|
308
|
+
// project set up at `<folderPath>/.seeflow/seeflow.json`:
|
|
309
|
+
// 1. Existing setup: read + validate the on-disk demo and register it
|
|
310
|
+
// as-is (no overwrite, no scaffolding). The user-supplied `name`
|
|
311
|
+
// becomes the registry display name; the on-disk demo's `name` is
|
|
312
|
+
// preserved on disk.
|
|
313
|
+
// 2. Fresh scaffold: mkdir -p the folder + .seeflow/, write a default
|
|
314
|
+
// scaffold seeflow.json keyed off `name`, and run the same SDK-emit
|
|
315
|
+
// helper write the CLI register flow uses (a no-op for an empty
|
|
316
|
+
// scaffold, but kept for parity).
|
|
317
|
+
api.post('/projects', async (c) => {
|
|
318
|
+
let body: unknown;
|
|
319
|
+
try {
|
|
320
|
+
body = await c.req.json();
|
|
321
|
+
} catch {
|
|
322
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const parsed = CreateProjectBodySchema.safeParse(body);
|
|
326
|
+
if (!parsed.success) {
|
|
327
|
+
return c.json({ error: 'Invalid create project body', issues: parsed.error.issues }, 400);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = await createProjectImpl({ registry, watcher, projectBaseDir }, parsed.data);
|
|
331
|
+
switch (result.kind) {
|
|
332
|
+
case 'ok':
|
|
333
|
+
return c.json(result.data);
|
|
334
|
+
case 'badJson':
|
|
335
|
+
return c.json({ error: `Existing demo file is not valid JSON: ${result.detail}` }, 400);
|
|
336
|
+
case 'badSchema':
|
|
337
|
+
return c.json(
|
|
338
|
+
{ error: 'Existing demo file failed schema validation', issues: result.issues },
|
|
339
|
+
400,
|
|
340
|
+
);
|
|
341
|
+
case 'scaffoldFailed':
|
|
342
|
+
return c.json({ error: `Failed to scaffold project: ${result.message}` }, 500);
|
|
343
|
+
case 'sdkWriteFailed':
|
|
344
|
+
return c.json({ error: `Failed to write SDK helper: ${result.message}` }, 500);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
api.get('/demos', (c) => {
|
|
349
|
+
const result = listDemosImpl({ registry });
|
|
350
|
+
return c.json(result.data);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
api.get('/demos/:id', async (c) => {
|
|
354
|
+
const result = await getDemoImpl({ registry, watcher }, c.req.param('id'));
|
|
355
|
+
switch (result.kind) {
|
|
356
|
+
case 'ok':
|
|
357
|
+
return c.json(result.data);
|
|
358
|
+
case 'notFound':
|
|
359
|
+
return c.json({ error: 'not found' }, 404);
|
|
360
|
+
case 'fileNotFound':
|
|
361
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// GET /api/projects/:id/files/<path> — stream a project-scoped file from
|
|
366
|
+
// <repoPath>/.seeflow/<path>. Path safety is layered: textual rejection
|
|
367
|
+
// (absolute / traversal), then realpath check that the resolved file stays
|
|
368
|
+
// inside the project's .seeflow root (defends against symlink escapes).
|
|
369
|
+
api.get('/projects/:id/files/:path{.+}', async (c) => {
|
|
370
|
+
const rawPath = c.req.param('path');
|
|
371
|
+
let relPath: string;
|
|
372
|
+
try {
|
|
373
|
+
relPath = decodeURIComponent(rawPath);
|
|
374
|
+
} catch {
|
|
375
|
+
return c.json({ error: 'invalid path encoding' }, 400);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const resolved = resolveProjectFile(registry, c.req.param('id'), relPath);
|
|
379
|
+
switch (resolved.kind) {
|
|
380
|
+
case 'unknownProject':
|
|
381
|
+
return c.json({ error: 'unknown project' }, 404);
|
|
382
|
+
case 'invalidPath':
|
|
383
|
+
return c.json({ error: resolved.reason }, 400);
|
|
384
|
+
case 'fileMissing':
|
|
385
|
+
return c.json({ error: 'file not found' }, 404);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const file = Bun.file(resolved.absPath);
|
|
389
|
+
if (!(await file.exists())) {
|
|
390
|
+
return c.json({ error: 'file not found' }, 404);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return new Response(file.stream(), {
|
|
394
|
+
headers: {
|
|
395
|
+
'content-type': file.type || 'application/octet-stream',
|
|
396
|
+
'content-length': String(file.size),
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// POST /api/projects/:id/files/open — shell out to `$EDITOR <abs>` so the
|
|
402
|
+
// user can edit a project-scoped file (htmlNode block, image asset) in
|
|
403
|
+
// their IDE. The endpoint always returns the resolved absolute path in
|
|
404
|
+
// the response body so the frontend can copy-to-clipboard when $EDITOR
|
|
405
|
+
// isn't set or the spawn fails. Path safety mirrors the GET route.
|
|
406
|
+
api.post('/projects/:id/files/open', async (c) => {
|
|
407
|
+
let body: unknown;
|
|
408
|
+
try {
|
|
409
|
+
body = await c.req.json();
|
|
410
|
+
} catch {
|
|
411
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
412
|
+
}
|
|
413
|
+
const parsed = FilePathBodySchema.safeParse(body);
|
|
414
|
+
if (!parsed.success) {
|
|
415
|
+
return c.json({ error: 'Invalid open body', issues: parsed.error.issues }, 400);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const resolved = resolveProjectFile(registry, c.req.param('id'), parsed.data.path);
|
|
419
|
+
switch (resolved.kind) {
|
|
420
|
+
case 'unknownProject':
|
|
421
|
+
return c.json({ error: 'unknown project' }, 404);
|
|
422
|
+
case 'invalidPath':
|
|
423
|
+
return c.json({ error: resolved.reason }, 400);
|
|
424
|
+
case 'fileMissing':
|
|
425
|
+
return c.json({ error: 'file not found', absPath: resolved.absPath }, 404);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const editor = process.env.EDITOR;
|
|
429
|
+
if (!editor || editor.trim().length === 0) {
|
|
430
|
+
return c.json({ ok: false, absPath: resolved.absPath, error: 'EDITOR not set' });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const run = await spawner(editor, [resolved.absPath]);
|
|
434
|
+
if (!run.ok) {
|
|
435
|
+
return c.json({ ok: false, absPath: resolved.absPath, error: run.error ?? 'spawn failed' });
|
|
436
|
+
}
|
|
437
|
+
return c.json({ ok: true, absPath: resolved.absPath });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// POST /api/projects/:id/files/reveal — open the OS file manager with the
|
|
441
|
+
// target file selected. Platform commands: `open -R <abs>` (macOS),
|
|
442
|
+
// `explorer /select,<abs>` (Windows), `xdg-open <dir>` (Linux — selects the
|
|
443
|
+
// containing directory; xdg has no portable "select-this-file" verb). Same
|
|
444
|
+
// fallback shape as /open: response always includes `absPath` for clipboard.
|
|
445
|
+
api.post('/projects/:id/files/reveal', async (c) => {
|
|
446
|
+
let body: unknown;
|
|
447
|
+
try {
|
|
448
|
+
body = await c.req.json();
|
|
449
|
+
} catch {
|
|
450
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
451
|
+
}
|
|
452
|
+
const parsed = FilePathBodySchema.safeParse(body);
|
|
453
|
+
if (!parsed.success) {
|
|
454
|
+
return c.json({ error: 'Invalid reveal body', issues: parsed.error.issues }, 400);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const resolved = resolveProjectFile(registry, c.req.param('id'), parsed.data.path);
|
|
458
|
+
switch (resolved.kind) {
|
|
459
|
+
case 'unknownProject':
|
|
460
|
+
return c.json({ error: 'unknown project' }, 404);
|
|
461
|
+
case 'invalidPath':
|
|
462
|
+
return c.json({ error: resolved.reason }, 400);
|
|
463
|
+
case 'fileMissing':
|
|
464
|
+
return c.json({ error: 'file not found', absPath: resolved.absPath }, 404);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let cmd: string;
|
|
468
|
+
let args: string[];
|
|
469
|
+
switch (platform) {
|
|
470
|
+
case 'darwin':
|
|
471
|
+
cmd = 'open';
|
|
472
|
+
args = ['-R', resolved.absPath];
|
|
473
|
+
break;
|
|
474
|
+
case 'win32':
|
|
475
|
+
cmd = 'explorer';
|
|
476
|
+
args = [`/select,${resolved.absPath}`];
|
|
477
|
+
break;
|
|
478
|
+
default:
|
|
479
|
+
cmd = 'xdg-open';
|
|
480
|
+
args = [dirname(resolved.absPath)];
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const run = await spawner(cmd, args);
|
|
485
|
+
if (!run.ok) {
|
|
486
|
+
return c.json({ ok: false, absPath: resolved.absPath, error: run.error ?? 'spawn failed' });
|
|
487
|
+
}
|
|
488
|
+
return c.json({ ok: true, absPath: resolved.absPath });
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// POST /api/projects/:id/files/upload — accept a multipart image upload and
|
|
492
|
+
// persist it under `<project>/.seeflow/assets/`. The frontend (US-008 OS
|
|
493
|
+
// drop) sends `file` (Blob) and optionally `filename` (the original OS name)
|
|
494
|
+
// in a multipart form; we sanitize the filename to a lowercased slug,
|
|
495
|
+
// dedupe with `-2`, `-3` suffixes inside the assets dir, and return the
|
|
496
|
+
// demo-relative path. Allowlist + 5 MB cap guard against arbitrary uploads.
|
|
497
|
+
api.post('/projects/:id/files/upload', async (c) => {
|
|
498
|
+
const projectId = c.req.param('id');
|
|
499
|
+
const entry = registry.getById(projectId);
|
|
500
|
+
if (!entry) return c.json({ error: 'unknown project' }, 404);
|
|
501
|
+
|
|
502
|
+
let form: FormData;
|
|
503
|
+
try {
|
|
504
|
+
form = await c.req.formData();
|
|
505
|
+
} catch {
|
|
506
|
+
return c.json({ error: 'Body must be valid multipart form-data' }, 400);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const fileField = form.get('file');
|
|
510
|
+
if (!(fileField instanceof File)) {
|
|
511
|
+
return c.json({ error: 'Missing file field' }, 400);
|
|
512
|
+
}
|
|
513
|
+
if (fileField.size > UPLOAD_MAX_BYTES) {
|
|
514
|
+
return c.json({ error: 'file too large', maxBytes: UPLOAD_MAX_BYTES }, 413);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const suggestedRaw = form.get('filename');
|
|
518
|
+
const suggested =
|
|
519
|
+
typeof suggestedRaw === 'string' && suggestedRaw.length > 0 ? suggestedRaw : fileField.name;
|
|
520
|
+
const sanitized = sanitizeUploadFilename(suggested);
|
|
521
|
+
if (!sanitized) {
|
|
522
|
+
return c.json({ error: 'invalid filename or extension' }, 400);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const assetsDir = join(entry.repoPath, '.seeflow', 'assets');
|
|
526
|
+
try {
|
|
527
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
528
|
+
} catch (err) {
|
|
529
|
+
return c.json(
|
|
530
|
+
{
|
|
531
|
+
error: `Failed to create assets dir: ${err instanceof Error ? err.message : String(err)}`,
|
|
532
|
+
},
|
|
533
|
+
500,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const finalName = pickUploadFilename(assetsDir, sanitized.base, sanitized.ext);
|
|
538
|
+
const absPath = join(assetsDir, finalName);
|
|
539
|
+
try {
|
|
540
|
+
await Bun.write(absPath, fileField);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
return c.json(
|
|
543
|
+
{ error: `Failed to write file: ${err instanceof Error ? err.message : String(err)}` },
|
|
544
|
+
500,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return c.json({ path: `assets/${finalName}` });
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
api.delete('/demos/:id', (c) => {
|
|
552
|
+
const result = deleteDemoImpl({ registry, watcher }, c.req.param('id'));
|
|
553
|
+
switch (result.kind) {
|
|
554
|
+
case 'ok':
|
|
555
|
+
return c.json({ ok: true });
|
|
556
|
+
case 'notFound':
|
|
557
|
+
return c.json({ ok: false, error: 'not found' }, 404);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
api.post('/demos/:id/play/:nodeId', async (c) => {
|
|
562
|
+
const id = c.req.param('id');
|
|
563
|
+
const nodeId = c.req.param('nodeId');
|
|
564
|
+
const entry = registry.getById(id);
|
|
565
|
+
if (!entry) return c.json({ error: 'unknown demo' }, 404);
|
|
566
|
+
if (!events) return c.json({ error: 'events not enabled' }, 500);
|
|
567
|
+
|
|
568
|
+
// Always re-read from disk so the user's most recent edit (validated or
|
|
569
|
+
// not yet observed by the watcher) drives the actual fetch.
|
|
570
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
571
|
+
if (!existsSync(fullPath)) {
|
|
572
|
+
return c.json({ error: `Demo file not found: ${fullPath}` }, 404);
|
|
573
|
+
}
|
|
574
|
+
let raw: unknown;
|
|
575
|
+
try {
|
|
576
|
+
raw = await Bun.file(fullPath).json();
|
|
577
|
+
} catch (err) {
|
|
578
|
+
return c.json(
|
|
579
|
+
{
|
|
580
|
+
error: `Demo file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
581
|
+
},
|
|
582
|
+
400,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const parsed = DemoSchema.safeParse(raw);
|
|
586
|
+
if (!parsed.success) {
|
|
587
|
+
return c.json({ error: 'Demo failed schema validation', issues: parsed.error.issues }, 400);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const node = parsed.data.nodes.find((n) => n.id === nodeId);
|
|
591
|
+
if (!node) return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
|
|
592
|
+
if (
|
|
593
|
+
node.type === 'shapeNode' ||
|
|
594
|
+
node.type === 'imageNode' ||
|
|
595
|
+
node.type === 'iconNode' ||
|
|
596
|
+
node.type === 'htmlNode' ||
|
|
597
|
+
!node.data.playAction
|
|
598
|
+
) {
|
|
599
|
+
return c.json({ error: `Node ${nodeId} has no playAction` }, 400);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Fan out the long-running statusAction scripts BEFORE awaiting the play
|
|
603
|
+
// spawn — fire-and-forget so a slow status batch can't delay the click.
|
|
604
|
+
// Individual spawn failures are surfaced via console.warn but never fail
|
|
605
|
+
// the /play call itself.
|
|
606
|
+
if (statusRunner) {
|
|
607
|
+
void statusRunner.restart(id).catch((err) => {
|
|
608
|
+
console.warn(
|
|
609
|
+
`[api] statusRunner.restart(${id}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const result = await proxy.runPlay({
|
|
615
|
+
events,
|
|
616
|
+
demoId: id,
|
|
617
|
+
nodeId,
|
|
618
|
+
cwd: entry.repoPath,
|
|
619
|
+
action: node.data.playAction,
|
|
620
|
+
spawner: processSpawner,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// Surface the symlink-escape error as a 400 so the frontend can show a
|
|
624
|
+
// distinct "fix your scriptPath" message instead of a generic run failure.
|
|
625
|
+
if (result.error === 'scriptPath escapes project root') {
|
|
626
|
+
return c.json({ error: result.error }, 400);
|
|
627
|
+
}
|
|
628
|
+
return c.json(result);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// POST /api/demos/:id/reset — the "Restart demo" workflow (US-008). Order:
|
|
632
|
+
// 1. Stop every live play-script + every long-running status-script for
|
|
633
|
+
// this demo in parallel — both must complete before any reset script
|
|
634
|
+
// spawns so the script sees no stragglers.
|
|
635
|
+
// 2. Run the demo's `resetAction` script (if declared); any non-zero exit
|
|
636
|
+
// becomes a 502 to the caller but does NOT suppress reload/restart.
|
|
637
|
+
// 3. Broadcast `demo:reload` unconditionally so the canvas re-fetches.
|
|
638
|
+
// 4. Fire-and-forget `statusRunner.restart` so the next status batch is
|
|
639
|
+
// spawning by the time the response lands. Individual spawn failures
|
|
640
|
+
// surface via console.warn but never fail the /reset call.
|
|
641
|
+
api.post('/demos/:id/reset', async (c) => {
|
|
642
|
+
const id = c.req.param('id');
|
|
643
|
+
const entry = registry.getById(id);
|
|
644
|
+
if (!entry) return c.json({ error: 'unknown demo' }, 404);
|
|
645
|
+
if (!events) return c.json({ error: 'events not enabled' }, 500);
|
|
646
|
+
|
|
647
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
648
|
+
if (!existsSync(fullPath)) {
|
|
649
|
+
return c.json({ error: `Demo file not found: ${fullPath}` }, 404);
|
|
650
|
+
}
|
|
651
|
+
let raw: unknown;
|
|
652
|
+
try {
|
|
653
|
+
raw = await Bun.file(fullPath).json();
|
|
654
|
+
} catch (err) {
|
|
655
|
+
return c.json(
|
|
656
|
+
{
|
|
657
|
+
error: `Demo file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
658
|
+
},
|
|
659
|
+
400,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const parsed = DemoSchema.safeParse(raw);
|
|
663
|
+
if (!parsed.success) {
|
|
664
|
+
return c.json({ error: 'Demo failed schema validation', issues: parsed.error.issues }, 400);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// 1. Stop every play + status script in parallel. await BOTH before
|
|
668
|
+
// spawning the reset script so a still-running play can't race the
|
|
669
|
+
// reset and re-dirty the running app's state.
|
|
670
|
+
const stopPromises: Array<Promise<void>> = [proxy.stopAllPlays(id)];
|
|
671
|
+
if (statusRunner) stopPromises.push(statusRunner.stop(id));
|
|
672
|
+
await Promise.all(stopPromises);
|
|
673
|
+
|
|
674
|
+
// 2. Run resetAction (if declared).
|
|
675
|
+
const resetAction = parsed.data.resetAction;
|
|
676
|
+
let calledResetAction = false;
|
|
677
|
+
let resetActionError: string | undefined;
|
|
678
|
+
|
|
679
|
+
if (resetAction) {
|
|
680
|
+
calledResetAction = true;
|
|
681
|
+
const result = await proxy.runReset({
|
|
682
|
+
events,
|
|
683
|
+
demoId: id,
|
|
684
|
+
cwd: entry.repoPath,
|
|
685
|
+
action: resetAction,
|
|
686
|
+
});
|
|
687
|
+
if (!result.ok && result.error) {
|
|
688
|
+
resetActionError = result.error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// 3. Broadcast reload unconditionally — even when resetAction failed,
|
|
693
|
+
// the canvas should still refresh from disk in case the user just
|
|
694
|
+
// edited the file.
|
|
695
|
+
events.broadcast({
|
|
696
|
+
type: 'demo:reload',
|
|
697
|
+
demoId: id,
|
|
698
|
+
payload: {},
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// 4. Fire-and-forget the next status batch.
|
|
702
|
+
if (statusRunner) {
|
|
703
|
+
void statusRunner.restart(id).catch((err) => {
|
|
704
|
+
console.warn(
|
|
705
|
+
`[api] statusRunner.restart(${id}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (resetActionError) {
|
|
711
|
+
return c.json({ error: resetActionError, calledResetAction }, 502);
|
|
712
|
+
}
|
|
713
|
+
return c.json({ ok: true, calledResetAction });
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// PATCH a single node's position back into the on-disk seeflow.json. This is
|
|
717
|
+
// the second (and only other) place the studio mutates user files — the
|
|
718
|
+
// first being the SDK helper write in `register`. Atomic write via tempfile
|
|
719
|
+
// + rename keeps editor diffs clean and avoids corruption mid-write.
|
|
720
|
+
api.patch('/demos/:id/nodes/:nodeId/position', async (c) => {
|
|
721
|
+
const id = c.req.param('id');
|
|
722
|
+
const nodeId = c.req.param('nodeId');
|
|
723
|
+
|
|
724
|
+
let body: unknown;
|
|
725
|
+
try {
|
|
726
|
+
body = await c.req.json();
|
|
727
|
+
} catch {
|
|
728
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
729
|
+
}
|
|
730
|
+
const parsed = PositionBodySchema.safeParse(body);
|
|
731
|
+
if (!parsed.success) {
|
|
732
|
+
return c.json({ error: 'Invalid position body', issues: parsed.error.issues }, 400);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const result = await moveNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
|
|
736
|
+
switch (result.kind) {
|
|
737
|
+
case 'ok':
|
|
738
|
+
return c.json({ ok: true, position: result.data.position });
|
|
739
|
+
case 'demoNotFound':
|
|
740
|
+
return c.json({ error: 'unknown demo' }, 404);
|
|
741
|
+
case 'fileNotFound':
|
|
742
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
743
|
+
case 'badJson':
|
|
744
|
+
return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
|
|
745
|
+
case 'badSchema':
|
|
746
|
+
return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
|
|
747
|
+
case 'unknownNode':
|
|
748
|
+
return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
|
|
749
|
+
case 'writeFailed':
|
|
750
|
+
return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// PATCH the z-order position of a single node within demo.nodes[]. React
|
|
755
|
+
// Flow's painter renders nodes in array order, so moving a node to a later
|
|
756
|
+
// index brings it visually forward (later nodes paint over earlier ones).
|
|
757
|
+
// Five ops are supported: forward / backward (single-step swap), toFront /
|
|
758
|
+
// toBack (remove + push/unshift), and toIndex (pin to an absolute index)
|
|
759
|
+
// which the undo path uses to faithfully revert forward/backward gestures
|
|
760
|
+
// even if the array changed between the original op and the undo.
|
|
761
|
+
api.patch('/demos/:id/nodes/:nodeId/order', async (c) => {
|
|
762
|
+
const id = c.req.param('id');
|
|
763
|
+
const nodeId = c.req.param('nodeId');
|
|
764
|
+
|
|
765
|
+
let body: unknown;
|
|
766
|
+
try {
|
|
767
|
+
body = await c.req.json();
|
|
768
|
+
} catch {
|
|
769
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
770
|
+
}
|
|
771
|
+
const parsed = ReorderBodySchema.safeParse(body);
|
|
772
|
+
if (!parsed.success) {
|
|
773
|
+
return c.json({ error: 'Invalid reorder body', issues: parsed.error.issues }, 400);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const result = await reorderNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
|
|
777
|
+
switch (result.kind) {
|
|
778
|
+
case 'ok':
|
|
779
|
+
return c.json({ ok: true });
|
|
780
|
+
case 'demoNotFound':
|
|
781
|
+
return c.json({ error: 'unknown demo' }, 404);
|
|
782
|
+
case 'fileNotFound':
|
|
783
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
784
|
+
case 'badJson':
|
|
785
|
+
return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
|
|
786
|
+
case 'badSchema':
|
|
787
|
+
return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
|
|
788
|
+
case 'unknownNode':
|
|
789
|
+
return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
|
|
790
|
+
case 'writeFailed':
|
|
791
|
+
return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// PATCH a single node — partial update of position, label, detail, visual
|
|
796
|
+
// fields, or shapeNode-only fields. Every UI-driven node edit (other than
|
|
797
|
+
// the high-frequency drag fast-path above) flows through here. The mutation
|
|
798
|
+
// is performed against the raw parsed JSON (so unknown v2 fields the schema
|
|
799
|
+
// doesn't yet recognize survive round-trips) and the WHOLE resulting demo
|
|
800
|
+
// is re-validated through DemoSchema before commit, preventing partial
|
|
801
|
+
// writes from breaking invariants like the connector→node superRefine.
|
|
802
|
+
api.patch('/demos/:id/nodes/:nodeId', async (c) => {
|
|
803
|
+
const id = c.req.param('id');
|
|
804
|
+
const nodeId = c.req.param('nodeId');
|
|
805
|
+
|
|
806
|
+
let body: unknown;
|
|
807
|
+
try {
|
|
808
|
+
body = await c.req.json();
|
|
809
|
+
} catch {
|
|
810
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
811
|
+
}
|
|
812
|
+
const parsed = NodePatchBodySchema.safeParse(body);
|
|
813
|
+
if (!parsed.success) {
|
|
814
|
+
return c.json({ error: 'Invalid node patch body', issues: parsed.error.issues }, 400);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const result = await patchNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
|
|
818
|
+
switch (result.kind) {
|
|
819
|
+
case 'ok':
|
|
820
|
+
return c.json({ ok: true });
|
|
821
|
+
case 'demoNotFound':
|
|
822
|
+
return c.json({ error: 'unknown demo' }, 404);
|
|
823
|
+
case 'fileNotFound':
|
|
824
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
825
|
+
case 'badJson':
|
|
826
|
+
return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
|
|
827
|
+
case 'badSchema':
|
|
828
|
+
return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
|
|
829
|
+
case 'unknownNode':
|
|
830
|
+
return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
|
|
831
|
+
case 'writeFailed':
|
|
832
|
+
return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// POST a new node into the demo. Body is the node payload (id auto-generated
|
|
837
|
+
// server-side if absent). Atomicity + final-DemoSchema validation match the
|
|
838
|
+
// PATCH path above, so a malformed node never produces a half-written file.
|
|
839
|
+
api.post('/demos/:id/nodes', async (c) => {
|
|
840
|
+
const id = c.req.param('id');
|
|
841
|
+
|
|
842
|
+
let body: unknown;
|
|
843
|
+
try {
|
|
844
|
+
body = await c.req.json();
|
|
845
|
+
} catch {
|
|
846
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
847
|
+
}
|
|
848
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
849
|
+
return c.json({ error: 'Body must be an object' }, 400);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const result = await addNodeImpl({ registry, watcher }, id, body as Record<string, unknown>);
|
|
853
|
+
switch (result.kind) {
|
|
854
|
+
case 'ok':
|
|
855
|
+
return c.json({ ok: true, id: result.data.id, node: result.data.node });
|
|
856
|
+
case 'demoNotFound':
|
|
857
|
+
return c.json({ error: 'unknown demo' }, 404);
|
|
858
|
+
case 'fileNotFound':
|
|
859
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
860
|
+
case 'badJson':
|
|
861
|
+
return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
|
|
862
|
+
case 'badSchema':
|
|
863
|
+
return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
|
|
864
|
+
case 'writeFailed':
|
|
865
|
+
return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// DELETE a node and cascade-remove every connector with source === nodeId or
|
|
870
|
+
// target === nodeId in the same atomic write. Final-DemoSchema validation
|
|
871
|
+
// is still run after the mutation — connector cascade closure means it
|
|
872
|
+
// should always pass, but the check makes the failure mode honest if the
|
|
873
|
+
// file had a pre-existing schema violation we'd otherwise paper over.
|
|
874
|
+
api.delete('/demos/:id/nodes/:nodeId', async (c) => {
|
|
875
|
+
const id = c.req.param('id');
|
|
876
|
+
const nodeId = c.req.param('nodeId');
|
|
877
|
+
|
|
878
|
+
const result = await deleteNodeImpl({ registry, watcher }, id, nodeId);
|
|
879
|
+
switch (result.kind) {
|
|
880
|
+
case 'ok':
|
|
881
|
+
return c.json({ ok: true });
|
|
882
|
+
case 'demoNotFound':
|
|
883
|
+
return c.json({ error: 'unknown demo' }, 404);
|
|
884
|
+
case 'fileNotFound':
|
|
885
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
886
|
+
case 'badJson':
|
|
887
|
+
return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
|
|
888
|
+
case 'badSchema':
|
|
889
|
+
return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
|
|
890
|
+
case 'unknownNode':
|
|
891
|
+
return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
|
|
892
|
+
case 'writeFailed':
|
|
893
|
+
return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// PATCH a single connector — partial update of label/style/color/direction
|
|
898
|
+
// and (optionally) kind + per-kind payload fields. When `kind` changes,
|
|
899
|
+
// stale kind-specific fields are dropped before the merge. The whole demo
|
|
900
|
+
// is re-validated through DemoSchema before commit so the discriminated
|
|
901
|
+
// union catches missing-required-fields (e.g. kind='event' without
|
|
902
|
+
// eventName) and the superRefine still gates source/target referential
|
|
903
|
+
// integrity.
|
|
904
|
+
api.patch('/demos/:id/connectors/:connId', async (c) => {
|
|
905
|
+
const id = c.req.param('id');
|
|
906
|
+
const connId = c.req.param('connId');
|
|
907
|
+
|
|
908
|
+
let body: unknown;
|
|
909
|
+
try {
|
|
910
|
+
body = await c.req.json();
|
|
911
|
+
} catch {
|
|
912
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
913
|
+
}
|
|
914
|
+
const parsed = ConnectorPatchBodySchema.safeParse(body);
|
|
915
|
+
if (!parsed.success) {
|
|
916
|
+
return c.json({ error: 'Invalid connector patch body', issues: parsed.error.issues }, 400);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const result = await patchConnectorImpl({ registry, watcher }, id, connId, parsed.data);
|
|
920
|
+
switch (result.kind) {
|
|
921
|
+
case 'ok':
|
|
922
|
+
return c.json({ ok: true });
|
|
923
|
+
case 'demoNotFound':
|
|
924
|
+
return c.json({ error: 'unknown demo' }, 404);
|
|
925
|
+
case 'fileNotFound':
|
|
926
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
927
|
+
case 'badJson':
|
|
928
|
+
return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
|
|
929
|
+
case 'badSchema':
|
|
930
|
+
return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
|
|
931
|
+
case 'unknownConnector':
|
|
932
|
+
return c.json({ error: `Unknown connectorId: ${connId}` }, 404);
|
|
933
|
+
case 'writeFailed':
|
|
934
|
+
return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
// POST a new connector. Body is the connector payload; `id` is auto-generated
|
|
939
|
+
// server-side if absent and `kind` defaults to 'default' (the no-semantics
|
|
940
|
+
// user-drawn variant). Source/target referential integrity is enforced by
|
|
941
|
+
// DemoSchema's superRefine on the post-mutation parse.
|
|
942
|
+
api.post('/demos/:id/connectors', async (c) => {
|
|
943
|
+
const id = c.req.param('id');
|
|
944
|
+
|
|
945
|
+
let body: unknown;
|
|
946
|
+
try {
|
|
947
|
+
body = await c.req.json();
|
|
948
|
+
} catch {
|
|
949
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
950
|
+
}
|
|
951
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
952
|
+
return c.json({ error: 'Body must be an object' }, 400);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const result = await addConnectorImpl(
|
|
956
|
+
{ registry, watcher },
|
|
957
|
+
id,
|
|
958
|
+
body as Record<string, unknown>,
|
|
959
|
+
);
|
|
960
|
+
switch (result.kind) {
|
|
961
|
+
case 'ok':
|
|
962
|
+
return c.json({ ok: true, id: result.data.id });
|
|
963
|
+
case 'demoNotFound':
|
|
964
|
+
return c.json({ error: 'unknown demo' }, 404);
|
|
965
|
+
case 'fileNotFound':
|
|
966
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
967
|
+
case 'badJson':
|
|
968
|
+
return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
|
|
969
|
+
case 'badSchema':
|
|
970
|
+
return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
|
|
971
|
+
case 'writeFailed':
|
|
972
|
+
return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// DELETE a connector. Just removes the entry from demo.connectors — node
|
|
977
|
+
// deletion is what cascades, not connector deletion.
|
|
978
|
+
api.delete('/demos/:id/connectors/:connId', async (c) => {
|
|
979
|
+
const id = c.req.param('id');
|
|
980
|
+
const connId = c.req.param('connId');
|
|
981
|
+
|
|
982
|
+
const result = await deleteConnectorImpl({ registry, watcher }, id, connId);
|
|
983
|
+
switch (result.kind) {
|
|
984
|
+
case 'ok':
|
|
985
|
+
return c.json({ ok: true });
|
|
986
|
+
case 'demoNotFound':
|
|
987
|
+
return c.json({ error: 'unknown demo' }, 404);
|
|
988
|
+
case 'fileNotFound':
|
|
989
|
+
return c.json({ error: `Demo file not found: ${result.path}` }, 404);
|
|
990
|
+
case 'badJson':
|
|
991
|
+
return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
|
|
992
|
+
case 'badSchema':
|
|
993
|
+
return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
|
|
994
|
+
case 'unknownConnector':
|
|
995
|
+
return c.json({ error: `Unknown connectorId: ${connId}` }, 404);
|
|
996
|
+
case 'writeFailed':
|
|
997
|
+
return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
api.post('/emit', async (c) => {
|
|
1002
|
+
if (!events) return c.json({ error: 'events not enabled' }, 500);
|
|
1003
|
+
|
|
1004
|
+
let body: unknown;
|
|
1005
|
+
try {
|
|
1006
|
+
body = await c.req.json();
|
|
1007
|
+
} catch {
|
|
1008
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const parsed = EmitBodySchema.safeParse(body);
|
|
1012
|
+
if (!parsed.success) {
|
|
1013
|
+
return c.json({ error: 'Invalid emit body', issues: parsed.error.issues }, 400);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const { demoId, nodeId, status, runId, payload } = parsed.data;
|
|
1017
|
+
if (!registry.getById(demoId)) {
|
|
1018
|
+
return c.json({ error: `Unknown demoId: ${demoId}` }, 404);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const extras =
|
|
1022
|
+
payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
1023
|
+
? (payload as Record<string, unknown>)
|
|
1024
|
+
: {};
|
|
1025
|
+
const eventPayload: Record<string, unknown> = { nodeId, ...extras };
|
|
1026
|
+
if (runId !== undefined) eventPayload.runId = runId;
|
|
1027
|
+
|
|
1028
|
+
events.broadcast({
|
|
1029
|
+
type: EMIT_STATUS_TO_EVENT[status],
|
|
1030
|
+
demoId,
|
|
1031
|
+
payload: eventPayload,
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
return c.json({ ok: true });
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
api.get('/events', (c) => {
|
|
1038
|
+
const demoId = c.req.query('demoId');
|
|
1039
|
+
if (!demoId) return c.json({ error: 'demoId query param required' }, 400);
|
|
1040
|
+
if (!registry.getById(demoId)) return c.json({ error: 'unknown demoId' }, 404);
|
|
1041
|
+
if (!events) return c.json({ error: 'events not enabled' }, 500);
|
|
1042
|
+
|
|
1043
|
+
return streamSSE(c, async (stream) => {
|
|
1044
|
+
let active = true;
|
|
1045
|
+
const queue: Array<{ event: string; data: string }> = [];
|
|
1046
|
+
let resume: (() => void) | null = null;
|
|
1047
|
+
|
|
1048
|
+
const wake = () => {
|
|
1049
|
+
if (resume) {
|
|
1050
|
+
const r = resume;
|
|
1051
|
+
resume = null;
|
|
1052
|
+
r();
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const unsubscribe = events.subscribe(demoId, (e) => {
|
|
1057
|
+
queue.push({ event: e.type, data: JSON.stringify({ ts: e.ts, ...(e.payload as object) }) });
|
|
1058
|
+
wake();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
stream.onAbort(() => {
|
|
1062
|
+
active = false;
|
|
1063
|
+
unsubscribe();
|
|
1064
|
+
wake();
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
// Initial 'hello' so reconnecting clients can confirm the stream is open
|
|
1068
|
+
// and trigger a re-fetch on the frontend.
|
|
1069
|
+
await stream.writeSSE({
|
|
1070
|
+
event: 'hello',
|
|
1071
|
+
data: JSON.stringify({ demoId, ts: Date.now() }),
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
try {
|
|
1075
|
+
while (active) {
|
|
1076
|
+
while (queue.length > 0) {
|
|
1077
|
+
const next = queue.shift();
|
|
1078
|
+
if (!next) break;
|
|
1079
|
+
await stream.writeSSE(next);
|
|
1080
|
+
}
|
|
1081
|
+
if (!active) break;
|
|
1082
|
+
await new Promise<void>((r) => {
|
|
1083
|
+
resume = r;
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
} finally {
|
|
1087
|
+
unsubscribe();
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
return api;
|
|
1093
|
+
}
|