@tuongaz/seeflow 0.1.47 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api.ts CHANGED
@@ -23,23 +23,8 @@ import {
23
23
  RegisterBodySchema,
24
24
  ReorderBodySchema,
25
25
  type ValidateBody,
26
- addConnectorImpl,
27
- addConnectorsBulkImpl,
28
- addNodeImpl,
29
- addNodesBulkImpl,
30
- createProjectImpl,
31
- deleteConnectorImpl,
32
- deleteFlowImpl,
33
- deleteNodeImpl,
34
- getFlowImpl,
35
- listDemosImpl,
36
- moveNodeImpl,
37
- patchConnectorImpl,
38
- patchNodeImpl,
39
- registerFlowImpl,
40
- reorderNodeImpl,
26
+ createOperations,
41
27
  resolveFilePath,
42
- validateImpl,
43
28
  writeFileAtomic,
44
29
  } from './operations.ts';
45
30
  import type { ProcessSpawner } from './process-spawner.ts';
@@ -220,6 +205,7 @@ export function createApi(options: ApiOptions): Hono {
220
205
  const processSpawner = options.processSpawner;
221
206
  const proxy = options.proxy ?? defaultProxyFacade;
222
207
  const projectBaseDir = options.projectBaseDir;
208
+ const ops = createOperations({ registry, watcher, projectBaseDir });
223
209
  const api = new Hono();
224
210
 
225
211
  api.post('/flows/register', async (c) => {
@@ -235,7 +221,7 @@ export function createApi(options: ApiOptions): Hono {
235
221
  return c.json({ error: 'Invalid register body', issues: parsed.error.issues }, 400);
236
222
  }
237
223
 
238
- const result = await registerFlowImpl({ registry, watcher }, parsed.data);
224
+ const result = await ops.registerFlow(parsed.data);
239
225
  switch (result.kind) {
240
226
  case 'ok':
241
227
  return c.json(result.data);
@@ -290,7 +276,7 @@ export function createApi(options: ApiOptions): Hono {
290
276
  if (!body || typeof body !== 'object' || !('flow' in body)) {
291
277
  return c.json({ error: 'Body must be { flow, style? }' }, 400);
292
278
  }
293
- return c.json(validateImpl(body as ValidateBody));
279
+ return c.json(ops.validate(body as ValidateBody));
294
280
  });
295
281
 
296
282
  // POST /api/diagram/propose-scope — Phase 2 helper. The skill POSTs the
@@ -452,7 +438,7 @@ export function createApi(options: ApiOptions): Hono {
452
438
  return c.json({ error: 'Invalid create project body', issues: parsed.error.issues }, 400);
453
439
  }
454
440
 
455
- const result = await createProjectImpl({ registry, watcher, projectBaseDir }, parsed.data);
441
+ const result = await ops.createProject(parsed.data);
456
442
  switch (result.kind) {
457
443
  case 'ok':
458
444
  return c.json(result.data);
@@ -471,12 +457,50 @@ export function createApi(options: ApiOptions): Hono {
471
457
  });
472
458
 
473
459
  api.get('/flows', (c) => {
474
- const result = listDemosImpl({ registry });
460
+ const result = ops.listFlows();
461
+ return c.json(result.data);
462
+ });
463
+
464
+ // Lightweight projection: id, name, description only. Reads from the
465
+ // watcher snapshot when available so author edits to flow.json show up
466
+ // immediately; falls back to the registry copy persisted at register time.
467
+ api.get('/flows/summary', (c) => {
468
+ const result = ops.listFlowsSummary();
475
469
  return c.json(result.data);
476
470
  });
477
471
 
478
472
  api.get('/flows/:id', async (c) => {
479
- const result = await getFlowImpl({ registry, watcher }, c.req.param('id'));
473
+ const result = await ops.getFlow(c.req.param('id'));
474
+ switch (result.kind) {
475
+ case 'ok':
476
+ return c.json(result.data);
477
+ case 'notFound':
478
+ return c.json({ error: 'not found' }, 404);
479
+ case 'fileNotFound':
480
+ return c.json({ error: `Flow file not found: ${result.path}` }, 404);
481
+ }
482
+ });
483
+
484
+ // Flow skeleton without per-node file content (detail.md / view.html).
485
+ // Pairs with GET /flows/:id/nodes/:nodeId for full per-node detail.
486
+ api.get('/flows/:id/graph', async (c) => {
487
+ const result = await ops.getFlowGraph(c.req.param('id'));
488
+ switch (result.kind) {
489
+ case 'ok':
490
+ return c.json(result.data);
491
+ case 'notFound':
492
+ return c.json({ error: 'not found' }, 404);
493
+ case 'fileNotFound':
494
+ return c.json({ error: `Flow file not found: ${result.path}` }, 404);
495
+ case 'badJson':
496
+ return c.json({ error: 'Flow file is not valid JSON', detail: result.detail }, 400);
497
+ case 'badSchema':
498
+ return c.json({ error: 'Flow file failed schema validation', issues: result.issues }, 400);
499
+ }
500
+ });
501
+
502
+ api.get('/flows/:id/nodes/:nodeId', async (c) => {
503
+ const result = await ops.getNode(c.req.param('id'), c.req.param('nodeId'));
480
504
  switch (result.kind) {
481
505
  case 'ok':
482
506
  return c.json(result.data);
@@ -484,6 +508,12 @@ export function createApi(options: ApiOptions): Hono {
484
508
  return c.json({ error: 'not found' }, 404);
485
509
  case 'fileNotFound':
486
510
  return c.json({ error: `Flow file not found: ${result.path}` }, 404);
511
+ case 'unknownNode':
512
+ return c.json({ error: `Unknown nodeId: ${c.req.param('nodeId')}` }, 404);
513
+ case 'badJson':
514
+ return c.json({ error: 'Flow file is not valid JSON', detail: result.detail }, 400);
515
+ case 'badSchema':
516
+ return c.json({ error: 'Flow file failed schema validation', issues: result.issues }, 400);
487
517
  }
488
518
  });
489
519
 
@@ -680,7 +710,7 @@ export function createApi(options: ApiOptions): Hono {
680
710
  });
681
711
 
682
712
  api.delete('/flows/:id', (c) => {
683
- const result = deleteFlowImpl({ registry, watcher }, c.req.param('id'));
713
+ const result = ops.deleteFlow(c.req.param('id'));
684
714
  switch (result.kind) {
685
715
  case 'ok':
686
716
  return c.json({ ok: true });
@@ -698,37 +728,6 @@ export function createApi(options: ApiOptions): Hono {
698
728
  // file / unknown id / bad JSON / write failure returns HTTP 4xx/5xx.
699
729
  api.post('/flows/:id/layout', async (c) => {
700
730
  const id = c.req.param('id');
701
- const entry = registry.getById(id);
702
- if (!entry) return c.json({ error: 'unknown demo' }, 404);
703
-
704
- const flowAbs = resolveFilePath(entry.repoPath, entry.flowPath);
705
- if (!existsSync(flowAbs)) return c.json({ error: `Flow file not found: ${flowAbs}` }, 404);
706
-
707
- let raw: unknown;
708
- try {
709
- raw = JSON.parse(readFileSync(flowAbs, 'utf8'));
710
- } catch (err) {
711
- return c.json(
712
- {
713
- error: 'Flow file is not valid JSON',
714
- detail: err instanceof Error ? err.message : String(err),
715
- },
716
- 400,
717
- );
718
- }
719
-
720
- const flowParse = FlowSchema.safeParse(raw);
721
- if (!flowParse.success) {
722
- return c.json({
723
- ok: false as const,
724
- issues: flowParse.error.issues.map((i) => ({
725
- scope: 'flow' as const,
726
- path: [...i.path],
727
- message: i.message,
728
- code: i.code,
729
- })),
730
- });
731
- }
732
731
 
733
732
  // Empty body is valid — the skill always uses defaults. Only parse if the
734
733
  // caller actually sent something.
@@ -743,41 +742,27 @@ export function createApi(options: ApiOptions): Hono {
743
742
  }
744
743
  }
745
744
 
746
- const flow = flowParse.data;
747
- const result = await computeLayout(
748
- flow.nodes.map((n) => ({
749
- id: n.id,
750
- type: n.type,
751
- // Only `shape` matters for layout (floating-annotation detection +
752
- // shape-specific sizing). Other Flow data fields are irrelevant.
753
- data: n.type === 'shapeNode' ? { shape: (n.data as { shape?: string }).shape } : undefined,
754
- })),
755
- flow.connectors.map((c) => ({ id: c.id, source: c.source, target: c.target })),
756
- options,
757
- );
758
-
759
- const styleAbs = join(dirname(flowAbs), 'style.json');
760
- const styleContent = `${JSON.stringify(result, null, 2)}\n`;
761
- try {
762
- writeFileAtomic(styleAbs, styleContent);
763
- } catch (err) {
764
- const msg = err instanceof Error ? err.message : String(err);
765
- return c.json({ error: `Failed to write style file: ${msg}` }, 500);
766
- }
767
-
768
- // Reparse + notifyWritten: the watcher seeds its snapshot AND broadcasts
769
- // flow:reload with the new merged payload directly, while suppressing the
770
- // fs-watcher echo that the style.json write would otherwise trigger.
771
- const snap = watcher?.reparse(id);
772
- if (watcher && snap) {
773
- const flowContent = readFileSync(flowAbs, 'utf8');
774
- watcher.notifyWritten(id, snap, flowContent, styleContent);
775
- } else {
776
- // No watcher (test harness, or watch() hasn't been called yet) — emit a
777
- // bare flow:reload so any subscribers still react.
778
- events?.broadcast({ type: 'flow:reload', flowId: id, payload: {} });
779
- }
780
- return c.json({ ok: true as const });
745
+ const result = await ops.applyLayout(id, options);
746
+ switch (result.kind) {
747
+ case 'ok':
748
+ // No watcher (test harness, or watch() hasn't been called yet) — emit
749
+ // a bare flow:reload so any subscribers still react. When the watcher
750
+ // exists, applyLayoutImpl already notified it directly.
751
+ if (!watcher) {
752
+ events?.broadcast({ type: 'flow:reload', flowId: id, payload: {} });
753
+ }
754
+ return c.json({ ok: true as const });
755
+ case 'flowNotFound':
756
+ return c.json({ error: 'unknown demo' }, 404);
757
+ case 'fileNotFound':
758
+ return c.json({ error: `Flow file not found: ${result.path}` }, 404);
759
+ case 'badJson':
760
+ return c.json({ error: 'Flow file is not valid JSON', detail: result.detail }, 400);
761
+ case 'badSchema':
762
+ return c.json({ ok: false as const, issues: result.issues });
763
+ case 'writeFailed':
764
+ return c.json({ error: `Failed to write style file: ${result.message}` }, 500);
765
+ }
781
766
  });
782
767
 
783
768
  api.post('/flows/:id/play/:nodeId', async (c) => {
@@ -934,7 +919,7 @@ export function createApi(options: ApiOptions): Hono {
934
919
  return c.json({ error: 'Invalid position body', issues: parsed.error.issues }, 400);
935
920
  }
936
921
 
937
- const result = await moveNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
922
+ const result = await ops.moveNode(id, nodeId, parsed.data);
938
923
  switch (result.kind) {
939
924
  case 'ok':
940
925
  return c.json({ ok: true, position: result.data.position });
@@ -975,7 +960,7 @@ export function createApi(options: ApiOptions): Hono {
975
960
  return c.json({ error: 'Invalid reorder body', issues: parsed.error.issues }, 400);
976
961
  }
977
962
 
978
- const result = await reorderNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
963
+ const result = await ops.reorderNode(id, nodeId, parsed.data);
979
964
  switch (result.kind) {
980
965
  case 'ok':
981
966
  return c.json({ ok: true });
@@ -1016,7 +1001,7 @@ export function createApi(options: ApiOptions): Hono {
1016
1001
  return c.json({ error: 'Invalid node patch body', issues: parsed.error.issues }, 400);
1017
1002
  }
1018
1003
 
1019
- const result = await patchNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
1004
+ const result = await ops.patchNode(id, nodeId, parsed.data);
1020
1005
  switch (result.kind) {
1021
1006
  case 'ok':
1022
1007
  return c.json({ ok: true });
@@ -1051,7 +1036,7 @@ export function createApi(options: ApiOptions): Hono {
1051
1036
  return c.json({ error: 'Body must be an object' }, 400);
1052
1037
  }
1053
1038
 
1054
- const result = await addNodeImpl({ registry, watcher }, id, body as Record<string, unknown>);
1039
+ const result = await ops.addNode(id, body as Record<string, unknown>);
1055
1040
  switch (result.kind) {
1056
1041
  case 'ok':
1057
1042
  return c.json({ ok: true, id: result.data.id, node: result.data.node });
@@ -1086,7 +1071,7 @@ export function createApi(options: ApiOptions): Hono {
1086
1071
  return c.json({ error: 'Invalid bulk nodes body', issues: parsed.error.issues }, 400);
1087
1072
  }
1088
1073
 
1089
- const result = await addNodesBulkImpl({ registry, watcher }, id, parsed.data);
1074
+ const result = await ops.addNodesBulk(id, parsed.data);
1090
1075
  switch (result.kind) {
1091
1076
  case 'ok':
1092
1077
  return c.json({ ok: true, nodes: result.data.nodes });
@@ -1116,7 +1101,7 @@ export function createApi(options: ApiOptions): Hono {
1116
1101
  const id = c.req.param('id');
1117
1102
  const nodeId = c.req.param('nodeId');
1118
1103
 
1119
- const result = await deleteNodeImpl({ registry, watcher }, id, nodeId);
1104
+ const result = await ops.deleteNode(id, nodeId);
1120
1105
  switch (result.kind) {
1121
1106
  case 'ok':
1122
1107
  return c.json({ ok: true });
@@ -1157,7 +1142,7 @@ export function createApi(options: ApiOptions): Hono {
1157
1142
  return c.json({ error: 'Invalid connector patch body', issues: parsed.error.issues }, 400);
1158
1143
  }
1159
1144
 
1160
- const result = await patchConnectorImpl({ registry, watcher }, id, connId, parsed.data);
1145
+ const result = await ops.patchConnector(id, connId, parsed.data);
1161
1146
  switch (result.kind) {
1162
1147
  case 'ok':
1163
1148
  return c.json({ ok: true });
@@ -1193,11 +1178,7 @@ export function createApi(options: ApiOptions): Hono {
1193
1178
  return c.json({ error: 'Body must be an object' }, 400);
1194
1179
  }
1195
1180
 
1196
- const result = await addConnectorImpl(
1197
- { registry, watcher },
1198
- id,
1199
- body as Record<string, unknown>,
1200
- );
1181
+ const result = await ops.addConnector(id, body as Record<string, unknown>);
1201
1182
  switch (result.kind) {
1202
1183
  case 'ok':
1203
1184
  return c.json({ ok: true, id: result.data.id });
@@ -1231,7 +1212,7 @@ export function createApi(options: ApiOptions): Hono {
1231
1212
  return c.json({ error: 'Invalid bulk connectors body', issues: parsed.error.issues }, 400);
1232
1213
  }
1233
1214
 
1234
- const result = await addConnectorsBulkImpl({ registry, watcher }, id, parsed.data);
1215
+ const result = await ops.addConnectorsBulk(id, parsed.data);
1235
1216
  switch (result.kind) {
1236
1217
  case 'ok':
1237
1218
  return c.json({ ok: true, connectors: result.data.connectors });
@@ -1258,7 +1239,7 @@ export function createApi(options: ApiOptions): Hono {
1258
1239
  const id = c.req.param('id');
1259
1240
  const connId = c.req.param('connId');
1260
1241
 
1261
- const result = await deleteConnectorImpl({ registry, watcher }, id, connId);
1242
+ const result = await ops.deleteConnector(id, connId);
1262
1243
  switch (result.kind) {
1263
1244
  case 'ok':
1264
1245
  return c.json({ ok: true });
@@ -1368,5 +1349,60 @@ export function createApi(options: ApiOptions): Hono {
1368
1349
  });
1369
1350
  });
1370
1351
 
1352
+ // Global registry channel — broadcasts `registry:reload` when an external
1353
+ // process (e.g. the CLI) writes to ~/.seeflow/registry.json. Subscribers
1354
+ // re-fetch the flow list. The channel id is the internal sentinel from
1355
+ // registry-watcher.ts (kept inline to avoid leaking the constant into
1356
+ // every SSE consumer).
1357
+ api.get('/registry/events', (c) => {
1358
+ if (!events) return c.json({ error: 'events not enabled' }, 500);
1359
+
1360
+ return streamSSE(c, async (stream) => {
1361
+ let active = true;
1362
+ const queue: Array<{ event: string; data: string }> = [];
1363
+ let resume: (() => void) | null = null;
1364
+
1365
+ const wake = () => {
1366
+ if (resume) {
1367
+ const r = resume;
1368
+ resume = null;
1369
+ r();
1370
+ }
1371
+ };
1372
+
1373
+ const unsubscribe = events.subscribe('__registry__', (e) => {
1374
+ queue.push({ event: e.type, data: JSON.stringify({ ts: e.ts }) });
1375
+ wake();
1376
+ });
1377
+
1378
+ stream.onAbort(() => {
1379
+ active = false;
1380
+ unsubscribe();
1381
+ wake();
1382
+ });
1383
+
1384
+ await stream.writeSSE({
1385
+ event: 'hello',
1386
+ data: JSON.stringify({ channel: 'registry', ts: Date.now() }),
1387
+ });
1388
+
1389
+ try {
1390
+ while (active) {
1391
+ while (queue.length > 0) {
1392
+ const next = queue.shift();
1393
+ if (!next) break;
1394
+ await stream.writeSSE(next);
1395
+ }
1396
+ if (!active) break;
1397
+ await new Promise<void>((r) => {
1398
+ resume = r;
1399
+ });
1400
+ }
1401
+ } finally {
1402
+ unsubscribe();
1403
+ }
1404
+ });
1405
+ });
1406
+
1371
1407
  return api;
1372
1408
  }
@@ -56,6 +56,85 @@ export function printError(message: string, opts: CliOutcomeOptions = {}): never
56
56
  return (opts.exit ?? (process.exit as (code: number) => never))(1);
57
57
  }
58
58
 
59
+ /**
60
+ * Print an Operations Outcome and exit with the differentiated exit code:
61
+ * - kind === 'ok' → printOk(data), exit 0
62
+ * - kind === 'badSchema'|'badJson' → exit 2
63
+ * - kind === 'notFound'|'flowNotFound'|'fileNotFound'|
64
+ * 'unknownNode'|'unknownConnector' → exit 3
65
+ * - kind === 'duplicateIdInBatch'|'idAlreadyExists' → exit 4
66
+ * - kind === 'writeFailed'|'sdkWriteFailed'|'scaffoldFailed' → exit 5
67
+ * - anything else → exit 1
68
+ *
69
+ * The error message mirrors the strings used by api.ts so the CLI's
70
+ * stderr output stays stable across the HTTP-to-in-process migration.
71
+ */
72
+ export function printOutcome<T extends { kind: string }>(
73
+ outcome: T,
74
+ opts: CliOutcomeOptions = {},
75
+ ): never {
76
+ if (outcome.kind === 'ok') {
77
+ const data = (outcome as unknown as { data: unknown }).data;
78
+ return printOk(
79
+ data && typeof data === 'object' && !Array.isArray(data) ? data : { data },
80
+ opts,
81
+ );
82
+ }
83
+ const err = opts.stderr ?? ((s) => process.stderr.write(s));
84
+ const message = describeOutcome(outcome);
85
+ err(`${JSON.stringify({ error: message, code: outcome.kind })}\n`);
86
+ return (opts.exit ?? (process.exit as (code: number) => never))(exitCodeForKind(outcome.kind));
87
+ }
88
+
89
+ function describeOutcome(outcome: { kind: string } & Record<string, unknown>): string {
90
+ switch (outcome.kind) {
91
+ case 'notFound':
92
+ case 'flowNotFound':
93
+ return 'not found';
94
+ case 'fileNotFound':
95
+ return `Flow file not found: ${String(outcome.path ?? '')}`;
96
+ case 'unknownNode':
97
+ return `Unknown nodeId: ${String(outcome.nodeId ?? '')}`;
98
+ case 'unknownConnector':
99
+ return `Unknown connectorId: ${String(outcome.connectorId ?? '')}`;
100
+ case 'badJson':
101
+ return `Flow file is not valid JSON: ${String(outcome.detail ?? outcome.message ?? '')}`;
102
+ case 'badSchema':
103
+ return `Flow failed schema validation: ${JSON.stringify(outcome.issues ?? [])}`;
104
+ case 'duplicateIdInBatch':
105
+ return `Duplicate id in batch: ${String(outcome.id ?? '')}`;
106
+ case 'idAlreadyExists':
107
+ return `Id already exists: ${String(outcome.id ?? '')}`;
108
+ case 'writeFailed':
109
+ return `Failed to write demo file: ${String(outcome.message ?? '')}`;
110
+ case 'sdkWriteFailed':
111
+ return `Failed to write SDK helper: ${String(outcome.message ?? '')}`;
112
+ case 'scaffoldFailed':
113
+ return `Failed to scaffold project: ${String(outcome.message ?? '')}`;
114
+ default:
115
+ return String(outcome.message ?? outcome.kind);
116
+ }
117
+ }
118
+
119
+ export const EXIT_CODE_BY_KIND: Record<string, number> = {
120
+ badSchema: 2,
121
+ badJson: 2,
122
+ notFound: 3,
123
+ flowNotFound: 3,
124
+ fileNotFound: 3,
125
+ unknownNode: 3,
126
+ unknownConnector: 3,
127
+ duplicateIdInBatch: 4,
128
+ idAlreadyExists: 4,
129
+ writeFailed: 5,
130
+ sdkWriteFailed: 5,
131
+ scaffoldFailed: 5,
132
+ };
133
+
134
+ export function exitCodeForKind(kind: string): number {
135
+ return EXIT_CODE_BY_KIND[kind] ?? 1;
136
+ }
137
+
59
138
  export const drainStdin: StdinReader = async () => {
60
139
  const chunks: Uint8Array[] = [];
61
140
  for await (const chunk of process.stdin as unknown as AsyncIterable<Uint8Array>) {