@tuongaz/seeflow 0.1.42 → 0.1.51
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 +14 -0
- package/dist/web/assets/{index-BPUoNIBm.js → index-CFn1Jdmi.js} +17 -17
- package/dist/web/assets/{index-BlkUOp7f.css → index-DSfixlbD.css} +1 -1
- package/dist/web/assets/{index.es-mje3R_63.js → index.es-DQFAA-Eu.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-DX3imOs2.js → jspdf.es.min-D7KeFi-m.js} +3 -3
- package/dist/web/index.html +2 -2
- package/examples/ecommerce-platform/.seeflow/flow.json +8 -8
- package/examples/order-pipeline/.seeflow/flow.json +4 -4
- package/package.json +2 -1
- package/src/api.ts +138 -102
- package/src/cli-e2e.ts +10 -6
- package/src/cli-helpers.ts +79 -0
- package/src/cli-manifest.ts +772 -0
- package/src/cli-ops.ts +18 -0
- package/src/cli.ts +164 -137
- package/src/events.ts +2 -1
- package/src/file-ref.ts +27 -16
- package/src/mcp.ts +104 -35
- package/src/merge.ts +3 -0
- package/src/node-files.ts +5 -2
- package/src/operations.ts +341 -7
- package/src/registry-watcher.ts +86 -0
- package/src/registry.ts +132 -24
- package/src/schema.ts +2 -0
- package/src/server.ts +9 -0
- package/src/watcher.ts +32 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"kind": "request"
|
|
13
13
|
},
|
|
14
14
|
"description": "Creates order, kicks off the pipeline.",
|
|
15
|
-
"detail": "file://
|
|
15
|
+
"detail": "file://detail.md",
|
|
16
16
|
"playAction": {
|
|
17
17
|
"kind": "script",
|
|
18
18
|
"interpreter": "bun",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"kind": "event"
|
|
34
34
|
},
|
|
35
35
|
"description": "Reserves stock.",
|
|
36
|
-
"detail": "file://
|
|
36
|
+
"detail": "file://detail.md",
|
|
37
37
|
"icon": "a-arrow-down-icon"
|
|
38
38
|
}
|
|
39
39
|
},
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"kind": "event"
|
|
48
48
|
},
|
|
49
49
|
"description": "Charges card.",
|
|
50
|
-
"detail": "file://
|
|
50
|
+
"detail": "file://detail.md"
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
{
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"kind": "event"
|
|
61
61
|
},
|
|
62
62
|
"description": "Enqueues shipment.",
|
|
63
|
-
"detail": "file://
|
|
63
|
+
"detail": "file://detail.md"
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuongaz/seeflow",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.51",
|
|
4
4
|
"description": "Local studio that hosts file-defined demos as React Flow canvases wired to a running app via REST + SSE + Zod schema.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"seeflow",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"zod-to-json-schema": "^3.25.2"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
+
"@playwright/test": "^1.60.0",
|
|
52
53
|
"@tailwindcss/browser": "^4.3.0",
|
|
53
54
|
"@types/bun": "^1.1.14",
|
|
54
55
|
"typescript": "^5.6.3"
|
package/src/api.ts
CHANGED
|
@@ -23,23 +23,8 @@ import {
|
|
|
23
23
|
RegisterBodySchema,
|
|
24
24
|
ReorderBodySchema,
|
|
25
25
|
type ValidateBody,
|
|
26
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
//
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/cli-e2e.ts
CHANGED
|
@@ -63,7 +63,7 @@ interface NodeShape {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
interface
|
|
66
|
+
interface FlowBody {
|
|
67
67
|
nodes?: NodeShape[];
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -71,7 +71,11 @@ interface FlowGetResponse {
|
|
|
71
71
|
id?: string;
|
|
72
72
|
valid?: boolean;
|
|
73
73
|
error?: string | null;
|
|
74
|
-
|
|
74
|
+
// GET /api/flows/:id returns the resolved flow under the `flow` key (see
|
|
75
|
+
// FlowGetResponse in operations.ts). Older versions of this file used `demo`,
|
|
76
|
+
// which left the validator effectively broken (every call returned `ok:false`
|
|
77
|
+
// with "demo not valid"). Renamed to match the wire format.
|
|
78
|
+
flow?: FlowBody | null;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
interface SseEvent {
|
|
@@ -200,21 +204,21 @@ export async function validateEndToEnd(options: ValidateOptions): Promise<Valida
|
|
|
200
204
|
};
|
|
201
205
|
}
|
|
202
206
|
const demoData = (await demoRes.json()) as FlowGetResponse;
|
|
203
|
-
if (!demoData.valid || !demoData.
|
|
207
|
+
if (!demoData.valid || !demoData.flow) {
|
|
204
208
|
return {
|
|
205
209
|
ok: false,
|
|
206
210
|
plays,
|
|
207
211
|
statuses,
|
|
208
212
|
skipped: [
|
|
209
213
|
{
|
|
210
|
-
nodeId: '<
|
|
211
|
-
reason: `
|
|
214
|
+
nodeId: '<flow>',
|
|
215
|
+
reason: `flow not valid: ${demoData.error ?? '<no error>'}`,
|
|
212
216
|
},
|
|
213
217
|
],
|
|
214
218
|
};
|
|
215
219
|
}
|
|
216
220
|
|
|
217
|
-
const nodes = demoData.
|
|
221
|
+
const nodes = demoData.flow.nodes ?? [];
|
|
218
222
|
|
|
219
223
|
const skipSet = new Set(options.skipNodes ?? []);
|
|
220
224
|
const playTargets: string[] = [];
|
package/src/cli-helpers.ts
CHANGED
|
@@ -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>) {
|