@tuongaz/seeflow 0.1.77 → 0.1.80
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 +40 -0
- package/dist/web/assets/{architectureDiagram-3BPJPVTR-D5iHwVvy.js → architectureDiagram-3BPJPVTR-id0XTZQC.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-MAYYm7FM.js → blockDiagram-GPEHLZMM-Cjvfg0ZP.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-7P7yfHg1.js → c4Diagram-AAUBKEIU-Dyq-0e8Q.js} +1 -1
- package/dist/web/assets/channel-Ajb6KiL3.js +1 -0
- package/dist/web/assets/{chart-C68vupBE.js → chart-DuTGW-Dj.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-Bb4cSusI.js → chunk-2J33WTMH-DsD65OzD.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-DXYpcpTh.js → chunk-4BX2VUAB-BpytKE8P.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-BxuYKDnf.js → chunk-55IACEB6-DIILAUq9.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-DbWlxAr2.js → chunk-727SXJPM-C4ih-gTo.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-DT8S1q80.js → chunk-AQP2D5EJ-BsYoWdVM.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-Dc0wDuZz.js → chunk-FMBD7UC4-Db6L0z4p.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-CqLLK6H0.js → chunk-ND2GUHAM-BNLqZYMx.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-CxF7nkDI.js → chunk-QZHKN3VN-DL5PK45j.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-Cgw6ezRo.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Cgw6ezRo.js +1 -0
- package/dist/web/assets/{code-block-DR9fiK_U.js → code-block-C1SJv-Al.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-BflFbtY2.js → cose-bilkent-S5V4N54A-ChX5nR0f.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-BJ5UdyYS.js → dagre-BM42HDAG-BXeL3fEN.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-D0M8fCf7.js → diagram-2AECGRRQ-B6WtmEP-.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-D67gAMS4.js → diagram-5GNKFQAL-SXs7ALwM.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-XX62HBG-.js → diagram-KO2AKTUF-D5zylPYo.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-DCFq3Oac.js → diagram-LMA3HP47-CByIUlQF.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-Be392NCN.js → diagram-OG6HWLK6-BH1MfUqV.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-DP4eP0as.js → erDiagram-TEJ5UH35-BOOnRFBh.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-Ch1GVJ9R.js → flowDiagram-I6XJVG4X-BynWDHJP.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-DtvkTizu.js → ganttDiagram-6RSMTGT7-Cgq_djyN.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-YGcuBgb9.js → gitGraphDiagram-PVQCEYII-ciGSgmfT.js} +1 -1
- package/dist/web/assets/index-DiakpHyc.js +8619 -0
- package/dist/web/assets/{index-DljfurDC.css → index-fl8DS9WO.css} +1 -1
- package/dist/web/assets/{index.es-jrsJPbYZ.js → index.es-C7TtaIfa.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-wce0BORz.js → infoDiagram-5YYISTIA-DqMb3_c-.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-u2MvPgdW.js → ishikawaDiagram-YF4QCWOH-CAO6KqQU.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-BsOyrTiA.js → journeyDiagram-JHISSGLW-Di8MsLTo.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-ptMERvnN.js → jspdf.es.min-Cq4dY-lT.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-BaraYV9q.js → kanban-definition-UN3LZRKU-ClOmVNcX.js} +1 -1
- package/dist/web/assets/{linear-BVqXcDUJ.js → linear-B3OKBKaT.js} +1 -1
- package/dist/web/assets/{markdown-DqP0Cywq.js → markdown-Dg8NEx1K.js} +1 -1
- package/dist/web/assets/{mermaid.core-CakR_vo1.js → mermaid.core-Bw-m7bH-.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL-CO5AsZw3.js → mindmap-definition-RKZ34NQL-CUBA1zfc.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-CiDJY-kx.js → pieDiagram-4H26LBE5-Dux5HvSU.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-BS6oN3s_.js → quadrantDiagram-W4KKPZXB-DU3gQGo3.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-CNbUR_FF.js → requirementDiagram-4Y6WPE33-CD3A_U9j.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-0Esj5uzm.js → sankeyDiagram-5OEKKPKP-Cd4mc26P.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-DR3U38Zi.js → sequenceDiagram-3UESZ5HK-Da0iOMgq.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-C50RQjWe.js → stateDiagram-AJRCARHV-P94LaOD2.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU--JLHF28o.js +1 -0
- package/dist/web/assets/{time-C_2J9tFX.js → time-0JEErjjJ.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-BQXyo2r_.js → timeline-definition-PNZ67QCA-BqAYomix.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-DZJ8M3EA.js → vennDiagram-CIIHVFJN-BWuPhfIM.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-B96HtW3i.js → wardley-L42UT6IY-iiGkgUQj.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BHkQ79WC.js → wardleyDiagram-YWT4CUSO-CtqzFQXL.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-B_f8koGI.js → xychartDiagram-2RQKCTM6-BGrOXndI.js} +1 -1
- package/dist/web/index.html +2 -2
- package/examples/component-showcase/seeflow.json +6 -0
- package/examples/ecommerce-platform/seeflow.json +6 -0
- package/examples/order-pipeline/seeflow.json +6 -0
- package/package.json +1 -1
- package/src/api.ts +739 -94
- package/src/cli-e2e.ts +24 -13
- package/src/cli-helpers.ts +26 -0
- package/src/cli-manifest.ts +330 -87
- package/src/cli-ops.ts +56 -2
- package/src/cli.ts +228 -81
- package/src/cors.ts +93 -0
- package/src/jq-filter.ts +253 -0
- package/src/mcp-shim.ts +114 -7
- package/src/mcp-ui.ts +126 -0
- package/src/mcp.ts +258 -97
- package/src/node-files.ts +18 -7
- package/src/operations.ts +68 -32
- package/src/project-scanner.ts +105 -0
- package/src/registry.ts +79 -18
- package/src/route-resolve.ts +41 -0
- package/src/schema.ts +54 -0
- package/src/server.ts +24 -3
- package/src/slugify.ts +16 -0
- package/dist/web/assets/channel-BjsQQK93.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-p3FY5uNC.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-p3FY5uNC.js +0 -1
- package/dist/web/assets/index-Bg3PU4Ev.js +0 -8614
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BbNrmkIR.js +0 -1
- /package/examples/component-showcase/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/chart/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/counter/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/actions/refresh.ts +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/form/spec.json +0 -0
- /package/examples/component-showcase/{style.json → flows/main/style.json} +0 -0
- /package/examples/ecommerce-platform/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-3zFtHg6ENc/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-5F424NWbEu/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-CbwYqb7NfB/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-XwygzfKPZ5/view.html +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-fkptXw7uvs/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-kwBY8YPmYM/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-mPqan8rFYN/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-yKrg9DV5fJ/detail.md +0 -0
- /package/examples/ecommerce-platform/{scripts → flows/main/scripts}/play.ts +0 -0
- /package/examples/ecommerce-platform/{style.json → flows/main/style.json} +0 -0
- /package/examples/order-pipeline/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-GXTKUcE3ye/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-XKIyds0TDg/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-YOYiHJpY0i/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-zUIH7WFnhK/detail.md +0 -0
- /package/examples/order-pipeline/{scripts → flows/main/scripts}/play.ts +0 -0
- /package/examples/order-pipeline/{style.json → flows/main/style.json} +0 -0
package/src/api.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
2
2
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
import { streamSSE } from 'hono/streaming';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
import { registerProject } from './cli-ops.ts';
|
|
6
7
|
import { runComponentAction } from './component-action-runner.ts';
|
|
7
8
|
import {
|
|
8
9
|
AssembleRequestSchema,
|
|
@@ -37,10 +38,11 @@ import {
|
|
|
37
38
|
runReset as defaultRunReset,
|
|
38
39
|
stopAllPlays as defaultStopAllPlays,
|
|
39
40
|
} from './proxy.ts';
|
|
40
|
-
import type { Registry } from './registry.ts';
|
|
41
|
+
import type { FlowEntry, Registry } from './registry.ts';
|
|
42
|
+
import { resolveProjectFlow } from './route-resolve.ts';
|
|
41
43
|
import { getSchemaCategory, listSchemaCategories, schemaCategoryNames } from './schema-catalog.ts';
|
|
42
|
-
import type { ComponentAction } from './schema.ts';
|
|
43
|
-
import { FlowSchema, ResolvedFlowSchema } from './schema.ts';
|
|
44
|
+
import type { ComponentAction, SeeflowManifest } from './schema.ts';
|
|
45
|
+
import { FlowIdPattern, FlowSchema, ResolvedFlowSchema, SeeflowManifestSchema } from './schema.ts';
|
|
44
46
|
import { type Spawner, defaultSpawner } from './shellout.ts';
|
|
45
47
|
import { ID_TYPES, MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
|
|
46
48
|
import type { StatusRunner } from './status-runner.ts';
|
|
@@ -55,6 +57,37 @@ const EmitBodySchema = z.object({
|
|
|
55
57
|
payload: z.unknown().optional(),
|
|
56
58
|
});
|
|
57
59
|
|
|
60
|
+
// Body for POST /api/projects/:project/flows (US-015). The flow id reuses
|
|
61
|
+
// FlowIdPattern from schema.ts — same constraint the seeflow.json manifest
|
|
62
|
+
// enforces, so a manifest round-trip after this route runs cannot fail
|
|
63
|
+
// validation on the new entry's id.
|
|
64
|
+
const CreateFlowBodySchema = z.object({
|
|
65
|
+
id: z.string().regex(FlowIdPattern, {
|
|
66
|
+
message: 'flow id must match /^[a-z0-9][a-z0-9-]*$/',
|
|
67
|
+
}),
|
|
68
|
+
name: z.string().min(1),
|
|
69
|
+
icon: z.string().min(1).optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Body for PATCH /api/projects/:project/flows/:flow (US-016). All three
|
|
73
|
+
// fields are optional; the .refine() rejects an empty body so callers can't
|
|
74
|
+
// no-op the route. The same FlowIdPattern that guards POST guards id renames
|
|
75
|
+
// here, so a renamed flow's id stays manifest-compatible.
|
|
76
|
+
const PatchFlowBodySchema = z
|
|
77
|
+
.object({
|
|
78
|
+
id: z
|
|
79
|
+
.string()
|
|
80
|
+
.regex(FlowIdPattern, {
|
|
81
|
+
message: 'flow id must match /^[a-z0-9][a-z0-9-]*$/',
|
|
82
|
+
})
|
|
83
|
+
.optional(),
|
|
84
|
+
name: z.string().min(1).optional(),
|
|
85
|
+
icon: z.string().min(1).optional(),
|
|
86
|
+
})
|
|
87
|
+
.refine((b) => b.id !== undefined || b.name !== undefined || b.icon !== undefined, {
|
|
88
|
+
message: 'body must include at least one of id, name, icon',
|
|
89
|
+
});
|
|
90
|
+
|
|
58
91
|
type RelativePathCheck = { kind: 'ok' } | { kind: 'invalid'; reason: string };
|
|
59
92
|
|
|
60
93
|
// Reject absolute paths and `..` traversal before any filesystem touch.
|
|
@@ -91,12 +124,16 @@ type ResolvedProjectFile =
|
|
|
91
124
|
// (defense against symlink escapes). Returns the realpath of an existing file
|
|
92
125
|
// on success, or `fileMissing` with the would-be absolute path so callers can
|
|
93
126
|
// soft-fail with that path included for clipboard fallback.
|
|
127
|
+
//
|
|
128
|
+
// Project addressing is by `projectSlug` (post-US-008): the first registry
|
|
129
|
+
// entry whose `projectSlug` matches supplies `repoPath` since every entry in
|
|
130
|
+
// a project shares the same on-disk root.
|
|
94
131
|
function resolveProjectFile(
|
|
95
132
|
registry: Registry,
|
|
96
|
-
|
|
133
|
+
projectSlug: string,
|
|
97
134
|
relPath: string,
|
|
98
135
|
): ResolvedProjectFile {
|
|
99
|
-
const entry = registry.
|
|
136
|
+
const entry = registry.list().find((e) => e.projectSlug === projectSlug);
|
|
100
137
|
if (!entry) return { kind: 'unknownProject' };
|
|
101
138
|
|
|
102
139
|
const guard = validateRelativePath(relPath);
|
|
@@ -126,6 +163,22 @@ function resolveProjectFile(
|
|
|
126
163
|
return { kind: 'ok', absPath: realTarget, projectRoot: realRoot };
|
|
127
164
|
}
|
|
128
165
|
|
|
166
|
+
// Read + validate `<repoPath>/seeflow.json` for the project listing routes.
|
|
167
|
+
// Returns `null` for missing or malformed manifests so callers can fall back
|
|
168
|
+
// to derived defaults (projectSlug + isDefault entry) instead of failing the
|
|
169
|
+
// whole listing on one bad project.
|
|
170
|
+
function readProjectManifest(repoPath: string): SeeflowManifest | null {
|
|
171
|
+
const manifestPath = join(repoPath, 'seeflow.json');
|
|
172
|
+
if (!existsSync(manifestPath)) return null;
|
|
173
|
+
try {
|
|
174
|
+
const raw = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
175
|
+
const parsed = SeeflowManifestSchema.safeParse(raw);
|
|
176
|
+
return parsed.success ? parsed.data : null;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
129
182
|
// Allowed extensions for /nodes/:nodeId/files/upload. Lowercased; matched after dropping the
|
|
130
183
|
// leading `.`. Stored as a Set so future expansion (PDF, video) is one-edit.
|
|
131
184
|
const UPLOAD_ALLOWED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
@@ -222,6 +275,9 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
222
275
|
}
|
|
223
276
|
|
|
224
277
|
const result = await ops.registerFlow(parsed.data);
|
|
278
|
+
if (result.kind === 'ok') {
|
|
279
|
+
events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
|
|
280
|
+
}
|
|
225
281
|
switch (result.kind) {
|
|
226
282
|
case 'ok':
|
|
227
283
|
return c.json(result.data);
|
|
@@ -234,6 +290,42 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
234
290
|
}
|
|
235
291
|
});
|
|
236
292
|
|
|
293
|
+
// POST /api/projects/register — manifest-driven registration. Reads
|
|
294
|
+
// <repoPath>/seeflow.json + walks declared flows under flows/<id>/flow.json,
|
|
295
|
+
// upserting one FlowEntry per declared flow with the manifest's name +
|
|
296
|
+
// per-flow names (vs. /api/flows/register which is the legacy single-flow
|
|
297
|
+
// path that uses the same name for both project and flow).
|
|
298
|
+
api.post('/projects/register', async (c) => {
|
|
299
|
+
let body: unknown;
|
|
300
|
+
try {
|
|
301
|
+
body = await c.req.json();
|
|
302
|
+
} catch {
|
|
303
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
304
|
+
}
|
|
305
|
+
const parsed = z.object({ repoPath: z.string().min(1) }).safeParse(body);
|
|
306
|
+
if (!parsed.success) {
|
|
307
|
+
return c.json({ error: 'Invalid register body', issues: parsed.error.issues }, 400);
|
|
308
|
+
}
|
|
309
|
+
const result = registerProject({ repoPath: parsed.data.repoPath, registry });
|
|
310
|
+
if (result.kind === 'ok') {
|
|
311
|
+
for (const entry of result.entries) watcher?.watch(entry.id);
|
|
312
|
+
events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
|
|
313
|
+
return c.json({
|
|
314
|
+
ok: true as const,
|
|
315
|
+
projectSlug: result.projectSlug,
|
|
316
|
+
entries: result.entries.map((e) => ({
|
|
317
|
+
id: e.id,
|
|
318
|
+
slug: e.slug,
|
|
319
|
+
projectSlug: e.projectSlug,
|
|
320
|
+
flowSlug: e.flowSlug,
|
|
321
|
+
name: e.name,
|
|
322
|
+
isDefault: e.isDefault,
|
|
323
|
+
})),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return c.json({ ok: false as const, error: result.kind }, 400);
|
|
327
|
+
});
|
|
328
|
+
|
|
237
329
|
// POST /api/flows/validate — dry-run validation. The skill's diagram
|
|
238
330
|
// pipeline calls this between assemble and register to decide whether to
|
|
239
331
|
// rewire. Runs the Zod schema, the soft node cap, and the tier playability
|
|
@@ -498,8 +590,399 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
498
590
|
return c.json(result.data);
|
|
499
591
|
});
|
|
500
592
|
|
|
501
|
-
api
|
|
502
|
-
|
|
593
|
+
// GET /api/projects — projects landing index. Groups registry.list() by
|
|
594
|
+
// projectSlug and reads each project's `seeflow.json` for the human-readable
|
|
595
|
+
// name + defaultFlow. When the manifest is missing or malformed, falls back
|
|
596
|
+
// to the projectSlug for name and the flowSlug of the registry's
|
|
597
|
+
// `isDefault: true` entry for defaultFlow — so a partially-broken project
|
|
598
|
+
// still surfaces in the picker.
|
|
599
|
+
api.get('/projects', (c) => {
|
|
600
|
+
const grouped = new Map<string, FlowEntry[]>();
|
|
601
|
+
for (const entry of registry.list()) {
|
|
602
|
+
const existing = grouped.get(entry.projectSlug);
|
|
603
|
+
if (existing) {
|
|
604
|
+
existing.push(entry);
|
|
605
|
+
} else {
|
|
606
|
+
grouped.set(entry.projectSlug, [entry]);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const projects: Array<{
|
|
610
|
+
projectSlug: string;
|
|
611
|
+
name: string;
|
|
612
|
+
description?: string;
|
|
613
|
+
defaultFlow: string;
|
|
614
|
+
flowCount: number;
|
|
615
|
+
repoPath: string;
|
|
616
|
+
}> = [];
|
|
617
|
+
for (const [projectSlug, entries] of grouped) {
|
|
618
|
+
const head = entries[0];
|
|
619
|
+
if (!head) continue;
|
|
620
|
+
const manifest = readProjectManifest(head.repoPath);
|
|
621
|
+
const defaultEntry = entries.find((e) => e.isDefault) ?? head;
|
|
622
|
+
projects.push({
|
|
623
|
+
projectSlug,
|
|
624
|
+
name: manifest?.name ?? projectSlug,
|
|
625
|
+
description: manifest?.description,
|
|
626
|
+
defaultFlow: manifest?.defaultFlow ?? defaultEntry.flowSlug,
|
|
627
|
+
flowCount: entries.length,
|
|
628
|
+
repoPath: head.repoPath,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return c.json({ projects });
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// GET /api/projects/:project — per-project metadata + flow entries. 404s
|
|
635
|
+
// with `project-not-found` when no registry entry shares the slug — same
|
|
636
|
+
// shape the flow-scoped routes (US-007) use for resolution failures, so
|
|
637
|
+
// clients have a single error pattern across the projects/* tree. The
|
|
638
|
+
// manifest read is best-effort (missing/malformed → null) so the route
|
|
639
|
+
// never depends on disk state for the slug check itself.
|
|
640
|
+
api.get('/projects/:project', (c) => {
|
|
641
|
+
const projectSlug = c.req.param('project');
|
|
642
|
+
const flows = registry.list().filter((e) => e.projectSlug === projectSlug);
|
|
643
|
+
const head = flows[0];
|
|
644
|
+
if (!head) {
|
|
645
|
+
return c.json({ ok: false as const, error: 'project-not-found' as const }, 404);
|
|
646
|
+
}
|
|
647
|
+
const manifest = readProjectManifest(head.repoPath);
|
|
648
|
+
const defaultEntry = flows.find((e) => e.isDefault) ?? head;
|
|
649
|
+
return c.json({
|
|
650
|
+
projectSlug,
|
|
651
|
+
name: manifest?.name ?? projectSlug,
|
|
652
|
+
description: manifest?.description,
|
|
653
|
+
defaultFlow: manifest?.defaultFlow ?? defaultEntry.flowSlug,
|
|
654
|
+
flows,
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// GET /api/projects/:project/flows — per-project flow listing. Powers the
|
|
659
|
+
// canvas page's Figma-style flow switcher popover (US-024). Returns the
|
|
660
|
+
// narrow shape the picker needs — id, flowSlug, name, icon, isDefault —
|
|
661
|
+
// rather than the full FlowEntry; clients that need repoPath/flowPath go
|
|
662
|
+
// through `GET /api/projects/:project` instead. 404s with `project-not-found`
|
|
663
|
+
// when no registry entry shares the slug (same shape US-007 + the
|
|
664
|
+
// GET /api/projects/:project route above use for resolution failures).
|
|
665
|
+
api.get('/projects/:project/flows', (c) => {
|
|
666
|
+
const projectSlug = c.req.param('project');
|
|
667
|
+
const entries = registry.list().filter((e) => e.projectSlug === projectSlug);
|
|
668
|
+
if (entries.length === 0) {
|
|
669
|
+
return c.json({ ok: false as const, error: 'project-not-found' as const }, 404);
|
|
670
|
+
}
|
|
671
|
+
const flows = entries.map((e) => ({
|
|
672
|
+
id: e.id,
|
|
673
|
+
flowSlug: e.flowSlug,
|
|
674
|
+
name: e.name,
|
|
675
|
+
icon: e.icon,
|
|
676
|
+
isDefault: e.isDefault,
|
|
677
|
+
}));
|
|
678
|
+
return c.json({ flows });
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// POST /api/projects/:project/flows — create a new flow within an existing
|
|
682
|
+
// project (US-015). Atomically: write `flows/<id>/flow.json` with an empty
|
|
683
|
+
// envelope → append the new entry to `seeflow.json` → `registry.upsert()`.
|
|
684
|
+
// If the manifest write fails after the flow folder is on disk, the folder
|
|
685
|
+
// is removed so the project state stays consistent with the manifest. New
|
|
686
|
+
// flows are never the project default — the caller has to use PATCH
|
|
687
|
+
// /projects/:project/flows/:flow (US-016) to flip defaultFlow.
|
|
688
|
+
api.post('/projects/:project/flows', async (c) => {
|
|
689
|
+
const projectSlug = c.req.param('project');
|
|
690
|
+
const entries = registry.list().filter((e) => e.projectSlug === projectSlug);
|
|
691
|
+
const head = entries[0];
|
|
692
|
+
if (!head) {
|
|
693
|
+
return c.json({ ok: false as const, error: 'project-not-found' as const }, 404);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
let body: unknown;
|
|
697
|
+
try {
|
|
698
|
+
body = await c.req.json();
|
|
699
|
+
} catch {
|
|
700
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
701
|
+
}
|
|
702
|
+
const parsed = CreateFlowBodySchema.safeParse(body);
|
|
703
|
+
if (!parsed.success) {
|
|
704
|
+
return c.json({ error: 'Invalid create flow body', issues: parsed.error.issues }, 400);
|
|
705
|
+
}
|
|
706
|
+
const { id, name, icon } = parsed.data;
|
|
707
|
+
|
|
708
|
+
// Duplicate check: any registered flow in the project or any pre-existing
|
|
709
|
+
// folder under `flows/<id>/` (covers manual edits that never made it to
|
|
710
|
+
// the registry) collides. Manifest entry duplication is structurally
|
|
711
|
+
// impossible if the registry is the source of truth, but the disk check
|
|
712
|
+
// catches drift.
|
|
713
|
+
if (entries.some((e) => e.flowSlug === id)) {
|
|
714
|
+
return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const repoPath = head.repoPath;
|
|
718
|
+
const manifestPath = join(repoPath, 'seeflow.json');
|
|
719
|
+
const manifest = readProjectManifest(repoPath);
|
|
720
|
+
if (!manifest) {
|
|
721
|
+
return c.json(
|
|
722
|
+
{
|
|
723
|
+
ok: false as const,
|
|
724
|
+
error: 'manifest-missing-or-invalid' as const,
|
|
725
|
+
path: manifestPath,
|
|
726
|
+
},
|
|
727
|
+
500,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (manifest.flows.some((f) => f.id === id)) {
|
|
731
|
+
return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const flowDir = join(repoPath, 'flows', id);
|
|
735
|
+
if (existsSync(flowDir)) {
|
|
736
|
+
return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// 1. Create the flow folder + flow.json. Atomic write guarantees no
|
|
740
|
+
// half-written file lands; mkdir is recursive in case `flows/` itself
|
|
741
|
+
// is missing on a partially-scaffolded project.
|
|
742
|
+
try {
|
|
743
|
+
mkdirSync(flowDir, { recursive: true });
|
|
744
|
+
const envelope = { version: 2 as const, name, nodes: [], connectors: [] };
|
|
745
|
+
writeFileAtomic(join(flowDir, 'flow.json'), `${JSON.stringify(envelope, null, 2)}\n`);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
try {
|
|
748
|
+
rmSync(flowDir, { recursive: true, force: true });
|
|
749
|
+
} catch {
|
|
750
|
+
// best-effort cleanup
|
|
751
|
+
}
|
|
752
|
+
return c.json(
|
|
753
|
+
{ ok: false as const, error: 'scaffold-failed' as const, detail: String(err) },
|
|
754
|
+
500,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// 2. Append the new entry to the manifest. Roll the folder back if the
|
|
759
|
+
// write fails so the project never has an orphan `flows/<id>/`.
|
|
760
|
+
const manifestEntry: { id: string; name: string; icon?: string } = { id, name };
|
|
761
|
+
if (icon !== undefined) manifestEntry.icon = icon;
|
|
762
|
+
const updatedManifest = {
|
|
763
|
+
...manifest,
|
|
764
|
+
flows: [...manifest.flows, manifestEntry],
|
|
765
|
+
};
|
|
766
|
+
try {
|
|
767
|
+
writeFileAtomic(manifestPath, `${JSON.stringify(updatedManifest, null, 2)}\n`);
|
|
768
|
+
} catch (err) {
|
|
769
|
+
try {
|
|
770
|
+
rmSync(flowDir, { recursive: true, force: true });
|
|
771
|
+
} catch {
|
|
772
|
+
// best-effort cleanup
|
|
773
|
+
}
|
|
774
|
+
return c.json(
|
|
775
|
+
{ ok: false as const, error: 'manifest-write-failed' as const, detail: String(err) },
|
|
776
|
+
500,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// 3. Register the new entry. `flowPath` is project-relative — matches the
|
|
781
|
+
// scanner output for manifest-driven projects.
|
|
782
|
+
const entry = registry.upsert({
|
|
783
|
+
name,
|
|
784
|
+
repoPath,
|
|
785
|
+
flowPath: `flows/${id}/flow.json`,
|
|
786
|
+
projectSlug,
|
|
787
|
+
flowSlug: id,
|
|
788
|
+
isDefault: false,
|
|
789
|
+
icon,
|
|
790
|
+
valid: true,
|
|
791
|
+
});
|
|
792
|
+
watcher?.watch(entry.id);
|
|
793
|
+
events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
|
|
794
|
+
|
|
795
|
+
return c.json(entry, 201);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// PATCH /api/projects/:project/flows/:flow — rename a flow id and/or update
|
|
799
|
+
// its name / icon (US-016). Two modes:
|
|
800
|
+
// 1. id change → rename `flows/<oldId>/` to `flows/<newId>/`, rewrite the
|
|
801
|
+
// manifest entry (and `defaultFlow` if it pointed at the renamed flow),
|
|
802
|
+
// then re-bind the registry entry + watcher under the new flowPath. On
|
|
803
|
+
// manifest-write failure, the folder rename is rolled back.
|
|
804
|
+
// 2. name/icon only → manifest-only edit; the filesystem layout and the
|
|
805
|
+
// registry entry id are untouched, only `name` / `icon` are refreshed.
|
|
806
|
+
// The handler is single-flight in the no-collision sense: it serialises
|
|
807
|
+
// through the registry + filesystem so two concurrent id renames against
|
|
808
|
+
// the same project cannot interleave to produce a duplicate folder.
|
|
809
|
+
api.patch('/projects/:project/flows/:flow', async (c) => {
|
|
810
|
+
const projectSlug = c.req.param('project');
|
|
811
|
+
const flowSlug = c.req.param('flow');
|
|
812
|
+
const resolved = resolveProjectFlow(registry, projectSlug, flowSlug);
|
|
813
|
+
if (resolved.kind === 'error') {
|
|
814
|
+
return c.json({ ok: false as const, error: resolved.code }, 404);
|
|
815
|
+
}
|
|
816
|
+
const entry = resolved.entry;
|
|
817
|
+
|
|
818
|
+
let body: unknown;
|
|
819
|
+
try {
|
|
820
|
+
body = await c.req.json();
|
|
821
|
+
} catch {
|
|
822
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
823
|
+
}
|
|
824
|
+
const parsed = PatchFlowBodySchema.safeParse(body);
|
|
825
|
+
if (!parsed.success) {
|
|
826
|
+
return c.json({ error: 'Invalid patch flow body', issues: parsed.error.issues }, 400);
|
|
827
|
+
}
|
|
828
|
+
const { id: requestedId, name: requestedName, icon: requestedIcon } = parsed.data;
|
|
829
|
+
|
|
830
|
+
const repoPath = entry.repoPath;
|
|
831
|
+
const manifestPath = join(repoPath, 'seeflow.json');
|
|
832
|
+
const manifest = readProjectManifest(repoPath);
|
|
833
|
+
if (!manifest) {
|
|
834
|
+
return c.json(
|
|
835
|
+
{
|
|
836
|
+
ok: false as const,
|
|
837
|
+
error: 'manifest-missing-or-invalid' as const,
|
|
838
|
+
path: manifestPath,
|
|
839
|
+
},
|
|
840
|
+
500,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const manifestEntryIdx = manifest.flows.findIndex((f) => f.id === entry.flowSlug);
|
|
845
|
+
if (manifestEntryIdx === -1) {
|
|
846
|
+
// Registry and manifest are out of sync — the entry knows itself by
|
|
847
|
+
// flowSlug but the manifest disagrees. Bail rather than rebuild the
|
|
848
|
+
// manifest unilaterally; a future `seeflow reconcile` verb could repair.
|
|
849
|
+
return c.json({ ok: false as const, error: 'manifest-entry-missing' as const }, 500);
|
|
850
|
+
}
|
|
851
|
+
const existingManifestEntry = manifest.flows[manifestEntryIdx];
|
|
852
|
+
if (!existingManifestEntry) {
|
|
853
|
+
return c.json({ ok: false as const, error: 'manifest-entry-missing' as const }, 500);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const idChanging = requestedId !== undefined && requestedId !== entry.flowSlug;
|
|
857
|
+
|
|
858
|
+
// Branch 1: id rename. The folder move is the only side-effect we have to
|
|
859
|
+
// undo on manifest-write failure, so it goes BEFORE the manifest write.
|
|
860
|
+
if (idChanging) {
|
|
861
|
+
const newId = requestedId;
|
|
862
|
+
if (newId === undefined) {
|
|
863
|
+
// Narrowing for TS — `idChanging` already implies non-undefined.
|
|
864
|
+
return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Collision checks: manifest, registry, on-disk folder. Each catches a
|
|
868
|
+
// different drift; cheapest first.
|
|
869
|
+
if (manifest.flows.some((f) => f.id === newId)) {
|
|
870
|
+
return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
|
|
871
|
+
}
|
|
872
|
+
const projectEntries = registry.list().filter((e) => e.projectSlug === projectSlug);
|
|
873
|
+
if (projectEntries.some((e) => e.flowSlug === newId)) {
|
|
874
|
+
return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
|
|
875
|
+
}
|
|
876
|
+
const oldFolder = join(repoPath, 'flows', entry.flowSlug);
|
|
877
|
+
const newFolder = join(repoPath, 'flows', newId);
|
|
878
|
+
if (existsSync(newFolder)) {
|
|
879
|
+
return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 1. Move the folder.
|
|
883
|
+
try {
|
|
884
|
+
renameSync(oldFolder, newFolder);
|
|
885
|
+
} catch (err) {
|
|
886
|
+
return c.json(
|
|
887
|
+
{ ok: false as const, error: 'folder-rename-failed' as const, detail: String(err) },
|
|
888
|
+
500,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// 2. Build + atomically write the updated manifest.
|
|
893
|
+
const finalName = requestedName ?? existingManifestEntry.name;
|
|
894
|
+
const finalIcon = requestedIcon !== undefined ? requestedIcon : existingManifestEntry.icon;
|
|
895
|
+
const updatedFlowEntry: { id: string; name: string; icon?: string } = {
|
|
896
|
+
id: newId,
|
|
897
|
+
name: finalName,
|
|
898
|
+
};
|
|
899
|
+
if (finalIcon !== undefined) updatedFlowEntry.icon = finalIcon;
|
|
900
|
+
const updatedManifest: SeeflowManifest = {
|
|
901
|
+
...manifest,
|
|
902
|
+
defaultFlow: manifest.defaultFlow === entry.flowSlug ? newId : manifest.defaultFlow,
|
|
903
|
+
flows: manifest.flows.map((f, i) => (i === manifestEntryIdx ? updatedFlowEntry : f)),
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
try {
|
|
907
|
+
writeFileAtomic(manifestPath, `${JSON.stringify(updatedManifest, null, 2)}\n`);
|
|
908
|
+
} catch (err) {
|
|
909
|
+
try {
|
|
910
|
+
renameSync(newFolder, oldFolder);
|
|
911
|
+
} catch {
|
|
912
|
+
// best-effort rollback
|
|
913
|
+
}
|
|
914
|
+
return c.json(
|
|
915
|
+
{ ok: false as const, error: 'manifest-write-failed' as const, detail: String(err) },
|
|
916
|
+
500,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// 3. Rebind the registry entry under the new flowPath. The shortId
|
|
921
|
+
// changes — registry slugs are addressed via projectSlug/flowSlug, so
|
|
922
|
+
// clients re-resolve via the new URL. Watcher is unwatch-then-watch
|
|
923
|
+
// because the flowPath that the watcher reads is sourced from the
|
|
924
|
+
// new registry entry.
|
|
925
|
+
watcher?.unwatch(entry.id);
|
|
926
|
+
registry.remove(entry.id);
|
|
927
|
+
const newEntry = registry.upsert({
|
|
928
|
+
name: finalName,
|
|
929
|
+
description: entry.description,
|
|
930
|
+
repoPath,
|
|
931
|
+
flowPath: `flows/${newId}/flow.json`,
|
|
932
|
+
projectSlug,
|
|
933
|
+
flowSlug: newId,
|
|
934
|
+
isDefault: entry.isDefault,
|
|
935
|
+
icon: finalIcon,
|
|
936
|
+
valid: entry.valid,
|
|
937
|
+
});
|
|
938
|
+
watcher?.watch(newEntry.id);
|
|
939
|
+
events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
|
|
940
|
+
|
|
941
|
+
return c.json(newEntry);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Branch 2: name / icon only. Filesystem layout untouched.
|
|
945
|
+
const finalName = requestedName ?? existingManifestEntry.name;
|
|
946
|
+
const finalIcon = requestedIcon !== undefined ? requestedIcon : existingManifestEntry.icon;
|
|
947
|
+
const updatedFlowEntry: { id: string; name: string; icon?: string } = {
|
|
948
|
+
id: existingManifestEntry.id,
|
|
949
|
+
name: finalName,
|
|
950
|
+
};
|
|
951
|
+
if (finalIcon !== undefined) updatedFlowEntry.icon = finalIcon;
|
|
952
|
+
const updatedManifest: SeeflowManifest = {
|
|
953
|
+
...manifest,
|
|
954
|
+
flows: manifest.flows.map((f, i) => (i === manifestEntryIdx ? updatedFlowEntry : f)),
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
try {
|
|
958
|
+
writeFileAtomic(manifestPath, `${JSON.stringify(updatedManifest, null, 2)}\n`);
|
|
959
|
+
} catch (err) {
|
|
960
|
+
return c.json(
|
|
961
|
+
{ ok: false as const, error: 'manifest-write-failed' as const, detail: String(err) },
|
|
962
|
+
500,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const updatedEntry = registry.upsert({
|
|
967
|
+
name: finalName,
|
|
968
|
+
description: entry.description,
|
|
969
|
+
repoPath,
|
|
970
|
+
flowPath: entry.flowPath,
|
|
971
|
+
projectSlug,
|
|
972
|
+
flowSlug: entry.flowSlug,
|
|
973
|
+
isDefault: entry.isDefault,
|
|
974
|
+
icon: finalIcon,
|
|
975
|
+
valid: entry.valid,
|
|
976
|
+
});
|
|
977
|
+
events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
|
|
978
|
+
|
|
979
|
+
return c.json(updatedEntry);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
api.get('/projects/:project/flows/:flow', async (c) => {
|
|
983
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
984
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
985
|
+
const result = await ops.getFlow(resolved.entry.id);
|
|
503
986
|
switch (result.kind) {
|
|
504
987
|
case 'ok':
|
|
505
988
|
return c.json(result.data);
|
|
@@ -511,9 +994,11 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
511
994
|
});
|
|
512
995
|
|
|
513
996
|
// Flow skeleton without per-node file content (detail.md / view.html).
|
|
514
|
-
// Pairs with GET /flows/:
|
|
515
|
-
api.get('/flows/:
|
|
516
|
-
const
|
|
997
|
+
// Pairs with GET /projects/:project/flows/:flow/nodes/:nodeId for full per-node detail.
|
|
998
|
+
api.get('/projects/:project/flows/:flow/graph', async (c) => {
|
|
999
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1000
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1001
|
+
const result = await ops.getFlowGraph(resolved.entry.id);
|
|
517
1002
|
switch (result.kind) {
|
|
518
1003
|
case 'ok':
|
|
519
1004
|
return c.json(result.data);
|
|
@@ -528,8 +1013,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
528
1013
|
}
|
|
529
1014
|
});
|
|
530
1015
|
|
|
531
|
-
api.get('/flows/:
|
|
532
|
-
const
|
|
1016
|
+
api.get('/projects/:project/flows/:flow/nodes/:nodeId', async (c) => {
|
|
1017
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1018
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1019
|
+
const result = await ops.getNode(resolved.entry.id, c.req.param('nodeId'));
|
|
533
1020
|
switch (result.kind) {
|
|
534
1021
|
case 'ok':
|
|
535
1022
|
return c.json(result.data);
|
|
@@ -546,11 +1033,13 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
546
1033
|
}
|
|
547
1034
|
});
|
|
548
1035
|
|
|
549
|
-
// GET /api/projects/:
|
|
550
|
-
// <repoPath>/<path>. Path safety is layered: textual rejection
|
|
551
|
-
// traversal), then realpath check that the resolved file stays
|
|
552
|
-
// project root (defends against symlink escapes).
|
|
553
|
-
|
|
1036
|
+
// GET /api/projects/:project/files/<path> — stream a project-scoped file
|
|
1037
|
+
// from <repoPath>/<path>. Path safety is layered: textual rejection
|
|
1038
|
+
// (absolute / traversal), then realpath check that the resolved file stays
|
|
1039
|
+
// inside the project root (defends against symlink escapes). The route is
|
|
1040
|
+
// shared across every flow within the project — assets that live at the
|
|
1041
|
+
// project root or under a sibling flow folder are addressable here.
|
|
1042
|
+
api.get('/projects/:project/files/:path{.+}', async (c) => {
|
|
554
1043
|
const rawPath = c.req.param('path');
|
|
555
1044
|
let relPath: string;
|
|
556
1045
|
try {
|
|
@@ -559,7 +1048,7 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
559
1048
|
return c.json({ error: 'invalid path encoding' }, 400);
|
|
560
1049
|
}
|
|
561
1050
|
|
|
562
|
-
const resolved = resolveProjectFile(registry, c.req.param('
|
|
1051
|
+
const resolved = resolveProjectFile(registry, c.req.param('project'), relPath);
|
|
563
1052
|
switch (resolved.kind) {
|
|
564
1053
|
case 'unknownProject':
|
|
565
1054
|
return c.json({ error: 'unknown project' }, 404);
|
|
@@ -582,12 +1071,12 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
582
1071
|
});
|
|
583
1072
|
});
|
|
584
1073
|
|
|
585
|
-
// POST /api/projects/:
|
|
586
|
-
// user can edit a project-scoped file (type:'html' block, image asset)
|
|
587
|
-
// their IDE. The endpoint always returns the resolved absolute path in
|
|
1074
|
+
// POST /api/projects/:project/files/open — shell out to `$EDITOR <abs>` so
|
|
1075
|
+
// the user can edit a project-scoped file (type:'html' block, image asset)
|
|
1076
|
+
// in their IDE. The endpoint always returns the resolved absolute path in
|
|
588
1077
|
// the response body so the frontend can copy-to-clipboard when $EDITOR
|
|
589
1078
|
// isn't set or the spawn fails. Path safety mirrors the GET route.
|
|
590
|
-
api.post('/projects/:
|
|
1079
|
+
api.post('/projects/:project/files/open', async (c) => {
|
|
591
1080
|
let body: unknown;
|
|
592
1081
|
try {
|
|
593
1082
|
body = await c.req.json();
|
|
@@ -599,7 +1088,7 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
599
1088
|
return c.json({ error: 'Invalid open body', issues: parsed.error.issues }, 400);
|
|
600
1089
|
}
|
|
601
1090
|
|
|
602
|
-
const resolved = resolveProjectFile(registry, c.req.param('
|
|
1091
|
+
const resolved = resolveProjectFile(registry, c.req.param('project'), parsed.data.path);
|
|
603
1092
|
switch (resolved.kind) {
|
|
604
1093
|
case 'unknownProject':
|
|
605
1094
|
return c.json({ error: 'unknown project' }, 404);
|
|
@@ -621,12 +1110,12 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
621
1110
|
return c.json({ ok: true, absPath: resolved.absPath });
|
|
622
1111
|
});
|
|
623
1112
|
|
|
624
|
-
// POST /api/projects/:
|
|
625
|
-
// target file selected. Platform commands: `open -R <abs>` (macOS),
|
|
1113
|
+
// POST /api/projects/:project/files/reveal — open the OS file manager with
|
|
1114
|
+
// the target file selected. Platform commands: `open -R <abs>` (macOS),
|
|
626
1115
|
// `explorer /select,<abs>` (Windows), `xdg-open <dir>` (Linux — selects the
|
|
627
1116
|
// containing directory; xdg has no portable "select-this-file" verb). Same
|
|
628
1117
|
// fallback shape as /open: response always includes `absPath` for clipboard.
|
|
629
|
-
api.post('/projects/:
|
|
1118
|
+
api.post('/projects/:project/files/reveal', async (c) => {
|
|
630
1119
|
let body: unknown;
|
|
631
1120
|
try {
|
|
632
1121
|
body = await c.req.json();
|
|
@@ -638,7 +1127,7 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
638
1127
|
return c.json({ error: 'Invalid reveal body', issues: parsed.error.issues }, 400);
|
|
639
1128
|
}
|
|
640
1129
|
|
|
641
|
-
const resolved = resolveProjectFile(registry, c.req.param('
|
|
1130
|
+
const resolved = resolveProjectFile(registry, c.req.param('project'), parsed.data.path);
|
|
642
1131
|
switch (resolved.kind) {
|
|
643
1132
|
case 'unknownProject':
|
|
644
1133
|
return c.json({ error: 'unknown project' }, 404);
|
|
@@ -672,17 +1161,21 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
672
1161
|
return c.json({ ok: true, absPath: resolved.absPath });
|
|
673
1162
|
});
|
|
674
1163
|
|
|
675
|
-
// POST /api/projects/:
|
|
676
|
-
// image upload and persist it under
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
1164
|
+
// POST /api/projects/:project/flows/:flow/nodes/:nodeId/files/upload —
|
|
1165
|
+
// accept a multipart image upload and persist it under
|
|
1166
|
+
// `<repoPath>/<dirname(entry.flowPath)>/nodes/<nodeId>/`. For manifest-
|
|
1167
|
+
// driven projects this resolves to `flows/<flow>/nodes/<nodeId>/`; for
|
|
1168
|
+
// legacy single-flow registrations (flow.json at the project root) it
|
|
1169
|
+
// collapses to `nodes/<nodeId>/`. Multipart shape: `file` (Blob) and
|
|
1170
|
+
// optional `filename` (the original OS name). Allowlist + 5 MB cap guard
|
|
1171
|
+
// against arbitrary uploads; the destination folder is scoped to the node,
|
|
1172
|
+
// so delete_node's removeNodeDir cascade cleans up the asset along with
|
|
1173
|
+
// the node row.
|
|
1174
|
+
api.post('/projects/:project/flows/:flow/nodes/:nodeId/files/upload', async (c) => {
|
|
1175
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1176
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1177
|
+
const entry = resolved.entry;
|
|
683
1178
|
const nodeId = c.req.param('nodeId');
|
|
684
|
-
const entry = registry.getById(projectId);
|
|
685
|
-
if (!entry) return c.json({ error: 'unknown project' }, 404);
|
|
686
1179
|
|
|
687
1180
|
// node id shape: `node-<10 base62 chars>` (matches shortId() output).
|
|
688
1181
|
if (!/^node-[A-Za-z0-9]{10}$/.test(nodeId)) {
|
|
@@ -712,7 +1205,8 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
712
1205
|
return c.json({ error: 'invalid filename or extension' }, 400);
|
|
713
1206
|
}
|
|
714
1207
|
|
|
715
|
-
const
|
|
1208
|
+
const flowDir = dirname(entry.flowPath);
|
|
1209
|
+
const nodeDir = join(entry.repoPath, flowDir, 'nodes', nodeId);
|
|
716
1210
|
try {
|
|
717
1211
|
mkdirSync(nodeDir, { recursive: true });
|
|
718
1212
|
} catch (err) {
|
|
@@ -738,25 +1232,149 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
738
1232
|
return c.json({ path: `nodes/${nodeId}/${finalName}` });
|
|
739
1233
|
});
|
|
740
1234
|
|
|
741
|
-
api
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1235
|
+
// DELETE /api/projects/:project/flows/:flow — manifest-aware delete (US-017).
|
|
1236
|
+
// Guards:
|
|
1237
|
+
// - last-flow: refuse when the target is the only flow in the project.
|
|
1238
|
+
// - default-flow-no-replacement: refuse when target is manifest.defaultFlow
|
|
1239
|
+
// and no `?newDefault=<other-flow-id>` query arg is supplied.
|
|
1240
|
+
// - invalid-new-default: the supplied newDefault must exist in the
|
|
1241
|
+
// manifest and must not be the flow being deleted.
|
|
1242
|
+
// On success: rename the flow folder to a sibling `.deleted-*` snapshot
|
|
1243
|
+
// (atomic on POSIX), write the updated manifest atomically, then rm the
|
|
1244
|
+
// snapshot and drop the registry entry. On manifest-write failure the
|
|
1245
|
+
// snapshot is renamed back so the externally-observable state is preserved.
|
|
1246
|
+
api.delete('/projects/:project/flows/:flow', (c) => {
|
|
1247
|
+
const projectSlug = c.req.param('project');
|
|
1248
|
+
const flowSlug = c.req.param('flow');
|
|
1249
|
+
const newDefault = c.req.query('newDefault');
|
|
1250
|
+
|
|
1251
|
+
const resolved = resolveProjectFlow(registry, projectSlug, flowSlug);
|
|
1252
|
+
if (resolved.kind === 'error') {
|
|
1253
|
+
return c.json({ ok: false as const, error: resolved.code }, 404);
|
|
1254
|
+
}
|
|
1255
|
+
const entry = resolved.entry;
|
|
1256
|
+
|
|
1257
|
+
const projectEntries = registry.list().filter((e) => e.projectSlug === projectSlug);
|
|
1258
|
+
if (projectEntries.length <= 1) {
|
|
1259
|
+
return c.json({ ok: false as const, error: 'last-flow' as const }, 409);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const repoPath = entry.repoPath;
|
|
1263
|
+
const manifestPath = join(repoPath, 'seeflow.json');
|
|
1264
|
+
const manifest = readProjectManifest(repoPath);
|
|
1265
|
+
if (!manifest) {
|
|
1266
|
+
return c.json(
|
|
1267
|
+
{
|
|
1268
|
+
ok: false as const,
|
|
1269
|
+
error: 'manifest-missing-or-invalid' as const,
|
|
1270
|
+
path: manifestPath,
|
|
1271
|
+
},
|
|
1272
|
+
500,
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const targetIsDefault = manifest.defaultFlow === entry.flowSlug;
|
|
1277
|
+
if (targetIsDefault && (newDefault === undefined || newDefault.length === 0)) {
|
|
1278
|
+
return c.json({ ok: false as const, error: 'default-flow-no-replacement' as const }, 409);
|
|
1279
|
+
}
|
|
1280
|
+
if (targetIsDefault && newDefault !== undefined) {
|
|
1281
|
+
if (newDefault === entry.flowSlug) {
|
|
1282
|
+
return c.json({ ok: false as const, error: 'invalid-new-default' as const }, 400);
|
|
1283
|
+
}
|
|
1284
|
+
if (!manifest.flows.some((f) => f.id === newDefault)) {
|
|
1285
|
+
return c.json({ ok: false as const, error: 'invalid-new-default' as const }, 400);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const flowDir = join(repoPath, 'flows', entry.flowSlug);
|
|
1290
|
+
const tmpHolder = join(repoPath, 'flows', `.deleted-${entry.flowSlug}-${Date.now()}`);
|
|
1291
|
+
|
|
1292
|
+
// Snapshot the folder by renaming it to a sibling tmp location. Same
|
|
1293
|
+
// filesystem → POSIX guarantees the rename is atomic. If the folder is
|
|
1294
|
+
// missing on disk (manual `rm -rf` between registration and delete), skip
|
|
1295
|
+
// the snapshot — the manifest+registry mutation still proceeds.
|
|
1296
|
+
let movedToTmp = false;
|
|
1297
|
+
if (existsSync(flowDir)) {
|
|
1298
|
+
try {
|
|
1299
|
+
renameSync(flowDir, tmpHolder);
|
|
1300
|
+
movedToTmp = true;
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
return c.json(
|
|
1303
|
+
{ ok: false as const, error: 'folder-rename-failed' as const, detail: String(err) },
|
|
1304
|
+
500,
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
748
1307
|
}
|
|
1308
|
+
|
|
1309
|
+
const updatedManifest: SeeflowManifest = {
|
|
1310
|
+
...manifest,
|
|
1311
|
+
defaultFlow: targetIsDefault ? (newDefault as string) : manifest.defaultFlow,
|
|
1312
|
+
flows: manifest.flows.filter((f) => f.id !== entry.flowSlug),
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
writeFileAtomic(manifestPath, `${JSON.stringify(updatedManifest, null, 2)}\n`);
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
if (movedToTmp) {
|
|
1319
|
+
try {
|
|
1320
|
+
renameSync(tmpHolder, flowDir);
|
|
1321
|
+
} catch {
|
|
1322
|
+
// best-effort restore — caller surfaces the original write error
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return c.json(
|
|
1326
|
+
{ ok: false as const, error: 'manifest-write-failed' as const, detail: String(err) },
|
|
1327
|
+
500,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Manifest committed — drop the snapshot.
|
|
1332
|
+
if (movedToTmp) {
|
|
1333
|
+
try {
|
|
1334
|
+
rmSync(tmpHolder, { recursive: true, force: true });
|
|
1335
|
+
} catch {
|
|
1336
|
+
// best-effort cleanup — manifest is the source of truth now
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Promote the new default in the registry (the upsert finds the existing
|
|
1341
|
+
// entry by repoPath + flowPath and updates in place, preserving its id).
|
|
1342
|
+
if (targetIsDefault && newDefault !== undefined) {
|
|
1343
|
+
const promoted = projectEntries.find((e) => e.flowSlug === newDefault);
|
|
1344
|
+
if (promoted) {
|
|
1345
|
+
registry.upsert({
|
|
1346
|
+
name: promoted.name,
|
|
1347
|
+
description: promoted.description,
|
|
1348
|
+
repoPath: promoted.repoPath,
|
|
1349
|
+
flowPath: promoted.flowPath,
|
|
1350
|
+
projectSlug: promoted.projectSlug,
|
|
1351
|
+
flowSlug: promoted.flowSlug,
|
|
1352
|
+
isDefault: true,
|
|
1353
|
+
icon: promoted.icon,
|
|
1354
|
+
valid: promoted.valid,
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
watcher?.unwatch(entry.id);
|
|
1360
|
+
registry.remove(entry.id);
|
|
1361
|
+
events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
|
|
1362
|
+
|
|
1363
|
+
return c.json({ ok: true });
|
|
749
1364
|
});
|
|
750
1365
|
|
|
751
|
-
// POST /api/flows/:
|
|
752
|
-
// from disk via the registry entry, computes
|
|
753
|
-
// atomically next to flow.json, and broadcasts
|
|
754
|
-
// canvas refreshes. Body is empty or `{ options? }`.
|
|
755
|
-
// just `{ ok: true }` — the layout is already on
|
|
756
|
-
// returns `{ ok: false, issues }` mirroring
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
|
|
1366
|
+
// POST /api/projects/:project/flows/:flow/layout — registered-flow ELK
|
|
1367
|
+
// layout. Reads flow.json from disk via the registry entry, computes
|
|
1368
|
+
// layout, writes style.json atomically next to flow.json, and broadcasts
|
|
1369
|
+
// flow:reload so any open canvas refreshes. Body is empty or `{ options? }`.
|
|
1370
|
+
// Response on success is just `{ ok: true }` — the layout is already on
|
|
1371
|
+
// disk. On schema failure returns `{ ok: false, issues }` mirroring
|
|
1372
|
+
// /api/validate; on missing flow file / bad JSON / write failure returns
|
|
1373
|
+
// HTTP 4xx/5xx.
|
|
1374
|
+
api.post('/projects/:project/flows/:flow/layout', async (c) => {
|
|
1375
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1376
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1377
|
+
const id = resolved.entry.id;
|
|
760
1378
|
|
|
761
1379
|
// Empty body is valid — the skill always uses defaults. Only parse if the
|
|
762
1380
|
// caller actually sent something.
|
|
@@ -794,11 +1412,12 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
794
1412
|
}
|
|
795
1413
|
});
|
|
796
1414
|
|
|
797
|
-
api.post('/flows/:
|
|
798
|
-
const
|
|
1415
|
+
api.post('/projects/:project/flows/:flow/play/:nodeId', async (c) => {
|
|
1416
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1417
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1418
|
+
const entry = resolved.entry;
|
|
1419
|
+
const id = entry.id;
|
|
799
1420
|
const nodeId = c.req.param('nodeId');
|
|
800
|
-
const entry = registry.getById(id);
|
|
801
|
-
if (!entry) return c.json({ error: 'unknown demo' }, 404);
|
|
802
1421
|
if (!events) return c.json({ error: 'events not enabled' }, 500);
|
|
803
1422
|
|
|
804
1423
|
// Always re-read from disk so the user's most recent edit (validated or
|
|
@@ -852,20 +1471,22 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
852
1471
|
return c.json(result);
|
|
853
1472
|
});
|
|
854
1473
|
|
|
855
|
-
// POST /api/flows/:
|
|
856
|
-
// node's named action over HTTP. Only `script`-kind
|
|
857
|
-
// `set`-kind actions mutate canvas state locally
|
|
858
|
-
// through the API (the runner rejects them with
|
|
859
|
-
// Payload is the JSON request body (defaults to {} on
|
|
860
|
-
// piped to the script's stdin by
|
|
861
|
-
// script's parsed JSON stdout on
|
|
862
|
-
|
|
863
|
-
|
|
1474
|
+
// POST /api/projects/:project/flows/:flow/nodes/:nodeId/actions/:name —
|
|
1475
|
+
// dispatch a component node's named action over HTTP. Only `script`-kind
|
|
1476
|
+
// actions cross this seam; `set`-kind actions mutate canvas state locally
|
|
1477
|
+
// and never round-trip through the API (the runner rejects them with
|
|
1478
|
+
// statusHint 400). Payload is the JSON request body (defaults to {} on
|
|
1479
|
+
// parse failure) and is piped to the script's stdin by
|
|
1480
|
+
// `runComponentAction`. Response is the script's parsed JSON stdout on
|
|
1481
|
+
// success.
|
|
1482
|
+
api.post('/projects/:project/flows/:flow/nodes/:nodeId/actions/:name', async (c) => {
|
|
1483
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1484
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1485
|
+
const entry = resolved.entry;
|
|
1486
|
+
const id = entry.id;
|
|
864
1487
|
const nodeId = c.req.param('nodeId');
|
|
865
1488
|
const actionName = c.req.param('name');
|
|
866
1489
|
|
|
867
|
-
const entry = registry.getById(id);
|
|
868
|
-
if (!entry) return c.json({ error: 'unknown demo' }, 404);
|
|
869
1490
|
if (!events) return c.json({ error: 'events not enabled' }, 500);
|
|
870
1491
|
|
|
871
1492
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -888,11 +1509,15 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
888
1509
|
if (!action) return c.json({ error: `Unknown action: ${actionName}` }, 404);
|
|
889
1510
|
|
|
890
1511
|
const payload = await c.req.json().catch(() => ({}));
|
|
1512
|
+
// US-031: per-node sidecar scripts now anchor at
|
|
1513
|
+
// `<repoPath>/<dirname(flowPath)>/nodes/<id>/` post-multi-flow migration —
|
|
1514
|
+
// the runner still resolves scripts as `<cwd>/nodes/<nodeId>/<scriptPath>`,
|
|
1515
|
+
// so feed the per-flow folder as `cwd`.
|
|
891
1516
|
const result = await runComponentAction({
|
|
892
1517
|
events,
|
|
893
1518
|
flowId: id,
|
|
894
1519
|
nodeId,
|
|
895
|
-
cwd: entry.repoPath,
|
|
1520
|
+
cwd: join(entry.repoPath, dirname(entry.flowPath)),
|
|
896
1521
|
actionName,
|
|
897
1522
|
action,
|
|
898
1523
|
payload,
|
|
@@ -904,7 +1529,8 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
904
1529
|
return c.json(result.body);
|
|
905
1530
|
});
|
|
906
1531
|
|
|
907
|
-
// POST /api/flows/:
|
|
1532
|
+
// POST /api/projects/:project/flows/:flow/reset — the "Restart demo"
|
|
1533
|
+
// workflow (US-008). Order:
|
|
908
1534
|
// 1. Stop every live play-script + every long-running status-script for
|
|
909
1535
|
// this demo in parallel — both must complete before any reset script
|
|
910
1536
|
// spawns so the script sees no stragglers.
|
|
@@ -914,10 +1540,11 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
914
1540
|
// 4. Fire-and-forget `statusRunner.restart` so the next status batch is
|
|
915
1541
|
// spawning by the time the response lands. Individual spawn failures
|
|
916
1542
|
// surface via console.warn but never fail the /reset call.
|
|
917
|
-
api.post('/flows/:
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
1543
|
+
api.post('/projects/:project/flows/:flow/reset', async (c) => {
|
|
1544
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1545
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1546
|
+
const entry = resolved.entry;
|
|
1547
|
+
const id = entry.id;
|
|
921
1548
|
if (!events) return c.json({ error: 'events not enabled' }, 500);
|
|
922
1549
|
|
|
923
1550
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -981,8 +1608,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
981
1608
|
// PATCH a single node's position back into the on-disk flow.json. Atomic
|
|
982
1609
|
// write via tempfile + rename keeps editor diffs clean and avoids
|
|
983
1610
|
// corruption mid-write.
|
|
984
|
-
api.patch('/flows/:
|
|
985
|
-
const
|
|
1611
|
+
api.patch('/projects/:project/flows/:flow/nodes/:nodeId/position', async (c) => {
|
|
1612
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1613
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1614
|
+
const id = resolved.entry.id;
|
|
986
1615
|
const nodeId = c.req.param('nodeId');
|
|
987
1616
|
|
|
988
1617
|
let body: unknown;
|
|
@@ -1022,8 +1651,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
1022
1651
|
// toBack (remove + push/unshift), and toIndex (pin to an absolute index)
|
|
1023
1652
|
// which the undo path uses to faithfully revert forward/backward gestures
|
|
1024
1653
|
// even if the array changed between the original op and the undo.
|
|
1025
|
-
api.patch('/flows/:
|
|
1026
|
-
const
|
|
1654
|
+
api.patch('/projects/:project/flows/:flow/nodes/:nodeId/order', async (c) => {
|
|
1655
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1656
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1657
|
+
const id = resolved.entry.id;
|
|
1027
1658
|
const nodeId = c.req.param('nodeId');
|
|
1028
1659
|
|
|
1029
1660
|
let body: unknown;
|
|
@@ -1063,8 +1694,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
1063
1694
|
// doesn't yet recognize survive round-trips) and the WHOLE resulting demo
|
|
1064
1695
|
// is re-validated through ResolvedFlowSchema before commit, preventing partial
|
|
1065
1696
|
// writes from breaking invariants like the connector→node superRefine.
|
|
1066
|
-
api.patch('/flows/:
|
|
1067
|
-
const
|
|
1697
|
+
api.patch('/projects/:project/flows/:flow/nodes/:nodeId', async (c) => {
|
|
1698
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1699
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1700
|
+
const id = resolved.entry.id;
|
|
1068
1701
|
const nodeId = c.req.param('nodeId');
|
|
1069
1702
|
|
|
1070
1703
|
let body: unknown;
|
|
@@ -1100,8 +1733,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
1100
1733
|
// POST a new node into the demo. Body is the node payload (id auto-generated
|
|
1101
1734
|
// server-side if absent). Atomicity + final-ResolvedFlowSchema validation match the
|
|
1102
1735
|
// PATCH path above, so a malformed node never produces a half-written file.
|
|
1103
|
-
api.post('/flows/:
|
|
1104
|
-
const
|
|
1736
|
+
api.post('/projects/:project/flows/:flow/nodes', async (c) => {
|
|
1737
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1738
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1739
|
+
const id = resolved.entry.id;
|
|
1105
1740
|
|
|
1106
1741
|
let body: unknown;
|
|
1107
1742
|
try {
|
|
@@ -1137,8 +1772,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
1137
1772
|
// nodes added in the same call; the parse sees the merged graph as a whole.
|
|
1138
1773
|
// Intended for skill/LLM seeding where multiple singular calls would burn
|
|
1139
1774
|
// tokens and round-trip latency.
|
|
1140
|
-
api.post('/flows/:
|
|
1141
|
-
const
|
|
1775
|
+
api.post('/projects/:project/flows/:flow/bulk', async (c) => {
|
|
1776
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1777
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1778
|
+
const id = resolved.entry.id;
|
|
1142
1779
|
|
|
1143
1780
|
let body: unknown;
|
|
1144
1781
|
try {
|
|
@@ -1186,8 +1823,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
1186
1823
|
// is still run after the mutation — connector cascade closure means it
|
|
1187
1824
|
// should always pass, but the check makes the failure mode honest if the
|
|
1188
1825
|
// file had a pre-existing schema violation we'd otherwise paper over.
|
|
1189
|
-
api.delete('/flows/:
|
|
1190
|
-
const
|
|
1826
|
+
api.delete('/projects/:project/flows/:flow/nodes/:nodeId', async (c) => {
|
|
1827
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1828
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1829
|
+
const id = resolved.entry.id;
|
|
1191
1830
|
const nodeId = c.req.param('nodeId');
|
|
1192
1831
|
|
|
1193
1832
|
const result = await ops.deleteNode(id, nodeId);
|
|
@@ -1216,8 +1855,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
1216
1855
|
// union catches missing-required-fields (e.g. kind='event' without
|
|
1217
1856
|
// eventName) and the superRefine still gates source/target referential
|
|
1218
1857
|
// integrity.
|
|
1219
|
-
api.patch('/flows/:
|
|
1220
|
-
const
|
|
1858
|
+
api.patch('/projects/:project/flows/:flow/connectors/:connId', async (c) => {
|
|
1859
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1860
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1861
|
+
const id = resolved.entry.id;
|
|
1221
1862
|
const connId = c.req.param('connId');
|
|
1222
1863
|
|
|
1223
1864
|
let body: unknown;
|
|
@@ -1254,8 +1895,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
1254
1895
|
// server-side if absent and `kind` defaults to 'default' (the no-semantics
|
|
1255
1896
|
// user-drawn variant). Source/target referential integrity is enforced by
|
|
1256
1897
|
// ResolvedFlowSchema's superRefine on the post-mutation parse.
|
|
1257
|
-
api.post('/flows/:
|
|
1258
|
-
const
|
|
1898
|
+
api.post('/projects/:project/flows/:flow/connectors', async (c) => {
|
|
1899
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1900
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1901
|
+
const id = resolved.entry.id;
|
|
1259
1902
|
|
|
1260
1903
|
let body: unknown;
|
|
1261
1904
|
try {
|
|
@@ -1286,8 +1929,10 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
1286
1929
|
|
|
1287
1930
|
// DELETE a connector. Just removes the entry from demo.connectors — node
|
|
1288
1931
|
// deletion is what cascades, not connector deletion.
|
|
1289
|
-
api.delete('/flows/:
|
|
1290
|
-
const
|
|
1932
|
+
api.delete('/projects/:project/flows/:flow/connectors/:connId', async (c) => {
|
|
1933
|
+
const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
|
|
1934
|
+
if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
|
|
1935
|
+
const id = resolved.entry.id;
|
|
1291
1936
|
const connId = c.req.param('connId');
|
|
1292
1937
|
|
|
1293
1938
|
const result = await ops.deleteConnector(id, connId);
|