@tuongaz/seeflow 0.1.40 → 0.1.42

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 (38) hide show
  1. package/README.md +2 -15
  2. package/dist/web/assets/{index-DTNk6GGk.js → index-BPUoNIBm.js} +1541 -1541
  3. package/dist/web/assets/{index-BwdVgB2y.css → index-BlkUOp7f.css} +1 -1
  4. package/dist/web/assets/{index.es-D_iCCj4R.js → index.es-mje3R_63.js} +1 -1
  5. package/dist/web/assets/{jspdf.es.min-C9FG4HQT.js → jspdf.es.min-DX3imOs2.js} +3 -3
  6. package/dist/web/index.html +2 -2
  7. package/examples/ecommerce-platform/.seeflow/flow.json +47 -47
  8. package/examples/ecommerce-platform/.seeflow/style.json +10 -10
  9. package/examples/order-pipeline/.seeflow/flow.json +17 -17
  10. package/examples/order-pipeline/.seeflow/style.json +4 -4
  11. package/package.json +1 -1
  12. package/src/api.ts +101 -14
  13. package/src/atomic-write.ts +16 -0
  14. package/src/cli-e2e.ts +420 -0
  15. package/src/cli-helpers.ts +65 -0
  16. package/src/cli.ts +371 -17
  17. package/src/mcp.ts +116 -23
  18. package/src/merge.ts +1 -1
  19. package/src/node-files.ts +45 -0
  20. package/src/operations.ts +304 -98
  21. package/src/proxy.ts +35 -6
  22. package/src/registry.ts +2 -1
  23. package/src/schema.ts +31 -25
  24. package/src/short-id.ts +24 -0
  25. package/src/status-runner.ts +9 -8
  26. package/src/watcher.ts +14 -14
  27. /package/examples/ecommerce-platform/.seeflow/{details/auth-service.md → nodes/node-3zFtHg6ENc/detail.md} +0 -0
  28. /package/examples/ecommerce-platform/.seeflow/{details/cart-service.md → nodes/node-5F424NWbEu/detail.md} +0 -0
  29. /package/examples/ecommerce-platform/.seeflow/{details/api-gateway.md → nodes/node-CbwYqb7NfB/detail.md} +0 -0
  30. /package/examples/ecommerce-platform/.seeflow/{scripts/platform-health.html → nodes/node-XwygzfKPZ5/view.html} +0 -0
  31. /package/examples/ecommerce-platform/.seeflow/{details/notification-service.md → nodes/node-fkptXw7uvs/detail.md} +0 -0
  32. /package/examples/ecommerce-platform/.seeflow/{details/product-service.md → nodes/node-kwBY8YPmYM/detail.md} +0 -0
  33. /package/examples/ecommerce-platform/.seeflow/{details/payment-service.md → nodes/node-mPqan8rFYN/detail.md} +0 -0
  34. /package/examples/ecommerce-platform/.seeflow/{details/order-service.md → nodes/node-yKrg9DV5fJ/detail.md} +0 -0
  35. /package/examples/order-pipeline/.seeflow/{details/inventory-service.md → nodes/node-GXTKUcE3ye/detail.md} +0 -0
  36. /package/examples/order-pipeline/.seeflow/{details/post-orders.md → nodes/node-XKIyds0TDg/detail.md} +0 -0
  37. /package/examples/order-pipeline/.seeflow/{details/payment-service.md → nodes/node-YOYiHJpY0i/detail.md} +0 -0
  38. /package/examples/order-pipeline/.seeflow/{details/fulfillment-service.md → nodes/node-zUIH7WFnhK/detail.md} +0 -0
package/src/cli-e2e.ts ADDED
@@ -0,0 +1,420 @@
1
+ // End-to-end validator used by `seeflow e2e <flowId>`. Opens an SSE channel to
2
+ // the studio, triggers every playNode's playAction (sequentially), then drains
3
+ // the channel for node:done/error + node:status reports. Mirrors the algorithm
4
+ // in skills/seeflow/scripts/validate-end-to-end.ts — kept here so the CLI does
5
+ // not depend on the skill folder.
6
+
7
+ const DEFAULT_HARD_CEILING_MS = 120_000;
8
+ const DEFAULT_STATUS_WAIT_MS = 10_000;
9
+ const SSE_PLAY_CONFIRM_MS = 5_000;
10
+
11
+ export interface PlayOutcome {
12
+ nodeId: string;
13
+ outcome: 'ok' | 'failed';
14
+ runId?: string;
15
+ body?: unknown;
16
+ error?: string;
17
+ }
18
+
19
+ export interface StatusFirstReport {
20
+ state: string;
21
+ summary?: string;
22
+ detail?: string;
23
+ ts?: number;
24
+ }
25
+
26
+ export interface StatusOutcome {
27
+ nodeId: string;
28
+ outcome: 'ok' | 'failed';
29
+ firstReport?: StatusFirstReport;
30
+ error?: string;
31
+ }
32
+
33
+ export interface SkippedItem {
34
+ nodeId: string;
35
+ reason: string;
36
+ }
37
+
38
+ export interface ValidationReport {
39
+ ok: boolean;
40
+ plays: PlayOutcome[];
41
+ statuses: StatusOutcome[];
42
+ skipped: SkippedItem[];
43
+ }
44
+
45
+ export interface ValidateOptions {
46
+ flowId: string;
47
+ url: string;
48
+ hardCeilingMs?: number;
49
+ statusWaitMs?: number;
50
+ skipNodes?: string[];
51
+ }
52
+
53
+ interface MaybeAction {
54
+ [k: string]: unknown;
55
+ }
56
+
57
+ interface NodeShape {
58
+ id: string;
59
+ type: string;
60
+ data?: {
61
+ playAction?: MaybeAction;
62
+ statusAction?: unknown;
63
+ };
64
+ }
65
+
66
+ interface DemoShape {
67
+ nodes?: NodeShape[];
68
+ }
69
+
70
+ interface FlowGetResponse {
71
+ id?: string;
72
+ valid?: boolean;
73
+ error?: string | null;
74
+ demo?: DemoShape | null;
75
+ }
76
+
77
+ interface SseEvent {
78
+ event: string;
79
+ data: string;
80
+ }
81
+
82
+ interface SseChannel {
83
+ next(timeoutMs: number): Promise<SseEvent | null>;
84
+ close(): void;
85
+ }
86
+
87
+ function openSseChannel(body: ReadableStream<Uint8Array>): SseChannel {
88
+ const decoder = new TextDecoder();
89
+ const reader = body.getReader();
90
+ const queue: SseEvent[] = [];
91
+ const waiters: Array<() => void> = [];
92
+ let buffer = '';
93
+ let ended = false;
94
+ let cancelled = false;
95
+
96
+ const wake = () => {
97
+ while (waiters.length > 0) {
98
+ const w = waiters.shift();
99
+ if (w) w();
100
+ }
101
+ };
102
+
103
+ const flushBlock = (block: string) => {
104
+ let eventType = 'message';
105
+ let data = '';
106
+ for (const line of block.split('\n')) {
107
+ if (line.startsWith('event:')) {
108
+ eventType = line.slice(6).trim();
109
+ } else if (line.startsWith('data:')) {
110
+ const piece = line.slice(5).replace(/^ /, '');
111
+ data = data.length === 0 ? piece : `${data}\n${piece}`;
112
+ }
113
+ }
114
+ queue.push({ event: eventType, data });
115
+ };
116
+
117
+ void (async () => {
118
+ try {
119
+ while (!cancelled) {
120
+ const { value, done } = await reader.read();
121
+ if (done) break;
122
+ buffer += decoder.decode(value, { stream: true });
123
+ let idx = buffer.indexOf('\n\n');
124
+ while (idx !== -1) {
125
+ const block = buffer.slice(0, idx);
126
+ buffer = buffer.slice(idx + 2);
127
+ flushBlock(block);
128
+ idx = buffer.indexOf('\n\n');
129
+ }
130
+ if (queue.length > 0) wake();
131
+ }
132
+ } catch {
133
+ // surface via ended
134
+ } finally {
135
+ ended = true;
136
+ wake();
137
+ }
138
+ })();
139
+
140
+ return {
141
+ async next(timeoutMs) {
142
+ const deadline = Date.now() + Math.max(0, timeoutMs);
143
+ while (queue.length === 0 && !ended) {
144
+ const left = deadline - Date.now();
145
+ if (left <= 0) return null;
146
+ const woke = await new Promise<boolean>((resolveWoke) => {
147
+ const t = setTimeout(() => resolveWoke(false), left);
148
+ waiters.push(() => {
149
+ clearTimeout(t);
150
+ resolveWoke(true);
151
+ });
152
+ });
153
+ if (!woke) return null;
154
+ }
155
+ return queue.shift() ?? null;
156
+ },
157
+ close() {
158
+ cancelled = true;
159
+ reader.cancel().catch(() => {});
160
+ wake();
161
+ },
162
+ };
163
+ }
164
+
165
+ function hasPlayAction(node: NodeShape): boolean {
166
+ return (
167
+ (node.type === 'playNode' || node.type === 'stateNode') && node.data?.playAction !== undefined
168
+ );
169
+ }
170
+
171
+ function hasStatusAction(node: NodeShape): boolean {
172
+ return (
173
+ (node.type === 'playNode' || node.type === 'stateNode') && node.data?.statusAction !== undefined
174
+ );
175
+ }
176
+
177
+ export async function validateEndToEnd(options: ValidateOptions): Promise<ValidationReport> {
178
+ const { url } = options;
179
+ const hardCeilingMs = options.hardCeilingMs ?? DEFAULT_HARD_CEILING_MS;
180
+ const statusWaitMs = options.statusWaitMs ?? DEFAULT_STATUS_WAIT_MS;
181
+ const startedAt = Date.now();
182
+ const overallDeadline = startedAt + hardCeilingMs;
183
+
184
+ const plays: PlayOutcome[] = [];
185
+ const statuses: StatusOutcome[] = [];
186
+ const skipped: SkippedItem[] = [];
187
+
188
+ const demoRes = await fetch(`${url}/api/flows/${encodeURIComponent(options.flowId)}`);
189
+ if (!demoRes.ok) {
190
+ return {
191
+ ok: false,
192
+ plays,
193
+ statuses,
194
+ skipped: [
195
+ {
196
+ nodeId: '<demo>',
197
+ reason: `GET /api/flows/${options.flowId} returned HTTP ${demoRes.status}`,
198
+ },
199
+ ],
200
+ };
201
+ }
202
+ const demoData = (await demoRes.json()) as FlowGetResponse;
203
+ if (!demoData.valid || !demoData.demo) {
204
+ return {
205
+ ok: false,
206
+ plays,
207
+ statuses,
208
+ skipped: [
209
+ {
210
+ nodeId: '<demo>',
211
+ reason: `demo not valid: ${demoData.error ?? '<no error>'}`,
212
+ },
213
+ ],
214
+ };
215
+ }
216
+
217
+ const nodes = demoData.demo.nodes ?? [];
218
+
219
+ const skipSet = new Set(options.skipNodes ?? []);
220
+ const playTargets: string[] = [];
221
+ for (const node of nodes) {
222
+ if (!hasPlayAction(node)) continue;
223
+ const action = node.data?.playAction;
224
+ if (action && (action as { validationSafe?: unknown }).validationSafe === false) {
225
+ skipped.push({ nodeId: node.id, reason: 'playAction.validationSafe is false' });
226
+ continue;
227
+ }
228
+ if (skipSet.has(node.id)) {
229
+ skipped.push({ nodeId: node.id, reason: 'in --skip-nodes' });
230
+ continue;
231
+ }
232
+ playTargets.push(node.id);
233
+ }
234
+ const statusTargets = nodes.filter(hasStatusAction).map((n) => n.id);
235
+
236
+ let channel: SseChannel | undefined;
237
+ try {
238
+ const sseRes = await fetch(`${url}/api/events?flowId=${encodeURIComponent(options.flowId)}`, {
239
+ headers: { accept: 'text/event-stream' },
240
+ });
241
+ if (sseRes.ok && sseRes.body) {
242
+ channel = openSseChannel(sseRes.body);
243
+ } else if (statusTargets.length > 0) {
244
+ for (const nid of statusTargets) {
245
+ statuses.push({
246
+ nodeId: nid,
247
+ outcome: 'failed',
248
+ error: `failed to open SSE stream: HTTP ${sseRes.status}`,
249
+ });
250
+ }
251
+ }
252
+ } catch (err) {
253
+ const message = err instanceof Error ? err.message : String(err);
254
+ if (statusTargets.length > 0) {
255
+ for (const nid of statusTargets) {
256
+ statuses.push({
257
+ nodeId: nid,
258
+ outcome: 'failed',
259
+ error: `failed to open SSE stream: ${message}`,
260
+ });
261
+ }
262
+ }
263
+ }
264
+
265
+ const httpResults = new Map<string, { runId?: string; httpError?: string; body?: unknown }>();
266
+
267
+ for (const nodeId of playTargets) {
268
+ if (Date.now() > overallDeadline) {
269
+ plays.push({ nodeId, outcome: 'failed', error: 'hard ceiling exceeded before play' });
270
+ continue;
271
+ }
272
+ let res: Response;
273
+ try {
274
+ res = await fetch(
275
+ `${url}/api/flows/${encodeURIComponent(options.flowId)}/play/${encodeURIComponent(nodeId)}`,
276
+ { method: 'POST' },
277
+ );
278
+ } catch (err) {
279
+ const message = err instanceof Error ? err.message : String(err);
280
+ httpResults.set(nodeId, { httpError: message });
281
+ continue;
282
+ }
283
+ let body: unknown;
284
+ try {
285
+ body = await res.json();
286
+ } catch {
287
+ body = null;
288
+ }
289
+ if (!res.ok) {
290
+ httpResults.set(nodeId, { httpError: `HTTP ${res.status}`, body });
291
+ } else {
292
+ const parsed = (body ?? {}) as { runId?: string; error?: string };
293
+ httpResults.set(nodeId, {
294
+ runId: parsed.runId,
295
+ httpError:
296
+ typeof parsed.error === 'string' && parsed.error.length > 0 ? parsed.error : undefined,
297
+ body,
298
+ });
299
+ }
300
+ }
301
+
302
+ const ssePlayResults = new Map<string, { type: 'done' | 'error'; message?: string }>();
303
+ const sseStatusReports = new Map<string, StatusFirstReport>();
304
+
305
+ if (channel) {
306
+ const pendingPlays = new Set(
307
+ playTargets.filter((id) => httpResults.has(id) && !httpResults.get(id)?.httpError),
308
+ );
309
+ const pendingStatuses = new Set(
310
+ statusTargets.filter((id) => !statuses.find((s) => s.nodeId === id)),
311
+ );
312
+
313
+ const drainDeadline = Math.min(
314
+ Date.now() + Math.max(SSE_PLAY_CONFIRM_MS, statusWaitMs),
315
+ overallDeadline,
316
+ );
317
+
318
+ while (pendingPlays.size + pendingStatuses.size > 0) {
319
+ const left = drainDeadline - Date.now();
320
+ if (left <= 0) break;
321
+ const evt = await channel.next(left);
322
+ if (!evt) break;
323
+
324
+ if (evt.event === 'node:done' || evt.event === 'node:error') {
325
+ let payload: { nodeId?: unknown; message?: unknown };
326
+ try {
327
+ payload = JSON.parse(evt.data);
328
+ } catch {
329
+ continue;
330
+ }
331
+ const nid = payload?.nodeId;
332
+ if (typeof nid !== 'string' || !pendingPlays.has(nid)) continue;
333
+ ssePlayResults.set(nid, {
334
+ type: evt.event === 'node:done' ? 'done' : 'error',
335
+ message: typeof payload.message === 'string' ? payload.message : undefined,
336
+ });
337
+ pendingPlays.delete(nid);
338
+ } else if (evt.event === 'node:status') {
339
+ let payload: {
340
+ nodeId?: unknown;
341
+ state?: unknown;
342
+ summary?: unknown;
343
+ detail?: unknown;
344
+ ts?: unknown;
345
+ };
346
+ try {
347
+ payload = JSON.parse(evt.data);
348
+ } catch {
349
+ continue;
350
+ }
351
+ const nid = payload?.nodeId;
352
+ const state = payload?.state;
353
+ if (typeof nid !== 'string' || typeof state !== 'string') continue;
354
+ if (!pendingStatuses.has(nid)) continue;
355
+ if (state === 'error') continue;
356
+ sseStatusReports.set(nid, {
357
+ state,
358
+ summary: typeof payload.summary === 'string' ? payload.summary : undefined,
359
+ detail: typeof payload.detail === 'string' ? payload.detail : undefined,
360
+ ts: typeof payload.ts === 'number' ? payload.ts : undefined,
361
+ });
362
+ pendingStatuses.delete(nid);
363
+ }
364
+ }
365
+
366
+ channel.close();
367
+ }
368
+
369
+ for (const nodeId of playTargets) {
370
+ if (plays.find((p) => p.nodeId === nodeId)) continue;
371
+ const http = httpResults.get(nodeId);
372
+ if (!http) continue;
373
+ const sse = ssePlayResults.get(nodeId);
374
+
375
+ if (http.httpError) {
376
+ plays.push({
377
+ nodeId,
378
+ outcome: 'failed',
379
+ runId: http.runId,
380
+ body: http.body,
381
+ error: http.httpError,
382
+ });
383
+ } else if (sse?.type === 'error') {
384
+ plays.push({
385
+ nodeId,
386
+ outcome: 'failed',
387
+ runId: http.runId,
388
+ body: http.body,
389
+ error: sse.message ?? 'script error confirmed via SSE node:error event',
390
+ });
391
+ } else if (sse?.type === 'done') {
392
+ plays.push({ nodeId, outcome: 'ok', runId: http.runId, body: http.body });
393
+ } else {
394
+ plays.push({ nodeId, outcome: 'ok', runId: http.runId, body: http.body });
395
+ }
396
+ }
397
+
398
+ const overallExceeded = Date.now() > overallDeadline;
399
+ for (const nodeId of statusTargets) {
400
+ if (statuses.find((s) => s.nodeId === nodeId)) continue;
401
+ const report = sseStatusReports.get(nodeId);
402
+ if (report) {
403
+ statuses.push({ nodeId, outcome: 'ok', firstReport: report });
404
+ } else {
405
+ statuses.push({
406
+ nodeId,
407
+ outcome: 'failed',
408
+ error: overallExceeded
409
+ ? 'hard ceiling exceeded before status received'
410
+ : 'no non-error status received within timeout',
411
+ });
412
+ }
413
+ }
414
+
415
+ const allPlaysOk = plays.every((p) => p.outcome === 'ok');
416
+ const allStatusesOk = statuses.every((s) => s.outcome === 'ok');
417
+ const ok = allPlaysOk && allStatusesOk && Date.now() <= overallDeadline;
418
+
419
+ return { ok, plays, statuses, skipped };
420
+ }
@@ -0,0 +1,65 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ export interface BodySource {
4
+ json: string | undefined;
5
+ file: string | undefined;
6
+ stdin: boolean;
7
+ }
8
+
9
+ export type StdinReader = () => Promise<string>;
10
+
11
+ export async function loadBody(src: BodySource, readStdin: StdinReader): Promise<unknown> {
12
+ const sources = [src.json !== undefined, src.file !== undefined, src.stdin].filter(
13
+ Boolean,
14
+ ).length;
15
+ if (sources !== 1) {
16
+ throw new Error('Provide exactly one of --json, --file, --stdin');
17
+ }
18
+
19
+ let raw: string;
20
+ let label: string;
21
+ if (src.json !== undefined) {
22
+ raw = src.json;
23
+ label = '--json';
24
+ } else if (src.file !== undefined) {
25
+ raw = readFileSync(src.file, 'utf8');
26
+ label = src.file;
27
+ } else {
28
+ raw = await readStdin();
29
+ label = '<stdin>';
30
+ }
31
+
32
+ try {
33
+ return JSON.parse(raw);
34
+ } catch (err) {
35
+ throw new Error(
36
+ `Invalid JSON from ${label}: ${err instanceof Error ? err.message : String(err)}`,
37
+ );
38
+ }
39
+ }
40
+
41
+ export interface CliOutcomeOptions {
42
+ stdout?: (s: string) => void;
43
+ stderr?: (s: string) => void;
44
+ exit?: (code: number) => never;
45
+ }
46
+
47
+ export function printOk(payload: unknown, opts: CliOutcomeOptions = {}): never {
48
+ const out = opts.stdout ?? ((s) => process.stdout.write(s));
49
+ out(`${JSON.stringify({ ok: true, ...(payload as object) })}\n`);
50
+ return (opts.exit ?? (process.exit as (code: number) => never))(0);
51
+ }
52
+
53
+ export function printError(message: string, opts: CliOutcomeOptions = {}): never {
54
+ const err = opts.stderr ?? ((s) => process.stderr.write(s));
55
+ err(`${message}\n`);
56
+ return (opts.exit ?? (process.exit as (code: number) => never))(1);
57
+ }
58
+
59
+ export const drainStdin: StdinReader = async () => {
60
+ const chunks: Uint8Array[] = [];
61
+ for await (const chunk of process.stdin as unknown as AsyncIterable<Uint8Array>) {
62
+ chunks.push(chunk);
63
+ }
64
+ return new TextDecoder().decode(Buffer.concat(chunks));
65
+ };