pmx-canvas 0.4.6 → 0.4.8
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/CHANGELOG.md +79 -0
- package/Readme.md +56 -33
- package/dist/canvas/global.css +101 -3
- package/dist/canvas/index.js +93 -89
- package/dist/types/cli/shared.d.ts +13 -1
- package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/state/canvas-store.d.ts +10 -0
- package/dist/types/client/state/intent-bridge.d.ts +9 -0
- package/dist/types/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-db.d.ts +14 -0
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-state.d.ts +8 -0
- package/dist/types/server/canvas-validation.d.ts +15 -21
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/RELEASE.md +48 -0
- package/docs/cli.md +19 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +17 -0
- package/docs/mcp.md +12 -0
- package/docs/node-types.md +45 -1
- package/docs/screenshot.png +0 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +74 -9
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/skills/pmx-canvas-testing/SKILL.md +40 -0
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +2 -2
- package/src/cli/commands/query.ts +1 -1
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +6 -0
- package/src/cli/shared.ts +34 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
- package/src/client/canvas/auto-fit.ts +10 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/ExtAppFrame.tsx +79 -2
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/sse-bridge.ts +4 -1
- package/src/client/theme/global.css +101 -3
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +299 -4
- package/src/server/canvas-db.ts +138 -32
- package/src/server/canvas-operations.ts +44 -17
- package/src/server/canvas-state.ts +20 -10
- package/src/server/canvas-validation.ts +50 -41
- package/src/server/demo-state.json +4605 -523
- package/src/server/demo.ts +24 -0
- package/src/server/file-content.ts +152 -0
- package/src/server/file-watcher.ts +7 -10
- package/src/server/html-primitives.ts +636 -0
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/nodes.ts +15 -1
- package/src/server/operations/ops/viewport.ts +7 -1
- package/src/server/server.ts +106 -5
- package/src/server/web-artifacts.ts +109 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/node-sizes.ts +52 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
package/src/server/demo.ts
CHANGED
|
@@ -13,6 +13,14 @@ interface DemoStateFixture {
|
|
|
13
13
|
edges: CanvasEdge[];
|
|
14
14
|
annotations?: CanvasAnnotation[];
|
|
15
15
|
contextPins?: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Canvas-bound AX state (work items and friends), restored verbatim so the
|
|
18
|
+
* `data.axStep.workItemId` stamped on the ⑧ agent-at-work step nodes resolves
|
|
19
|
+
* to a real work item — without it the seeded Start/Done controls 404.
|
|
20
|
+
* Timeline rows (events/evidence/steering) are deliberately absent: snapshots
|
|
21
|
+
* do not restore them either.
|
|
22
|
+
*/
|
|
23
|
+
ax?: unknown;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
const demoState = demoStateJson as DemoStateFixture;
|
|
@@ -29,6 +37,22 @@ export function seedDemoCanvas(): { nodes: number; edges: number; groups: number
|
|
|
29
37
|
for (const annotation of annotations) canvasState.addAnnotation(annotation);
|
|
30
38
|
canvasState.setContextPins(pins);
|
|
31
39
|
canvasState.setViewport(demoState.viewport);
|
|
40
|
+
// After the nodes exist: the AX partition is normalized against the current
|
|
41
|
+
// node set, so applying it first would strip every work item's `nodeIds`.
|
|
42
|
+
//
|
|
43
|
+
// Only when the canvas-bound AX partition is EMPTY. applyPersistedAxState
|
|
44
|
+
// REPLACES it (work items, gates, review annotations, and the policy
|
|
45
|
+
// singleton) and the seed is flushed to disk — and the caller's
|
|
46
|
+
// only-seed-when-empty guard counts NODES only. Work items need no nodes,
|
|
47
|
+
// so an agent tracking a plan on a bare board would have it destroyed by
|
|
48
|
+
// `--demo`.
|
|
49
|
+
const axPartitionEmpty =
|
|
50
|
+
canvasState.getWorkItems().length === 0 &&
|
|
51
|
+
canvasState.getApprovalGates().length === 0 &&
|
|
52
|
+
canvasState.getReviewAnnotations().length === 0;
|
|
53
|
+
if (demoState.ax !== undefined && axPartitionEmpty) {
|
|
54
|
+
canvasState.applyPersistedAxState(structuredClone(demoState.ax));
|
|
55
|
+
}
|
|
32
56
|
});
|
|
33
57
|
canvasState.flushToDisk();
|
|
34
58
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared disk reader for `file` canvas nodes.
|
|
3
|
+
*
|
|
4
|
+
* Both entry points that put a file on the canvas — `buildFileNodeData`
|
|
5
|
+
* (creation) and the file watcher (live updates) — go through here so their
|
|
6
|
+
* notion of "what this file is" can never drift. Reading a file blind with
|
|
7
|
+
* `readFileSync(path, 'utf-8')` turned binaries into mojibake and had no size
|
|
8
|
+
* ceiling at all, so a multi-GB file was pulled into memory as a string.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs';
|
|
12
|
+
import { extname } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** Text past this many bytes is stored truncated instead of whole. */
|
|
15
|
+
export const MAX_FILE_TEXT_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/** Bytes sniffed to classify a file without reading all of it. */
|
|
18
|
+
const SNIFF_BYTES = 8192;
|
|
19
|
+
|
|
20
|
+
const PDF_MAGIC = Buffer.from('%PDF-', 'ascii');
|
|
21
|
+
|
|
22
|
+
export type FileNodeContent =
|
|
23
|
+
/** Missing, unreadable, or not a regular file — the node stays path-backed. */
|
|
24
|
+
| { kind: 'unavailable' }
|
|
25
|
+
| { kind: 'binary'; byteSize: number; mtimeMs: number; mimeType?: string }
|
|
26
|
+
| { kind: 'text'; byteSize: number; mtimeMs: number; text: string; lineCount: number; truncated: boolean };
|
|
27
|
+
|
|
28
|
+
function isPdfFile(prefix: Buffer, filePath: string): boolean {
|
|
29
|
+
if (prefix.subarray(0, PDF_MAGIC.length).equals(PDF_MAGIC)) return true;
|
|
30
|
+
return extname(filePath).toLowerCase() === '.pdf';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* PDF check straight from a path, for the byte route's server-derived
|
|
35
|
+
* Content-Type. Reads only the magic-byte prefix. Any read failure is "not a
|
|
36
|
+
* PDF" — the caller then serves an opaque download rather than an inline type.
|
|
37
|
+
*/
|
|
38
|
+
export function isPdfFilePath(filePath: string): boolean {
|
|
39
|
+
let fd: number | null = null;
|
|
40
|
+
try {
|
|
41
|
+
fd = openSync(filePath, 'r');
|
|
42
|
+
const prefix = Buffer.alloc(PDF_MAGIC.length);
|
|
43
|
+
const read = readSync(fd, prefix, 0, PDF_MAGIC.length, 0);
|
|
44
|
+
return isPdfFile(prefix.subarray(0, read), filePath);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
} finally {
|
|
48
|
+
if (fd !== null) {
|
|
49
|
+
try {
|
|
50
|
+
closeSync(fd);
|
|
51
|
+
} catch {
|
|
52
|
+
// best effort
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Classify a file and read at most `MAX_FILE_TEXT_BYTES` of it.
|
|
60
|
+
*
|
|
61
|
+
* Never throws: an unreadable path is reported as `unavailable`, matching the
|
|
62
|
+
* pre-existing behavior where a bad path still rendered as a file node.
|
|
63
|
+
*/
|
|
64
|
+
export function readFileNodeContent(filePath: string): FileNodeContent {
|
|
65
|
+
let fd: number | null = null;
|
|
66
|
+
try {
|
|
67
|
+
const stat = statSync(filePath);
|
|
68
|
+
if (!stat.isFile()) return { kind: 'unavailable' };
|
|
69
|
+
const byteSize = stat.size;
|
|
70
|
+
const mtimeMs = stat.mtimeMs;
|
|
71
|
+
|
|
72
|
+
fd = openSync(filePath, 'r');
|
|
73
|
+
|
|
74
|
+
// Classify from a prefix only — never read a huge file just to type it.
|
|
75
|
+
const sniff = Buffer.alloc(Math.min(SNIFF_BYTES, byteSize));
|
|
76
|
+
const prefix = sniff.subarray(0, sniff.length > 0 ? readSync(fd, sniff, 0, sniff.length, 0) : 0);
|
|
77
|
+
|
|
78
|
+
// A PDF need not carry a NUL byte in its header, so the magic bytes decide
|
|
79
|
+
// first; `.pdf` is the secondary signal for the same call.
|
|
80
|
+
if (isPdfFile(prefix, filePath)) {
|
|
81
|
+
return { kind: 'binary', byteSize, mtimeMs, mimeType: 'application/pdf' };
|
|
82
|
+
}
|
|
83
|
+
if (prefix.includes(0x00)) {
|
|
84
|
+
return { kind: 'binary', byteSize, mtimeMs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const readLength = Math.min(byteSize, MAX_FILE_TEXT_BYTES);
|
|
88
|
+
const buffer = Buffer.alloc(readLength);
|
|
89
|
+
let filled = 0;
|
|
90
|
+
while (filled < readLength) {
|
|
91
|
+
const read = readSync(fd, buffer, filled, readLength - filled, filled);
|
|
92
|
+
if (read <= 0) break;
|
|
93
|
+
filled += read;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// `stream: true` makes the decoder hold back a multi-byte sequence the cap
|
|
97
|
+
// sliced in half instead of emitting U+FFFD; we never flush, so the partial
|
|
98
|
+
// trailing bytes are simply dropped and the text ends on a real character.
|
|
99
|
+
const text = new TextDecoder('utf-8').decode(buffer.subarray(0, filled), { stream: true });
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
kind: 'text',
|
|
103
|
+
byteSize,
|
|
104
|
+
mtimeMs,
|
|
105
|
+
text,
|
|
106
|
+
lineCount: text.split('\n').length,
|
|
107
|
+
truncated: byteSize > MAX_FILE_TEXT_BYTES,
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return { kind: 'unavailable' };
|
|
111
|
+
} finally {
|
|
112
|
+
if (fd !== null) closeSync(fd);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Map a read result onto `file` node data in place, clearing the fields the new
|
|
118
|
+
* state contradicts (a file that turns binary must not keep stale decoded text).
|
|
119
|
+
*/
|
|
120
|
+
export function applyFileContentToNodeData(
|
|
121
|
+
data: Record<string, unknown>,
|
|
122
|
+
content: FileNodeContent,
|
|
123
|
+
): Record<string, unknown> {
|
|
124
|
+
if (content.kind === 'unavailable') return data;
|
|
125
|
+
|
|
126
|
+
data.byteSize = content.byteSize;
|
|
127
|
+
data.updatedAt = new Date(content.mtimeMs).toISOString();
|
|
128
|
+
|
|
129
|
+
if (content.kind === 'binary') {
|
|
130
|
+
data.binary = true;
|
|
131
|
+
delete data.fileContent;
|
|
132
|
+
delete data.lineCount;
|
|
133
|
+
delete data.truncated;
|
|
134
|
+
if (content.mimeType) {
|
|
135
|
+
data.mimeType = content.mimeType;
|
|
136
|
+
} else {
|
|
137
|
+
delete data.mimeType;
|
|
138
|
+
}
|
|
139
|
+
return data;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
data.fileContent = content.text;
|
|
143
|
+
data.lineCount = content.lineCount;
|
|
144
|
+
delete data.binary;
|
|
145
|
+
delete data.mimeType;
|
|
146
|
+
if (content.truncated) {
|
|
147
|
+
data.truncated = true;
|
|
148
|
+
} else {
|
|
149
|
+
delete data.truncated;
|
|
150
|
+
}
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* edits a file, the canvas node updates automatically.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { watch, existsSync,
|
|
9
|
+
import { watch, existsSync, statSync } from 'node:fs';
|
|
10
10
|
import type { FSWatcher } from 'node:fs';
|
|
11
11
|
import { canvasState } from './canvas-state.js';
|
|
12
|
+
import { applyFileContentToNodeData, readFileNodeContent } from './file-content.js';
|
|
12
13
|
|
|
13
14
|
function logFileWatcherWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
14
15
|
console.warn(`[file-watcher] ${action}`, { error, ...(details ?? {}) });
|
|
@@ -97,21 +98,17 @@ function handleFileChange(filePath: string): void {
|
|
|
97
98
|
if (stat.mtimeMs === entry.lastMtime) return;
|
|
98
99
|
entry.lastMtime = stat.mtimeMs;
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
101
|
+
// Shared with node creation, so a file that becomes binary or crosses the
|
|
102
|
+
// text cap updates the same fields here (and drops stale decoded text).
|
|
103
|
+
const content = readFileNodeContent(filePath);
|
|
104
|
+
if (content.kind === 'unavailable') return;
|
|
103
105
|
|
|
104
106
|
// Update all nodes watching this file
|
|
105
107
|
for (const nodeId of entry.nodeIds) {
|
|
106
108
|
const node = canvasState.getNode(nodeId);
|
|
107
109
|
if (!node) continue;
|
|
108
110
|
canvasState.updateNode(nodeId, {
|
|
109
|
-
data: {
|
|
110
|
-
...node.data,
|
|
111
|
-
fileContent: content,
|
|
112
|
-
lineCount,
|
|
113
|
-
updatedAt,
|
|
114
|
-
},
|
|
111
|
+
data: applyFileContentToNodeData({ ...node.data }, content),
|
|
115
112
|
});
|
|
116
113
|
onFileChanged?.(nodeId);
|
|
117
114
|
}
|