@tuongaz/seeflow 0.1.57 → 0.1.63

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.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/dist/web/assets/{index-CPlccVLi.js → index-DAP_yx-l.js} +354 -354
  3. package/dist/web/assets/{index.es-CYTTDW0Q.js → index.es-2bA-nRVD.js} +1 -1
  4. package/dist/web/assets/{jspdf.es.min-DOaPC0dc.js → jspdf.es.min-C7u0-VKd.js} +3 -3
  5. package/dist/web/index.html +1 -1
  6. package/examples/ecommerce-platform/{.seeflow/flow.json → flow.json} +3 -25
  7. package/examples/ecommerce-platform/{.seeflow/scripts → scripts}/play.ts +1 -1
  8. package/examples/order-pipeline/{.seeflow/flow.json → flow.json} +1 -10
  9. package/package.json +1 -1
  10. package/src/api.ts +83 -55
  11. package/src/cli-helpers.ts +6 -5
  12. package/src/cli-manifest.ts +129 -15
  13. package/src/cli.ts +106 -13
  14. package/src/diagram.ts +0 -1
  15. package/src/file-ref.ts +16 -15
  16. package/src/mcp.ts +96 -16
  17. package/src/merge.ts +0 -1
  18. package/src/node-files.ts +5 -5
  19. package/src/operations.ts +40 -101
  20. package/src/paths.ts +16 -0
  21. package/src/proxy.ts +13 -13
  22. package/src/schema-catalog.ts +114 -0
  23. package/src/schema.ts +110 -133
  24. package/src/server.ts +3 -5
  25. package/src/short-id.ts +24 -0
  26. package/src/status-runner.ts +3 -3
  27. package/src/watcher.ts +15 -27
  28. package/src/sdk-template.ts +0 -37
  29. package/src/sdk-writer.ts +0 -37
  30. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-3zFtHg6ENc/detail.md +0 -0
  31. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-5F424NWbEu/detail.md +0 -0
  32. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-CbwYqb7NfB/detail.md +0 -0
  33. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-XwygzfKPZ5/view.html +0 -0
  34. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-fkptXw7uvs/detail.md +0 -0
  35. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-kwBY8YPmYM/detail.md +0 -0
  36. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-mPqan8rFYN/detail.md +0 -0
  37. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-yKrg9DV5fJ/detail.md +0 -0
  38. /package/examples/ecommerce-platform/{.seeflow/style.json → style.json} +0 -0
  39. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-GXTKUcE3ye/detail.md +0 -0
  40. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-XKIyds0TDg/detail.md +0 -0
  41. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-YOYiHJpY0i/detail.md +0 -0
  42. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-zUIH7WFnhK/detail.md +0 -0
  43. /package/examples/order-pipeline/{.seeflow/scripts → scripts}/play.ts +0 -0
  44. /package/examples/order-pipeline/{.seeflow/style.json → style.json} +0 -0
package/src/merge.ts CHANGED
@@ -76,7 +76,6 @@ const CONNECTOR_FLOW_KEYS = new Set([
76
76
  'id',
77
77
  'source',
78
78
  'target',
79
- 'kind',
80
79
  'label',
81
80
  'method',
82
81
  'url',
package/src/node-files.ts CHANGED
@@ -3,9 +3,9 @@ import { dirname, join } from 'node:path';
3
3
  import { writeFileAtomic } from './atomic-write.ts';
4
4
 
5
5
  // Spec for fields that the studio externalizes to disk under
6
- // `<project>/.seeflow/nodes/<id>/<fileName>`. `nodeTypes` (when present)
7
- // scopes the spec entry to specific node types; absent means "applies to
8
- // every node type". Adding a future text field is one line.
6
+ // `<project>/nodes/<id>/<fileName>`. `nodeTypes` (when present) scopes the
7
+ // spec entry to specific node types; absent means "applies to every node
8
+ // type". Adding a future text field is one line.
9
9
  export interface ExternalizedFieldSpec {
10
10
  field: string;
11
11
  fileName: string;
@@ -36,7 +36,7 @@ export const nodeFileRelPath = (nodeId: string, fileName: string): string =>
36
36
  export const nodeFileRef = (_nodeId: string, fileName: string): string => `file://${fileName}`;
37
37
 
38
38
  export const nodeFileAbsPath = (repoPath: string, nodeId: string, fileName: string): string =>
39
- join(repoPath, '.seeflow', nodeFileRelPath(nodeId, fileName));
39
+ join(repoPath, nodeFileRelPath(nodeId, fileName));
40
40
 
41
41
  export function writeNodeFile(absPath: string, content: string): void {
42
42
  mkdirSync(dirname(absPath), { recursive: true });
@@ -44,5 +44,5 @@ export function writeNodeFile(absPath: string, content: string): void {
44
44
  }
45
45
 
46
46
  export function removeNodeDir(repoPath: string, nodeId: string): void {
47
- rmSync(join(repoPath, '.seeflow', 'nodes', nodeId), { recursive: true, force: true });
47
+ rmSync(join(repoPath, 'nodes', nodeId), { recursive: true, force: true });
48
48
  }
package/src/operations.ts CHANGED
@@ -21,8 +21,7 @@ import {
21
21
  removeNodeDir,
22
22
  writeNodeFile,
23
23
  } from './node-files.ts';
24
- import { seeflowHome } from './paths.ts';
25
- import { type Registry, slugify } from './registry.ts';
24
+ import type { Registry } from './registry.ts';
26
25
  import {
27
26
  ColorTokenSchema,
28
27
  EdgePinSchema,
@@ -38,11 +37,13 @@ import {
38
37
  StyleSchema,
39
38
  TargetHandleIdSchema,
40
39
  } from './schema.ts';
41
- import { writeSdkEmitIfNeeded } from './sdk-writer.ts';
42
40
  import { shortId } from './short-id.ts';
43
41
  import { type FlowSnapshot, type FlowWatcher, readMergedFlow } from './watcher.ts';
44
42
 
45
- const DEFAULT_FLOW_RELATIVE_PATH = '.seeflow/flow.json';
43
+ // Both projects:create and flows:register write/read flow.json at the project
44
+ // root. The studio never assumes a `.seeflow/` subdirectory — whatever path
45
+ // the caller supplies is treated as the seeflow project root.
46
+ const PROJECT_FLOW_RELATIVE_PATH = 'flow.json';
46
47
 
47
48
  export const RegisterBodySchema = z.object({
48
49
  name: z.string().min(1).optional(),
@@ -52,7 +53,9 @@ export const RegisterBodySchema = z.object({
52
53
  export type RegisterBody = z.infer<typeof RegisterBodySchema>;
53
54
 
54
55
  export const CreateProjectBodySchema = z.object({
56
+ path: z.string().min(1),
55
57
  name: z.string().min(1),
58
+ description: z.string().min(1).optional(),
56
59
  });
57
60
  export type CreateProjectBody = z.infer<typeof CreateProjectBodySchema>;
58
61
 
@@ -96,8 +99,8 @@ export const NodePatchBodySchema = z
96
99
  // preserved, and the post-merge ResolvedFlowSchema reparse enforces the
97
100
  // new type's required fields (e.g. stateNode → playNode without a
98
101
  // playAction in the same body surfaces as `badSchema`). The per-node
99
- // folder under `.seeflow/nodes/<id>/` is keyed by id, so retype keeps
100
- // scripts, detail.md, and view.html attached.
102
+ // folder under `nodes/<id>/` is keyed by id, so retype keeps scripts,
103
+ // detail.md, and view.html attached.
101
104
  type: NodeTypeSchema.optional(),
102
105
  position: PositionBodySchema.optional(),
103
106
  name: z.string().optional(),
@@ -136,8 +139,8 @@ export const NodePatchBodySchema = z
136
139
  description: z.string().optional(),
137
140
  detail: z.string().optional(),
138
141
  // htmlNode-only: inline HTML content. Externalized to
139
- // `<project>/.seeflow/nodes/<id>/view.html` by patchNodeImpl; the file://
140
- // ref on the node persists. Empty string empties the file but keeps the ref.
142
+ // `<project>/nodes/<id>/view.html` by patchNodeImpl; the file:// ref on
143
+ // the node persists. Empty string empties the file but keeps the ref.
141
144
  html: z.string().optional(),
142
145
  // P5 overlay attach: lets the skill (or any consumer) wire executable
143
146
  // behaviour onto a previously-created node without re-issuing it. Final
@@ -283,10 +286,10 @@ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePat
283
286
  // (they route to style.json on write); any semantic data key not allowed
284
287
  // by the new type's FlowDataSchema is stripped so the post-merge reparse
285
288
  // doesn't reject lingering fields. The per-node folder under
286
- // `.seeflow/nodes/<id>/` is keyed by id (unchanged), so scripts and
287
- // externalized files stay attached. Missing required fields on the new
288
- // type (e.g. stateNode → playNode without a playAction in the same patch)
289
- // surface as `badSchema` from the ResolvedFlowSchema reparse.
289
+ // `nodes/<id>/` is keyed by id (unchanged), so scripts and externalized
290
+ // files stay attached. Missing required fields on the new type (e.g.
291
+ // stateNode → playNode without a playAction in the same patch) surface as
292
+ // `badSchema` from the ResolvedFlowSchema reparse.
290
293
  if (updates.type !== undefined && updates.type !== node.type) {
291
294
  node.type = updates.type;
292
295
  const allowedSemantic = SEMANTIC_KEYS_BY_TYPE[updates.type];
@@ -335,12 +338,6 @@ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePat
335
338
  export interface OperationsDeps {
336
339
  registry: Registry;
337
340
  watcher?: FlowWatcher;
338
- /**
339
- * Override the base directory for new projects. Defaults to seeflowHome()
340
- * — `${SEEFLOW_WORKSPACE}/.seeflow` inside Docker, `~/.seeflow` locally.
341
- * Tests inject a tmp dir.
342
- */
343
- projectBaseDir?: string;
344
341
  }
345
342
 
346
343
  export interface FlowListItem {
@@ -365,13 +362,11 @@ export interface FlowGetResponse {
365
362
  export interface RegisterFlowSuccess {
366
363
  id: string;
367
364
  slug: string;
368
- sdk: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
369
365
  }
370
366
 
371
367
  export interface CreateProjectSuccess {
372
368
  id: string;
373
369
  slug: string;
374
- scaffolded: boolean;
375
370
  }
376
371
 
377
372
  export type ListFlowsOutcome = { kind: 'ok'; data: FlowListItem[] };
@@ -432,17 +427,14 @@ export type RegisterFlowOutcome =
432
427
  | { kind: 'ok'; data: RegisterFlowSuccess }
433
428
  | { kind: 'fileNotFound'; path: string }
434
429
  | { kind: 'badJson'; detail: string }
435
- | { kind: 'badSchema'; issues: ZodIssue[] }
436
- | { kind: 'sdkWriteFailed'; id: string; slug: string; message: string };
430
+ | { kind: 'badSchema'; issues: ZodIssue[] };
437
431
 
438
432
  export type DeleteFlowOutcome = { kind: 'ok' } | { kind: 'notFound' };
439
433
 
440
434
  export type CreateProjectOutcome =
441
435
  | { kind: 'ok'; data: CreateProjectSuccess }
442
- | { kind: 'badJson'; detail: string }
443
- | { kind: 'badSchema'; issues: ZodIssue[] }
444
- | { kind: 'scaffoldFailed'; message: string }
445
- | { kind: 'sdkWriteFailed'; message: string };
436
+ | { kind: 'alreadyExists'; path: string }
437
+ | { kind: 'scaffoldFailed'; message: string };
446
438
 
447
439
  // Outcomes for the four node-lifecycle helpers. Every variant lines up with
448
440
  // an existing REST error response so api.ts can translate them back to the
@@ -517,9 +509,8 @@ export type PatchNodeOutcome =
517
509
  | { kind: 'writeFailed'; message: string };
518
510
 
519
511
  // Partial connector update body. Strict at the top level so client typos
520
- // surface as 400. Per-kind invariants (e.g. kind='event' requires eventName)
521
- // are enforced post-merge by re-parsing the whole demo through ResolvedFlowSchema.
522
- const ConnectorKindSchema = z.enum(['http', 'event', 'queue', 'default']);
512
+ // surface as 400. Field shape invariants are enforced post-merge by
513
+ // re-parsing the whole demo through ResolvedFlowSchema.
523
514
  export const ConnectorPatchBodySchema = z
524
515
  .object({
525
516
  label: z.string().optional(),
@@ -530,7 +521,6 @@ export const ConnectorPatchBodySchema = z
530
521
  path: z.enum(['curve', 'step']).optional(),
531
522
  // US-018: per-connector label font size (mirrors NodeVisualBaseShape.fontSize).
532
523
  fontSize: z.number().positive().optional(),
533
- kind: ConnectorKindSchema.optional(),
534
524
  eventName: z.string().optional(),
535
525
  queueName: z.string().optional(),
536
526
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
@@ -567,21 +557,10 @@ export const ConnectorPatchBodySchema = z
567
557
  .strict();
568
558
  export type ConnectorPatchBody = z.infer<typeof ConnectorPatchBodySchema>;
569
559
 
570
- // Kind-specific connector fields. When `kind` changes via PATCH, these are
571
- // dropped first so the resulting connector doesn't carry phantom payloads
572
- // from the previous kind (e.g. an event→default change leaving eventName
573
- // behind, which ResolvedFlowSchema would silently strip on parse but leave on disk).
574
- const CONNECTOR_KIND_FIELDS = ['method', 'url', 'eventName', 'queueName'] as const;
575
-
576
560
  export const mergeConnectorUpdates = (
577
561
  conn: Record<string, unknown>,
578
562
  updates: ConnectorPatchBody,
579
563
  ): void => {
580
- if (updates.kind !== undefined && updates.kind !== conn.kind) {
581
- for (const key of CONNECTOR_KIND_FIELDS) {
582
- delete conn[key];
583
- }
584
- }
585
564
  for (const [key, value] of Object.entries(updates)) {
586
565
  if (value === undefined) continue;
587
566
  // US-025: explicit null in the patch body means "clear this field on
@@ -1119,20 +1098,11 @@ export async function registerFlowImpl(
1119
1098
 
1120
1099
  watcher?.watch(entry.id);
1121
1100
 
1122
- let sdkResult: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
1123
- try {
1124
- sdkResult = writeSdkEmitIfNeeded(repoPath, merged.flow);
1125
- } catch (err) {
1126
- const message = err instanceof Error ? err.message : String(err);
1127
- return { kind: 'sdkWriteFailed', id: entry.id, slug: entry.slug, message };
1128
- }
1129
-
1130
1101
  return {
1131
1102
  kind: 'ok',
1132
1103
  data: {
1133
1104
  id: entry.id,
1134
1105
  slug: entry.slug,
1135
- sdk: { outcome: sdkResult.outcome, filePath: sdkResult.filePath },
1136
1106
  },
1137
1107
  };
1138
1108
  }
@@ -1151,63 +1121,41 @@ export async function createProjectImpl(
1151
1121
  body: CreateProjectBody,
1152
1122
  ): Promise<CreateProjectOutcome> {
1153
1123
  const { registry, watcher } = deps;
1154
- const { name } = body;
1155
- const baseDir = deps.projectBaseDir ?? seeflowHome();
1156
- const folderPath = join(baseDir, slugify(name));
1124
+ const { path: folderPath, name, description } = body;
1157
1125
 
1158
- const demoFullPath = join(folderPath, DEFAULT_FLOW_RELATIVE_PATH);
1126
+ const demoFullPath = join(folderPath, PROJECT_FLOW_RELATIVE_PATH);
1159
1127
 
1160
1128
  if (existsSync(demoFullPath)) {
1161
- let raw: unknown;
1162
- try {
1163
- raw = await Bun.file(demoFullPath).json();
1164
- } catch (err) {
1165
- return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
1166
- }
1167
- const flowParse = FlowSchema.safeParse(raw);
1168
- if (!flowParse.success) return { kind: 'badSchema', issues: flowParse.error.issues };
1169
-
1170
- const lastModified = statSync(demoFullPath).mtimeMs;
1171
- const entry = registry.upsert({
1172
- name,
1173
- repoPath: folderPath,
1174
- flowPath: DEFAULT_FLOW_RELATIVE_PATH,
1175
- valid: true,
1176
- lastModified,
1177
- });
1178
- watcher?.watch(entry.id);
1179
- return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: false } };
1129
+ return { kind: 'alreadyExists', path: folderPath };
1180
1130
  }
1181
1131
 
1182
1132
  // Flow-only scaffold: empty nodes/connectors, no style.json needed.
1183
- const scaffold: Flow = { version: 2, name, nodes: [], connectors: [] };
1133
+ const scaffold: Flow = {
1134
+ version: 2,
1135
+ name,
1136
+ ...(description !== undefined ? { description } : {}),
1137
+ nodes: [],
1138
+ connectors: [],
1139
+ };
1184
1140
 
1185
1141
  try {
1186
- mkdirSync(join(folderPath, '.seeflow'), { recursive: true });
1142
+ mkdirSync(folderPath, { recursive: true });
1187
1143
  writeFileSync(demoFullPath, `${JSON.stringify(scaffold, null, 2)}\n`);
1188
1144
  } catch (err) {
1189
1145
  return { kind: 'scaffoldFailed', message: err instanceof Error ? err.message : String(err) };
1190
1146
  }
1191
1147
 
1192
- // Same SDK-emit path as the CLI register flow. For a fresh scaffold with no
1193
- // event-bound state nodes this returns 'skipped' and writes nothing —
1194
- // retained for parity with `seeflow register`.
1195
- try {
1196
- writeSdkEmitIfNeeded(folderPath, scaffold);
1197
- } catch (err) {
1198
- return { kind: 'sdkWriteFailed', message: err instanceof Error ? err.message : String(err) };
1199
- }
1200
-
1201
1148
  const lastModified = statSync(demoFullPath).mtimeMs;
1202
1149
  const entry = registry.upsert({
1203
1150
  name,
1151
+ description,
1204
1152
  repoPath: folderPath,
1205
- flowPath: DEFAULT_FLOW_RELATIVE_PATH,
1153
+ flowPath: PROJECT_FLOW_RELATIVE_PATH,
1206
1154
  valid: true,
1207
1155
  lastModified,
1208
1156
  });
1209
1157
  watcher?.watch(entry.id);
1210
- return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: true } };
1158
+ return { kind: 'ok', data: { id: entry.id, slug: entry.slug } };
1211
1159
  }
1212
1160
 
1213
1161
  // Append a new node to the demo. Auto-generates an id when absent; ResolvedFlowSchema
@@ -1346,9 +1294,6 @@ export async function addFlowBulkImpl(
1346
1294
  if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
1347
1295
  newConn.id = `conn-${shortId()}`;
1348
1296
  }
1349
- if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
1350
- newConn.kind = 'default';
1351
- }
1352
1297
  const newId = newConn.id as string;
1353
1298
  if (connIdsInBatch.has(newId)) {
1354
1299
  return { kind: 'duplicateIdInBatch', collection: 'connectors', id: newId };
@@ -1432,8 +1377,8 @@ export async function addFlowBulkImpl(
1432
1377
  // atomic write. Final ResolvedFlowSchema parse stays in place so a pre-existing
1433
1378
  // schema violation surfaces honestly instead of being silently papered over.
1434
1379
  // After the flow.json write, `removeNodeDir` cascades the node's whole
1435
- // `<project>/.seeflow/nodes/<id>/` folder — covering detail.md, view.html,
1436
- // and any imageNode upload that lived there.
1380
+ // `<project>/nodes/<id>/` folder — covering detail.md, view.html, and any
1381
+ // imageNode upload that lived there.
1437
1382
  export async function deleteNodeImpl(
1438
1383
  deps: OperationsDeps,
1439
1384
  flowId: string,
@@ -1617,9 +1562,6 @@ export async function addConnectorImpl(
1617
1562
  if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
1618
1563
  newConn.id = `conn-${shortId()}`;
1619
1564
  }
1620
- if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
1621
- newConn.kind = 'default';
1622
- }
1623
1565
  const newId = newConn.id as string;
1624
1566
 
1625
1567
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
@@ -1636,13 +1578,10 @@ export async function addConnectorImpl(
1636
1578
 
1637
1579
  // Apply a partial PATCH body to a single connector. Mutation runs against
1638
1580
  // the raw parsed JSON (so unknown forward-compat fields survive a round-trip).
1639
- // When `kind` changes, the previous kind's payload fields are dropped first
1640
- // so the connector doesn't carry phantom data; explicit `null` in the patch
1641
- // clears the field on disk (used by reconnect-to-body to drop a pinned
1642
- // handle id). The whole demo is re-validated through ResolvedFlowSchema before
1643
- // commit so the discriminated union catches missing-required-fields
1644
- // (e.g. kind='event' without eventName) and the superRefine gates
1645
- // source/target referential integrity + handle role invariants.
1581
+ // Explicit `null` in the patch clears the field on disk (used by
1582
+ // reconnect-to-body to drop a pinned handle id). The whole demo is
1583
+ // re-validated through ResolvedFlowSchema before commit so the superRefine
1584
+ // gates source/target referential integrity + handle role invariants.
1646
1585
  export async function patchConnectorImpl(
1647
1586
  deps: OperationsDeps,
1648
1587
  flowId: string,
package/src/paths.ts CHANGED
@@ -11,3 +11,19 @@ export function seeflowHome(): string {
11
11
  if (workspace && workspace.length > 0) return join(workspace, '.seeflow');
12
12
  return join(homedir(), '.seeflow');
13
13
  }
14
+
15
+ // Per-project layout: everything lives at the project root. The studio never
16
+ // assumes a `.seeflow/` subdirectory — whatever path the CLI / API was handed
17
+ // is treated as the seeflow project root. The `/seeflow` skill creates a
18
+ // `<host>/.seeflow/<flow-name>/` container per flow and passes that as the
19
+ // project path, but that's a skill convention, not a studio rule.
20
+ export const PROJECT_FLOW_FILENAME = 'flow.json';
21
+
22
+ export const projectFlowPath = (repoPath: string): string => join(repoPath, PROJECT_FLOW_FILENAME);
23
+
24
+ export const projectNodesRoot = (repoPath: string): string => join(repoPath, 'nodes');
25
+
26
+ export const projectNodeDir = (repoPath: string, nodeId: string): string =>
27
+ join(repoPath, 'nodes', nodeId);
28
+
29
+ export const projectSdkDir = (repoPath: string): string => join(repoPath, 'sdk');
package/src/proxy.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Defense-in-depth on scriptPath: schema.ts already rejects absolute paths
10
10
  * and `..` traversal textually. Here we additionally realpath-resolve the
11
- * script under `<cwd>/.seeflow/` and reject anything that escapes that root —
11
+ * script under the project root and reject anything that escapes that root —
12
12
  * symlink-escape defense in line with `resolveProjectFile` in api.ts.
13
13
  */
14
14
 
@@ -34,7 +34,7 @@ export interface RunPlayOptions {
34
34
  events: EventBus;
35
35
  flowId: string;
36
36
  nodeId: string;
37
- /** Project root (`<repoPath>`). Script resolves under `<cwd>/.seeflow/`. */
37
+ /** Project root (`<repoPath>`). Script resolves under `<cwd>/nodes/<nodeId>/`. */
38
38
  cwd: string;
39
39
  action: PlayAction;
40
40
  /** Injectable for tests; defaults to `defaultProcessSpawner`. */
@@ -48,11 +48,11 @@ const SCRIPT_PATH_ESCAPE = 'scriptPath escapes project root';
48
48
 
49
49
  type Resolved = { ok: true; absPath: string } | { ok: false };
50
50
 
51
- // Resolve `<cwd>/.seeflow/nodes/<nodeId>/<scriptPath>` and verify via realpath
52
- // it stays inside the node folder. The per-node anchor means scriptPath is
51
+ // Resolve `<cwd>/nodes/<nodeId>/<scriptPath>` and verify via realpath it
52
+ // stays inside the node folder. The per-node anchor means scriptPath is
53
53
  // "scripts/play.ts" — no node id leaks into its own path.
54
54
  function resolveScript(cwd: string, nodeId: string, scriptPath: string): Resolved {
55
- const nodeRoot = join(cwd, '.seeflow', 'nodes', nodeId);
55
+ const nodeRoot = join(cwd, 'nodes', nodeId);
56
56
  let realRoot: string;
57
57
  try {
58
58
  realRoot = realpathSync(nodeRoot);
@@ -74,17 +74,17 @@ function resolveScript(cwd: string, nodeId: string, scriptPath: string): Resolve
74
74
  }
75
75
 
76
76
  // Legacy anchor for resetAction (kept until resetAction gets its own design
77
- // round). Same realpath escape check as resolveScript, but rooted at
78
- // <cwd>/.seeflow/ rather than a per-node folder.
77
+ // round). Same realpath escape check as resolveScript, but rooted at the
78
+ // project root rather than a per-node folder.
79
79
  function resolveResetScript(cwd: string, scriptPath: string): Resolved {
80
- const seeflowRoot = join(cwd, '.seeflow');
80
+ const projectRoot = cwd;
81
81
  let realRoot: string;
82
82
  try {
83
- realRoot = realpathSync(seeflowRoot);
83
+ realRoot = realpathSync(projectRoot);
84
84
  } catch {
85
85
  return { ok: false };
86
86
  }
87
- const target = resolve(seeflowRoot, scriptPath);
87
+ const target = resolve(projectRoot, scriptPath);
88
88
  let realTarget: string;
89
89
  try {
90
90
  realTarget = realpathSync(target);
@@ -307,7 +307,7 @@ export async function runPlay(options: RunPlayOptions): Promise<PlayResult> {
307
307
  export interface RunResetOptions {
308
308
  events: EventBus;
309
309
  flowId: string;
310
- /** Project root (`<repoPath>`). Script resolves under `<cwd>/.seeflow/`. */
310
+ /** Project root (`<repoPath>`). Script resolves under `<cwd>/`. */
311
311
  cwd: string;
312
312
  action: ResetAction;
313
313
  /** Injectable for tests; defaults to `defaultProcessSpawner`. */
@@ -330,8 +330,8 @@ export async function runReset(options: RunResetOptions): Promise<ResetResult> {
330
330
  const { events, flowId, cwd, action } = options;
331
331
  const spawner = options.spawner ?? defaultProcessSpawner;
332
332
 
333
- // resetAction stays anchored at .seeflow/ for now — design defers per-node
334
- // resetAction to a later round (decision #7). Mirrors the previous behaviour.
333
+ // resetAction is anchored at the project root — design defers per-node
334
+ // resetAction to a later round (decision #7).
335
335
  const resolved = resolveResetScript(cwd, action.scriptPath);
336
336
  if (!resolved.ok) {
337
337
  events.broadcast({
@@ -0,0 +1,114 @@
1
+ // Single source of truth for runtime schema introspection. The CLI
2
+ // (`seeflow schema`), the MCP tool (`seeflow_schema`), and the REST routes
3
+ // (`GET /api/schema[/:name]`) all delegate here so the agent-facing surface
4
+ // stays in lockstep with the on-disk Zod schemas in schema.ts. Built once at
5
+ // module load — each call returns a fresh shallow copy so callers can't
6
+ // mutate the cached payload.
7
+
8
+ import type { ZodTypeAny } from 'zod';
9
+ import { zodToJsonSchema } from 'zod-to-json-schema';
10
+ import {
11
+ FlowConnectorSchema,
12
+ FlowEnvelopeSchema,
13
+ FlowHtmlNodeSchema,
14
+ FlowIconNodeSchema,
15
+ FlowImageNodeSchema,
16
+ FlowPlayNodeSchema,
17
+ FlowShapeNodeSchema,
18
+ FlowStateNodeSchema,
19
+ PlayActionSchema,
20
+ ResetActionSchema,
21
+ StatusActionSchema,
22
+ StatusReportSchema,
23
+ StyleSchema,
24
+ } from './schema.ts';
25
+
26
+ export interface SchemaCategory {
27
+ name: string;
28
+ description: string;
29
+ }
30
+
31
+ export interface SchemaPayload {
32
+ schemas: Record<string, unknown>;
33
+ notes: string[];
34
+ }
35
+
36
+ // Draft-07 pin matches the widest tool support; the same target string is
37
+ // used by the MCP `tools/list` JSON Schemas (default in zod-to-json-schema)
38
+ // so consumers see one consistent dialect across the whole surface.
39
+ const toJsonSchema = (schema: ZodTypeAny): unknown =>
40
+ zodToJsonSchema(schema, { $refStrategy: 'none', target: 'jsonSchema7' });
41
+
42
+ const CATEGORIES: SchemaCategory[] = [
43
+ { name: 'flow', description: 'Top-level flow.json envelope.' },
44
+ {
45
+ name: 'node',
46
+ description:
47
+ 'All six node variants (playNode, stateNode, shapeNode, imageNode, iconNode, htmlNode).',
48
+ },
49
+ {
50
+ name: 'connector',
51
+ description: 'Edge between two nodes (id/source/target + optional label/style/metadata).',
52
+ },
53
+ {
54
+ name: 'action',
55
+ description: 'playAction, statusAction, resetAction, statusReport.',
56
+ },
57
+ { name: 'style', description: 'style.json (studio-owned).' },
58
+ ];
59
+
60
+ const PAYLOADS: Record<string, SchemaPayload> = {
61
+ flow: {
62
+ schemas: { flow: toJsonSchema(FlowEnvelopeSchema) },
63
+ notes: ['connectors[].source and connectors[].target must reference an existing nodes[].id.'],
64
+ },
65
+ node: {
66
+ schemas: {
67
+ playNode: toJsonSchema(FlowPlayNodeSchema),
68
+ stateNode: toJsonSchema(FlowStateNodeSchema),
69
+ shapeNode: toJsonSchema(FlowShapeNodeSchema),
70
+ imageNode: toJsonSchema(FlowImageNodeSchema),
71
+ iconNode: toJsonSchema(FlowIconNodeSchema),
72
+ htmlNode: toJsonSchema(FlowHtmlNodeSchema),
73
+ },
74
+ notes: [
75
+ "imageNode.data.path must start with 'nodes/<id>/'.",
76
+ "scriptPath in playAction/statusAction is relative to nodes/<nodeId>/ and may not contain '..' or absolute paths.",
77
+ ],
78
+ },
79
+ connector: {
80
+ schemas: {
81
+ connector: toJsonSchema(FlowConnectorSchema),
82
+ },
83
+ notes: [],
84
+ },
85
+ action: {
86
+ schemas: {
87
+ playAction: toJsonSchema(PlayActionSchema),
88
+ statusAction: toJsonSchema(StatusActionSchema),
89
+ resetAction: toJsonSchema(ResetActionSchema),
90
+ statusReport: toJsonSchema(StatusReportSchema),
91
+ },
92
+ notes: [
93
+ "scriptPath in playAction/statusAction is relative to nodes/<nodeId>/ and may not contain '..' or absolute paths.",
94
+ ],
95
+ },
96
+ style: {
97
+ schemas: { style: toJsonSchema(StyleSchema) },
98
+ notes: [],
99
+ },
100
+ };
101
+
102
+ export function listSchemaCategories(): SchemaCategory[] {
103
+ return CATEGORIES.map((c) => ({ ...c }));
104
+ }
105
+
106
+ export function getSchemaCategory(name: string): SchemaPayload | null {
107
+ const payload = PAYLOADS[name];
108
+ if (!payload) return null;
109
+ return { schemas: { ...payload.schemas }, notes: [...payload.notes] };
110
+ }
111
+
112
+ export function schemaCategoryNames(): string[] {
113
+ return CATEGORIES.map((c) => c.name);
114
+ }