@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.
@@ -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: 'Trigger a play action on one node. Requires a running studio.',
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
- outputKind: 'stream',
299
- outputs: {},
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: ['flowNotFound', 'fileNotFound', 'unknownNode', 'badSchema', 'writeFailed'],
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: { errorKinds: ['flowNotFound', 'fileNotFound', 'unknownNode', 'writeFailed'] },
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: { errorKinds: ['flowNotFound', 'fileNotFound', 'unknownNode', 'writeFailed'] },
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: ['flowNotFound', 'unknownNode'],
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: 'Add a connector. Body is the connector object (source/target required).',
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: { nodeId: 'a' }, target: { nodeId: 'b' } },
501
+ example: { source: 'a', target: 'b' },
470
502
  },
471
503
  outputs: {
472
504
  okExample: { id: 'conn-abc' },
473
- errorKinds: ['flowNotFound', 'badSchema', 'idAlreadyExists', 'writeFailed'],
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: { errorKinds: ['flowNotFound', 'unknownConnector', 'badSchema', 'writeFailed'] },
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: { okExample: { ok: true }, errorKinds: ['flowNotFound', 'unknownConnector'] },
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 runtime. Call ' +
567
- 'without arguments to list the five categories (flow, node, connector, ' +
568
- 'action, style); call with a category name to get its full JSON Schema(s) ' +
569
- '(Draft-07) plus a `notes` array of cross-field invariants the schema ' +
570
- "can't express. Use this before authoring any flow.json write — never " +
571
- 'memorise field shapes.',
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: ['seeflow schema', 'seeflow schema node', 'seeflow schema connector'],
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: 'End-to-end validate a registered flow. Requires a running studio.',
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
- outputKind: 'stream',
604
- outputs: {},
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 schema. Call with no args for a category index; ' +
200
- "call with `name` for one category's full JSON Schemas. Use this to learn " +
201
- 'what a node, connector, action, or flow envelope looks like before authoring ' +
202
- 'writes. Categories: `flow`, `node`, `connector`, `action`, `style`.',
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: {
@@ -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 12 flat node variants (rectangle, ellipse, sticky, text, database, server, user, queue, cloud, image, html, icon). Visual kind is the type; capabilities (playAction / statusAction / stateSource) are independent optional fields on every variant.',
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: 'playAction, statusAction, resetAction, statusReport.',
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
- // 12 flat node types. The first 9 are geometric/illustrative and share
145
- // GeometricNodeData. `image`, `html`, `icon` carry per-type fields.
146
- // The renderer picks the SVG / chrome by `type`; the schema treats them
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
- const handle = handles.get(flowId);
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);