pmx-canvas 0.3.4 → 0.4.0
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 +53 -0
- package/Readme.md +11 -3
- package/dist/canvas/index.js +67 -67
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/dist/types/client/state/node-factory.d.ts +29 -0
- package/dist/types/client/uuid-polyfill.d.ts +0 -0
- package/dist/types/server/canvas-db.d.ts +0 -1
- package/dist/types/server/canvas-state.d.ts +14 -15
- package/dist/types/server/operations/composites.d.ts +6 -21
- package/dist/types/server/server.d.ts +1 -0
- package/docs/cli.md +5 -0
- package/docs/environment.md +2 -2
- package/docs/http-api.md +25 -1
- package/docs/mcp.md +21 -30
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -12
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +5 -5
- package/package.json +7 -7
- package/skills/pmx-canvas/SKILL.md +16 -6
- package/skills/pmx-canvas/evals/evals.json +2 -2
- package/skills/pmx-canvas/references/full-reference.md +17 -15
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +31 -1
- package/src/cli/index.ts +8 -2
- package/src/cli/shared.ts +1 -0
- package/src/client/index.tsx +2 -0
- package/src/client/nodes/ExtAppFrame.tsx +17 -2
- package/src/client/state/node-factory.ts +72 -0
- package/src/client/state/sse-bridge.ts +209 -86
- package/src/client/uuid-polyfill.ts +14 -0
- package/src/server/canvas-db.ts +0 -8
- package/src/server/canvas-schema.ts +2 -8
- package/src/server/canvas-state.ts +51 -151
- package/src/server/index.ts +3 -9
- package/src/server/operations/composites.ts +24 -21
- package/src/server/operations/ops/snapshots.ts +6 -7
- package/src/server/operations/registry.ts +4 -1
- package/src/server/server.ts +170 -74
package/src/server/server.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
|
|
25
25
|
* - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
|
|
26
26
|
* - GET /api/workbench/events -> SSE event stream
|
|
27
|
+
* - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
|
|
27
28
|
* - GET /api/workbench/state -> workbench state snapshot
|
|
28
29
|
* - POST /api/workbench/intent -> workbench intents
|
|
29
30
|
* - GET /api/workbench/webview -> Bun.WebView automation status
|
|
@@ -710,7 +711,24 @@ function toSseFrame(event: string, payload: PrimaryWorkbenchEventPayload): Uint8
|
|
|
710
711
|
return textEncoder.encode(`${lines.join('\n')}\n`);
|
|
711
712
|
}
|
|
712
713
|
|
|
714
|
+
// ── Workbench event ring (proxy-safe polling transport) ─────────
|
|
715
|
+
// Buffering proxies (e.g. the Amp orb portal) hold a long-lived SSE response
|
|
716
|
+
// open and never flush it to the browser, so an EventSource client behind one
|
|
717
|
+
// receives nothing and the canvas never boots. Every broadcast event is also
|
|
718
|
+
// recorded here with a monotonic seq; GET /api/workbench/poll replays them as
|
|
719
|
+
// a normal, immediately-closed JSON response that any proxy delivers. Bounded:
|
|
720
|
+
// a client whose `since` fell off the ring gets a fresh connect snapshot,
|
|
721
|
+
// which fully rebuilds client state (same events an SSE connect sends).
|
|
722
|
+
const WORKBENCH_EVENT_RING_MAX = 500;
|
|
723
|
+
let workbenchEventSeq = 0;
|
|
724
|
+
const workbenchEventRing: Array<{ seq: number; event: string; payload: PrimaryWorkbenchEventPayload }> = [];
|
|
725
|
+
|
|
713
726
|
function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPayload): void {
|
|
727
|
+
workbenchEventSeq += 1;
|
|
728
|
+
workbenchEventRing.push({ seq: workbenchEventSeq, event, payload });
|
|
729
|
+
if (workbenchEventRing.length > WORKBENCH_EVENT_RING_MAX) {
|
|
730
|
+
workbenchEventRing.splice(0, workbenchEventRing.length - WORKBENCH_EVENT_RING_MAX);
|
|
731
|
+
}
|
|
714
732
|
const frame = toSseFrame(event, payload);
|
|
715
733
|
for (const [subscriberId, controller] of workbenchSubscribers.entries()) {
|
|
716
734
|
try {
|
|
@@ -1372,14 +1390,19 @@ function responseJson(data: unknown, status = 200): Response {
|
|
|
1372
1390
|
});
|
|
1373
1391
|
}
|
|
1374
1392
|
|
|
1393
|
+
// v0.4.0 (M4): every hand-written error response is a JSON envelope
|
|
1394
|
+
// ({ ok: false, error }, same status codes) matching the registry dispatcher —
|
|
1395
|
+
// consumers no longer need to branch on text/plain vs JSON error bodies.
|
|
1375
1396
|
function responseText(text: string, status = 400): Response {
|
|
1376
|
-
return
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1397
|
+
return Response.json(
|
|
1398
|
+
{ ok: false, error: text },
|
|
1399
|
+
{
|
|
1400
|
+
status,
|
|
1401
|
+
headers: {
|
|
1402
|
+
'Cache-Control': 'no-store',
|
|
1403
|
+
},
|
|
1381
1404
|
},
|
|
1382
|
-
|
|
1405
|
+
);
|
|
1383
1406
|
}
|
|
1384
1407
|
|
|
1385
1408
|
function handleArtifactView(url: URL): Response {
|
|
@@ -1692,15 +1715,134 @@ async function handleWorkbenchIntent(req: Request): Promise<Response> {
|
|
|
1692
1715
|
return responseJson({ ok: true, intent });
|
|
1693
1716
|
}
|
|
1694
1717
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1718
|
+
/**
|
|
1719
|
+
* The connect snapshot: the ordered event list that fully rebuilds a client's
|
|
1720
|
+
* state. Single source for BOTH transports — the SSE stream enqueues these
|
|
1721
|
+
* frames on connect, and /api/workbench/poll returns them as JSON when a
|
|
1722
|
+
* poller has no usable `since` (fresh client, server restart, or ring
|
|
1723
|
+
* eviction). Adding a new connect-time event here reaches both paths.
|
|
1724
|
+
*/
|
|
1725
|
+
function buildWorkbenchConnectSnapshot(
|
|
1726
|
+
requestedSessionId: string,
|
|
1727
|
+
): Array<{ event: string; payload: Record<string, unknown> }> {
|
|
1698
1728
|
const continuity =
|
|
1699
1729
|
requestedSessionId.length === 0
|
|
1700
1730
|
? 'fresh'
|
|
1701
1731
|
: requestedSessionId === primaryWorkbenchSessionId
|
|
1702
1732
|
? 'resumed'
|
|
1703
1733
|
: 'rotated';
|
|
1734
|
+
const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
1735
|
+
events.push({
|
|
1736
|
+
event: 'connected',
|
|
1737
|
+
payload: {
|
|
1738
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1739
|
+
requestedSessionId: requestedSessionId || null,
|
|
1740
|
+
continuity,
|
|
1741
|
+
path: primaryWorkbenchPath,
|
|
1742
|
+
theme: canvasState.theme,
|
|
1743
|
+
timestamp: new Date().toISOString(),
|
|
1744
|
+
},
|
|
1745
|
+
});
|
|
1746
|
+
if (primaryWorkbenchPath) {
|
|
1747
|
+
events.push({
|
|
1748
|
+
event: 'workbench-open',
|
|
1749
|
+
payload: {
|
|
1750
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1751
|
+
path: primaryWorkbenchPath,
|
|
1752
|
+
title: basename(primaryWorkbenchPath),
|
|
1753
|
+
source: 'snapshot',
|
|
1754
|
+
updatedAt: new Date().toISOString(),
|
|
1755
|
+
},
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
if (lastWorkbenchContextCardsEnvelope) {
|
|
1759
|
+
events.push({
|
|
1760
|
+
event: 'context-cards',
|
|
1761
|
+
payload: {
|
|
1762
|
+
...lastWorkbenchContextCardsEnvelope,
|
|
1763
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1764
|
+
timestamp: new Date().toISOString(),
|
|
1765
|
+
},
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
events.push({
|
|
1769
|
+
event: 'mcp-app-host-snapshot',
|
|
1770
|
+
payload: {
|
|
1771
|
+
reason: 'connect-snapshot',
|
|
1772
|
+
...getMcpAppHostSnapshot(),
|
|
1773
|
+
timestamp: new Date().toISOString(),
|
|
1774
|
+
},
|
|
1775
|
+
});
|
|
1776
|
+
events.push({
|
|
1777
|
+
event: 'canvas-layout-update',
|
|
1778
|
+
payload: {
|
|
1779
|
+
layout: canvasState.getLayout(),
|
|
1780
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1781
|
+
timestamp: new Date().toISOString(),
|
|
1782
|
+
},
|
|
1783
|
+
});
|
|
1784
|
+
events.push({
|
|
1785
|
+
event: 'context-pins-changed',
|
|
1786
|
+
payload: {
|
|
1787
|
+
count: canvasState.contextPinnedNodeIds.size,
|
|
1788
|
+
nodeIds: Array.from(canvasState.contextPinnedNodeIds),
|
|
1789
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1790
|
+
timestamp: new Date().toISOString(),
|
|
1791
|
+
},
|
|
1792
|
+
});
|
|
1793
|
+
for (const intent of intentRegistry.list()) {
|
|
1794
|
+
events.push({
|
|
1795
|
+
event: 'ax-intent',
|
|
1796
|
+
payload: {
|
|
1797
|
+
intent,
|
|
1798
|
+
reason: 'connect-snapshot',
|
|
1799
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1800
|
+
timestamp: new Date().toISOString(),
|
|
1801
|
+
},
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
return events;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* Proxy-safe transport (Amp orb portals and any proxy that buffers streaming
|
|
1809
|
+
* responses): returns the buffered events after `since` — or the full connect
|
|
1810
|
+
* snapshot when `since` is absent, from a previous server run, or already
|
|
1811
|
+
* evicted from the ring — as a normal JSON response that closes immediately.
|
|
1812
|
+
* The client polls every couple of seconds; each response flushes through any
|
|
1813
|
+
* proxy. `seq` is the cursor for the next poll.
|
|
1814
|
+
*/
|
|
1815
|
+
function handleWorkbenchPoll(url: URL): Response {
|
|
1816
|
+
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
1817
|
+
const requestedSessionId = url.searchParams.get('session')?.trim() ?? '';
|
|
1818
|
+
const sinceRaw = url.searchParams.get('since');
|
|
1819
|
+
const since = sinceRaw === null ? Number.NaN : Number(sinceRaw);
|
|
1820
|
+
const ringHeadSeq =
|
|
1821
|
+
workbenchEventRing.length > 0 ? (workbenchEventRing[0] as { seq: number }).seq : workbenchEventSeq + 1;
|
|
1822
|
+
const usableSince = Number.isInteger(since) && since >= 0 && since <= workbenchEventSeq && since + 1 >= ringHeadSeq;
|
|
1823
|
+
if (!usableSince) {
|
|
1824
|
+
return responseJson({
|
|
1825
|
+
ok: true,
|
|
1826
|
+
transport: 'poll',
|
|
1827
|
+
snapshot: true,
|
|
1828
|
+
seq: workbenchEventSeq,
|
|
1829
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1830
|
+
events: buildWorkbenchConnectSnapshot(requestedSessionId),
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
return responseJson({
|
|
1834
|
+
ok: true,
|
|
1835
|
+
transport: 'poll',
|
|
1836
|
+
snapshot: false,
|
|
1837
|
+
seq: workbenchEventSeq,
|
|
1838
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1839
|
+
events: workbenchEventRing.filter((entry) => entry.seq > since),
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function handleWorkbenchEvents(req: Request): Response {
|
|
1844
|
+
const reqUrl = new URL(req.url);
|
|
1845
|
+
const requestedSessionId = reqUrl.searchParams.get('session')?.trim() ?? '';
|
|
1704
1846
|
const subscriberId = nextWorkbenchSubscriberId++;
|
|
1705
1847
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
1706
1848
|
|
|
@@ -1708,68 +1850,8 @@ function handleWorkbenchEvents(req: Request): Response {
|
|
|
1708
1850
|
start(controller) {
|
|
1709
1851
|
workbenchSubscribers.set(subscriberId, controller);
|
|
1710
1852
|
syncCanvasBrowserOpenedFromSubscribers();
|
|
1711
|
-
|
|
1712
|
-
toSseFrame(
|
|
1713
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1714
|
-
requestedSessionId: requestedSessionId || null,
|
|
1715
|
-
continuity,
|
|
1716
|
-
path: primaryWorkbenchPath,
|
|
1717
|
-
theme: canvasState.theme,
|
|
1718
|
-
timestamp: new Date().toISOString(),
|
|
1719
|
-
}),
|
|
1720
|
-
);
|
|
1721
|
-
if (primaryWorkbenchPath) {
|
|
1722
|
-
controller.enqueue(
|
|
1723
|
-
toSseFrame('workbench-open', {
|
|
1724
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1725
|
-
path: primaryWorkbenchPath,
|
|
1726
|
-
title: basename(primaryWorkbenchPath),
|
|
1727
|
-
source: 'snapshot',
|
|
1728
|
-
updatedAt: new Date().toISOString(),
|
|
1729
|
-
}),
|
|
1730
|
-
);
|
|
1731
|
-
}
|
|
1732
|
-
if (lastWorkbenchContextCardsEnvelope) {
|
|
1733
|
-
controller.enqueue(
|
|
1734
|
-
toSseFrame('context-cards', {
|
|
1735
|
-
...lastWorkbenchContextCardsEnvelope,
|
|
1736
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1737
|
-
timestamp: new Date().toISOString(),
|
|
1738
|
-
}),
|
|
1739
|
-
);
|
|
1740
|
-
}
|
|
1741
|
-
controller.enqueue(
|
|
1742
|
-
toSseFrame('mcp-app-host-snapshot', {
|
|
1743
|
-
reason: 'connect-snapshot',
|
|
1744
|
-
...getMcpAppHostSnapshot(),
|
|
1745
|
-
timestamp: new Date().toISOString(),
|
|
1746
|
-
}),
|
|
1747
|
-
);
|
|
1748
|
-
const layout = canvasState.getLayout();
|
|
1749
|
-
controller.enqueue(
|
|
1750
|
-
toSseFrame('canvas-layout-update', {
|
|
1751
|
-
layout,
|
|
1752
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1753
|
-
timestamp: new Date().toISOString(),
|
|
1754
|
-
}),
|
|
1755
|
-
);
|
|
1756
|
-
controller.enqueue(
|
|
1757
|
-
toSseFrame('context-pins-changed', {
|
|
1758
|
-
count: canvasState.contextPinnedNodeIds.size,
|
|
1759
|
-
nodeIds: Array.from(canvasState.contextPinnedNodeIds),
|
|
1760
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1761
|
-
timestamp: new Date().toISOString(),
|
|
1762
|
-
}),
|
|
1763
|
-
);
|
|
1764
|
-
for (const intent of intentRegistry.list()) {
|
|
1765
|
-
controller.enqueue(
|
|
1766
|
-
toSseFrame('ax-intent', {
|
|
1767
|
-
intent,
|
|
1768
|
-
reason: 'connect-snapshot',
|
|
1769
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1770
|
-
timestamp: new Date().toISOString(),
|
|
1771
|
-
}),
|
|
1772
|
-
);
|
|
1853
|
+
for (const { event, payload } of buildWorkbenchConnectSnapshot(requestedSessionId)) {
|
|
1854
|
+
controller.enqueue(toSseFrame(event, payload));
|
|
1773
1855
|
}
|
|
1774
1856
|
pingTimer = setInterval(() => {
|
|
1775
1857
|
try {
|
|
@@ -2855,7 +2937,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2855
2937
|
|
|
2856
2938
|
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
2857
2939
|
|
|
2858
|
-
|
|
2940
|
+
// M11: PMX_WEB_CANVAS_PORT (server-specific) wins, then PMX_CANVAS_PORT — so
|
|
2941
|
+
// setting only the client var no longer splits the client and server ports.
|
|
2942
|
+
const preferredPort =
|
|
2943
|
+
options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? DEFAULT_PORT);
|
|
2859
2944
|
const portCandidates =
|
|
2860
2945
|
options.port === 0
|
|
2861
2946
|
? [0]
|
|
@@ -2884,8 +2969,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2884
2969
|
if (url.pathname === '/health') {
|
|
2885
2970
|
// `pid` lets `serve status` report the ACTUAL serving process even
|
|
2886
2971
|
// when the pid file is stale (e.g. an adapter respawned the server
|
|
2887
|
-
// outside `serve --daemon` — 0.3.2 report Finding P).
|
|
2888
|
-
|
|
2972
|
+
// outside `serve --daemon` — 0.3.2 report Finding P). `persistence`
|
|
2973
|
+
// surfaces silent save failures (M4): the canvas keeps serving from
|
|
2974
|
+
// memory, but ok:false means durability is degraded.
|
|
2975
|
+
return responseJson({
|
|
2976
|
+
ok: true,
|
|
2977
|
+
workspace: activeWorkspaceRoot,
|
|
2978
|
+
pid: process.pid,
|
|
2979
|
+
persistence: canvasState.persistenceHealth,
|
|
2980
|
+
});
|
|
2889
2981
|
}
|
|
2890
2982
|
|
|
2891
2983
|
if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.svg') {
|
|
@@ -2952,6 +3044,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2952
3044
|
return handleWorkbenchEvents(req);
|
|
2953
3045
|
}
|
|
2954
3046
|
|
|
3047
|
+
if (url.pathname === '/api/workbench/poll' && req.method === 'GET') {
|
|
3048
|
+
return handleWorkbenchPoll(url);
|
|
3049
|
+
}
|
|
3050
|
+
|
|
2955
3051
|
if (url.pathname === '/api/workbench/intent' && req.method === 'POST') {
|
|
2956
3052
|
return handleWorkbenchIntent(req);
|
|
2957
3053
|
}
|