pmx-canvas 0.3.4 → 0.4.1
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 +76 -0
- package/Readme.md +16 -3
- package/dist/canvas/index.js +80 -80
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
- package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/iframe-mode.d.ts +25 -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/RELEASE.md +10 -4
- package/docs/cli.md +5 -0
- package/docs/environment.md +2 -2
- package/docs/http-api.md +47 -1
- package/docs/mcp.md +21 -30
- package/docs/plans/plan-009-tech-debt-backlog.md +22 -13
- 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 +36 -10
- package/skills/pmx-canvas/evals/evals.json +2 -2
- package/skills/pmx-canvas/references/full-reference.md +17 -15
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- 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 +7 -0
- package/src/client/nodes/ExtAppFrame.tsx +50 -2
- package/src/client/nodes/HtmlNode.tsx +5 -1
- package/src/client/nodes/McpAppNode.tsx +5 -1
- package/src/client/nodes/iframe-document-url.ts +62 -9
- package/src/client/nodes/use-surface-frame.ts +53 -0
- package/src/client/state/canvas-store.ts +5 -0
- package/src/client/state/iframe-mode.ts +103 -0
- package/src/client/state/node-factory.ts +72 -0
- package/src/client/state/sse-bridge.ts +212 -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 +194 -75
|
@@ -22,10 +22,11 @@ import {
|
|
|
22
22
|
restoreLayout,
|
|
23
23
|
sessionId,
|
|
24
24
|
traceEnabled,
|
|
25
|
-
updateNode,
|
|
26
25
|
updateNodeData,
|
|
26
|
+
workbenchConnectionEpoch,
|
|
27
27
|
} from './canvas-store';
|
|
28
28
|
import { fetchAxSurfaceState } from './intent-bridge';
|
|
29
|
+
import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
|
|
29
30
|
import { invalidateTokenCache } from '../theme/tokens';
|
|
30
31
|
import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
|
|
31
32
|
import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
|
|
@@ -36,10 +37,36 @@ let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
|
|
|
36
37
|
let reconnectAttempts = 0;
|
|
37
38
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
39
|
|
|
40
|
+
// ── Proxy-safe polling transport ──────────────────────────────
|
|
41
|
+
// Some proxies (e.g. the Amp orb portal) buffer streaming responses and only
|
|
42
|
+
// flush them on close, so the SSE stream delivers NOTHING — the connection
|
|
43
|
+
// "opens" (headers pass through) but no event ever arrives. Fallback: if the
|
|
44
|
+
// stream produces no event at all within the watchdog window, switch to
|
|
45
|
+
// polling GET /api/workbench/poll, which returns buffered events (or a full
|
|
46
|
+
// connect snapshot) as a normal response every couple of seconds. The same
|
|
47
|
+
// EVENT_HANDLERS map serves both transports. `?transport=poll` forces polling
|
|
48
|
+
// from the start; `?transport=sse` disables the fallback.
|
|
49
|
+
const SSE_FIRST_EVENT_WATCHDOG_MS = 3000;
|
|
50
|
+
const POLL_INTERVAL_MS = 2000;
|
|
51
|
+
const POLL_ERROR_INTERVAL_MS = 5000;
|
|
52
|
+
let pollingMode = false;
|
|
53
|
+
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
54
|
+
let pollSeq: number | null = null;
|
|
55
|
+
let pollGeneration = 0;
|
|
56
|
+
let sseFirstEventWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
|
|
58
|
+
function forcedTransport(): 'poll' | 'sse' | null {
|
|
59
|
+
if (typeof location === 'undefined') return null;
|
|
60
|
+
const value = new URLSearchParams(location.search).get('transport');
|
|
61
|
+
return value === 'poll' || value === 'sse' ? value : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
// Maps responseNodeId → thread prompt node ID so response deltas/completions
|
|
40
65
|
// are routed into the thread's turns array instead of creating separate nodes.
|
|
41
|
-
// Entries are added on response-start and removed on response-complete.
|
|
42
|
-
//
|
|
66
|
+
// Entries are added on response-start and removed on response-complete. Streams
|
|
67
|
+
// still in flight when the connection drops would otherwise leak entries, so
|
|
68
|
+
// connectSSE() — the shared entry path for both transports — clears the map
|
|
69
|
+
// alongside the rest of the per-connection state.
|
|
43
70
|
const responseToThreadMap = new Map<string, string>();
|
|
44
71
|
|
|
45
72
|
// ── Helpers ───────────────────────────────────────────────────
|
|
@@ -71,44 +98,15 @@ function applyLayoutOverrides(node: CanvasNodeState): CanvasNodeState {
|
|
|
71
98
|
};
|
|
72
99
|
}
|
|
73
100
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
Record<'prompt' | 'response', { x: number; y: number; w: number; h: number }> = {
|
|
77
|
-
status: { x: 40, y: 80, w: 300, h: 120 },
|
|
78
|
-
markdown: { x: 380, y: 80, w: 720, h: 600 },
|
|
79
|
-
context: { x: 1130, y: 80, w: 320, h: 400 },
|
|
80
|
-
'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
|
|
81
|
-
webpage: { x: 380, y: 80, w: 520, h: 420 },
|
|
82
|
-
'json-render': { x: 380, y: 720, w: 840, h: 620 },
|
|
83
|
-
graph: { x: 380, y: 720, w: 760, h: 520 },
|
|
84
|
-
ledger: { x: 1130, y: 520, w: 320, h: 280 },
|
|
85
|
-
trace: { x: 40, y: 900, w: 200, h: 56 },
|
|
86
|
-
file: { x: 380, y: 80, w: 720, h: 600 },
|
|
87
|
-
image: { x: 380, y: 80, w: 720, h: 520 },
|
|
88
|
-
html: { x: 380, y: 80, w: 720, h: 640 },
|
|
89
|
-
group: { x: 220, y: 60, w: 840, h: 560 },
|
|
90
|
-
prompt: { x: 380, y: 1260, w: 520, h: 400 },
|
|
91
|
-
response: { x: 380, y: 1480, w: 720, h: 400 },
|
|
92
|
-
};
|
|
93
|
-
|
|
101
|
+
// Default geometry lives in the shared node factory (node-factory.ts); this
|
|
102
|
+
// wrapper just layers the connection-local saved-layout overrides on top.
|
|
94
103
|
function makeNode(
|
|
95
104
|
id: string,
|
|
96
105
|
type: CanvasNodeState['type'],
|
|
97
106
|
data: Record<string, unknown>,
|
|
98
107
|
dockPosition: 'left' | 'right' | null = null,
|
|
99
108
|
): CanvasNodeState {
|
|
100
|
-
|
|
101
|
-
return applyLayoutOverrides({
|
|
102
|
-
id,
|
|
103
|
-
type,
|
|
104
|
-
position: { x: pos.x, y: pos.y },
|
|
105
|
-
size: { width: pos.w, height: pos.h },
|
|
106
|
-
zIndex: type === 'status' ? 0 : 1,
|
|
107
|
-
collapsed: false,
|
|
108
|
-
pinned: false,
|
|
109
|
-
dockPosition,
|
|
110
|
-
data,
|
|
111
|
-
});
|
|
109
|
+
return applyLayoutOverrides(makeNodeState(id, type, data, { dockPosition }));
|
|
112
110
|
}
|
|
113
111
|
|
|
114
112
|
function getMarkdownPlacement(): { x: number; y: number } {
|
|
@@ -205,26 +203,20 @@ function ensureExtAppNode(data: Record<string, unknown>): void {
|
|
|
205
203
|
customX === undefined || customY === undefined
|
|
206
204
|
? findOpenCanvasPosition([...nodes.value.values()], width, height)
|
|
207
205
|
: null;
|
|
208
|
-
const node = applyLayoutOverrides(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
dockPosition: null,
|
|
223
|
-
data: {
|
|
224
|
-
mode: 'ext-app',
|
|
225
|
-
...data,
|
|
226
|
-
},
|
|
227
|
-
});
|
|
206
|
+
const node = applyLayoutOverrides(
|
|
207
|
+
makeNodeState(
|
|
208
|
+
id,
|
|
209
|
+
'mcp-app',
|
|
210
|
+
{ mode: 'ext-app', ...data },
|
|
211
|
+
{
|
|
212
|
+
position: {
|
|
213
|
+
x: customX ?? autoPos?.x ?? pos.x,
|
|
214
|
+
y: customY ?? autoPos?.y ?? pos.y,
|
|
215
|
+
},
|
|
216
|
+
size: { width, height },
|
|
217
|
+
},
|
|
218
|
+
),
|
|
219
|
+
);
|
|
228
220
|
addNode(node);
|
|
229
221
|
if (!node.dockPosition) {
|
|
230
222
|
focusNode(id, { recordHistory: false });
|
|
@@ -402,6 +394,8 @@ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation |
|
|
|
402
394
|
function handleConnected(data: Record<string, unknown>): void {
|
|
403
395
|
sessionId.value = (data.sessionId as string) || '';
|
|
404
396
|
connectionStatus.value = 'connected';
|
|
397
|
+
// Reconnect marker for holders of server-minted URLs (Finding S).
|
|
398
|
+
workbenchConnectionEpoch.value += 1;
|
|
405
399
|
if (typeof data.theme === 'string') {
|
|
406
400
|
applyCanvasTheme(data.theme);
|
|
407
401
|
}
|
|
@@ -637,24 +631,21 @@ function handleCanvasPromptCreated(data: Record<string, unknown>): void {
|
|
|
637
631
|
if (!nodes.value.has(nodeId)) {
|
|
638
632
|
const pos = position ?? DEFAULT_POSITIONS.prompt;
|
|
639
633
|
addNode(
|
|
640
|
-
applyLayoutOverrides(
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
contextNodeIds,
|
|
656
|
-
},
|
|
657
|
-
}),
|
|
634
|
+
applyLayoutOverrides(
|
|
635
|
+
makeNodeState(
|
|
636
|
+
nodeId,
|
|
637
|
+
'prompt',
|
|
638
|
+
{
|
|
639
|
+
text,
|
|
640
|
+
turns: text ? [{ role: 'user', text, status: 'pending' }] : [],
|
|
641
|
+
threadStatus: text ? 'pending' : 'draft',
|
|
642
|
+
status: text ? 'pending' : 'draft',
|
|
643
|
+
parentNodeId,
|
|
644
|
+
contextNodeIds,
|
|
645
|
+
},
|
|
646
|
+
{ position: { x: pos.x, y: pos.y } },
|
|
647
|
+
),
|
|
648
|
+
),
|
|
658
649
|
);
|
|
659
650
|
focusNode(nodeId);
|
|
660
651
|
}
|
|
@@ -706,17 +697,14 @@ function handleCanvasResponseStart(data: Record<string, unknown>): void {
|
|
|
706
697
|
|
|
707
698
|
if (!nodes.value.has(responseNodeId)) {
|
|
708
699
|
addNode(
|
|
709
|
-
applyLayoutOverrides(
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
dockPosition: null,
|
|
718
|
-
data: { content: '', status: 'streaming', promptNodeId },
|
|
719
|
-
}),
|
|
700
|
+
applyLayoutOverrides(
|
|
701
|
+
makeNodeState(
|
|
702
|
+
responseNodeId,
|
|
703
|
+
'response',
|
|
704
|
+
{ content: '', status: 'streaming', promptNodeId },
|
|
705
|
+
{ position: pos },
|
|
706
|
+
),
|
|
707
|
+
),
|
|
720
708
|
);
|
|
721
709
|
}
|
|
722
710
|
|
|
@@ -991,17 +979,124 @@ export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => v
|
|
|
991
979
|
'ax-intent-clear': handleAxIntentClear,
|
|
992
980
|
};
|
|
993
981
|
|
|
982
|
+
/** Dispatch one event (from either transport) through the shared handler map. */
|
|
983
|
+
function dispatchWorkbenchEvent(event: string, payload: unknown): void {
|
|
984
|
+
const handler = EVENT_HANDLERS[event as keyof typeof EVENT_HANDLERS];
|
|
985
|
+
if (!handler) return;
|
|
986
|
+
// The map's handlers each declare their own payload type; over the wire the
|
|
987
|
+
// payload is untyped JSON either way (SSE previously passed JSON.parse output
|
|
988
|
+
// straight in). The double cast states that honestly without `any`.
|
|
989
|
+
const invoke = handler as unknown as (value: unknown) => void;
|
|
990
|
+
try {
|
|
991
|
+
invoke(payload);
|
|
992
|
+
} catch (err) {
|
|
993
|
+
console.warn(`[sse-bridge] Handler for "${event}" failed:`, err);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Polling transport loop. Each response is short-lived, so it flushes through
|
|
999
|
+
* any buffering proxy. Snapshot responses (fresh client / server restart /
|
|
1000
|
+
* ring eviction) carry the same event list an SSE connect sends, so replaying
|
|
1001
|
+
* them through the handlers matches existing reconnect semantics.
|
|
1002
|
+
*/
|
|
1003
|
+
function startPollingTransport(): () => void {
|
|
1004
|
+
pollingMode = true;
|
|
1005
|
+
pollGeneration += 1;
|
|
1006
|
+
const generation = pollGeneration;
|
|
1007
|
+
connectionStatus.value = 'connecting';
|
|
1008
|
+
// Server-run identity from the last poll response. The event seq counter
|
|
1009
|
+
// restarts at 0 on every server boot, so a cursor from a previous run can
|
|
1010
|
+
// look "usable" to a new run and silently skip its earlier events.
|
|
1011
|
+
let lastPollSessionId: string | null = null;
|
|
1012
|
+
let dispatchedOnce = false;
|
|
1013
|
+
|
|
1014
|
+
const pollOnce = async (): Promise<void> => {
|
|
1015
|
+
if (generation !== pollGeneration) return;
|
|
1016
|
+
try {
|
|
1017
|
+
const params = new URLSearchParams();
|
|
1018
|
+
if (pollSeq !== null) params.set('since', String(pollSeq));
|
|
1019
|
+
const sid = sessionId.value;
|
|
1020
|
+
if (sid) params.set('session', sid);
|
|
1021
|
+
const query = params.toString();
|
|
1022
|
+
const response = await fetch(`/api/workbench/poll${query ? `?${query}` : ''}`);
|
|
1023
|
+
if (generation !== pollGeneration) return;
|
|
1024
|
+
if (!response.ok) throw new Error(`poll failed with HTTP ${response.status}`);
|
|
1025
|
+
const body = (await response.json()) as {
|
|
1026
|
+
ok: boolean;
|
|
1027
|
+
seq: number;
|
|
1028
|
+
snapshot: boolean;
|
|
1029
|
+
sessionId?: string;
|
|
1030
|
+
events: Array<{ event: string; payload: unknown }>;
|
|
1031
|
+
};
|
|
1032
|
+
if (generation !== pollGeneration) return;
|
|
1033
|
+
if (!body.ok) throw new Error('poll returned ok: false');
|
|
1034
|
+
const priorSession = lastPollSessionId;
|
|
1035
|
+
lastPollSessionId = body.sessionId ?? null;
|
|
1036
|
+
if (priorSession !== null && body.sessionId && priorSession !== body.sessionId && !body.snapshot) {
|
|
1037
|
+
// The server identity changed under our cursor (restart): a non-snapshot
|
|
1038
|
+
// batch may have silently skipped the new run's earlier events. Discard
|
|
1039
|
+
// it, drop the cursor, and re-poll immediately for a full snapshot.
|
|
1040
|
+
pollSeq = null;
|
|
1041
|
+
pollTimer = setTimeout(() => void pollOnce(), 0);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (body.snapshot && dispatchedOnce) {
|
|
1045
|
+
// A mid-polling snapshot (ring eviction or server restart) replaces
|
|
1046
|
+
// client state wholesale — run the same per-connection resets an SSE
|
|
1047
|
+
// reconnect performs so in-flight stream routes and transient state
|
|
1048
|
+
// from the previous window can't leak (H5 orphan class).
|
|
1049
|
+
savedLayout = restoreLayout();
|
|
1050
|
+
ensureStatusNode();
|
|
1051
|
+
hasInitialServerLayout.value = false;
|
|
1052
|
+
resetAttentionBridge();
|
|
1053
|
+
resetIntents();
|
|
1054
|
+
responseToThreadMap.clear();
|
|
1055
|
+
}
|
|
1056
|
+
for (const entry of body.events) dispatchWorkbenchEvent(entry.event, entry.payload);
|
|
1057
|
+
dispatchedOnce = true;
|
|
1058
|
+
pollSeq = body.seq;
|
|
1059
|
+
connectionStatus.value = 'connected';
|
|
1060
|
+
pollTimer = setTimeout(() => void pollOnce(), POLL_INTERVAL_MS);
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
if (generation !== pollGeneration) return;
|
|
1063
|
+
console.warn('[sse-bridge] poll transport error:', err);
|
|
1064
|
+
connectionStatus.value = 'disconnected';
|
|
1065
|
+
pollTimer = setTimeout(() => void pollOnce(), POLL_ERROR_INTERVAL_MS);
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
void pollOnce();
|
|
1069
|
+
|
|
1070
|
+
return () => {
|
|
1071
|
+
pollGeneration += 1;
|
|
1072
|
+
pollingMode = false;
|
|
1073
|
+
pollSeq = null;
|
|
1074
|
+
if (pollTimer) {
|
|
1075
|
+
clearTimeout(pollTimer);
|
|
1076
|
+
pollTimer = null;
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
994
1081
|
export function connectSSE(): () => void {
|
|
995
1082
|
savedLayout = restoreLayout();
|
|
996
1083
|
ensureStatusNode();
|
|
997
1084
|
hasInitialServerLayout.value = false;
|
|
998
1085
|
resetAttentionBridge();
|
|
999
1086
|
resetIntents();
|
|
1087
|
+
// Response→thread routes belong to the dropped connection's in-flight
|
|
1088
|
+
// streams; clear them so orphans can't accumulate across reconnect cycles.
|
|
1089
|
+
responseToThreadMap.clear();
|
|
1000
1090
|
if (reconnectTimer) {
|
|
1001
1091
|
clearTimeout(reconnectTimer);
|
|
1002
1092
|
reconnectTimer = null;
|
|
1003
1093
|
}
|
|
1004
1094
|
|
|
1095
|
+
const transport = forcedTransport();
|
|
1096
|
+
if (transport === 'poll' || pollingMode) {
|
|
1097
|
+
return startPollingTransport();
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1005
1100
|
const sid = sessionId.value;
|
|
1006
1101
|
const url = sid ? `/api/workbench/events?session=${sid}` : '/api/workbench/events';
|
|
1007
1102
|
connectionStatus.value = 'connecting';
|
|
@@ -1009,10 +1104,28 @@ export function connectSSE(): () => void {
|
|
|
1009
1104
|
const source = new EventSource(url);
|
|
1010
1105
|
eventSource = source;
|
|
1011
1106
|
|
|
1012
|
-
|
|
1107
|
+
// Proxy detection: a buffered stream "opens" (headers pass through) but never
|
|
1108
|
+
// delivers a single event — not even the immediate `connected` frame. Key the
|
|
1109
|
+
// watchdog on the FIRST EVENT, not onopen.
|
|
1110
|
+
let gotFirstEvent = false;
|
|
1111
|
+
if (sseFirstEventWatchdog) clearTimeout(sseFirstEventWatchdog);
|
|
1112
|
+
sseFirstEventWatchdog = null;
|
|
1113
|
+
if (transport !== 'sse') {
|
|
1114
|
+
sseFirstEventWatchdog = setTimeout(() => {
|
|
1115
|
+
sseFirstEventWatchdog = null;
|
|
1116
|
+
if (gotFirstEvent || eventSource !== source) return;
|
|
1117
|
+
console.warn('[sse-bridge] No SSE event within watchdog window — switching to polling transport.');
|
|
1118
|
+
source.close();
|
|
1119
|
+
eventSource = null;
|
|
1120
|
+
startPollingTransport();
|
|
1121
|
+
}, SSE_FIRST_EVENT_WATCHDOG_MS);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
for (const [event] of Object.entries(EVENT_HANDLERS)) {
|
|
1013
1125
|
source.addEventListener(event, (e) => {
|
|
1126
|
+
gotFirstEvent = true;
|
|
1014
1127
|
try {
|
|
1015
|
-
|
|
1128
|
+
dispatchWorkbenchEvent(event, JSON.parse((e as MessageEvent).data));
|
|
1016
1129
|
} catch (err) {
|
|
1017
1130
|
// H5: Surface malformed SSE data during debugging instead of silently swallowing
|
|
1018
1131
|
console.warn(`[sse-bridge] Failed to parse "${event}" event:`, err);
|
|
@@ -1043,6 +1156,19 @@ export function connectSSE(): () => void {
|
|
|
1043
1156
|
clearTimeout(reconnectTimer);
|
|
1044
1157
|
reconnectTimer = null;
|
|
1045
1158
|
}
|
|
1159
|
+
if (sseFirstEventWatchdog) {
|
|
1160
|
+
clearTimeout(sseFirstEventWatchdog);
|
|
1161
|
+
sseFirstEventWatchdog = null;
|
|
1162
|
+
}
|
|
1163
|
+
if (pollingMode) {
|
|
1164
|
+
pollGeneration += 1;
|
|
1165
|
+
pollingMode = false;
|
|
1166
|
+
pollSeq = null;
|
|
1167
|
+
if (pollTimer) {
|
|
1168
|
+
clearTimeout(pollTimer);
|
|
1169
|
+
pollTimer = null;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1046
1172
|
source.close();
|
|
1047
1173
|
eventSource = null;
|
|
1048
1174
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// crypto.randomUUID is only defined in SECURE CONTEXTS. Embedded hosts that
|
|
2
|
+
// serve the canvas over plain http inside an iframe (portal/proxy setups) get
|
|
3
|
+
// `crypto` without `randomUUID`, which would throw on every per-mount nonce
|
|
4
|
+
// (AX tokens, frame tokens, session ids). Polyfill with the standard v4
|
|
5
|
+
// fallback; a strict no-op wherever the native implementation exists. These
|
|
6
|
+
// ids are nonces/correlation ids, not cryptographic secrets, so Math.random
|
|
7
|
+
// quality is acceptable for the fallback path.
|
|
8
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
|
|
9
|
+
(crypto as { randomUUID?: () => string }).randomUUID = () =>
|
|
10
|
+
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
11
|
+
const r = (Math.random() * 16) | 0;
|
|
12
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
13
|
+
});
|
|
14
|
+
}
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -823,14 +823,6 @@ export function readBlobFromDB(db: Database, sha256: string): string | null {
|
|
|
823
823
|
return gunzipSync(row.data).toString('utf-8');
|
|
824
824
|
}
|
|
825
825
|
|
|
826
|
-
export function hasBlobInDB(db: Database, sha256: string): boolean {
|
|
827
|
-
interface CountRow {
|
|
828
|
-
c: number;
|
|
829
|
-
}
|
|
830
|
-
const row = db.query<CountRow, [string]>('SELECT COUNT(*) as c FROM blobs WHERE sha256 = ?').get(sha256);
|
|
831
|
-
return (row?.c ?? 0) > 0;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
826
|
// ── AX Timeline Persistence (NOT snapshotted; bounded by retention) ──
|
|
835
827
|
|
|
836
828
|
function safeParseJson(raw: string | null | undefined): unknown {
|
|
@@ -937,7 +937,7 @@ export function describeCanvasSchema(): {
|
|
|
937
937
|
htmlPrimitives: listHtmlPrimitiveDescriptors(),
|
|
938
938
|
mcp: {
|
|
939
939
|
tools: [
|
|
940
|
-
//
|
|
940
|
+
// 16 composites (canvas_snapshot folded the legacy snapshot standalones in v0.4.0)
|
|
941
941
|
'canvas_node',
|
|
942
942
|
'canvas_render',
|
|
943
943
|
'canvas_edge',
|
|
@@ -947,6 +947,7 @@ export function describeCanvasSchema(): {
|
|
|
947
947
|
'canvas_query',
|
|
948
948
|
'canvas_webview',
|
|
949
949
|
'canvas_app',
|
|
950
|
+
'canvas_snapshot',
|
|
950
951
|
'canvas_ax_state',
|
|
951
952
|
'canvas_ax_work',
|
|
952
953
|
'canvas_ax_gate',
|
|
@@ -960,13 +961,6 @@ export function describeCanvasSchema(): {
|
|
|
960
961
|
'canvas_ax_interaction',
|
|
961
962
|
'canvas_ingest_activity',
|
|
962
963
|
'canvas_screenshot',
|
|
963
|
-
// 6 snapshot tools (deprecated; fold into canvas_snapshot composite in v0.4)
|
|
964
|
-
'canvas_snapshot',
|
|
965
|
-
'canvas_list_snapshots',
|
|
966
|
-
'canvas_restore',
|
|
967
|
-
'canvas_delete_snapshot',
|
|
968
|
-
'canvas_gc_snapshots',
|
|
969
|
-
'canvas_diff',
|
|
970
964
|
],
|
|
971
965
|
resources: ['canvas://schema'],
|
|
972
966
|
nodeTypeRouting: buildMcpNodeTypeRouting(nodeTypes),
|