pmx-canvas 0.4.5 → 0.4.7
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 +118 -0
- package/Readme.md +76 -36
- package/dist/canvas/global.css +169 -3
- package/dist/canvas/index.js +112 -87
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/smoke.d.ts +1 -0
- 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/mermaid-entry.d.ts +11 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/DiffNode.d.ts +20 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/nodes/MermaidNode.d.ts +12 -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/client/types.d.ts +2 -2
- 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-operations.d.ts +5 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +18 -1
- package/dist/types/server/canvas-validation.d.ts +28 -1
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/html-surface.d.ts +8 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/cli.md +31 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +29 -0
- package/docs/mcp.md +13 -1
- package/docs/node-types.md +47 -1
- package/docs/screenshot.png +0 -0
- package/package.json +5 -4
- package/skills/pmx-canvas/SKILL.md +123 -25
- 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 +3 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +28 -3
- 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/smoke.ts +212 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +7 -0
- package/src/cli/shared.ts +35 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +12 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +32 -1
- package/src/client/theme/global.css +169 -3
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +19 -1
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +305 -4
- package/src/server/canvas-operations.ts +83 -26
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +46 -10
- package/src/server/canvas-validation.ts +88 -0
- 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/html-surface.ts +11 -0
- package/src/server/index.ts +25 -6
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/batch.ts +8 -0
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +24 -2
- package/src/server/operations/ops/viewport.ts +15 -2
- package/src/server/server.ts +154 -6
- package/src/server/web-artifacts.ts +109 -0
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/node-sizes.ts +52 -0
- package/src/shared/surface.ts +2 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { z } from 'zod';
|
|
19
|
-
import type
|
|
19
|
+
import { clampAxFlowMaxRuns, type AxFlowStamp, type AxFlowStepStamp } from '../shared/ax-flow.js';
|
|
20
|
+
import { findOpenCanvasPosition, overlapsAny, type CanvasPlacementRect } from '../shared/placement.js';
|
|
21
|
+
import type { CanvasEdge, CanvasLayout, CanvasNodeState } from './canvas-state.js';
|
|
20
22
|
import type { CanvasNodeType } from './canvas-provenance.js';
|
|
21
23
|
import type {
|
|
22
24
|
PmxAxApprovalGate,
|
|
@@ -54,6 +56,7 @@ export const AX_INTERACTION_TYPES = [
|
|
|
54
56
|
'ax.command.invoke',
|
|
55
57
|
'ax.elicitation.request',
|
|
56
58
|
'ax.mode.request',
|
|
59
|
+
'ax.flow.materialize',
|
|
57
60
|
] as const;
|
|
58
61
|
|
|
59
62
|
export type AxInteractionType = (typeof AX_INTERACTION_TYPES)[number];
|
|
@@ -86,9 +89,21 @@ function caps(
|
|
|
86
89
|
* a node can anchor AX state but only eligible types may EMIT interactions.
|
|
87
90
|
*/
|
|
88
91
|
export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabilities> = {
|
|
92
|
+
// `ax.work.update` is what the native step controls (Start/Done/Blocked) on a
|
|
93
|
+
// materialized flow step emit, and every step node is a `markdown` node. Two
|
|
94
|
+
// reasons this is not an escalation: it is no broader in kind than the
|
|
95
|
+
// `ax.work.create` / `ax.steer` this type already grants (creating work and
|
|
96
|
+
// steering the agent are strictly more powerful than flipping a status), and a
|
|
97
|
+
// markdown node has no sandboxed emit bridge — `window.PMX_AX` is injected only
|
|
98
|
+
// into `html` / `mcp-app` surfaces, so the only emitters here are the trusted
|
|
99
|
+
// native controls in the canvas app and direct local API calls, which can
|
|
100
|
+
// already PATCH a work item with no node capability at all.
|
|
101
|
+
// NB: markdown IS rendered as HTML into the parent document (see the trust
|
|
102
|
+
// boundary note in MarkdownNode.tsx — localhost, user-owned content), so the
|
|
103
|
+
// justification is "no sandboxed emitter", NOT "no scriptable surface".
|
|
89
104
|
markdown: caps(
|
|
90
105
|
true,
|
|
91
|
-
['ax.steer', 'ax.work.create', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
|
|
106
|
+
['ax.steer', 'ax.work.create', 'ax.work.update', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
|
|
92
107
|
'notify-agent',
|
|
93
108
|
),
|
|
94
109
|
context: caps(
|
|
@@ -102,6 +117,12 @@ export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabili
|
|
|
102
117
|
'notify-agent',
|
|
103
118
|
),
|
|
104
119
|
file: caps(true, ['ax.evidence.add', 'ax.review.add', 'ax.focus.set', 'ax.event.record']),
|
|
120
|
+
diff: caps(true, ['ax.evidence.add', 'ax.review.add', 'ax.focus.set', 'ax.event.record']),
|
|
121
|
+
mermaid: caps(
|
|
122
|
+
true,
|
|
123
|
+
['ax.steer', 'ax.work.create', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
|
|
124
|
+
'notify-agent',
|
|
125
|
+
),
|
|
105
126
|
'json-render': caps(true, [
|
|
106
127
|
'ax.work.create',
|
|
107
128
|
'ax.work.update',
|
|
@@ -131,6 +152,11 @@ export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabili
|
|
|
131
152
|
'ax.mode.request',
|
|
132
153
|
'ax.command.invoke',
|
|
133
154
|
'ax.event.record',
|
|
155
|
+
// Only `html` gets the flow materializer: a panel is the one surface that
|
|
156
|
+
// authors a flow the human then wants ON the board. The output shape is
|
|
157
|
+
// server-owned (fixed markdown/flow/references geometry), so this grants no
|
|
158
|
+
// arbitrary node creation — see materializeFlow.
|
|
159
|
+
'ax.flow.materialize',
|
|
134
160
|
],
|
|
135
161
|
'notify-agent',
|
|
136
162
|
),
|
|
@@ -240,6 +266,23 @@ export interface AxInteractionInput {
|
|
|
240
266
|
metadata?: Record<string, unknown>;
|
|
241
267
|
}
|
|
242
268
|
|
|
269
|
+
// ── ax.flow.materialize bounds + fixed layout ──────────────────
|
|
270
|
+
/** Hard cap on materialized steps: one screen-band of nodes, not a node bomb. */
|
|
271
|
+
const FLOW_MAX_STEPS = 12;
|
|
272
|
+
const FLOW_TITLE_MAX = 120;
|
|
273
|
+
const FLOW_DETAIL_MAX = 2000;
|
|
274
|
+
const FLOW_MAX_LOOP_RUNS = 20;
|
|
275
|
+
/**
|
|
276
|
+
* Step node geometry. 360 is markdown's minimum create size, but it is too
|
|
277
|
+
* narrow for a step's NAME: the title bar also carries the type icon, the work
|
|
278
|
+
* status pill and the control cluster, so a title like "3. Regenerate + verify"
|
|
279
|
+
* ellipsised away at 100% zoom. Wide enough that the step reads at a glance.
|
|
280
|
+
*/
|
|
281
|
+
const FLOW_STEP_WIDTH = 460;
|
|
282
|
+
const FLOW_STEP_HEIGHT = 200;
|
|
283
|
+
const FLOW_STEP_GAP = 48;
|
|
284
|
+
const FLOW_STEP_COLUMNS = 4;
|
|
285
|
+
|
|
243
286
|
const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
|
|
244
287
|
'ax.event.record': z.object({
|
|
245
288
|
kind: z.enum(EVENT_KINDS),
|
|
@@ -305,6 +348,27 @@ const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
|
|
|
305
348
|
name: z.string().min(1),
|
|
306
349
|
args: z.record(z.string(), z.unknown()).optional(),
|
|
307
350
|
}),
|
|
351
|
+
// TEXT ONLY. The caller never supplies a node type, geometry, or node data —
|
|
352
|
+
// the server owns the materialized shape (see materializeFlow), which is what
|
|
353
|
+
// keeps this from being "a sandboxed panel can create arbitrary canvas nodes".
|
|
354
|
+
'ax.flow.materialize': z.object({
|
|
355
|
+
title: z.string().trim().max(FLOW_TITLE_MAX).optional(),
|
|
356
|
+
steps: z
|
|
357
|
+
.array(
|
|
358
|
+
z.object({
|
|
359
|
+
title: z.string().trim().min(1).max(FLOW_TITLE_MAX),
|
|
360
|
+
detail: z.string().trim().max(FLOW_DETAIL_MAX).optional(),
|
|
361
|
+
}),
|
|
362
|
+
)
|
|
363
|
+
.min(1)
|
|
364
|
+
.max(FLOW_MAX_STEPS),
|
|
365
|
+
loop: z
|
|
366
|
+
.object({
|
|
367
|
+
enabled: z.boolean().optional(),
|
|
368
|
+
maxRuns: z.number().int().min(1).max(FLOW_MAX_LOOP_RUNS).optional(),
|
|
369
|
+
})
|
|
370
|
+
.optional(),
|
|
371
|
+
}),
|
|
308
372
|
};
|
|
309
373
|
|
|
310
374
|
// ── Dispatch ───────────────────────────────────────────────────
|
|
@@ -315,6 +379,13 @@ const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
|
|
|
315
379
|
*/
|
|
316
380
|
export interface AxInteractionManager {
|
|
317
381
|
getNode(id: string): CanvasNodeState | undefined;
|
|
382
|
+
/** Canvas read for `ax.flow.materialize` (placement obstacles + stale flow sweep). */
|
|
383
|
+
getLayout(): CanvasLayout;
|
|
384
|
+
addNode(node: CanvasNodeState): void;
|
|
385
|
+
updateNode(id: string, patch: Partial<CanvasNodeState>): void;
|
|
386
|
+
/** Also drops the node's edges — the flow's replace path relies on that. */
|
|
387
|
+
removeNode(id: string): void;
|
|
388
|
+
addEdge(edge: CanvasEdge): boolean;
|
|
318
389
|
recordAxEvent(
|
|
319
390
|
input: {
|
|
320
391
|
kind: PmxAxEventKind;
|
|
@@ -422,6 +493,227 @@ function accept(
|
|
|
422
493
|
};
|
|
423
494
|
}
|
|
424
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Add a layout broadcast to a work-item result when the item is linked to nodes.
|
|
498
|
+
*
|
|
499
|
+
* A linked work item mirrors its status onto `data.axWorkStatus` of every node it
|
|
500
|
+
* names, so a work interaction IS a node-data change even though the op itself is
|
|
501
|
+
* layout-neutral. Without this the browser keeps rendering a stale status chip
|
|
502
|
+
* (the AX SSE frames carry AX state, not nodes) — the same reason materializeFlow
|
|
503
|
+
* emits one.
|
|
504
|
+
*/
|
|
505
|
+
function withLayoutBroadcast(
|
|
506
|
+
result: AxInteractionResult,
|
|
507
|
+
manager: AxInteractionManager,
|
|
508
|
+
workItems: PmxAxWorkItem[],
|
|
509
|
+
): AxInteractionResult {
|
|
510
|
+
if (!workItems.some((item) => item.nodeIds.length > 0)) return result;
|
|
511
|
+
result.events.push({ event: 'canvas-layout-update', payload: { layout: manager.getLayout() } });
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── ax.flow.materialize ────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
export interface AxFlowMaterializeInput {
|
|
518
|
+
title?: string;
|
|
519
|
+
steps: Array<{ title: string; detail?: string }>;
|
|
520
|
+
loop?: { enabled?: boolean; maxRuns?: number };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Shared tag on every node this source materialized — the unit of idempotent replace. */
|
|
524
|
+
function axFlowId(sourceNodeId: string): string {
|
|
525
|
+
return `axflow-${sourceNodeId}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Ids the source node recorded the last time it materialized. This manifest — not
|
|
530
|
+
* the `axFlowId` tag alone — is what the replace sweep deletes from: `axFlowId` is
|
|
531
|
+
* ordinary node data, so any actor could stamp a plausible-looking one onto an
|
|
532
|
+
* unrelated node and get it swept. A node is only removed when the source node
|
|
533
|
+
* says it created it AND the node still carries the matching tag.
|
|
534
|
+
*/
|
|
535
|
+
function axFlowManifest(source: CanvasNodeState): string[] {
|
|
536
|
+
const ids = source.data.axFlowNodeIds;
|
|
537
|
+
return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : [];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* A clear band for the flow: directly under the source panel when that space is
|
|
542
|
+
* free, otherwise the canvas's normal open-position search. The band is placed
|
|
543
|
+
* as ONE rect, so the steps inside it are collision-free by construction.
|
|
544
|
+
*/
|
|
545
|
+
function flowBandOrigin(
|
|
546
|
+
source: CanvasNodeState | undefined,
|
|
547
|
+
obstacles: CanvasPlacementRect[],
|
|
548
|
+
width: number,
|
|
549
|
+
height: number,
|
|
550
|
+
): { x: number; y: number } {
|
|
551
|
+
if (source) {
|
|
552
|
+
const below = { x: source.position.x, y: source.position.y + source.size.height + FLOW_STEP_GAP };
|
|
553
|
+
if (!overlapsAny(below, width, height, obstacles, FLOW_STEP_GAP)) return below;
|
|
554
|
+
}
|
|
555
|
+
return findOpenCanvasPosition(obstacles, width, height, FLOW_STEP_GAP);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Lay a validated, text-only flow out as real canvas nodes and edges.
|
|
560
|
+
*
|
|
561
|
+
* The SERVER owns the entire output shape — one `markdown` node per step, `flow`
|
|
562
|
+
* edges in order, a dashed `references` loop-back edge when looping, and one AX
|
|
563
|
+
* work item per step linked to its node so the existing work-item→node status
|
|
564
|
+
* mirror lights the `axWorkStatus` chip. The caller supplies text only, which is
|
|
565
|
+
* why this can be granted to a sandboxed panel at all.
|
|
566
|
+
*
|
|
567
|
+
* Idempotent: the source node records the ids it created in `data.axFlowNodeIds`
|
|
568
|
+
* and a re-materialize removes exactly those first (removeNode takes their edges
|
|
569
|
+
* with them) instead of stacking a second copy. Work items from a previous
|
|
570
|
+
* materialize survive with their dangling node ref stripped — they are the durable
|
|
571
|
+
* AX record, exactly as for any other node deletion.
|
|
572
|
+
*/
|
|
573
|
+
function materializeFlow(
|
|
574
|
+
manager: AxInteractionManager,
|
|
575
|
+
sourceNode: CanvasNodeState,
|
|
576
|
+
input: AxFlowMaterializeInput,
|
|
577
|
+
source: PmxAxSource,
|
|
578
|
+
): AxInteractionResult {
|
|
579
|
+
const sourceNodeId = sourceNode.id;
|
|
580
|
+
const flowId = axFlowId(sourceNodeId);
|
|
581
|
+
const title = input.title?.trim() || 'Flow';
|
|
582
|
+
const loopEnabled = input.loop?.enabled === true;
|
|
583
|
+
const maxRuns = input.loop?.maxRuns ?? 3;
|
|
584
|
+
|
|
585
|
+
// Replace, never duplicate — and only what this panel actually created: the id
|
|
586
|
+
// must be on the source's manifest AND still carry the matching tag. The source
|
|
587
|
+
// itself is excluded so a self-tagged panel cannot delete itself.
|
|
588
|
+
const owned = new Set(axFlowManifest(sourceNode));
|
|
589
|
+
const stale = manager
|
|
590
|
+
.getLayout()
|
|
591
|
+
.nodes.filter((node) => node.id !== sourceNodeId && owned.has(node.id) && node.data.axFlowId === flowId);
|
|
592
|
+
for (const node of stale) manager.removeNode(node.id);
|
|
593
|
+
|
|
594
|
+
const layout = manager.getLayout();
|
|
595
|
+
const obstacles: CanvasPlacementRect[] = layout.nodes.map((node) => ({ position: node.position, size: node.size }));
|
|
596
|
+
const columns = Math.min(input.steps.length, FLOW_STEP_COLUMNS);
|
|
597
|
+
const rows = Math.ceil(input.steps.length / FLOW_STEP_COLUMNS);
|
|
598
|
+
const origin = flowBandOrigin(
|
|
599
|
+
layout.nodes.find((node) => node.id === sourceNodeId),
|
|
600
|
+
obstacles,
|
|
601
|
+
columns * FLOW_STEP_WIDTH + (columns - 1) * FLOW_STEP_GAP,
|
|
602
|
+
rows * FLOW_STEP_HEIGHT + (rows - 1) * FLOW_STEP_GAP,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const total = input.steps.length;
|
|
606
|
+
// Last-written data of the anchor step, so its `axFlow` stamp below never has
|
|
607
|
+
// to assume the manager can read a node back.
|
|
608
|
+
let anchorData: Record<string, unknown> = {};
|
|
609
|
+
const steps = input.steps.map((step, index) => {
|
|
610
|
+
const nodeId = `${flowId}-step-${index + 1}`;
|
|
611
|
+
const nodeData: Record<string, unknown> = {
|
|
612
|
+
title: `${index + 1}. ${step.title}`,
|
|
613
|
+
content: step.detail ?? `Step ${index + 1} of ${total} in "${title}".`,
|
|
614
|
+
axFlowId: flowId,
|
|
615
|
+
axFlowStep: index + 1,
|
|
616
|
+
axFlowSourceNodeId: sourceNodeId,
|
|
617
|
+
};
|
|
618
|
+
manager.addNode({
|
|
619
|
+
id: nodeId,
|
|
620
|
+
type: 'markdown',
|
|
621
|
+
position: {
|
|
622
|
+
x: origin.x + (index % FLOW_STEP_COLUMNS) * (FLOW_STEP_WIDTH + FLOW_STEP_GAP),
|
|
623
|
+
y: origin.y + Math.floor(index / FLOW_STEP_COLUMNS) * (FLOW_STEP_HEIGHT + FLOW_STEP_GAP),
|
|
624
|
+
},
|
|
625
|
+
size: { width: FLOW_STEP_WIDTH, height: FLOW_STEP_HEIGHT },
|
|
626
|
+
zIndex: 1,
|
|
627
|
+
collapsed: false,
|
|
628
|
+
pinned: false,
|
|
629
|
+
dockPosition: null,
|
|
630
|
+
data: nodeData,
|
|
631
|
+
});
|
|
632
|
+
const workItem = manager.addWorkItem(
|
|
633
|
+
{
|
|
634
|
+
title: `${title} — ${index + 1}. ${step.title}`,
|
|
635
|
+
status: 'todo',
|
|
636
|
+
detail: step.detail ?? null,
|
|
637
|
+
nodeIds: [nodeId],
|
|
638
|
+
},
|
|
639
|
+
{ source },
|
|
640
|
+
);
|
|
641
|
+
// `data.axStep` is the contract the native step controls read (see
|
|
642
|
+
// shared/ax-flow.ts). It can only be stamped once the work item exists, and
|
|
643
|
+
// the node is re-read first because addWorkItem's status mirror has already
|
|
644
|
+
// written `axWorkStatus` onto it.
|
|
645
|
+
const axStep: AxFlowStepStamp = { flowId, index: index + 1, total, workItemId: workItem.id };
|
|
646
|
+
const stamped = { ...(manager.getNode(nodeId)?.data ?? nodeData), axStep };
|
|
647
|
+
manager.updateNode(nodeId, { data: stamped });
|
|
648
|
+
if (index === 0) anchorData = stamped;
|
|
649
|
+
return { index: index + 1, title: step.title, nodeId, workItemId: workItem.id };
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const edgeIds: string[] = [];
|
|
653
|
+
for (let i = 0; i < steps.length - 1; i++) {
|
|
654
|
+
const edge: CanvasEdge = {
|
|
655
|
+
id: `${flowId}-edge-${i + 1}`,
|
|
656
|
+
from: steps[i].nodeId,
|
|
657
|
+
to: steps[i + 1].nodeId,
|
|
658
|
+
type: 'flow',
|
|
659
|
+
style: 'solid',
|
|
660
|
+
};
|
|
661
|
+
if (manager.addEdge(edge)) edgeIds.push(edge.id);
|
|
662
|
+
}
|
|
663
|
+
if (loopEnabled && steps.length > 1) {
|
|
664
|
+
const loopEdge: CanvasEdge = {
|
|
665
|
+
id: `${flowId}-loop`,
|
|
666
|
+
from: steps[steps.length - 1].nodeId,
|
|
667
|
+
to: steps[0].nodeId,
|
|
668
|
+
type: 'references',
|
|
669
|
+
label: 'loop',
|
|
670
|
+
style: 'dashed',
|
|
671
|
+
animated: true,
|
|
672
|
+
};
|
|
673
|
+
if (manager.addEdge(loopEdge)) edgeIds.push(loopEdge.id);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// `data.axFlow` on the anchor (first) step: the whole flow plus its loop state,
|
|
677
|
+
// so the anchor's native controls can run/stop the loop and the server loop
|
|
678
|
+
// (ax-flow-loop.ts) can advance it. Always stamped, at rest (`running: false`,
|
|
679
|
+
// no completed passes) — a loop only ever starts from an explicit human click.
|
|
680
|
+
const anchorId = steps[0].nodeId;
|
|
681
|
+
const axFlow: AxFlowStamp = {
|
|
682
|
+
flowId,
|
|
683
|
+
title,
|
|
684
|
+
steps: steps.map((step) => ({
|
|
685
|
+
index: step.index,
|
|
686
|
+
nodeId: step.nodeId,
|
|
687
|
+
workItemId: step.workItemId,
|
|
688
|
+
title: step.title,
|
|
689
|
+
})),
|
|
690
|
+
loop: { running: false, run: 0, maxRuns: clampAxFlowMaxRuns(maxRuns) },
|
|
691
|
+
};
|
|
692
|
+
manager.updateNode(anchorId, { data: { ...(manager.getNode(anchorId)?.data ?? anchorData), axFlow } });
|
|
693
|
+
|
|
694
|
+
// The manifest the NEXT materialize sweeps from.
|
|
695
|
+
manager.updateNode(sourceNodeId, {
|
|
696
|
+
data: { ...sourceNode.data, axFlowNodeIds: steps.map((step) => step.nodeId) },
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const primitive = {
|
|
700
|
+
flowId,
|
|
701
|
+
title,
|
|
702
|
+
loop: { enabled: loopEnabled, maxRuns },
|
|
703
|
+
replacedNodeCount: stale.length,
|
|
704
|
+
steps,
|
|
705
|
+
edgeIds,
|
|
706
|
+
};
|
|
707
|
+
return {
|
|
708
|
+
result: { ok: true, type: 'ax.flow.materialize', sourceNodeId, primitive },
|
|
709
|
+
events: [
|
|
710
|
+
outcomeEvent({ ok: true, type: 'ax.flow.materialize', sourceNodeId }),
|
|
711
|
+
{ event: 'canvas-layout-update', payload: { layout: manager.getLayout() } },
|
|
712
|
+
{ event: 'ax-state-changed', payload: { flow: primitive } },
|
|
713
|
+
],
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
425
717
|
/**
|
|
426
718
|
* Validate + execute a node-originated AX interaction. Returns the public result
|
|
427
719
|
* plus the SSE events the caller should emit (accepted/rejected outcome + the
|
|
@@ -542,7 +834,9 @@ export function applyAxInteraction(
|
|
|
542
834
|
},
|
|
543
835
|
opts,
|
|
544
836
|
);
|
|
545
|
-
return accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem })
|
|
837
|
+
return withLayoutBroadcast(accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem }), manager, [
|
|
838
|
+
workItem,
|
|
839
|
+
]);
|
|
546
840
|
}
|
|
547
841
|
case 'ax.work.update': {
|
|
548
842
|
const p = payloadParsed.data as {
|
|
@@ -556,7 +850,9 @@ export function applyAxInteraction(
|
|
|
556
850
|
if (scoped && patch.nodeIds !== undefined) patch.nodeIds = [sourceNodeId];
|
|
557
851
|
const workItem = manager.updateWorkItem(id, patch, opts);
|
|
558
852
|
if (!workItem) return reject(type, sourceNodeId, 404, 'work-item-not-found', `Work item "${id}" not found.`);
|
|
559
|
-
return accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem })
|
|
853
|
+
return withLayoutBroadcast(accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem }), manager, [
|
|
854
|
+
workItem,
|
|
855
|
+
]);
|
|
560
856
|
}
|
|
561
857
|
case 'ax.evidence.add': {
|
|
562
858
|
const p = payloadParsed.data as {
|
|
@@ -679,6 +975,11 @@ export function applyAxInteraction(
|
|
|
679
975
|
if (!event) return reject(type, sourceNodeId, 400, 'unknown-command', `Unknown command "${p.name}".`);
|
|
680
976
|
return accept(type, sourceNodeId, event, 'ax-event-created', { event });
|
|
681
977
|
}
|
|
978
|
+
case 'ax.flow.materialize':
|
|
979
|
+
// No `nodeIds` in this payload, so `scopedNodeIds` never applies: the step
|
|
980
|
+
// nodes are created BY the server and their work-item links are server-owned
|
|
981
|
+
// ids, not caller-supplied ones. Sandboxed callers pass through unchanged.
|
|
982
|
+
return materializeFlow(manager, node, payloadParsed.data as AxFlowMaterializeInput, source);
|
|
682
983
|
default:
|
|
683
984
|
return reject(type, sourceNodeId, 501, 'not-executable', `"${type}" is recognized but not yet executable.`);
|
|
684
985
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type CanvasNodeUpdate,
|
|
9
9
|
type CanvasSnapshot,
|
|
10
10
|
} from './canvas-state.js';
|
|
11
|
+
import { applyFileContentToNodeData, readFileNodeContent } from './file-content.js';
|
|
11
12
|
import { rewatchAllFileNodes, unwatchAll, unwatchFileForNode, watchFileForNode } from './file-watcher.js';
|
|
12
13
|
import {
|
|
13
14
|
closeMcpAppSession,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
type ExternalMcpTransportConfig,
|
|
18
19
|
} from './mcp-app-runtime.js';
|
|
19
20
|
import { mutationHistory } from './mutation-history.js';
|
|
21
|
+
import { clampCreateNodeSize } from './canvas-validation.js';
|
|
20
22
|
import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
|
|
21
23
|
import { searchNodes } from './spatial-analysis.js';
|
|
22
24
|
import { getCanvasNodeTitle } from './canvas-serialization.js';
|
|
@@ -114,6 +116,10 @@ export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
|
|
|
114
116
|
export const IMAGE_NODE_DEFAULT_SIZE = { width: 480, height: 360 };
|
|
115
117
|
export const LEDGER_NODE_DEFAULT_SIZE = { width: 420, height: 280 };
|
|
116
118
|
|
|
119
|
+
// Per-type minimum creation sizes + clamp live in canvas-validation.ts
|
|
120
|
+
// (NODE_MIN_CREATE_SIZES / clampCreateNodeSize) — shared with the validate
|
|
121
|
+
// surface's sizeWarnings.
|
|
122
|
+
|
|
117
123
|
interface CanvasCreateGroupInput {
|
|
118
124
|
title?: string;
|
|
119
125
|
childIds?: string[];
|
|
@@ -360,11 +366,20 @@ export function buildJsonRenderNodeUpdate(
|
|
|
360
366
|
if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
|
|
361
367
|
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
362
368
|
const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
|
|
369
|
+
// Bump specVersion so the viewer URL changes: the iframe src is keyed on
|
|
370
|
+
// nodeId + ?v=specVersion, and the viewer only reads the spec at document
|
|
371
|
+
// load. Without the bump the src is byte-identical after a content update,
|
|
372
|
+
// the iframe never reloads, and the node renders stale forever (the live
|
|
373
|
+
// workboard's refresh depends on this).
|
|
374
|
+
const prevVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : 0;
|
|
363
375
|
return {
|
|
364
376
|
spec,
|
|
365
377
|
data: {
|
|
366
378
|
...node.data,
|
|
367
|
-
...createJsonRenderNodeData(node.id, title, spec, {
|
|
379
|
+
...createJsonRenderNodeData(node.id, title, spec, {
|
|
380
|
+
viewerType: 'json-render',
|
|
381
|
+
specVersion: prevVersion + 1,
|
|
382
|
+
}),
|
|
368
383
|
},
|
|
369
384
|
};
|
|
370
385
|
}
|
|
@@ -554,6 +569,11 @@ function prepareExtAppNodesForSessionSync(forceRehydrate: boolean): string[] {
|
|
|
554
569
|
|
|
555
570
|
const transportConfig = normalizeTransportConfig(node.data.transportConfig);
|
|
556
571
|
if (!transportConfig) {
|
|
572
|
+
// Only a node that HAD a session has one to fail restoring. A static
|
|
573
|
+
// ext-app node (no session, no transport — e.g. the demo board's
|
|
574
|
+
// showcase tile) would otherwise be stamped with a persisted error
|
|
575
|
+
// banner on every start after the first, for a session it never had.
|
|
576
|
+
if (!sessionId) continue;
|
|
557
577
|
setExtAppRuntimeState(node.id, {
|
|
558
578
|
appSessionId: null,
|
|
559
579
|
sessionStatus: 'error',
|
|
@@ -734,19 +754,8 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
|
734
754
|
title: input.title ?? fileName,
|
|
735
755
|
};
|
|
736
756
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const fileContent = readFileSync(resolved, 'utf-8');
|
|
740
|
-
const stat = statSync(resolved);
|
|
741
|
-
data.fileContent = fileContent;
|
|
742
|
-
data.lineCount = fileContent.split('\n').length;
|
|
743
|
-
data.updatedAt = new Date(stat.mtimeMs).toISOString();
|
|
744
|
-
}
|
|
745
|
-
} catch {
|
|
746
|
-
// Missing or unreadable files still render as path-backed file nodes.
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
return data;
|
|
757
|
+
// Missing or unreadable files still render as path-backed file nodes.
|
|
758
|
+
return applyFileContentToNodeData(data, readFileNodeContent(resolved));
|
|
750
759
|
}
|
|
751
760
|
|
|
752
761
|
function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
@@ -819,7 +828,7 @@ function normalizeTraceNodeData(input: CanvasAddNodeInput): Record<string, unkno
|
|
|
819
828
|
return data;
|
|
820
829
|
}
|
|
821
830
|
|
|
822
|
-
function
|
|
831
|
+
function buildNodeDataForType(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
823
832
|
if (input.type === 'file') return buildFileNodeData(input);
|
|
824
833
|
if (input.type === 'image') return buildImageNodeData(input);
|
|
825
834
|
if (input.type === 'webpage') return buildWebpageNodeData(input);
|
|
@@ -828,10 +837,21 @@ function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
|
828
837
|
...(input.data ?? {}),
|
|
829
838
|
...(input.title ? { title: input.title } : {}),
|
|
830
839
|
...(input.content ? { content: input.content } : {}),
|
|
831
|
-
...(input.strictSize ? { strictSize: true } : {}),
|
|
832
840
|
};
|
|
833
841
|
}
|
|
834
842
|
|
|
843
|
+
function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
844
|
+
const data = buildNodeDataForType(input);
|
|
845
|
+
// `strictSize` is type-agnostic — it exempts a node from BOTH the creation
|
|
846
|
+
// size clamp and the browser's auto-fit. It used to be applied inside each
|
|
847
|
+
// per-type builder, and the file / image / webpage builders return a fresh
|
|
848
|
+
// object without it, so those three silently dropped the flag: the clamp
|
|
849
|
+
// honored it (it reads `input.strictSize` directly) but nothing persisted
|
|
850
|
+
// `data.strictSize`, so auto-fit resized the node in the browser anyway.
|
|
851
|
+
// Applying it once here makes it uniform for every type.
|
|
852
|
+
return input.strictSize ? { ...data, strictSize: true } : data;
|
|
853
|
+
}
|
|
854
|
+
|
|
835
855
|
export function mergeTraceNodeDataFields(
|
|
836
856
|
base: Record<string, unknown>,
|
|
837
857
|
input: Record<string, unknown>,
|
|
@@ -906,8 +926,12 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
|
|
|
906
926
|
throw new Error(`Use the dedicated ${input.type} node APIs for structured viewer nodes.`);
|
|
907
927
|
}
|
|
908
928
|
|
|
909
|
-
const
|
|
910
|
-
|
|
929
|
+
const { width, height } = clampCreateNodeSize(
|
|
930
|
+
input.type,
|
|
931
|
+
input.width ?? input.defaultWidth ?? 720,
|
|
932
|
+
input.height ?? input.defaultHeight ?? 600,
|
|
933
|
+
input.strictSize,
|
|
934
|
+
);
|
|
911
935
|
const position =
|
|
912
936
|
input.x !== undefined && input.y !== undefined
|
|
913
937
|
? { x: input.x, y: input.y }
|
|
@@ -1540,8 +1564,12 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
|
|
|
1540
1564
|
node: CanvasNodeState;
|
|
1541
1565
|
} {
|
|
1542
1566
|
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
1543
|
-
const width
|
|
1544
|
-
|
|
1567
|
+
const { width, height } = clampCreateNodeSize(
|
|
1568
|
+
'json-render',
|
|
1569
|
+
input.width ?? JSON_RENDER_NODE_SIZE.width,
|
|
1570
|
+
input.height ?? JSON_RENDER_NODE_SIZE.height,
|
|
1571
|
+
input.strictSize,
|
|
1572
|
+
);
|
|
1545
1573
|
const position =
|
|
1546
1574
|
input.x !== undefined && input.y !== undefined
|
|
1547
1575
|
? { x: input.x, y: input.y }
|
|
@@ -1580,8 +1608,12 @@ export function createCanvasStreamingJsonRenderNode(input: {
|
|
|
1580
1608
|
strictSize?: boolean;
|
|
1581
1609
|
}): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState } {
|
|
1582
1610
|
const spec = emptyStreamingSpec();
|
|
1583
|
-
const width
|
|
1584
|
-
|
|
1611
|
+
const { width, height } = clampCreateNodeSize(
|
|
1612
|
+
'json-render',
|
|
1613
|
+
input.width ?? JSON_RENDER_NODE_SIZE.width,
|
|
1614
|
+
input.height ?? JSON_RENDER_NODE_SIZE.height,
|
|
1615
|
+
input.strictSize,
|
|
1616
|
+
);
|
|
1585
1617
|
const position =
|
|
1586
1618
|
input.x !== undefined && input.y !== undefined
|
|
1587
1619
|
? { x: input.x, y: input.y }
|
|
@@ -1652,8 +1684,12 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
|
|
|
1652
1684
|
} {
|
|
1653
1685
|
const title = input.title?.trim() || 'Graph';
|
|
1654
1686
|
const spec = buildGraphSpec(input);
|
|
1655
|
-
const width
|
|
1656
|
-
|
|
1687
|
+
const { width, height } = clampCreateNodeSize(
|
|
1688
|
+
'graph',
|
|
1689
|
+
input.width ?? GRAPH_NODE_SIZE.width,
|
|
1690
|
+
input.heightPx ?? GRAPH_NODE_SIZE.height,
|
|
1691
|
+
input.strictSize,
|
|
1692
|
+
);
|
|
1657
1693
|
const position =
|
|
1658
1694
|
input.x !== undefined && input.y !== undefined
|
|
1659
1695
|
? { x: input.x, y: input.y }
|
|
@@ -1679,9 +1715,30 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
|
|
|
1679
1715
|
return { id, url: String(node.data.url), spec, node };
|
|
1680
1716
|
}
|
|
1681
1717
|
|
|
1718
|
+
/**
|
|
1719
|
+
* The connected workbench's actual window size, reported by the client (0.4.6
|
|
1720
|
+
* orb feedback #2). `fit` used to compute against a hardcoded 1440x900, so an
|
|
1721
|
+
* agent calling fit without dimensions produced a scale sized for a window the
|
|
1722
|
+
* human did not have — the board overflowed and clipped on both sides. The
|
|
1723
|
+
* client reports its size on connect and on resize; explicit width/height in a
|
|
1724
|
+
* fit call still win. In-memory only (a live client fact, never persisted);
|
|
1725
|
+
* with several clients connected, the most recent reporter wins.
|
|
1726
|
+
*/
|
|
1727
|
+
let lastClientViewportSize: { width: number; height: number } | null = null;
|
|
1728
|
+
|
|
1729
|
+
export function setClientViewportSize(width: unknown, height: unknown): void {
|
|
1730
|
+
if (typeof width !== 'number' || typeof height !== 'number') return;
|
|
1731
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
|
|
1732
|
+
lastClientViewportSize = { width, height };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
export function getClientViewportSize(): { width: number; height: number } | null {
|
|
1736
|
+
return lastClientViewportSize ? { ...lastClientViewportSize } : null;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1682
1739
|
export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
|
|
1683
|
-
const width = positiveNumber(options.width, 1440);
|
|
1684
|
-
const height = positiveNumber(options.height, 900);
|
|
1740
|
+
const width = positiveNumber(options.width, lastClientViewportSize?.width ?? 1440);
|
|
1741
|
+
const height = positiveNumber(options.height, lastClientViewportSize?.height ?? 900);
|
|
1685
1742
|
const padding = positiveNumber(options.padding, 60);
|
|
1686
1743
|
const maxScale = positiveNumber(options.maxScale, 1);
|
|
1687
1744
|
const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
|
|
@@ -209,6 +209,39 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
209
209
|
'Path-backed file nodes automatically persist `data.provenance` with a file URI and file-watch refresh strategy.',
|
|
210
210
|
],
|
|
211
211
|
},
|
|
212
|
+
{
|
|
213
|
+
type: 'diff',
|
|
214
|
+
kind: 'node',
|
|
215
|
+
description: 'Unified diff viewer with per-file sections and colored add/remove lines.',
|
|
216
|
+
endpoint: '/api/canvas/node',
|
|
217
|
+
mcpTool: 'canvas_node (action:"add")',
|
|
218
|
+
fields: [
|
|
219
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
220
|
+
{ name: 'content', type: 'string', required: false, description: 'Unified diff text (git or raw hunks).' },
|
|
221
|
+
],
|
|
222
|
+
example: {
|
|
223
|
+
type: 'diff',
|
|
224
|
+
title: 'Fix null check',
|
|
225
|
+
content: '--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1,3 +1,3 @@\n-const a = b.c;\n+const a = b?.c;\n context',
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
type: 'mermaid',
|
|
230
|
+
kind: 'node',
|
|
231
|
+
description:
|
|
232
|
+
'Text-to-diagram node rendered client-side with Mermaid (flowcharts, sequence, state, class diagrams, …).',
|
|
233
|
+
endpoint: '/api/canvas/node',
|
|
234
|
+
mcpTool: 'canvas_node (action:"add")',
|
|
235
|
+
fields: [
|
|
236
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
237
|
+
{ name: 'content', type: 'string', required: false, description: 'Mermaid diagram source text.' },
|
|
238
|
+
],
|
|
239
|
+
example: {
|
|
240
|
+
type: 'mermaid',
|
|
241
|
+
title: 'Request flow',
|
|
242
|
+
content: 'graph TD; A[Client] --> B[Server]; B --> C[(DB)];',
|
|
243
|
+
},
|
|
244
|
+
},
|
|
212
245
|
{
|
|
213
246
|
type: 'image',
|
|
214
247
|
kind: 'node',
|