pmx-canvas 0.5.1 → 0.6.2
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 +50 -0
- package/Readme.md +10 -7
- package/dist/canvas/global.css +101 -7
- package/dist/canvas/index.js +171 -128
- package/dist/canvas/mermaid-entry.js +1303 -1303
- package/dist/json-render/index.js +101 -102
- package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
- package/docs/adr-001-bun-only-runtime.md +1 -1
- package/docs/bun-webview-integration.md +2 -2
- package/docs/node-types.md +1 -1
- package/docs/product-context-vision-2026-09.md +207 -0
- package/docs/product-review-2026-09.md +174 -0
- package/docs/product-vision-2026-09.md +200 -0
- package/docs/screenshot.png +0 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +62 -36
- package/skills/pmx-canvas/evals/evals.json +1 -1
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
- package/skills/pmx-canvas/references/full-reference.md +5 -4
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
- package/skills/pmx-canvas-testing/SKILL.md +13 -4
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +18 -8
- package/src/client/canvas/CanvasNode.tsx +9 -12
- package/src/client/canvas/CanvasViewport.tsx +20 -33
- package/src/client/canvas/ContextMenu.tsx +13 -0
- package/src/client/canvas/EdgeLayer.tsx +2 -2
- package/src/client/canvas/SessionPanel.tsx +23 -4
- package/src/client/canvas/ShortcutOverlay.tsx +2 -2
- package/src/client/canvas/TopBar.tsx +85 -45
- package/src/client/canvas/use-pan-zoom.ts +23 -3
- package/src/client/nodes/ExtAppFrame.tsx +97 -28
- package/src/client/state/canvas-store.ts +17 -5
- package/src/client/state/sse-bridge.ts +3 -1
- package/src/client/theme/global.css +101 -7
- package/src/server/canvas-operations.ts +2 -2
- package/src/server/operations/mcp.ts +45 -31
- package/src/server/operations/ops/ax-presence.ts +3 -0
- package/src/server/operations/ops/viewport.ts +6 -1
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +9 -9
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +29 -8
|
@@ -986,10 +986,21 @@ body,
|
|
|
986
986
|
/* NOT overflow:hidden — the BarHint tooltips hang below the bar and were
|
|
987
987
|
clipped invisible for everyone (the e2e asserted opacity, which cannot
|
|
988
988
|
see ancestor clipping). Children ellipsize themselves; the narrow-screen
|
|
989
|
-
collapse is media-query display, not clipping.
|
|
989
|
+
collapse is media-query display, not clipping. overflow-x: clip is the
|
|
990
|
+
one safe axis (visible/clip is the valid mixed pair): tooltips still
|
|
991
|
+
hang below, but bar content can never paint over the session panel
|
|
992
|
+
again (0.5.1 user feedback — the agent-chip flood pushed the zoom
|
|
993
|
+
cluster out of the bar). */
|
|
994
|
+
overflow-x: clip;
|
|
990
995
|
min-width: 0;
|
|
991
996
|
z-index: 9998;
|
|
992
997
|
}
|
|
998
|
+
/* CHIP BarHint anchors must pass shrink through to their chip (flex min-auto
|
|
999
|
+
sizing) or an unshrinkable anchor re-creates the overflow. Chip anchors
|
|
1000
|
+
ONLY — a blanket min-width: 0 crushed the zoom buttons to slivers. */
|
|
1001
|
+
.top-bar .toolbar-tooltip-anchor:has(.agent-chip) {
|
|
1002
|
+
min-width: 0;
|
|
1003
|
+
}
|
|
993
1004
|
|
|
994
1005
|
.top-bar-title {
|
|
995
1006
|
font-size: 13px;
|
|
@@ -1104,14 +1115,46 @@ body,
|
|
|
1104
1115
|
font-size: 11px;
|
|
1105
1116
|
font-weight: 600;
|
|
1106
1117
|
white-space: nowrap;
|
|
1118
|
+
/* Rigid: the chip never shrinks and never lies about its min size (a
|
|
1119
|
+
min-width: 0 here let BarHint anchors collapse under their chips, and
|
|
1120
|
+
neighboring chips painted over each other). The .agent-chips wrapper is
|
|
1121
|
+
the ONE squeeze point — it clips whole trailing chips at its edge. */
|
|
1107
1122
|
flex-shrink: 0;
|
|
1108
|
-
min-width: 0;
|
|
1109
1123
|
max-width: 280px;
|
|
1110
1124
|
}
|
|
1125
|
+
/* Many-agent bar (0.5.1 user feedback): the visible chips sit in ONE
|
|
1126
|
+
clipping flex wrapper. Chips keep their natural readable width; when the
|
|
1127
|
+
bar is overconstrained the WRAPPER shrinks and clips at its edge, so chip
|
|
1128
|
+
content can never shove or paint over the census chip, the context meter,
|
|
1129
|
+
or the zoom cluster (16 unshrinkable chips used to push the zoom controls
|
|
1130
|
+
out of the bar and over the session panel). */
|
|
1131
|
+
.agent-chips {
|
|
1132
|
+
display: inline-flex;
|
|
1133
|
+
align-items: center;
|
|
1134
|
+
gap: 12px;
|
|
1135
|
+
min-width: 0;
|
|
1136
|
+
overflow: hidden;
|
|
1137
|
+
}
|
|
1138
|
+
/* Rigid anchors: nested-flex min-content lets a BarHint anchor collapse
|
|
1139
|
+
under its chip (ellipsizing spans zero the chip's min-content), and the
|
|
1140
|
+
chip then paints into the next anchor. Only the wrapper above may give. */
|
|
1141
|
+
.agent-chips .toolbar-tooltip-anchor {
|
|
1142
|
+
flex-shrink: 0;
|
|
1143
|
+
}
|
|
1111
1144
|
.agent-chip.phase-idle {
|
|
1112
1145
|
background: none;
|
|
1113
1146
|
border-color: var(--c-line);
|
|
1114
1147
|
}
|
|
1148
|
+
/* The census chip is the "how many" answer — it never compresses away. Its
|
|
1149
|
+
BarHint anchor must hold width too: a shrinking anchor around an
|
|
1150
|
+
unshrinkable chip lets the chip paint over the next bar item. */
|
|
1151
|
+
.agent-chip-more {
|
|
1152
|
+
flex-shrink: 0;
|
|
1153
|
+
}
|
|
1154
|
+
.top-bar .toolbar-tooltip-anchor:has(.agent-chip-more) {
|
|
1155
|
+
flex-shrink: 0;
|
|
1156
|
+
min-width: max-content;
|
|
1157
|
+
}
|
|
1115
1158
|
.agent-chip-dot {
|
|
1116
1159
|
width: 7px;
|
|
1117
1160
|
height: 7px;
|
|
@@ -1134,6 +1177,11 @@ body,
|
|
|
1134
1177
|
font-weight: 400;
|
|
1135
1178
|
color: var(--c-muted);
|
|
1136
1179
|
white-space: nowrap;
|
|
1180
|
+
/* Leaf-level truncation: a compressed chip must ellipsize the agent name,
|
|
1181
|
+
never paint it past the pill (0.5.1 user feedback, many-agent bar). */
|
|
1182
|
+
overflow: hidden;
|
|
1183
|
+
text-overflow: ellipsis;
|
|
1184
|
+
min-width: 0;
|
|
1137
1185
|
}
|
|
1138
1186
|
|
|
1139
1187
|
/* On-canvas cursor + phase chip (world layer; counter-scaled inner) */
|
|
@@ -1146,7 +1194,9 @@ body,
|
|
|
1146
1194
|
top: 0;
|
|
1147
1195
|
z-index: 9000;
|
|
1148
1196
|
pointer-events: none;
|
|
1149
|
-
transition:
|
|
1197
|
+
transition:
|
|
1198
|
+
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
1199
|
+
opacity 320ms ease;
|
|
1150
1200
|
color: var(--presence-color);
|
|
1151
1201
|
}
|
|
1152
1202
|
.agent-cursor.is-worker {
|
|
@@ -1160,6 +1210,20 @@ body,
|
|
|
1160
1210
|
font-size: 9px;
|
|
1161
1211
|
padding: 1px 6px;
|
|
1162
1212
|
}
|
|
1213
|
+
/* Idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
|
|
1214
|
+
attached, a wall of parked "Idle" pills covers real node content. The
|
|
1215
|
+
element stays MOUNTED — counts, twin-merge, and cursor-follow logic are
|
|
1216
|
+
untouched, and visibility (not display) keeps its layout box so position
|
|
1217
|
+
polls still resolve — but it paints nothing; any non-idle phase fades it
|
|
1218
|
+
back in. Placed after .is-worker so opacity: 0 wins that collision. */
|
|
1219
|
+
.agent-cursor.phase-idle {
|
|
1220
|
+
opacity: 0;
|
|
1221
|
+
visibility: hidden;
|
|
1222
|
+
transition:
|
|
1223
|
+
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
1224
|
+
opacity 320ms ease,
|
|
1225
|
+
visibility 0s linear 320ms;
|
|
1226
|
+
}
|
|
1163
1227
|
.agent-chip-workers {
|
|
1164
1228
|
margin-left: 4px;
|
|
1165
1229
|
padding: 0 5px;
|
|
@@ -1313,7 +1377,7 @@ body,
|
|
|
1313
1377
|
animation: session-panel-in 180ms ease;
|
|
1314
1378
|
}
|
|
1315
1379
|
.session-panel.is-collapsed {
|
|
1316
|
-
width:
|
|
1380
|
+
width: 40px;
|
|
1317
1381
|
align-items: center;
|
|
1318
1382
|
padding-top: 10px;
|
|
1319
1383
|
gap: 10px;
|
|
@@ -1326,6 +1390,17 @@ body,
|
|
|
1326
1390
|
text-transform: uppercase;
|
|
1327
1391
|
color: var(--c-dim);
|
|
1328
1392
|
}
|
|
1393
|
+
.session-collapsed-live {
|
|
1394
|
+
display: inline-flex;
|
|
1395
|
+
flex-direction: column;
|
|
1396
|
+
align-items: center;
|
|
1397
|
+
gap: 4px;
|
|
1398
|
+
font: 9px var(--mono);
|
|
1399
|
+
color: var(--c-dim);
|
|
1400
|
+
}
|
|
1401
|
+
.session-collapsed-live .session-live-dot {
|
|
1402
|
+
background: var(--c-ok);
|
|
1403
|
+
}
|
|
1329
1404
|
|
|
1330
1405
|
.session-panel-header {
|
|
1331
1406
|
height: var(--hud-bar-height);
|
|
@@ -1653,7 +1728,7 @@ body,
|
|
|
1653
1728
|
}
|
|
1654
1729
|
.session-timeline-filters .activity-filter {
|
|
1655
1730
|
flex-shrink: 0;
|
|
1656
|
-
padding: 2px
|
|
1731
|
+
padding: 2px 3px;
|
|
1657
1732
|
}
|
|
1658
1733
|
|
|
1659
1734
|
.session-timeline-row {
|
|
@@ -2830,9 +2905,20 @@ body,
|
|
|
2830
2905
|
}
|
|
2831
2906
|
|
|
2832
2907
|
/* Pan tool / held Space: the world layer goes pointer-inert so drags land on
|
|
2833
|
-
the viewport container itself and pan — nodes cannot swallow the drag.
|
|
2908
|
+
the viewport container itself and pan — nodes cannot swallow the drag.
|
|
2909
|
+
user-select too: text selection sweeps straight through pointer-inert
|
|
2910
|
+
content, and a selection made mid-pan later becomes a native text drag the
|
|
2911
|
+
drop handler mints webpage nodes from (0.5.1 Amp finding D). */
|
|
2834
2912
|
.canvas-viewport.pan-mode .canvas-world {
|
|
2835
2913
|
pointer-events: none;
|
|
2914
|
+
-webkit-user-select: none;
|
|
2915
|
+
user-select: none;
|
|
2916
|
+
}
|
|
2917
|
+
/* The resize handle and connect ports carry their own pointer-events: auto,
|
|
2918
|
+
which punches through the inert world — a pan started on them never pans. */
|
|
2919
|
+
.canvas-viewport.pan-mode .node-resize-handle,
|
|
2920
|
+
.canvas-viewport.pan-mode .node-port {
|
|
2921
|
+
pointer-events: none;
|
|
2836
2922
|
}
|
|
2837
2923
|
.canvas-viewport.pan-mode:active {
|
|
2838
2924
|
cursor: grabbing;
|
|
@@ -3670,6 +3756,12 @@ body,
|
|
|
3670
3756
|
}
|
|
3671
3757
|
|
|
3672
3758
|
/* Node resize handle */
|
|
3759
|
+
.canvas-node:has(.node-resize-handle) {
|
|
3760
|
+
/* Keep the four-pixel corner grab inside the shell's inner clip, not on
|
|
3761
|
+
its rounded border. The other corners retain the standard card radius. */
|
|
3762
|
+
border-bottom-right-radius: 8px;
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3673
3765
|
.canvas-node .node-resize-handle {
|
|
3674
3766
|
position: absolute;
|
|
3675
3767
|
bottom: 0;
|
|
@@ -3714,7 +3806,9 @@ html.is-node-dragging .attention-field-layer {
|
|
|
3714
3806
|
}
|
|
3715
3807
|
|
|
3716
3808
|
html.is-node-dragging,
|
|
3717
|
-
html.is-node-dragging
|
|
3809
|
+
html.is-node-dragging *,
|
|
3810
|
+
html.is-canvas-panning,
|
|
3811
|
+
html.is-canvas-panning * {
|
|
3718
3812
|
cursor: grabbing !important;
|
|
3719
3813
|
user-select: none !important;
|
|
3720
3814
|
-webkit-user-select: none !important;
|
|
@@ -726,7 +726,7 @@ function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
|
|
|
726
726
|
const content = input.content?.trim() ?? '';
|
|
727
727
|
if (!content || content.includes('\n') || content.includes('\r')) return false;
|
|
728
728
|
if (typeof input.data?.path === 'string' && input.data.path.length > 0) return true;
|
|
729
|
-
if (existsSync(resolve(content))) return true;
|
|
729
|
+
if (existsSync(resolve(canvasState.getWorkspaceRoot(), content))) return true;
|
|
730
730
|
if (!input.title) return true;
|
|
731
731
|
return content.startsWith('/') || content.startsWith('./') || content.startsWith('../') || content.includes('/');
|
|
732
732
|
}
|
|
@@ -748,7 +748,7 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
|
748
748
|
|
|
749
749
|
const rawPath =
|
|
750
750
|
typeof input.data?.path === 'string' && input.data.path.length > 0 ? input.data.path : (input.content ?? '');
|
|
751
|
-
const resolved = resolve(rawPath);
|
|
751
|
+
const resolved = resolve(canvasState.getWorkspaceRoot(), rawPath);
|
|
752
752
|
const fileName = basename(resolved) || rawPath;
|
|
753
753
|
const data: Record<string, unknown> = {
|
|
754
754
|
...(input.data ?? {}),
|
|
@@ -27,10 +27,18 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
|
|
|
27
27
|
if (foldedOpNames.has(op.name)) continue;
|
|
28
28
|
const tool = op.mcp;
|
|
29
29
|
if (!tool) continue;
|
|
30
|
-
server.
|
|
30
|
+
server.registerTool(
|
|
31
31
|
tool.toolName,
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
{
|
|
33
|
+
description: tool.description,
|
|
34
|
+
// A LOOSE object, not a raw shape: the SDK wraps raw shapes in a
|
|
35
|
+
// stripping z.object, which silently dropped unknown args before the
|
|
36
|
+
// op layer could reject them — a removed field (dockPosition) looked
|
|
37
|
+
// successful over MCP while the same HTTP call 400'd (0.5.1 Amp
|
|
38
|
+
// finding A). Loose parsing forwards unknown keys to the op layer,
|
|
39
|
+
// the single authority on dead-field rejection, HTTP and MCP alike.
|
|
40
|
+
inputSchema: z.looseObject({ ...op.inputShape, ...(tool.extraShape ?? {}) }),
|
|
41
|
+
},
|
|
34
42
|
async (input: Record<string, unknown>) => {
|
|
35
43
|
try {
|
|
36
44
|
const host = await getHost();
|
|
@@ -231,34 +239,40 @@ export function registerCompositeTools(
|
|
|
231
239
|
for (const [publicKey, opField] of Object.entries(def.fieldRemap ?? {})) {
|
|
232
240
|
publicFieldName.set(opField, publicKey);
|
|
233
241
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
242
|
+
// Loose for the same reason as registerOperationTools above: unknown args
|
|
243
|
+
// must reach the op layer's dead-field rejection instead of being stripped.
|
|
244
|
+
server.registerTool(
|
|
245
|
+
def.toolName,
|
|
246
|
+
{ description: def.description, inputSchema: z.looseObject(buildCompositeShape(def)) },
|
|
247
|
+
async (input: Record<string, unknown>) => {
|
|
248
|
+
try {
|
|
249
|
+
const host = await getHost();
|
|
250
|
+
const opName = resolveCompositeOp(def, input);
|
|
251
|
+
const op = getOperation(opName);
|
|
252
|
+
// Strip the composite discriminators (action + any extra, e.g. `kind`)
|
|
253
|
+
// and undo any field remap; the rest is the op's raw MCP args — the same
|
|
254
|
+
// value the standalone tool would receive.
|
|
255
|
+
const rest = stripCompositeDiscriminators(def, input);
|
|
256
|
+
const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
|
|
257
|
+
if (missing.length > 0) {
|
|
258
|
+
const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
|
|
259
|
+
throw new OperationError(
|
|
260
|
+
`${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
|
|
264
|
+
const result = await host.invoker().invoke(opName, opInput);
|
|
265
|
+
if (op.mcp?.formatResult) {
|
|
266
|
+
return await op.mcp.formatResult(result, rest, host);
|
|
267
|
+
}
|
|
268
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
272
|
+
isError: true,
|
|
273
|
+
};
|
|
254
274
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return {
|
|
258
|
-
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
259
|
-
isError: true,
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
});
|
|
275
|
+
},
|
|
276
|
+
);
|
|
263
277
|
}
|
|
264
278
|
}
|
|
@@ -34,6 +34,9 @@ const presenceSetShape = {
|
|
|
34
34
|
focusNodeId: PRESENCE_SET_SHAPE.focusNodeId.describe('Node the agent is working on'),
|
|
35
35
|
cursor: PRESENCE_SET_SHAPE.cursor.describe('Agent cursor in world coordinates'),
|
|
36
36
|
attached: PRESENCE_SET_SHAPE.attached.describe('true attaches a session (Focus Session chrome); false detaches it'),
|
|
37
|
+
contextUsage: PRESENCE_SET_SHAPE.contextUsage.describe(
|
|
38
|
+
"The agent's real token window as {used,total} — shown on the board's context meter instead of the pinned-context estimate (null clears it)",
|
|
39
|
+
),
|
|
37
40
|
};
|
|
38
41
|
const presenceSetSchema = z.looseObject(presenceSetShape);
|
|
39
42
|
|
|
@@ -135,8 +135,10 @@ const focusOperation = defineOperation<z.infer<typeof focusSchema>, Record<strin
|
|
|
135
135
|
}
|
|
136
136
|
const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
|
|
137
137
|
ctx.emit('ax-state-changed', { focus });
|
|
138
|
-
|
|
138
|
+
// Apply the server fallback first; the browser then refines it using its
|
|
139
|
+
// actual canvas region and floating chrome instead of cancelling that fit.
|
|
139
140
|
if (!noPan) ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
141
|
+
ctx.emit('canvas-focus-node', { nodeId, noPan });
|
|
140
142
|
return { ok: true, focused: nodeId, panned: !noPan, axFocus: focus };
|
|
141
143
|
},
|
|
142
144
|
});
|
|
@@ -250,6 +252,9 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
|
|
|
250
252
|
// sizes to the human's actual window instead of a hardcoded 1440x900
|
|
251
253
|
// (0.4.6 orb feedback #2).
|
|
252
254
|
setClientViewportSize(body.clientWidth, body.clientHeight);
|
|
255
|
+
// A size-only report is not a camera command. Echoing the stored camera
|
|
256
|
+
// here would cancel an in-flight browser focus with its stale fallback.
|
|
257
|
+
if (![body.x, body.y, body.scale].some((value) => typeof value === 'number')) return { ok: true };
|
|
253
258
|
const next = {
|
|
254
259
|
x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
|
|
255
260
|
y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
|
|
@@ -199,7 +199,7 @@ export function checkScopeOwnership(rawInput: unknown, writerKey?: string): stri
|
|
|
199
199
|
return 'the scope fence is set and cleared by the human in the session panel, not by the agent.';
|
|
200
200
|
const agentScopes = input.agentScopes;
|
|
201
201
|
if (writerKey && agentScopes && typeof agentScopes === 'object' && !Array.isArray(agentScopes)) {
|
|
202
|
-
if (Object.
|
|
202
|
+
if (Object.hasOwn(agentScopes, writerKey)) {
|
|
203
203
|
return `an agent cannot set or clear its OWN territory ("${writerKey}") — only the human or a different writer (the orchestrator) may.`;
|
|
204
204
|
}
|
|
205
205
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -556,22 +556,22 @@ export async function startCanvasAutomationWebView(
|
|
|
556
556
|
await closeCanvasAutomationWebViewInternal();
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
-
|
|
560
|
-
width: normalized.width,
|
|
561
|
-
height: normalized.height,
|
|
562
|
-
headless: true,
|
|
563
|
-
backend,
|
|
564
|
-
dataStore: normalized.dataStoreDir ? { directory: normalized.dataStoreDir } : 'ephemeral',
|
|
565
|
-
});
|
|
566
|
-
|
|
559
|
+
let view: CanvasWebViewLike | null = null;
|
|
567
560
|
try {
|
|
561
|
+
view = new WebView({
|
|
562
|
+
width: normalized.width,
|
|
563
|
+
height: normalized.height,
|
|
564
|
+
headless: true,
|
|
565
|
+
backend,
|
|
566
|
+
dataStore: normalized.dataStoreDir ? { directory: normalized.dataStoreDir } : 'ephemeral',
|
|
567
|
+
});
|
|
568
568
|
await withCanvasAutomationWebViewTimeout(view.navigate(url), 'starting the workbench automation WebView');
|
|
569
569
|
} catch (error) {
|
|
570
570
|
canvasAutomationWebViewStatus = {
|
|
571
571
|
...canvasAutomationWebViewStatus,
|
|
572
572
|
lastError: error instanceof Error ? error.message : String(error),
|
|
573
573
|
};
|
|
574
|
-
await Promise.resolve(view.close()).catch(() => undefined);
|
|
574
|
+
if (view) await Promise.resolve(view.close()).catch(() => undefined);
|
|
575
575
|
throw error;
|
|
576
576
|
}
|
|
577
577
|
|
|
@@ -56,14 +56,12 @@ function replay_filtered_stderr() {
|
|
|
56
56
|
|
|
57
57
|
function run_with_filtered_stderr() {
|
|
58
58
|
local stderr_file
|
|
59
|
+
local status
|
|
59
60
|
stderr_file="$(mktemp)"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
fi
|
|
65
|
-
|
|
66
|
-
local status=$?
|
|
61
|
+
set +e
|
|
62
|
+
"$@" 2>"$stderr_file"
|
|
63
|
+
status=$?
|
|
64
|
+
set -e
|
|
67
65
|
replay_filtered_stderr "$stderr_file"
|
|
68
66
|
rm -f "$stderr_file"
|
|
69
67
|
return "$status"
|
|
@@ -109,6 +107,17 @@ function ensure_bundle_dependencies() {
|
|
|
109
107
|
fi
|
|
110
108
|
done
|
|
111
109
|
|
|
110
|
+
# package.json lists the deps, but node_modules may not be in sync (e.g. fresh
|
|
111
|
+
# CI checkout where node_modules is gitignored). Run a regular install so the
|
|
112
|
+
# local binaries actually exist before we hand off to Parcel. The build
|
|
113
|
+
# allowlist is already persisted in package.json's pnpm.onlyBuiltDependencies
|
|
114
|
+
# by the original `pnpm add` step, so plain `install` is enough here.
|
|
115
|
+
if [ ! -x "./node_modules/.bin/parcel" ]; then
|
|
116
|
+
echo "📦 Syncing bundling dependencies into node_modules..."
|
|
117
|
+
run_pnpm_quiet install
|
|
118
|
+
return 0
|
|
119
|
+
fi
|
|
120
|
+
|
|
112
121
|
echo "✅ Reusing existing bundling dependencies"
|
|
113
122
|
}
|
|
114
123
|
|
|
@@ -150,7 +159,19 @@ rm -rf dist bundle.html
|
|
|
150
159
|
|
|
151
160
|
# Build with Parcel
|
|
152
161
|
echo "🔨 Building with Parcel..."
|
|
153
|
-
|
|
162
|
+
parcel_status=0
|
|
163
|
+
run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error || parcel_status=$?
|
|
164
|
+
|
|
165
|
+
# Self-heal a wedged cache: these build projects are REUSED across artifact
|
|
166
|
+
# builds, and a parcel killed mid-write (e.g. the canvas server torn down
|
|
167
|
+
# during a build) leaves a corrupted .parcel-cache that makes every later
|
|
168
|
+
# build die before emitting anything — permanently, until the cache goes.
|
|
169
|
+
# Retry exactly once from a cold cache, then preserve a genuine build failure.
|
|
170
|
+
if [ "$parcel_status" -ne 0 ] || [ ! -s "dist/index.html" ]; then
|
|
171
|
+
echo "🧹 Parcel build failed or produced no output — clearing .parcel-cache and retrying cold..."
|
|
172
|
+
rm -rf .parcel-cache dist
|
|
173
|
+
run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error
|
|
174
|
+
fi
|
|
154
175
|
|
|
155
176
|
if [ ! -s "dist/index.html" ]; then
|
|
156
177
|
echo "❌ Error: Parcel did not produce dist/index.html" >&2
|