@tuongaz/seeflow 0.1.31 → 0.1.40
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 +8 -8
- package/dist/web/assets/index-BwdVgB2y.css +1 -0
- package/dist/web/assets/index-DTNk6GGk.js +7838 -0
- package/dist/web/assets/{index.es-B9awKpqd.js → index.es-D_iCCj4R.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-BPVV_TTL.js → jspdf.es.min-C9FG4HQT.js} +3 -3
- package/dist/web/index.html +2 -2
- package/package.json +3 -4
- package/src/api.ts +212 -20
- package/src/cli.ts +156 -35
- package/src/diagram.ts +29 -69
- package/src/layout.ts +217 -0
- package/src/mcp.ts +10 -10
- package/src/merge.ts +50 -51
- package/src/operations.ts +184 -121
- package/src/registry.ts +10 -16
- package/src/schema.ts +46 -55
- package/src/status-runner.ts +6 -6
- package/src/watcher.ts +124 -31
- package/dist/web/assets/index-CYxryPhh.css +0 -1
- package/dist/web/assets/index-CeQZymwF.js +0 -7838
- /package/examples/ecommerce-platform/.seeflow/{architecture.json → flow.json} +0 -0
- /package/examples/order-pipeline/.seeflow/{architecture.json → flow.json} +0 -0
package/src/registry.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface FlowEntry {
|
|
|
7
7
|
slug: string;
|
|
8
8
|
name: string;
|
|
9
9
|
repoPath: string;
|
|
10
|
-
|
|
10
|
+
flowPath: string;
|
|
11
11
|
lastModified: number;
|
|
12
12
|
valid: boolean;
|
|
13
13
|
}
|
|
@@ -15,7 +15,7 @@ export interface FlowEntry {
|
|
|
15
15
|
export interface RegisterInput {
|
|
16
16
|
name: string;
|
|
17
17
|
repoPath: string;
|
|
18
|
-
|
|
18
|
+
flowPath: string;
|
|
19
19
|
valid?: boolean;
|
|
20
20
|
lastModified?: number;
|
|
21
21
|
}
|
|
@@ -25,10 +25,7 @@ export interface Registry {
|
|
|
25
25
|
getById(id: string): FlowEntry | undefined;
|
|
26
26
|
getBySlug(slug: string): FlowEntry | undefined;
|
|
27
27
|
getByRepoPath(repoPath: string): FlowEntry | undefined;
|
|
28
|
-
|
|
29
|
-
repoPath: string,
|
|
30
|
-
architecturePath: string,
|
|
31
|
-
): FlowEntry | undefined;
|
|
28
|
+
getByRepoPathAndFlowPath(repoPath: string, flowPath: string): FlowEntry | undefined;
|
|
32
29
|
upsert(input: RegisterInput): FlowEntry;
|
|
33
30
|
remove(id: string): boolean;
|
|
34
31
|
}
|
|
@@ -60,7 +57,7 @@ export function createRegistry(options: { path?: string } = {}): Registry {
|
|
|
60
57
|
typeof e.slug === 'string' &&
|
|
61
58
|
typeof e.repoPath === 'string'
|
|
62
59
|
) {
|
|
63
|
-
if (typeof e.
|
|
60
|
+
if (typeof e.flowPath !== 'string') {
|
|
64
61
|
console.warn(
|
|
65
62
|
`[registry] ignoring legacy entry ${e.id} (${e.slug}) — pre-split format, please re-register`,
|
|
66
63
|
);
|
|
@@ -87,12 +84,9 @@ export function createRegistry(options: { path?: string } = {}): Registry {
|
|
|
87
84
|
return undefined;
|
|
88
85
|
};
|
|
89
86
|
|
|
90
|
-
const
|
|
91
|
-
repoPath: string,
|
|
92
|
-
architecturePath: string,
|
|
93
|
-
): FlowEntry | undefined => {
|
|
87
|
+
const findByRepoPathAndFlowPath = (repoPath: string, flowPath: string): FlowEntry | undefined => {
|
|
94
88
|
for (const e of entries.values()) {
|
|
95
|
-
if (e.repoPath === repoPath && e.
|
|
89
|
+
if (e.repoPath === repoPath && e.flowPath === flowPath) return e;
|
|
96
90
|
}
|
|
97
91
|
return undefined;
|
|
98
92
|
};
|
|
@@ -110,16 +104,16 @@ export function createRegistry(options: { path?: string } = {}): Registry {
|
|
|
110
104
|
getById: (id) => entries.get(id),
|
|
111
105
|
getBySlug: (slug) => [...entries.values()].find((e) => e.slug === slug),
|
|
112
106
|
getByRepoPath: findByRepoPath,
|
|
113
|
-
|
|
107
|
+
getByRepoPathAndFlowPath: findByRepoPathAndFlowPath,
|
|
114
108
|
upsert(input) {
|
|
115
109
|
const lastModified = input.lastModified ?? Date.now();
|
|
116
110
|
const valid = input.valid ?? true;
|
|
117
|
-
const existing =
|
|
111
|
+
const existing = findByRepoPathAndFlowPath(input.repoPath, input.flowPath);
|
|
118
112
|
if (existing) {
|
|
119
113
|
const updated: FlowEntry = {
|
|
120
114
|
...existing,
|
|
121
115
|
name: input.name,
|
|
122
|
-
|
|
116
|
+
flowPath: input.flowPath,
|
|
123
117
|
lastModified,
|
|
124
118
|
valid,
|
|
125
119
|
};
|
|
@@ -134,7 +128,7 @@ export function createRegistry(options: { path?: string } = {}): Registry {
|
|
|
134
128
|
slug,
|
|
135
129
|
name: input.name,
|
|
136
130
|
repoPath: input.repoPath,
|
|
137
|
-
|
|
131
|
+
flowPath: input.flowPath,
|
|
138
132
|
lastModified,
|
|
139
133
|
valid,
|
|
140
134
|
};
|
package/src/schema.ts
CHANGED
|
@@ -27,10 +27,6 @@ export const ColorTokenSchema = z.enum([
|
|
|
27
27
|
|
|
28
28
|
// Visual fields shared by every node type (functional + decorative). All
|
|
29
29
|
// optional — existing demo files predate them and must continue to parse.
|
|
30
|
-
// US-019: `locked` freezes a node in place (no drag / resize / delete) and
|
|
31
|
-
// renders a lock badge on its top-right corner. Absent → unlocked default.
|
|
32
|
-
// Mirrored explicitly into IconNodeDataSchema below
|
|
33
|
-
// since that variant doesn't spread this base shape.
|
|
34
30
|
const NodeVisualBaseShape = {
|
|
35
31
|
width: z.number().positive().optional(),
|
|
36
32
|
height: z.number().positive().optional(),
|
|
@@ -41,7 +37,6 @@ const NodeVisualBaseShape = {
|
|
|
41
37
|
fontSize: z.number().positive().optional(),
|
|
42
38
|
textColor: ColorTokenSchema.optional(),
|
|
43
39
|
cornerRadius: z.number().min(0).optional(),
|
|
44
|
-
locked: z.boolean().optional(),
|
|
45
40
|
};
|
|
46
41
|
|
|
47
42
|
// Consolidated three-field metadata shared by every node variant. `description`
|
|
@@ -267,9 +262,6 @@ const IconNodeDataSchema = z.object({
|
|
|
267
262
|
// `alt` (screen-reader text). Absent / empty → no caption rendered and the
|
|
268
263
|
// node's bounding box is byte-identical to the unlabeled layout.
|
|
269
264
|
name: z.string().optional(),
|
|
270
|
-
// US-019: lock state mirror of NodeVisualBaseShape.locked. IconNode does
|
|
271
|
-
// not spread the visual base so we declare it here explicitly.
|
|
272
|
-
locked: z.boolean().optional(),
|
|
273
265
|
...NodeDescriptionBaseShape,
|
|
274
266
|
});
|
|
275
267
|
|
|
@@ -396,7 +388,7 @@ const ConnectorSchema = z.discriminatedUnion('kind', [
|
|
|
396
388
|
DefaultConnectorSchema,
|
|
397
389
|
]);
|
|
398
390
|
|
|
399
|
-
export const
|
|
391
|
+
export const ResolvedFlowSchema = z
|
|
400
392
|
.object({
|
|
401
393
|
version: z.literal(2),
|
|
402
394
|
name: z.string().min(1),
|
|
@@ -408,9 +400,9 @@ export const FlowSchema = z
|
|
|
408
400
|
// script (US-008), so the script sees no stragglers.
|
|
409
401
|
resetAction: ResetActionSchema.optional(),
|
|
410
402
|
})
|
|
411
|
-
.superRefine((
|
|
412
|
-
const nodeIds = new Set(
|
|
413
|
-
|
|
403
|
+
.superRefine((resolved, ctx) => {
|
|
404
|
+
const nodeIds = new Set(resolved.nodes.map((n) => n.id));
|
|
405
|
+
resolved.connectors.forEach((c, idx) => {
|
|
414
406
|
if (!nodeIds.has(c.source)) {
|
|
415
407
|
ctx.addIssue({
|
|
416
408
|
code: z.ZodIssueCode.custom,
|
|
@@ -428,8 +420,8 @@ export const FlowSchema = z
|
|
|
428
420
|
});
|
|
429
421
|
});
|
|
430
422
|
|
|
431
|
-
export type
|
|
432
|
-
export type
|
|
423
|
+
export type ResolvedFlow = z.infer<typeof ResolvedFlowSchema>;
|
|
424
|
+
export type ResolvedFlowNode = z.infer<typeof NodeSchema>;
|
|
433
425
|
export type ShapeNode = z.infer<typeof ShapeNodeSchema>;
|
|
434
426
|
export type ImageNode = z.infer<typeof ImageNodeSchema>;
|
|
435
427
|
export type IconNode = z.infer<typeof IconNodeSchema>;
|
|
@@ -454,11 +446,11 @@ export type ResetAction = z.infer<typeof ResetActionSchema>;
|
|
|
454
446
|
export type StateSource = z.infer<typeof StateSourceSchema>;
|
|
455
447
|
|
|
456
448
|
// =============================================================================
|
|
457
|
-
//
|
|
458
|
-
// What lives on disk in <project>/.seeflow/
|
|
449
|
+
// Flow schema — pure semantic data, every visual/layout field stripped.
|
|
450
|
+
// What lives on disk in <project>/.seeflow/flow.json after the split.
|
|
459
451
|
// =============================================================================
|
|
460
452
|
|
|
461
|
-
const
|
|
453
|
+
const FlowNodeDataBaseShape = {
|
|
462
454
|
name: z.string().min(1),
|
|
463
455
|
kind: z.string().min(1),
|
|
464
456
|
stateSource: StateSourceSchema,
|
|
@@ -467,23 +459,23 @@ const ArchitectureNodeDataBaseShape = {
|
|
|
467
459
|
...NodeDescriptionBaseShape,
|
|
468
460
|
};
|
|
469
461
|
|
|
470
|
-
const
|
|
462
|
+
const FlowPlayNodeDataSchema = z
|
|
471
463
|
.object({
|
|
472
|
-
...
|
|
464
|
+
...FlowNodeDataBaseShape,
|
|
473
465
|
playAction: PlayActionSchema,
|
|
474
466
|
statusAction: StatusActionSchema.optional(),
|
|
475
467
|
})
|
|
476
468
|
.strict();
|
|
477
469
|
|
|
478
|
-
const
|
|
470
|
+
const FlowStateNodeDataSchema = z
|
|
479
471
|
.object({
|
|
480
|
-
...
|
|
472
|
+
...FlowNodeDataBaseShape,
|
|
481
473
|
playAction: PlayActionSchema.optional(),
|
|
482
474
|
statusAction: StatusActionSchema.optional(),
|
|
483
475
|
})
|
|
484
476
|
.strict();
|
|
485
477
|
|
|
486
|
-
const
|
|
478
|
+
const FlowShapeNodeDataSchema = z
|
|
487
479
|
.object({
|
|
488
480
|
shape: ShapeKindSchema,
|
|
489
481
|
name: z.string().optional(),
|
|
@@ -491,7 +483,7 @@ const ArchitectureShapeNodeDataSchema = z
|
|
|
491
483
|
})
|
|
492
484
|
.strict();
|
|
493
485
|
|
|
494
|
-
const
|
|
486
|
+
const FlowImageNodeDataSchema = z
|
|
495
487
|
.object({
|
|
496
488
|
path: z.string().min(1).refine(isCleanRelativePath, {
|
|
497
489
|
message: 'path must be a relative path under .seeflow/ (no absolute / traversal)',
|
|
@@ -501,7 +493,7 @@ const ArchitectureImageNodeDataSchema = z
|
|
|
501
493
|
})
|
|
502
494
|
.strict();
|
|
503
495
|
|
|
504
|
-
const
|
|
496
|
+
const FlowIconNodeDataSchema = z
|
|
505
497
|
.object({
|
|
506
498
|
icon: z.string().min(1),
|
|
507
499
|
alt: z.string().optional(),
|
|
@@ -510,7 +502,7 @@ const ArchitectureIconNodeDataSchema = z
|
|
|
510
502
|
})
|
|
511
503
|
.strict();
|
|
512
504
|
|
|
513
|
-
const
|
|
505
|
+
const FlowHtmlNodeDataSchema = z
|
|
514
506
|
.object({
|
|
515
507
|
htmlPath: z.string().min(1).refine(isCleanRelativePath, {
|
|
516
508
|
message: 'htmlPath must be a relative path under .seeflow/ (no absolute / traversal)',
|
|
@@ -521,66 +513,66 @@ const ArchitectureHtmlNodeDataSchema = z
|
|
|
521
513
|
})
|
|
522
514
|
.strict();
|
|
523
515
|
|
|
524
|
-
const
|
|
516
|
+
const FlowNodeBaseShape = {
|
|
525
517
|
id: z.string().min(1),
|
|
526
518
|
};
|
|
527
519
|
|
|
528
|
-
const
|
|
520
|
+
const FlowNodeSchema = z.discriminatedUnion('type', [
|
|
529
521
|
z
|
|
530
522
|
.object({
|
|
531
|
-
...
|
|
523
|
+
...FlowNodeBaseShape,
|
|
532
524
|
type: z.literal('playNode'),
|
|
533
|
-
data:
|
|
525
|
+
data: FlowPlayNodeDataSchema,
|
|
534
526
|
})
|
|
535
527
|
.strict(),
|
|
536
528
|
z
|
|
537
529
|
.object({
|
|
538
|
-
...
|
|
530
|
+
...FlowNodeBaseShape,
|
|
539
531
|
type: z.literal('stateNode'),
|
|
540
|
-
data:
|
|
532
|
+
data: FlowStateNodeDataSchema,
|
|
541
533
|
})
|
|
542
534
|
.strict(),
|
|
543
535
|
z
|
|
544
536
|
.object({
|
|
545
|
-
...
|
|
537
|
+
...FlowNodeBaseShape,
|
|
546
538
|
type: z.literal('shapeNode'),
|
|
547
|
-
data:
|
|
539
|
+
data: FlowShapeNodeDataSchema,
|
|
548
540
|
})
|
|
549
541
|
.strict(),
|
|
550
542
|
z
|
|
551
543
|
.object({
|
|
552
|
-
...
|
|
544
|
+
...FlowNodeBaseShape,
|
|
553
545
|
type: z.literal('imageNode'),
|
|
554
|
-
data:
|
|
546
|
+
data: FlowImageNodeDataSchema,
|
|
555
547
|
})
|
|
556
548
|
.strict(),
|
|
557
549
|
z
|
|
558
550
|
.object({
|
|
559
|
-
...
|
|
551
|
+
...FlowNodeBaseShape,
|
|
560
552
|
type: z.literal('iconNode'),
|
|
561
|
-
data:
|
|
553
|
+
data: FlowIconNodeDataSchema,
|
|
562
554
|
})
|
|
563
555
|
.strict(),
|
|
564
556
|
z
|
|
565
557
|
.object({
|
|
566
|
-
...
|
|
558
|
+
...FlowNodeBaseShape,
|
|
567
559
|
type: z.literal('htmlNode'),
|
|
568
|
-
data:
|
|
560
|
+
data: FlowHtmlNodeDataSchema,
|
|
569
561
|
})
|
|
570
562
|
.strict(),
|
|
571
563
|
]);
|
|
572
564
|
|
|
573
|
-
const
|
|
565
|
+
const FlowConnectorBaseShape = {
|
|
574
566
|
id: z.string().min(1),
|
|
575
567
|
source: z.string().min(1),
|
|
576
568
|
target: z.string().min(1),
|
|
577
569
|
label: z.string().optional(),
|
|
578
570
|
};
|
|
579
571
|
|
|
580
|
-
const
|
|
572
|
+
const FlowConnectorSchema = z.discriminatedUnion('kind', [
|
|
581
573
|
z
|
|
582
574
|
.object({
|
|
583
|
-
...
|
|
575
|
+
...FlowConnectorBaseShape,
|
|
584
576
|
kind: z.literal('http'),
|
|
585
577
|
method: HttpMethodSchema.optional(),
|
|
586
578
|
url: z.string().min(1).optional(),
|
|
@@ -588,38 +580,38 @@ const ArchitectureConnectorSchema = z.discriminatedUnion('kind', [
|
|
|
588
580
|
.strict(),
|
|
589
581
|
z
|
|
590
582
|
.object({
|
|
591
|
-
...
|
|
583
|
+
...FlowConnectorBaseShape,
|
|
592
584
|
kind: z.literal('event'),
|
|
593
585
|
eventName: z.string().min(1),
|
|
594
586
|
})
|
|
595
587
|
.strict(),
|
|
596
588
|
z
|
|
597
589
|
.object({
|
|
598
|
-
...
|
|
590
|
+
...FlowConnectorBaseShape,
|
|
599
591
|
kind: z.literal('queue'),
|
|
600
592
|
queueName: z.string().min(1),
|
|
601
593
|
})
|
|
602
594
|
.strict(),
|
|
603
595
|
z
|
|
604
596
|
.object({
|
|
605
|
-
...
|
|
597
|
+
...FlowConnectorBaseShape,
|
|
606
598
|
kind: z.literal('default'),
|
|
607
599
|
})
|
|
608
600
|
.strict(),
|
|
609
601
|
]);
|
|
610
602
|
|
|
611
|
-
export const
|
|
603
|
+
export const FlowSchema = z
|
|
612
604
|
.object({
|
|
613
605
|
version: z.literal(2),
|
|
614
606
|
name: z.string().min(1),
|
|
615
607
|
resetAction: ResetActionSchema.optional(),
|
|
616
|
-
nodes: z.array(
|
|
617
|
-
connectors: z.array(
|
|
608
|
+
nodes: z.array(FlowNodeSchema),
|
|
609
|
+
connectors: z.array(FlowConnectorSchema),
|
|
618
610
|
})
|
|
619
611
|
.strict()
|
|
620
|
-
.superRefine((
|
|
621
|
-
const ids = new Set(
|
|
622
|
-
|
|
612
|
+
.superRefine((flow, ctx) => {
|
|
613
|
+
const ids = new Set(flow.nodes.map((n) => n.id));
|
|
614
|
+
flow.connectors.forEach((c, idx) => {
|
|
623
615
|
if (!ids.has(c.source)) {
|
|
624
616
|
ctx.addIssue({
|
|
625
617
|
code: z.ZodIssueCode.custom,
|
|
@@ -637,9 +629,9 @@ export const ArchitectureSchema = z
|
|
|
637
629
|
});
|
|
638
630
|
});
|
|
639
631
|
|
|
640
|
-
export type
|
|
641
|
-
export type
|
|
642
|
-
export type
|
|
632
|
+
export type Flow = z.infer<typeof FlowSchema>;
|
|
633
|
+
export type FlowNode = z.infer<typeof FlowNodeSchema>;
|
|
634
|
+
export type FlowConnector = z.infer<typeof FlowConnectorSchema>;
|
|
643
635
|
|
|
644
636
|
// =============================================================================
|
|
645
637
|
// Style schema — keyed map of presentation overrides, side-table by id.
|
|
@@ -658,7 +650,6 @@ const NodeStyleSchema = z
|
|
|
658
650
|
fontSize: z.number().positive().optional(),
|
|
659
651
|
textColor: ColorTokenSchema.optional(),
|
|
660
652
|
cornerRadius: z.number().min(0).optional(),
|
|
661
|
-
locked: z.boolean().optional(),
|
|
662
653
|
// imageNode-specific
|
|
663
654
|
borderWidth: z.number().min(1).max(8).optional(),
|
|
664
655
|
// iconNode-specific
|
package/src/status-runner.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
|
25
25
|
import type { EventBus } from './events.ts';
|
|
26
26
|
import { type ProcessSpawner, type SpawnHandle, defaultProcessSpawner } from './process-spawner.ts';
|
|
27
27
|
import type { FlowEntry, Registry } from './registry.ts';
|
|
28
|
-
import { type
|
|
28
|
+
import { type ResolvedFlow, type StatusAction, StatusReportSchema } from './schema.ts';
|
|
29
29
|
import { readMergedFlow } from './watcher.ts';
|
|
30
30
|
|
|
31
31
|
export interface StatusRunner {
|
|
@@ -141,10 +141,10 @@ function truncate(s: string, n: number): string {
|
|
|
141
141
|
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
async function loadDemo(entry: FlowEntry): Promise<
|
|
145
|
-
const fullPath = isAbsolute(entry.
|
|
146
|
-
? entry.
|
|
147
|
-
: join(entry.repoPath, entry.
|
|
144
|
+
async function loadDemo(entry: FlowEntry): Promise<ResolvedFlow | undefined> {
|
|
145
|
+
const fullPath = isAbsolute(entry.flowPath)
|
|
146
|
+
? entry.flowPath
|
|
147
|
+
: join(entry.repoPath, entry.flowPath);
|
|
148
148
|
if (!existsSync(fullPath)) return undefined;
|
|
149
149
|
const result = readMergedFlow(fullPath);
|
|
150
150
|
return result.flow ?? undefined;
|
|
@@ -155,7 +155,7 @@ interface StatusNode {
|
|
|
155
155
|
action: StatusAction;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
function collectStatusNodes(demo:
|
|
158
|
+
function collectStatusNodes(demo: ResolvedFlow): StatusNode[] {
|
|
159
159
|
const out: StatusNode[] = [];
|
|
160
160
|
for (const node of demo.nodes) {
|
|
161
161
|
if (node.type !== 'playNode' && node.type !== 'stateNode') continue;
|
package/src/watcher.ts
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { type FSWatcher, existsSync, readFileSync, watch } from 'node:fs';
|
|
2
3
|
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
3
4
|
import type { EventBus } from './events.ts';
|
|
4
5
|
import { resolveFileRefs } from './file-ref.ts';
|
|
5
|
-
import {
|
|
6
|
+
import { mergeFlowAndStyle } from './merge.ts';
|
|
6
7
|
import type { Registry } from './registry.ts';
|
|
7
|
-
import { type
|
|
8
|
+
import { type Flow, FlowSchema, type ResolvedFlow, StyleSchema } from './schema.ts';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_DEBOUNCE_MS = 100;
|
|
10
11
|
|
|
12
|
+
/** Max recent self-write hashes retained per flow for own-echo suppression. */
|
|
13
|
+
const WRITTEN_HASH_RING_SIZE = 4;
|
|
14
|
+
|
|
15
|
+
const sha256Hex = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Canonical "what's on disk for this flow" string used for own-write
|
|
19
|
+
* dedupe. Combines flow.json and style.json bytes so a self-write that
|
|
20
|
+
* touches either file is recognized; a NUL separator keeps the boundary
|
|
21
|
+
* unambiguous. `styleContent` is `''` when style.json doesn't exist.
|
|
22
|
+
*/
|
|
23
|
+
const combinedContent = (flowContent: string, styleContent: string): string =>
|
|
24
|
+
`${flowContent}\0${styleContent}`;
|
|
25
|
+
|
|
11
26
|
export interface FlowSnapshot {
|
|
12
27
|
/** Last successfully parsed flow, if we ever saw one. */
|
|
13
|
-
flow:
|
|
28
|
+
flow: ResolvedFlow | null;
|
|
14
29
|
/** Result of the most recent parse attempt. */
|
|
15
30
|
valid: boolean;
|
|
16
31
|
/** Human-readable error from the most recent parse, when `valid: false`. */
|
|
@@ -41,6 +56,19 @@ export interface FlowWatcher {
|
|
|
41
56
|
closeAll(): void;
|
|
42
57
|
/** Force a reparse synchronously. Useful for tests + initial load. */
|
|
43
58
|
reparse(flowId: string): FlowSnapshot | null;
|
|
59
|
+
/**
|
|
60
|
+
* Record a snapshot that the server just wrote and broadcast flow:reload
|
|
61
|
+
* directly from it. Stores the file-content hash so the upcoming fs-watcher
|
|
62
|
+
* echo for this same write is suppressed (see startWatch's debounce
|
|
63
|
+
* callback). `flowContent` / `styleContent` are the exact bytes written —
|
|
64
|
+
* pass `''` for style when style.json was deleted or doesn't exist.
|
|
65
|
+
*/
|
|
66
|
+
notifyWritten(
|
|
67
|
+
flowId: string,
|
|
68
|
+
snap: FlowSnapshot,
|
|
69
|
+
flowContent: string,
|
|
70
|
+
styleContent: string,
|
|
71
|
+
): void;
|
|
44
72
|
/**
|
|
45
73
|
* Relative paths (under `<project>/.seeflow/`) currently being watched
|
|
46
74
|
* because they're referenced by a node's `data.htmlPath` or `data.path`.
|
|
@@ -69,8 +97,24 @@ interface WatchHandle {
|
|
|
69
97
|
fileWatchers: Map<string, FileWatchEntry>;
|
|
70
98
|
}
|
|
71
99
|
|
|
72
|
-
const resolveFilePath = (repoPath: string,
|
|
73
|
-
isAbsolute(
|
|
100
|
+
const resolveFilePath = (repoPath: string, flowPath: string): string =>
|
|
101
|
+
isAbsolute(flowPath) ? flowPath : join(repoPath, flowPath);
|
|
102
|
+
|
|
103
|
+
// `file://` refs in flow.json resolve against `<project>/.seeflow/` per the
|
|
104
|
+
// skill spec — not against the flow file's own directory. Walk up from the
|
|
105
|
+
// flow's parent looking for an ancestor named `.seeflow`. Fallback to the
|
|
106
|
+
// flow's parent for flows registered outside the `.seeflow/` convention.
|
|
107
|
+
const computeSeeflowRoot = (flowPath: string): string => {
|
|
108
|
+
const flowDir = dirname(flowPath);
|
|
109
|
+
let current = flowDir;
|
|
110
|
+
while (true) {
|
|
111
|
+
if (basename(current) === '.seeflow') return current;
|
|
112
|
+
const parent = dirname(current);
|
|
113
|
+
if (parent === current) break;
|
|
114
|
+
current = parent;
|
|
115
|
+
}
|
|
116
|
+
return flowDir;
|
|
117
|
+
};
|
|
74
118
|
|
|
75
119
|
const isCleanRelativePath = (p: string): boolean => {
|
|
76
120
|
if (!p) return false;
|
|
@@ -85,7 +129,7 @@ const isCleanRelativePath = (p: string): boolean => {
|
|
|
85
129
|
};
|
|
86
130
|
|
|
87
131
|
/**
|
|
88
|
-
* Walk raw
|
|
132
|
+
* Walk raw flow JSON (pre-schema-parse) collecting referenced file
|
|
89
133
|
* paths: `nodes[].data.htmlPath` (htmlNode) and `nodes[].data.path`
|
|
90
134
|
* (imageNode). Operates on the raw JSON so the watcher works before those
|
|
91
135
|
* fields are formally validated.
|
|
@@ -110,21 +154,21 @@ const collectReferencedPaths = (raw: unknown): string[] => {
|
|
|
110
154
|
};
|
|
111
155
|
|
|
112
156
|
/**
|
|
113
|
-
* Read
|
|
114
|
-
*
|
|
115
|
-
*
|
|
157
|
+
* Read flow.json + optional style.json, resolve file:// refs in the flow,
|
|
158
|
+
* validate both, and merge into a ResolvedFlow. Shared by the watcher and
|
|
159
|
+
* by sync read fallbacks (getFlowImpl) so they produce identical results.
|
|
116
160
|
*/
|
|
117
161
|
export interface ReadMergedFlowResult {
|
|
118
|
-
flow:
|
|
162
|
+
flow: ResolvedFlow | null;
|
|
119
163
|
valid: boolean;
|
|
120
164
|
error: string | null;
|
|
121
165
|
/** Sorted relative paths under `<seeflowRoot>` resolved via file://. */
|
|
122
166
|
fileRefs: string[];
|
|
123
|
-
/**
|
|
167
|
+
/** Flow file paths referenced via htmlPath / imageNode.path. */
|
|
124
168
|
staticRefs: string[];
|
|
125
169
|
}
|
|
126
170
|
|
|
127
|
-
export function readMergedFlow(
|
|
171
|
+
export function readMergedFlow(flowPath: string): ReadMergedFlowResult {
|
|
128
172
|
const empty: ReadMergedFlowResult = {
|
|
129
173
|
flow: null,
|
|
130
174
|
valid: false,
|
|
@@ -132,34 +176,35 @@ export function readMergedFlow(archPath: string): ReadMergedFlowResult {
|
|
|
132
176
|
fileRefs: [],
|
|
133
177
|
staticRefs: [],
|
|
134
178
|
};
|
|
135
|
-
if (!existsSync(
|
|
136
|
-
return { ...empty, error: `
|
|
179
|
+
if (!existsSync(flowPath)) {
|
|
180
|
+
return { ...empty, error: `Flow file not found: ${flowPath}` };
|
|
137
181
|
}
|
|
138
182
|
|
|
139
|
-
const
|
|
140
|
-
const
|
|
183
|
+
const flowDir = dirname(flowPath);
|
|
184
|
+
const seeflowRoot = computeSeeflowRoot(flowPath);
|
|
185
|
+
const stylePath = join(flowDir, 'style.json');
|
|
141
186
|
|
|
142
|
-
let
|
|
187
|
+
let rawFlow: unknown;
|
|
143
188
|
try {
|
|
144
|
-
|
|
189
|
+
rawFlow = JSON.parse(readFileSync(flowPath, 'utf8'));
|
|
145
190
|
} catch (err) {
|
|
146
191
|
return {
|
|
147
192
|
...empty,
|
|
148
|
-
error: `Invalid JSON in
|
|
193
|
+
error: `Invalid JSON in flow.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
149
194
|
};
|
|
150
195
|
}
|
|
151
196
|
|
|
152
|
-
const { resolved, refs } = resolveFileRefs(
|
|
153
|
-
const staticRefs = collectReferencedPaths(
|
|
197
|
+
const { resolved, refs } = resolveFileRefs(rawFlow, seeflowRoot);
|
|
198
|
+
const staticRefs = collectReferencedPaths(rawFlow);
|
|
154
199
|
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
157
|
-
const message =
|
|
200
|
+
const flowParse = FlowSchema.safeParse(resolved);
|
|
201
|
+
if (!flowParse.success) {
|
|
202
|
+
const message = flowParse.error.issues
|
|
158
203
|
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
|
159
204
|
.join('; ');
|
|
160
205
|
return {
|
|
161
206
|
...empty,
|
|
162
|
-
error: `
|
|
207
|
+
error: `Flow schema validation failed: ${message}`,
|
|
163
208
|
fileRefs: refs,
|
|
164
209
|
staticRefs,
|
|
165
210
|
};
|
|
@@ -192,7 +237,7 @@ export function readMergedFlow(archPath: string): ReadMergedFlowResult {
|
|
|
192
237
|
};
|
|
193
238
|
}
|
|
194
239
|
|
|
195
|
-
const flow =
|
|
240
|
+
const flow = mergeFlowAndStyle(flowParse.data as Flow, styleParse.data);
|
|
196
241
|
return { flow, valid: true, error: null, fileRefs: refs, staticRefs };
|
|
197
242
|
}
|
|
198
243
|
|
|
@@ -210,6 +255,43 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
210
255
|
|
|
211
256
|
const handles = new Map<string, WatchHandle>();
|
|
212
257
|
const snapshots = new Map<string, FlowSnapshot>();
|
|
258
|
+
/**
|
|
259
|
+
* Ring buffer of recent self-write content hashes per flow. The fs watcher
|
|
260
|
+
* computes the same hash on its debounced callback and short-circuits when
|
|
261
|
+
* it matches — that's how a server-initiated PATCH avoids re-broadcasting
|
|
262
|
+
* itself on top of the direct notifyWritten broadcast.
|
|
263
|
+
*/
|
|
264
|
+
const writtenHashes = new Map<string, string[]>();
|
|
265
|
+
|
|
266
|
+
const rememberWrittenHash = (flowId: string, hash: string): void => {
|
|
267
|
+
const ring = writtenHashes.get(flowId);
|
|
268
|
+
if (!ring) {
|
|
269
|
+
writtenHashes.set(flowId, [hash]);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
ring.push(hash);
|
|
273
|
+
if (ring.length > WRITTEN_HASH_RING_SIZE) ring.shift();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const isOwnWriteEcho = (flowId: string, hash: string): boolean =>
|
|
277
|
+
writtenHashes.get(flowId)?.includes(hash) ?? false;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Read flow.json + style.json bytes at this moment so the fs callback can
|
|
281
|
+
* compute the same combined hash that notifyWritten recorded. Missing
|
|
282
|
+
* style.json maps to empty string — matches notifyWritten's contract.
|
|
283
|
+
*/
|
|
284
|
+
const readCombinedFromDisk = (flowPath: string): string | null => {
|
|
285
|
+
let flowContent: string;
|
|
286
|
+
try {
|
|
287
|
+
flowContent = readFileSync(flowPath, 'utf8');
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
const stylePath = join(dirname(flowPath), 'style.json');
|
|
292
|
+
const styleContent = existsSync(stylePath) ? readFileSync(stylePath, 'utf8') : '';
|
|
293
|
+
return combinedContent(flowContent, styleContent);
|
|
294
|
+
};
|
|
213
295
|
|
|
214
296
|
// Reconcile the file-watch set for `flowId` against the desired referenced
|
|
215
297
|
// paths. Closes watchers for dirs that disappeared, updates the basename
|
|
@@ -300,7 +382,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
300
382
|
const reparse = (flowId: string): FlowSnapshot | null => {
|
|
301
383
|
const entry = registry.getById(flowId);
|
|
302
384
|
if (!entry) return null;
|
|
303
|
-
const filePath = resolveFilePath(entry.repoPath, entry.
|
|
385
|
+
const filePath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
304
386
|
|
|
305
387
|
const previous = snapshots.get(flowId) ?? null;
|
|
306
388
|
const parsedAt = Date.now();
|
|
@@ -313,14 +395,14 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
313
395
|
snapshots.set(flowId, next);
|
|
314
396
|
|
|
315
397
|
// Reconcile the referenced-file watch set: htmlPath/imageNode.path from
|
|
316
|
-
//
|
|
398
|
+
// flow + any file:// targets that resolved cleanly. Schema errors
|
|
317
399
|
// shouldn't drop the watch set — the user is mid-edit and the referenced
|
|
318
400
|
// files are still valid targets, so this reconciles whenever the JSON
|
|
319
401
|
// parsed (even if schema validation failed).
|
|
320
402
|
const handle = handles.get(flowId);
|
|
321
403
|
if (handle) {
|
|
322
404
|
const allRefs = [...result.fileRefs, ...result.staticRefs];
|
|
323
|
-
reconcileFileWatchers(flowId, handle,
|
|
405
|
+
reconcileFileWatchers(flowId, handle, computeSeeflowRoot(filePath), allRefs);
|
|
324
406
|
}
|
|
325
407
|
|
|
326
408
|
return next;
|
|
@@ -346,7 +428,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
346
428
|
const entry = registry.getById(flowId);
|
|
347
429
|
if (!entry) return;
|
|
348
430
|
|
|
349
|
-
const filePath = resolveFilePath(entry.repoPath, entry.
|
|
431
|
+
const filePath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
350
432
|
const dir = dirname(filePath);
|
|
351
433
|
const base = basename(filePath);
|
|
352
434
|
|
|
@@ -360,7 +442,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
360
442
|
let fsWatcher: FSWatcher;
|
|
361
443
|
try {
|
|
362
444
|
fsWatcher = watch(dir, { persistent: true }, (_event, changed) => {
|
|
363
|
-
// React to
|
|
445
|
+
// React to flow.json, style.json, or rename-on-save events
|
|
364
446
|
// (some platforms emit those with no filename).
|
|
365
447
|
if (changed && changed !== base && changed !== 'style.json') return;
|
|
366
448
|
const handle = handles.get(flowId);
|
|
@@ -368,6 +450,11 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
368
450
|
if (handle.debounceTimer) clearTimeout(handle.debounceTimer);
|
|
369
451
|
handle.debounceTimer = setTimeout(() => {
|
|
370
452
|
handle.debounceTimer = null;
|
|
453
|
+
// Own-write dedupe: if the on-disk bytes match what the server just
|
|
454
|
+
// wrote (recent hash in the ring), this is our own echo — drop it.
|
|
455
|
+
// notifyWritten already broadcast and seeded the snapshot.
|
|
456
|
+
const combined = readCombinedFromDisk(filePath);
|
|
457
|
+
if (combined !== null && isOwnWriteEcho(flowId, sha256Hex(combined))) return;
|
|
371
458
|
const snap = reparse(flowId);
|
|
372
459
|
if (snap) broadcastReload(flowId, snap);
|
|
373
460
|
}, debounceMs);
|
|
@@ -419,8 +506,14 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
|
|
|
419
506
|
}
|
|
420
507
|
handles.clear();
|
|
421
508
|
snapshots.clear();
|
|
509
|
+
writtenHashes.clear();
|
|
422
510
|
},
|
|
423
511
|
reparse,
|
|
512
|
+
notifyWritten(flowId, snap, flowContent, styleContent) {
|
|
513
|
+
snapshots.set(flowId, snap);
|
|
514
|
+
rememberWrittenHash(flowId, sha256Hex(combinedContent(flowContent, styleContent)));
|
|
515
|
+
broadcastReload(flowId, snap);
|
|
516
|
+
},
|
|
424
517
|
referencedPaths(flowId) {
|
|
425
518
|
const h = handles.get(flowId);
|
|
426
519
|
if (!h) return [];
|