@tuongaz/seeflow 0.1.71 → 0.1.74
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/dist/web/assets/{chart-CpHY42Vq.js → chart-Bk7w2gX0.js} +1 -1
- package/dist/web/assets/{code-block-CagXzbCC.js → code-block-DTATyN2X.js} +1 -1
- package/dist/web/assets/{index-NG9m5oBj.js → index-B1T3YjX6.js} +3 -3
- package/dist/web/assets/{index.es-xel3SIo8.js → index.es-CgEQDFi5.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-CIMMqiVE.js → jspdf.es.min-CvwMPY1k.js} +3 -3
- package/dist/web/assets/{markdown-DAvRjvM0.js → markdown-tPuLLX8d.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/cli-manifest.ts +105 -37
- package/src/mcp.ts +8 -4
- package/src/schema-catalog.ts +27 -2
- package/src/schema.ts +4 -4
- package/src/watcher.ts +61 -13
package/src/cli-manifest.ts
CHANGED
|
@@ -287,16 +287,21 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
287
287
|
},
|
|
288
288
|
{
|
|
289
289
|
name: 'flows:play',
|
|
290
|
-
synopsis: 'seeflow flows:play <flowId> <nodeId>',
|
|
291
|
-
description:
|
|
290
|
+
synopsis: 'seeflow flows:play <flowId> <nodeId> [--no-start]',
|
|
291
|
+
description:
|
|
292
|
+
"Trigger the node's playAction on the studio and wait for the spawn-level " +
|
|
293
|
+
'result. The studio also broadcasts node:running/done/error events on the ' +
|
|
294
|
+
"flow's SSE stream — subscribe separately if you want live progress. " +
|
|
295
|
+
'Requires a running studio.',
|
|
292
296
|
category: 'live',
|
|
293
297
|
args: [
|
|
294
298
|
{ name: 'flowId', required: true, description: 'Flow id or slug' },
|
|
295
299
|
{ name: 'nodeId', required: true, description: 'Node id in the flow' },
|
|
296
300
|
],
|
|
297
301
|
flags: [{ name: 'no-start', description: 'Fail if the studio is not already running' }],
|
|
298
|
-
|
|
299
|
-
|
|
302
|
+
outputs: {
|
|
303
|
+
okExample: { runId: 'run-9b3', status: 200, body: { ok: true } },
|
|
304
|
+
},
|
|
300
305
|
requiresStudio: true,
|
|
301
306
|
examples: ['seeflow flows:play abc12345 api-checkout'],
|
|
302
307
|
},
|
|
@@ -350,14 +355,7 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
350
355
|
},
|
|
351
356
|
outputs: {
|
|
352
357
|
okExample: { id: 'node-abc' },
|
|
353
|
-
errorKinds: [
|
|
354
|
-
'flowNotFound',
|
|
355
|
-
'fileNotFound',
|
|
356
|
-
'badJson',
|
|
357
|
-
'badSchema',
|
|
358
|
-
'idAlreadyExists',
|
|
359
|
-
'writeFailed',
|
|
360
|
-
],
|
|
358
|
+
errorKinds: ['flowNotFound', 'fileNotFound', 'badJson', 'badSchema', 'writeFailed'],
|
|
361
359
|
},
|
|
362
360
|
requiresStudio: false,
|
|
363
361
|
examples: ['seeflow nodes:add abc12345 --json \'{"type":"rectangle","data":{}}\''],
|
|
@@ -390,7 +388,14 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
390
388
|
flags: BODY_FLAGS,
|
|
391
389
|
body: { schemaRef: 'NodePatchBody' },
|
|
392
390
|
outputs: {
|
|
393
|
-
errorKinds: [
|
|
391
|
+
errorKinds: [
|
|
392
|
+
'flowNotFound',
|
|
393
|
+
'fileNotFound',
|
|
394
|
+
'unknownNode',
|
|
395
|
+
'badJson',
|
|
396
|
+
'badSchema',
|
|
397
|
+
'writeFailed',
|
|
398
|
+
],
|
|
394
399
|
},
|
|
395
400
|
requiresStudio: false,
|
|
396
401
|
examples: ['seeflow nodes:patch abc12345 api-checkout --json \'{"data":{"name":"renamed"}}\''],
|
|
@@ -409,7 +414,16 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
409
414
|
{ name: 'y', valuePlaceholder: '<n>', description: 'Y coordinate', required: true },
|
|
410
415
|
],
|
|
411
416
|
body: { schemaRef: 'PositionBody' },
|
|
412
|
-
outputs: {
|
|
417
|
+
outputs: {
|
|
418
|
+
errorKinds: [
|
|
419
|
+
'flowNotFound',
|
|
420
|
+
'fileNotFound',
|
|
421
|
+
'unknownNode',
|
|
422
|
+
'badJson',
|
|
423
|
+
'badSchema',
|
|
424
|
+
'writeFailed',
|
|
425
|
+
],
|
|
426
|
+
},
|
|
413
427
|
requiresStudio: false,
|
|
414
428
|
examples: ['seeflow nodes:move abc12345 api-checkout --x 250 --y 320'],
|
|
415
429
|
},
|
|
@@ -433,7 +447,16 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
433
447
|
{ name: 'index', valuePlaceholder: '<n>', description: 'Required when --op toIndex' },
|
|
434
448
|
],
|
|
435
449
|
body: { schemaRef: 'ReorderBody' },
|
|
436
|
-
outputs: {
|
|
450
|
+
outputs: {
|
|
451
|
+
errorKinds: [
|
|
452
|
+
'flowNotFound',
|
|
453
|
+
'fileNotFound',
|
|
454
|
+
'unknownNode',
|
|
455
|
+
'badJson',
|
|
456
|
+
'badSchema',
|
|
457
|
+
'writeFailed',
|
|
458
|
+
],
|
|
459
|
+
},
|
|
437
460
|
requiresStudio: false,
|
|
438
461
|
examples: [
|
|
439
462
|
'seeflow nodes:reorder abc12345 api-checkout --op forward',
|
|
@@ -452,7 +475,14 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
452
475
|
flags: [],
|
|
453
476
|
outputs: {
|
|
454
477
|
okExample: { ok: true, removedConnectors: 0 },
|
|
455
|
-
errorKinds: [
|
|
478
|
+
errorKinds: [
|
|
479
|
+
'flowNotFound',
|
|
480
|
+
'fileNotFound',
|
|
481
|
+
'unknownNode',
|
|
482
|
+
'badJson',
|
|
483
|
+
'badSchema',
|
|
484
|
+
'writeFailed',
|
|
485
|
+
],
|
|
456
486
|
},
|
|
457
487
|
requiresStudio: false,
|
|
458
488
|
examples: ['seeflow nodes:delete abc12345 api-checkout'],
|
|
@@ -461,21 +491,21 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
461
491
|
{
|
|
462
492
|
name: 'connectors:add',
|
|
463
493
|
synopsis: 'seeflow connectors:add <flowId> [--json | --file | --stdin]',
|
|
464
|
-
description:
|
|
494
|
+
description:
|
|
495
|
+
'Add a connector. Body is the connector object — `source` and `target` are ' +
|
|
496
|
+
'the connected node ids (strings). Auto-generates an id when absent.',
|
|
465
497
|
category: 'connectors',
|
|
466
498
|
args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
|
|
467
499
|
flags: BODY_FLAGS,
|
|
468
500
|
body: {
|
|
469
|
-
example: { source:
|
|
501
|
+
example: { source: 'a', target: 'b' },
|
|
470
502
|
},
|
|
471
503
|
outputs: {
|
|
472
504
|
okExample: { id: 'conn-abc' },
|
|
473
|
-
errorKinds: ['flowNotFound', '
|
|
505
|
+
errorKinds: ['flowNotFound', 'fileNotFound', 'badJson', 'badSchema', 'writeFailed'],
|
|
474
506
|
},
|
|
475
507
|
requiresStudio: false,
|
|
476
|
-
examples: [
|
|
477
|
-
'seeflow connectors:add abc12345 --json \'{"source":{"nodeId":"a"},"target":{"nodeId":"b"}}\'',
|
|
478
|
-
],
|
|
508
|
+
examples: ['seeflow connectors:add abc12345 --json \'{"source":"a","target":"b"}\''],
|
|
479
509
|
},
|
|
480
510
|
{
|
|
481
511
|
name: 'connectors:patch',
|
|
@@ -488,7 +518,16 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
488
518
|
],
|
|
489
519
|
flags: BODY_FLAGS,
|
|
490
520
|
body: { schemaRef: 'ConnectorPatchBody' },
|
|
491
|
-
outputs: {
|
|
521
|
+
outputs: {
|
|
522
|
+
errorKinds: [
|
|
523
|
+
'flowNotFound',
|
|
524
|
+
'fileNotFound',
|
|
525
|
+
'unknownConnector',
|
|
526
|
+
'badJson',
|
|
527
|
+
'badSchema',
|
|
528
|
+
'writeFailed',
|
|
529
|
+
],
|
|
530
|
+
},
|
|
492
531
|
requiresStudio: false,
|
|
493
532
|
examples: ['seeflow connectors:patch abc12345 conn-1 --json \'{"label":"new"}\''],
|
|
494
533
|
},
|
|
@@ -502,7 +541,17 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
502
541
|
{ name: 'connectorId', required: true, description: 'Connector id in the flow' },
|
|
503
542
|
],
|
|
504
543
|
flags: [],
|
|
505
|
-
outputs: {
|
|
544
|
+
outputs: {
|
|
545
|
+
okExample: { ok: true },
|
|
546
|
+
errorKinds: [
|
|
547
|
+
'flowNotFound',
|
|
548
|
+
'fileNotFound',
|
|
549
|
+
'unknownConnector',
|
|
550
|
+
'badJson',
|
|
551
|
+
'badSchema',
|
|
552
|
+
'writeFailed',
|
|
553
|
+
],
|
|
554
|
+
},
|
|
506
555
|
requiresStudio: false,
|
|
507
556
|
examples: ['seeflow connectors:delete abc12345 conn-1'],
|
|
508
557
|
},
|
|
@@ -563,18 +612,21 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
563
612
|
name: 'schema',
|
|
564
613
|
synopsis: 'seeflow schema [<category>]',
|
|
565
614
|
description:
|
|
566
|
-
'Introspect the SeeFlow flow.json / style.json schemas at
|
|
567
|
-
'without arguments to list the
|
|
568
|
-
'action, style); call with a category name to
|
|
569
|
-
'(Draft-07) plus a `notes` array of cross-
|
|
570
|
-
"can't express.
|
|
571
|
-
'
|
|
615
|
+
'Introspect the SeeFlow flow.json / style.json / spec.json schemas at ' +
|
|
616
|
+
'runtime. Call without arguments to list the six categories (flow, node, ' +
|
|
617
|
+
'connector, action, componentSpec, style); call with a category name to ' +
|
|
618
|
+
'get its full JSON Schema(s) (Draft-07) plus a `notes` array of cross-' +
|
|
619
|
+
"field invariants the schema can't express. The `node` payload includes " +
|
|
620
|
+
"all 13 flat variants (including type:'component', whose `spec` field " +
|
|
621
|
+
'lives in a sidecar — drill into `componentSpec` for that shape). Use ' +
|
|
622
|
+
'this before authoring any flow.json / spec.json write — never memorise ' +
|
|
623
|
+
'field shapes.',
|
|
572
624
|
category: 'meta',
|
|
573
625
|
args: [
|
|
574
626
|
{
|
|
575
627
|
name: 'category',
|
|
576
628
|
required: false,
|
|
577
|
-
description: 'One of: flow, node, connector, action, style',
|
|
629
|
+
description: 'One of: flow, node, connector, action, componentSpec, style',
|
|
578
630
|
},
|
|
579
631
|
],
|
|
580
632
|
flags: [],
|
|
@@ -583,13 +635,23 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
583
635
|
errorKinds: ['notFound'],
|
|
584
636
|
},
|
|
585
637
|
requiresStudio: false,
|
|
586
|
-
examples: [
|
|
638
|
+
examples: [
|
|
639
|
+
'seeflow schema',
|
|
640
|
+
'seeflow schema node',
|
|
641
|
+
'seeflow schema connector',
|
|
642
|
+
'seeflow schema componentSpec',
|
|
643
|
+
],
|
|
587
644
|
},
|
|
588
645
|
// ---- live --------------------------------------------------------------
|
|
589
646
|
{
|
|
590
647
|
name: 'e2e',
|
|
591
|
-
synopsis: 'seeflow e2e <flowId> [--skip-nodes a,b]',
|
|
592
|
-
description:
|
|
648
|
+
synopsis: 'seeflow e2e <flowId> [--skip-nodes a,b] [--no-start]',
|
|
649
|
+
description:
|
|
650
|
+
'End-to-end validate a registered flow. Walks every node with a playAction ' +
|
|
651
|
+
"in flow.json order, POSTs each play, then drains the flow's SSE stream " +
|
|
652
|
+
'for node:done/error + node:status reports. Returns a single JSON report ' +
|
|
653
|
+
'when finished; exits non-zero if any play failed, any statusAction failed ' +
|
|
654
|
+
'to report, or the 120s ceiling was exceeded. Requires a running studio.',
|
|
593
655
|
category: 'live',
|
|
594
656
|
args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
|
|
595
657
|
flags: [
|
|
@@ -600,10 +662,16 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
|
|
|
600
662
|
},
|
|
601
663
|
{ name: 'no-start', description: 'Fail if the studio is not already running' },
|
|
602
664
|
],
|
|
603
|
-
|
|
604
|
-
|
|
665
|
+
outputs: {
|
|
666
|
+
okExample: {
|
|
667
|
+
ok: true,
|
|
668
|
+
plays: [{ nodeId: 'api-checkout', outcome: 'ok', runId: 'run-9b3' }],
|
|
669
|
+
statuses: [],
|
|
670
|
+
skipped: [],
|
|
671
|
+
},
|
|
672
|
+
},
|
|
605
673
|
requiresStudio: true,
|
|
606
|
-
examples: ['seeflow e2e abc12345'],
|
|
674
|
+
examples: ['seeflow e2e abc12345', 'seeflow e2e abc12345 --skip-nodes flaky-1,flaky-2'],
|
|
607
675
|
},
|
|
608
676
|
{
|
|
609
677
|
name: 'emit',
|
package/src/mcp.ts
CHANGED
|
@@ -196,10 +196,14 @@ const buildTools = (ops: Operations): McpTool[] => [
|
|
|
196
196
|
{
|
|
197
197
|
name: 'seeflow_schema',
|
|
198
198
|
description:
|
|
199
|
-
'Get the SeeFlow flow.json
|
|
200
|
-
"call with `name` for one category's full JSON Schemas.
|
|
201
|
-
'what a node, connector, action,
|
|
202
|
-
'writes. Categories: `flow`,
|
|
199
|
+
'Get the SeeFlow flow.json / spec.json schemas. Call with no args for a ' +
|
|
200
|
+
"category index; call with `name` for one category's full JSON Schemas. " +
|
|
201
|
+
'Use this to learn what a node, connector, action, component spec, or ' +
|
|
202
|
+
'flow envelope looks like before authoring writes. Categories: `flow`, ' +
|
|
203
|
+
'`node` (13 flat variants — rectangle/ellipse/sticky/text/database/server/' +
|
|
204
|
+
'user/queue/cloud/image/html/icon/component), `connector`, `action` ' +
|
|
205
|
+
'(playAction/statusAction/resetAction/statusReport/componentAction), ' +
|
|
206
|
+
"`componentSpec` (sidecar shape for type:'component' nodes), `style`.",
|
|
203
207
|
inputSchema: {
|
|
204
208
|
type: 'object',
|
|
205
209
|
properties: {
|
package/src/schema-catalog.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
import type { ZodTypeAny } from 'zod';
|
|
9
9
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
10
10
|
import {
|
|
11
|
+
ComponentActionSchema,
|
|
12
|
+
ComponentSpecElementSchema,
|
|
13
|
+
ComponentSpecSchema,
|
|
11
14
|
FlowCloudNodeSchema,
|
|
15
|
+
FlowComponentNodeSchema,
|
|
12
16
|
FlowConnectorSchema,
|
|
13
17
|
FlowDatabaseNodeSchema,
|
|
14
18
|
FlowEllipseNodeSchema,
|
|
@@ -50,7 +54,7 @@ const CATEGORIES: SchemaCategory[] = [
|
|
|
50
54
|
{
|
|
51
55
|
name: 'node',
|
|
52
56
|
description:
|
|
53
|
-
'All
|
|
57
|
+
'All 13 flat node variants (rectangle, ellipse, sticky, text, database, server, user, queue, cloud, image, html, icon, component). Visual kind is the type; capabilities (playAction / statusAction / stateSource) are independent optional fields on every variant.',
|
|
54
58
|
},
|
|
55
59
|
{
|
|
56
60
|
name: 'connector',
|
|
@@ -58,7 +62,13 @@ const CATEGORIES: SchemaCategory[] = [
|
|
|
58
62
|
},
|
|
59
63
|
{
|
|
60
64
|
name: 'action',
|
|
61
|
-
description:
|
|
65
|
+
description:
|
|
66
|
+
'playAction, statusAction, resetAction, statusReport, plus componentAction (the set | script discriminated union dispatched on component-node action handles).',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'componentSpec',
|
|
70
|
+
description:
|
|
71
|
+
"Sidecar shape written to <project>/nodes/<id>/spec.json for type:'component' nodes. Carries the json-render element tree, initial state, and named actions the renderer dispatches on user input.",
|
|
62
72
|
},
|
|
63
73
|
{ name: 'style', description: 'style.json (studio-owned).' },
|
|
64
74
|
];
|
|
@@ -82,10 +92,12 @@ const PAYLOADS: Record<string, SchemaPayload> = {
|
|
|
82
92
|
image: toJsonSchema(FlowImageNodeSchema),
|
|
83
93
|
html: toJsonSchema(FlowHtmlNodeSchema),
|
|
84
94
|
icon: toJsonSchema(FlowIconNodeSchema),
|
|
95
|
+
component: toJsonSchema(FlowComponentNodeSchema),
|
|
85
96
|
},
|
|
86
97
|
notes: [
|
|
87
98
|
"type:'image' data.path must start with 'nodes/<id>/'.",
|
|
88
99
|
"scriptPath in playAction/statusAction is relative to nodes/<nodeId>/ and may not contain '..' or absolute paths.",
|
|
100
|
+
"type:'component' nodes have no `spec` field on disk — the spec lives in <project>/nodes/<id>/spec.json (see `seeflow schema componentSpec`). The resolver inlines it into data.spec for runtime / SSE broadcasts.",
|
|
89
101
|
],
|
|
90
102
|
},
|
|
91
103
|
connector: {
|
|
@@ -100,9 +112,22 @@ const PAYLOADS: Record<string, SchemaPayload> = {
|
|
|
100
112
|
statusAction: toJsonSchema(StatusActionSchema),
|
|
101
113
|
resetAction: toJsonSchema(ResetActionSchema),
|
|
102
114
|
statusReport: toJsonSchema(StatusReportSchema),
|
|
115
|
+
componentAction: toJsonSchema(ComponentActionSchema),
|
|
103
116
|
},
|
|
104
117
|
notes: [
|
|
105
118
|
"scriptPath in playAction/statusAction is relative to nodes/<nodeId>/ and may not contain '..' or absolute paths.",
|
|
119
|
+
"componentAction is a `set | script` discriminated union: `set` mutates canvas state locally (path is a JSON Pointer starting with '/'), `script` shells out via POST /api/flows/:id/nodes/:nodeId/actions/:name with the same scriptPath rooting rules as playAction.",
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
componentSpec: {
|
|
123
|
+
schemas: {
|
|
124
|
+
componentSpec: toJsonSchema(ComponentSpecSchema),
|
|
125
|
+
componentSpecElement: toJsonSchema(ComponentSpecElementSchema),
|
|
126
|
+
},
|
|
127
|
+
notes: [
|
|
128
|
+
"spec.json is the on-disk source of truth for type:'component' nodes; the resolver inlines it into data.spec at read time and splitFlow strips it back out before writing flow.json so the sidecar is never double-stored.",
|
|
129
|
+
'elements is keyed by element id; `root` names the entry element. Element ids referenced from children / actions must exist in elements.',
|
|
130
|
+
'state and actions are both keyed by user-chosen names. Action handles in the rendered UI reference these names; see `seeflow schema action` for the per-action shape.',
|
|
106
131
|
],
|
|
107
132
|
},
|
|
108
133
|
style: {
|
package/src/schema.ts
CHANGED
|
@@ -141,10 +141,10 @@ const NodeCapabilitiesShape = {
|
|
|
141
141
|
handlerModule: z.string().optional(),
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
-
//
|
|
145
|
-
// GeometricNodeData. `image`, `html`, `icon` carry per-type
|
|
146
|
-
// The renderer picks the SVG / chrome by `type`; the schema treats
|
|
147
|
-
// (apart from the per-type fields below) as identical.
|
|
144
|
+
// 13 flat node types. The first 9 are geometric/illustrative and share
|
|
145
|
+
// GeometricNodeData. `image`, `html`, `icon`, `component` carry per-type
|
|
146
|
+
// fields. The renderer picks the SVG / chrome by `type`; the schema treats
|
|
147
|
+
// them (apart from the per-type fields below) as identical.
|
|
148
148
|
export const GEOMETRIC_NODE_TYPES = [
|
|
149
149
|
'rectangle',
|
|
150
150
|
'ellipse',
|
package/src/watcher.ts
CHANGED
|
@@ -20,6 +20,15 @@ import {
|
|
|
20
20
|
|
|
21
21
|
const DEFAULT_DEBOUNCE_MS = 100;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Polling backup interval. fs.watch on macOS occasionally drops events
|
|
25
|
+
* (notably right after a fresh watcher is registered), so a low-frequency
|
|
26
|
+
* stat poll over every watched handle guarantees external writes still
|
|
27
|
+
* trigger reparse+broadcast. Own-write echoes are still suppressed by
|
|
28
|
+
* the writtenHashes ring, so this doesn't double-fire on server writes.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_POLL_INTERVAL_MS = 500;
|
|
31
|
+
|
|
23
32
|
/** Max recent self-write hashes retained per flow for own-echo suppression. */
|
|
24
33
|
const WRITTEN_HASH_RING_SIZE = 4;
|
|
25
34
|
|
|
@@ -52,6 +61,12 @@ export interface WatcherDeps {
|
|
|
52
61
|
events: EventBus;
|
|
53
62
|
/** Override for tests. */
|
|
54
63
|
debounceMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Polling backup interval for missed fs.watch events. Defaults to 500ms.
|
|
66
|
+
* Pass `0` to disable (tests that rely on deterministic fs.watch-only
|
|
67
|
+
* behaviour).
|
|
68
|
+
*/
|
|
69
|
+
pollIntervalMs?: number;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
export interface FlowWatcher {
|
|
@@ -276,6 +291,7 @@ const closeFileWatchers = (handle: WatchHandle): void => {
|
|
|
276
291
|
export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
277
292
|
const { registry, events } = deps;
|
|
278
293
|
const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
294
|
+
const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
279
295
|
|
|
280
296
|
const handles = new Map<string, WatchHandle>();
|
|
281
297
|
const snapshots = new Map<string, FlowSnapshot>();
|
|
@@ -455,6 +471,49 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
455
471
|
});
|
|
456
472
|
};
|
|
457
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Common path for "something on disk may have changed" — used by both
|
|
476
|
+
* fs.watch callbacks and the polling backup. Debounces, drops own-write
|
|
477
|
+
* echoes, then reparses + broadcasts. Idempotent under repeated calls
|
|
478
|
+
* within the debounce window.
|
|
479
|
+
*/
|
|
480
|
+
const scheduleChange = (flowId: string): void => {
|
|
481
|
+
const handle = handles.get(flowId);
|
|
482
|
+
if (!handle) return;
|
|
483
|
+
if (handle.debounceTimer) clearTimeout(handle.debounceTimer);
|
|
484
|
+
handle.debounceTimer = setTimeout(() => {
|
|
485
|
+
handle.debounceTimer = null;
|
|
486
|
+
// Own-write dedupe: if the on-disk bytes match what the server just
|
|
487
|
+
// wrote (recent hash in the ring), this is our own echo — drop it.
|
|
488
|
+
// notifyWritten already broadcast and seeded the snapshot.
|
|
489
|
+
const combined = readCombinedFromDisk(handle.filePath);
|
|
490
|
+
if (combined !== null && isOwnWriteEcho(flowId, sha256Hex(combined))) return;
|
|
491
|
+
const snap = reparse(flowId);
|
|
492
|
+
if (snap) broadcastReload(flowId, snap);
|
|
493
|
+
}, debounceMs);
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// Single shared interval that stat-polls every watched handle. Cheap (one
|
|
497
|
+
// statSync per watched flow per tick) and bounded — flows that registry
|
|
498
|
+
// doesn't know about aren't watched, so the loop scales with active
|
|
499
|
+
// projects, not historic ones. Backs up macOS fs.watch's occasional
|
|
500
|
+
// dropped events; the debounce + own-write dedupe in scheduleChange
|
|
501
|
+
// keeps it from double-firing alongside a fs.watch hit.
|
|
502
|
+
const pollTimer: ReturnType<typeof setInterval> | null =
|
|
503
|
+
pollIntervalMs > 0
|
|
504
|
+
? setInterval(() => {
|
|
505
|
+
for (const [flowId, handle] of handles) {
|
|
506
|
+
const seen = lastSeenMtimes.get(flowId);
|
|
507
|
+
if (seen === undefined) continue;
|
|
508
|
+
const now = combinedMtimeMs(handle.filePath);
|
|
509
|
+
if (now > seen) scheduleChange(flowId);
|
|
510
|
+
}
|
|
511
|
+
}, pollIntervalMs)
|
|
512
|
+
: null;
|
|
513
|
+
// Don't keep the event loop alive on this timer alone — the studio process
|
|
514
|
+
// should exit cleanly when nothing else is pending.
|
|
515
|
+
pollTimer?.unref?.();
|
|
516
|
+
|
|
458
517
|
const startWatch = (flowId: string) => {
|
|
459
518
|
const existing = handles.get(flowId);
|
|
460
519
|
if (existing) {
|
|
@@ -484,19 +543,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
484
543
|
// React to flow.json, style.json, or rename-on-save events
|
|
485
544
|
// (some platforms emit those with no filename).
|
|
486
545
|
if (changed && changed !== base && changed !== 'style.json') return;
|
|
487
|
-
|
|
488
|
-
if (!handle) return;
|
|
489
|
-
if (handle.debounceTimer) clearTimeout(handle.debounceTimer);
|
|
490
|
-
handle.debounceTimer = setTimeout(() => {
|
|
491
|
-
handle.debounceTimer = null;
|
|
492
|
-
// Own-write dedupe: if the on-disk bytes match what the server just
|
|
493
|
-
// wrote (recent hash in the ring), this is our own echo — drop it.
|
|
494
|
-
// notifyWritten already broadcast and seeded the snapshot.
|
|
495
|
-
const combined = readCombinedFromDisk(filePath);
|
|
496
|
-
if (combined !== null && isOwnWriteEcho(flowId, sha256Hex(combined))) return;
|
|
497
|
-
const snap = reparse(flowId);
|
|
498
|
-
if (snap) broadcastReload(flowId, snap);
|
|
499
|
-
}, debounceMs);
|
|
546
|
+
scheduleChange(flowId);
|
|
500
547
|
});
|
|
501
548
|
} catch (err) {
|
|
502
549
|
console.error(`[watcher] failed to watch ${dir} for flow ${flowId}:`, err);
|
|
@@ -551,6 +598,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
551
598
|
for (const entry of registry.list()) startWatch(entry.id);
|
|
552
599
|
},
|
|
553
600
|
closeAll() {
|
|
601
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
554
602
|
for (const [, h] of handles) {
|
|
555
603
|
h.fsWatcher.close();
|
|
556
604
|
if (h.debounceTimer) clearTimeout(h.debounceTimer);
|