pmx-canvas 0.4.4 → 0.4.6
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 +108 -0
- package/Readme.md +52 -4
- package/dist/canvas/global.css +115 -11
- package/dist/canvas/index.js +93 -72
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/canvas/surface-theme.css +2 -2
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/skills.d.ts +1 -1
- package/dist/types/cli/commands/smoke.d.ts +1 -0
- package/dist/types/client/mermaid-entry.d.ts +11 -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/MermaidNode.d.ts +12 -0
- package/dist/types/client/state/intent-store.d.ts +6 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/ax-state-manager.d.ts +5 -0
- package/dist/types/server/ax-state.d.ts +9 -3
- package/dist/types/server/canvas-db.d.ts +3 -2
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +22 -1
- package/dist/types/server/canvas-validation.d.ts +34 -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-shared.d.ts +4 -1
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-intent.d.ts +6 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/docs/cli.md +12 -0
- package/docs/environment.md +1 -0
- package/docs/http-api.md +12 -0
- package/docs/mcp.md +1 -1
- package/docs/node-types.md +2 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/amp-orb-portal.png +0 -0
- package/docs/screenshots/claude-code-desktop.png +0 -0
- package/docs/screenshots/codex-app.png +0 -0
- package/docs/screenshots/github-copilot-app.png +0 -0
- package/package.json +4 -3
- package/skills/pmx-canvas/SKILL.md +71 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
- 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/skills.ts +1 -1
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/index.ts +15 -2
- package/src/cli/shared.ts +1 -0
- package/src/client/canvas/CanvasNode.tsx +12 -1
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
- package/src/client/canvas/IntentLayer.tsx +8 -6
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +2 -0
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/intent-bridge.ts +12 -3
- package/src/client/state/intent-store.ts +47 -2
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +28 -0
- package/src/client/theme/global.css +115 -11
- package/src/client/theme/surface-theme.css +2 -2
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +22 -6
- package/src/server/ax-interaction.ts +6 -0
- package/src/server/ax-state-manager.ts +27 -6
- package/src/server/ax-state.ts +36 -3
- package/src/server/canvas-db.ts +83 -60
- package/src/server/canvas-operations.ts +39 -9
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +78 -6
- package/src/server/canvas-validation.ts +79 -0
- package/src/server/html-surface.ts +11 -0
- package/src/server/index.ts +25 -6
- package/src/server/intent-registry.ts +2 -0
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/http.ts +5 -1
- package/src/server/operations/ops/ax-shared.ts +20 -1
- package/src/server/operations/ops/ax-state.ts +4 -7
- package/src/server/operations/ops/ax-timeline.ts +26 -3
- package/src/server/operations/ops/ax-work.ts +27 -1
- package/src/server/operations/ops/batch.ts +11 -1
- 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 +8 -1
- package/src/server/operations/registry.ts +122 -1
- package/src/server/server.ts +58 -3
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-intent.ts +6 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/surface.ts +2 -0
- package/src/shared/themes.ts +1 -1
package/src/server/canvas-db.ts
CHANGED
|
@@ -178,7 +178,8 @@ const SCHEMA_SQL = `
|
|
|
178
178
|
node_ids TEXT NOT NULL DEFAULT '[]',
|
|
179
179
|
data TEXT,
|
|
180
180
|
created_at TEXT NOT NULL,
|
|
181
|
-
source TEXT
|
|
181
|
+
source TEXT,
|
|
182
|
+
agent_id TEXT
|
|
182
183
|
);
|
|
183
184
|
CREATE INDEX IF NOT EXISTS idx_ax_events_seq ON ax_events (seq);
|
|
184
185
|
|
|
@@ -202,7 +203,9 @@ const SCHEMA_SQL = `
|
|
|
202
203
|
message TEXT NOT NULL,
|
|
203
204
|
delivered INTEGER NOT NULL DEFAULT 0,
|
|
204
205
|
created_at TEXT NOT NULL,
|
|
205
|
-
source TEXT
|
|
206
|
+
source TEXT,
|
|
207
|
+
agent_id TEXT,
|
|
208
|
+
target TEXT
|
|
206
209
|
);
|
|
207
210
|
CREATE INDEX IF NOT EXISTS idx_ax_steering_seq ON ax_steering (seq);
|
|
208
211
|
|
|
@@ -213,6 +216,22 @@ const SCHEMA_SQL = `
|
|
|
213
216
|
);
|
|
214
217
|
`;
|
|
215
218
|
|
|
219
|
+
/** Idempotently add a column to an existing table (no prior-migrations system in this repo). */
|
|
220
|
+
function ensureColumn(db: Database, table: string, column: string, ddl: string): void {
|
|
221
|
+
interface ColumnInfoRow {
|
|
222
|
+
name: string;
|
|
223
|
+
}
|
|
224
|
+
const columns = db.query<ColumnInfoRow, []>(`PRAGMA table_info(${table})`).all();
|
|
225
|
+
if (columns.some((c) => c.name === column)) return;
|
|
226
|
+
try {
|
|
227
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// Two processes can race the check-then-ALTER on the first post-upgrade
|
|
230
|
+
// open; the loser's duplicate-column error means the column exists — done.
|
|
231
|
+
if (!/duplicate column/i.test(error instanceof Error ? error.message : String(error))) throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
216
235
|
function normalizePositiveInteger(value: number | undefined): number | undefined {
|
|
217
236
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
|
|
218
237
|
return Math.floor(value);
|
|
@@ -258,6 +277,11 @@ export function openCanvasDb(dbPath: string): Database {
|
|
|
258
277
|
db.exec('PRAGMA busy_timeout=5000');
|
|
259
278
|
db.exec(SCHEMA_SQL);
|
|
260
279
|
|
|
280
|
+
// Additive columns for pre-existing DBs (fresh installs already get them via SCHEMA_SQL above).
|
|
281
|
+
ensureColumn(db, 'ax_events', 'agent_id', 'agent_id TEXT');
|
|
282
|
+
ensureColumn(db, 'ax_steering', 'agent_id', 'agent_id TEXT');
|
|
283
|
+
ensureColumn(db, 'ax_steering', 'target', 'target TEXT');
|
|
284
|
+
|
|
261
285
|
// Set schema version if not present
|
|
262
286
|
const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('schema_version');
|
|
263
287
|
if (!row) {
|
|
@@ -858,7 +882,7 @@ function readLastSeq(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_stee
|
|
|
858
882
|
|
|
859
883
|
export function appendAxEventToDB(db: Database, ev: Omit<PmxAxEvent, 'seq'>): PmxAxEvent {
|
|
860
884
|
db.run(
|
|
861
|
-
'INSERT INTO ax_events (id, kind, summary, detail, node_ids, data, created_at, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
885
|
+
'INSERT INTO ax_events (id, kind, summary, detail, node_ids, data, created_at, source, agent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
862
886
|
[
|
|
863
887
|
ev.id,
|
|
864
888
|
ev.kind,
|
|
@@ -868,6 +892,7 @@ export function appendAxEventToDB(db: Database, ev: Omit<PmxAxEvent, 'seq'>): Pm
|
|
|
868
892
|
ev.data ? JSON.stringify(ev.data) : null,
|
|
869
893
|
ev.createdAt,
|
|
870
894
|
ev.source,
|
|
895
|
+
ev.agentId,
|
|
871
896
|
],
|
|
872
897
|
);
|
|
873
898
|
const seq = readLastSeq(db, 'ax_events');
|
|
@@ -896,20 +921,20 @@ export function appendAxEvidenceToDB(db: Database, ev: Omit<PmxAxEvidence, 'seq'
|
|
|
896
921
|
}
|
|
897
922
|
|
|
898
923
|
export function appendAxSteeringToDB(db: Database, s: Omit<PmxAxSteeringMessage, 'seq'>): PmxAxSteeringMessage {
|
|
899
|
-
db.run(
|
|
900
|
-
|
|
901
|
-
s.message,
|
|
902
|
-
|
|
903
|
-
s.createdAt,
|
|
904
|
-
s.source,
|
|
905
|
-
]);
|
|
924
|
+
db.run(
|
|
925
|
+
'INSERT INTO ax_steering (id, message, delivered, created_at, source, agent_id, target) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
926
|
+
[s.id, s.message, s.delivered ? 1 : 0, s.createdAt, s.source, s.agentId, s.target],
|
|
927
|
+
);
|
|
906
928
|
const seq = readLastSeq(db, 'ax_steering');
|
|
907
929
|
trimAxTable(db, 'ax_steering');
|
|
908
930
|
return { ...s, seq };
|
|
909
931
|
}
|
|
910
932
|
|
|
911
933
|
export function markAxSteeringDeliveredInDB(db: Database, id: string): boolean {
|
|
912
|
-
|
|
934
|
+
// Compare-and-set: true ONLY on the undelivered→delivered transition. Two
|
|
935
|
+
// adapters racing the same steer see exactly one true — the loser (and any
|
|
936
|
+
// re-mark) gets false and must not treat the message as its own delivery.
|
|
937
|
+
const r = db.run('UPDATE ax_steering SET delivered = 1 WHERE id = ? AND delivered = 0', [id]);
|
|
913
938
|
return r.changes > 0;
|
|
914
939
|
}
|
|
915
940
|
|
|
@@ -924,6 +949,7 @@ export function loadAxEventsFromDB(db: Database, q: AxTimelineQuery = {}): PmxAx
|
|
|
924
949
|
data: string | null;
|
|
925
950
|
created_at: string;
|
|
926
951
|
source: string | null;
|
|
952
|
+
agent_id: string | null;
|
|
927
953
|
}
|
|
928
954
|
const rows = db
|
|
929
955
|
.query<Row, [number]>('SELECT * FROM ax_events ORDER BY seq DESC LIMIT ?')
|
|
@@ -935,6 +961,7 @@ export function loadAxEventsFromDB(db: Database, q: AxTimelineQuery = {}): PmxAx
|
|
|
935
961
|
createdAt: r.created_at,
|
|
936
962
|
nodeIds: safeParseJson(r.node_ids),
|
|
937
963
|
data: safeParseJson(r.data),
|
|
964
|
+
agentId: r.agent_id,
|
|
938
965
|
}),
|
|
939
966
|
)
|
|
940
967
|
.filter((e): e is PmxAxEvent => e !== null);
|
|
@@ -968,58 +995,62 @@ export function loadAxEvidenceFromDB(db: Database, q: AxTimelineQuery = {}): Pmx
|
|
|
968
995
|
.filter((e): e is PmxAxEvidence => e !== null);
|
|
969
996
|
}
|
|
970
997
|
|
|
998
|
+
interface AxSteeringRow {
|
|
999
|
+
seq: number;
|
|
1000
|
+
id: string;
|
|
1001
|
+
message: string;
|
|
1002
|
+
delivered: number;
|
|
1003
|
+
created_at: string;
|
|
1004
|
+
source: string | null;
|
|
1005
|
+
agent_id: string | null;
|
|
1006
|
+
target: string | null;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function mapAxSteeringRow(r: AxSteeringRow): PmxAxSteeringMessage | null {
|
|
1010
|
+
return normalizeAxSteeringMessage({
|
|
1011
|
+
...r,
|
|
1012
|
+
createdAt: r.created_at,
|
|
1013
|
+
delivered: r.delivered === 1,
|
|
1014
|
+
agentId: r.agent_id,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
971
1018
|
export function loadAxSteeringFromDB(
|
|
972
1019
|
db: Database,
|
|
973
1020
|
q: AxTimelineQuery & { onlyPending?: boolean } = {},
|
|
974
1021
|
): PmxAxSteeringMessage[] {
|
|
975
|
-
interface Row {
|
|
976
|
-
seq: number;
|
|
977
|
-
id: string;
|
|
978
|
-
message: string;
|
|
979
|
-
delivered: number;
|
|
980
|
-
created_at: string;
|
|
981
|
-
source: string | null;
|
|
982
|
-
}
|
|
983
1022
|
const sql = q.onlyPending
|
|
984
1023
|
? 'SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?'
|
|
985
1024
|
: 'SELECT * FROM ax_steering ORDER BY seq DESC LIMIT ?';
|
|
986
|
-
const rows = db.query<
|
|
987
|
-
return rows
|
|
988
|
-
.map((r) => normalizeAxSteeringMessage({ ...r, createdAt: r.created_at, delivered: r.delivered === 1 }))
|
|
989
|
-
.filter((s): s is PmxAxSteeringMessage => s !== null);
|
|
1025
|
+
const rows = db.query<AxSteeringRow, [number]>(sql).all(clampTimelineLimit(q.limit));
|
|
1026
|
+
return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
|
|
990
1027
|
}
|
|
991
1028
|
|
|
992
1029
|
export function loadPendingAxSteeringFromDB(
|
|
993
1030
|
db: Database,
|
|
994
1031
|
options: { consumer?: string; limit?: number } = {},
|
|
995
1032
|
): PmxAxSteeringMessage[] {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
message: string;
|
|
1000
|
-
delivered: number;
|
|
1001
|
-
created_at: string;
|
|
1002
|
-
source: string | null;
|
|
1003
|
-
}
|
|
1004
|
-
// FIFO (oldest undelivered first); exclude the consumer's own steering in SQL
|
|
1005
|
-
// so the LIMIT is applied AFTER loop-prevention, not before.
|
|
1033
|
+
// FIFO (oldest undelivered first); exclude the consumer's own steering (loop
|
|
1034
|
+
// prevention) and any steering addressed to a DIFFERENT consumer (target scoping)
|
|
1035
|
+
// in SQL so the LIMIT is applied AFTER both filters, not before.
|
|
1006
1036
|
const limit = clampTimelineLimit(options.limit);
|
|
1007
1037
|
const rows = options.consumer
|
|
1008
1038
|
? db
|
|
1009
|
-
.query<
|
|
1010
|
-
'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) ORDER BY seq ASC LIMIT ?',
|
|
1039
|
+
.query<AxSteeringRow, [string, string, string, number]>(
|
|
1040
|
+
'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?) ORDER BY seq ASC LIMIT ?',
|
|
1011
1041
|
)
|
|
1012
|
-
.all(options.consumer, limit)
|
|
1013
|
-
: db
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1042
|
+
.all(options.consumer, options.consumer, options.consumer, limit)
|
|
1043
|
+
: db
|
|
1044
|
+
.query<AxSteeringRow, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq ASC LIMIT ?')
|
|
1045
|
+
.all(limit);
|
|
1046
|
+
return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
|
|
1017
1047
|
}
|
|
1018
1048
|
|
|
1019
1049
|
/**
|
|
1020
1050
|
* NEWEST undelivered steering first (report #57) for the compact AX context lead
|
|
1021
1051
|
* block — so a fresh steer is visible even behind a long backlog. Loop-safe: excludes
|
|
1022
|
-
* the consumer's own steering
|
|
1052
|
+
* the consumer's own steering and steering targeted at a different consumer in SQL
|
|
1053
|
+
* so the LIMIT applies after both filters.
|
|
1023
1054
|
* Distinct from loadPendingAxSteeringFromDB (FIFO oldest-first) which the claim/ack
|
|
1024
1055
|
* delivery queue uses for ordered processing.
|
|
1025
1056
|
*/
|
|
@@ -1027,35 +1058,27 @@ export function loadNewestPendingAxSteeringFromDB(
|
|
|
1027
1058
|
db: Database,
|
|
1028
1059
|
options: { consumer?: string; limit?: number } = {},
|
|
1029
1060
|
): PmxAxSteeringMessage[] {
|
|
1030
|
-
interface Row {
|
|
1031
|
-
seq: number;
|
|
1032
|
-
id: string;
|
|
1033
|
-
message: string;
|
|
1034
|
-
delivered: number;
|
|
1035
|
-
created_at: string;
|
|
1036
|
-
source: string | null;
|
|
1037
|
-
}
|
|
1038
1061
|
const limit = clampTimelineLimit(options.limit);
|
|
1039
1062
|
const rows = options.consumer
|
|
1040
1063
|
? db
|
|
1041
|
-
.query<
|
|
1042
|
-
'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) ORDER BY seq DESC LIMIT ?',
|
|
1064
|
+
.query<AxSteeringRow, [string, string, string, number]>(
|
|
1065
|
+
'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?) ORDER BY seq DESC LIMIT ?',
|
|
1043
1066
|
)
|
|
1044
|
-
.all(options.consumer, limit)
|
|
1045
|
-
: db
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1067
|
+
.all(options.consumer, options.consumer, options.consumer, limit)
|
|
1068
|
+
: db
|
|
1069
|
+
.query<AxSteeringRow, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?')
|
|
1070
|
+
.all(limit);
|
|
1071
|
+
return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
|
|
1049
1072
|
}
|
|
1050
1073
|
|
|
1051
|
-
/** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own). */
|
|
1074
|
+
/** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own; target-scoped). */
|
|
1052
1075
|
export function countPendingAxSteeringFromDB(db: Database, consumer?: string): number {
|
|
1053
1076
|
const n = consumer
|
|
1054
1077
|
? db
|
|
1055
|
-
.query<{ n: number }, [string]>(
|
|
1056
|
-
'SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?)',
|
|
1078
|
+
.query<{ n: number }, [string, string, string]>(
|
|
1079
|
+
'SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?)',
|
|
1057
1080
|
)
|
|
1058
|
-
.get(consumer)?.n
|
|
1081
|
+
.get(consumer, consumer, consumer)?.n
|
|
1059
1082
|
: db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0').get()?.n;
|
|
1060
1083
|
return Number(n ?? 0);
|
|
1061
1084
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ExternalMcpTransportConfig,
|
|
18
18
|
} from './mcp-app-runtime.js';
|
|
19
19
|
import { mutationHistory } from './mutation-history.js';
|
|
20
|
+
import { clampCreateNodeSize } from './canvas-validation.js';
|
|
20
21
|
import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
|
|
21
22
|
import { searchNodes } from './spatial-analysis.js';
|
|
22
23
|
import { getCanvasNodeTitle } from './canvas-serialization.js';
|
|
@@ -114,6 +115,10 @@ export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
|
|
|
114
115
|
export const IMAGE_NODE_DEFAULT_SIZE = { width: 480, height: 360 };
|
|
115
116
|
export const LEDGER_NODE_DEFAULT_SIZE = { width: 420, height: 280 };
|
|
116
117
|
|
|
118
|
+
// Per-type minimum creation sizes + clamp live in canvas-validation.ts
|
|
119
|
+
// (NODE_MIN_CREATE_SIZES / clampCreateNodeSize) — shared with the validate
|
|
120
|
+
// surface's sizeWarnings.
|
|
121
|
+
|
|
117
122
|
interface CanvasCreateGroupInput {
|
|
118
123
|
title?: string;
|
|
119
124
|
childIds?: string[];
|
|
@@ -360,11 +365,20 @@ export function buildJsonRenderNodeUpdate(
|
|
|
360
365
|
if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
|
|
361
366
|
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
362
367
|
const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
|
|
368
|
+
// Bump specVersion so the viewer URL changes: the iframe src is keyed on
|
|
369
|
+
// nodeId + ?v=specVersion, and the viewer only reads the spec at document
|
|
370
|
+
// load. Without the bump the src is byte-identical after a content update,
|
|
371
|
+
// the iframe never reloads, and the node renders stale forever (the live
|
|
372
|
+
// workboard's refresh depends on this).
|
|
373
|
+
const prevVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : 0;
|
|
363
374
|
return {
|
|
364
375
|
spec,
|
|
365
376
|
data: {
|
|
366
377
|
...node.data,
|
|
367
|
-
...createJsonRenderNodeData(node.id, title, spec, {
|
|
378
|
+
...createJsonRenderNodeData(node.id, title, spec, {
|
|
379
|
+
viewerType: 'json-render',
|
|
380
|
+
specVersion: prevVersion + 1,
|
|
381
|
+
}),
|
|
368
382
|
},
|
|
369
383
|
};
|
|
370
384
|
}
|
|
@@ -906,8 +920,12 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
|
|
|
906
920
|
throw new Error(`Use the dedicated ${input.type} node APIs for structured viewer nodes.`);
|
|
907
921
|
}
|
|
908
922
|
|
|
909
|
-
const
|
|
910
|
-
|
|
923
|
+
const { width, height } = clampCreateNodeSize(
|
|
924
|
+
input.type,
|
|
925
|
+
input.width ?? input.defaultWidth ?? 720,
|
|
926
|
+
input.height ?? input.defaultHeight ?? 600,
|
|
927
|
+
input.strictSize,
|
|
928
|
+
);
|
|
911
929
|
const position =
|
|
912
930
|
input.x !== undefined && input.y !== undefined
|
|
913
931
|
? { x: input.x, y: input.y }
|
|
@@ -1540,8 +1558,12 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
|
|
|
1540
1558
|
node: CanvasNodeState;
|
|
1541
1559
|
} {
|
|
1542
1560
|
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
1543
|
-
const width
|
|
1544
|
-
|
|
1561
|
+
const { width, height } = clampCreateNodeSize(
|
|
1562
|
+
'json-render',
|
|
1563
|
+
input.width ?? JSON_RENDER_NODE_SIZE.width,
|
|
1564
|
+
input.height ?? JSON_RENDER_NODE_SIZE.height,
|
|
1565
|
+
input.strictSize,
|
|
1566
|
+
);
|
|
1545
1567
|
const position =
|
|
1546
1568
|
input.x !== undefined && input.y !== undefined
|
|
1547
1569
|
? { x: input.x, y: input.y }
|
|
@@ -1580,8 +1602,12 @@ export function createCanvasStreamingJsonRenderNode(input: {
|
|
|
1580
1602
|
strictSize?: boolean;
|
|
1581
1603
|
}): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState } {
|
|
1582
1604
|
const spec = emptyStreamingSpec();
|
|
1583
|
-
const width
|
|
1584
|
-
|
|
1605
|
+
const { width, height } = clampCreateNodeSize(
|
|
1606
|
+
'json-render',
|
|
1607
|
+
input.width ?? JSON_RENDER_NODE_SIZE.width,
|
|
1608
|
+
input.height ?? JSON_RENDER_NODE_SIZE.height,
|
|
1609
|
+
input.strictSize,
|
|
1610
|
+
);
|
|
1585
1611
|
const position =
|
|
1586
1612
|
input.x !== undefined && input.y !== undefined
|
|
1587
1613
|
? { x: input.x, y: input.y }
|
|
@@ -1652,8 +1678,12 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
|
|
|
1652
1678
|
} {
|
|
1653
1679
|
const title = input.title?.trim() || 'Graph';
|
|
1654
1680
|
const spec = buildGraphSpec(input);
|
|
1655
|
-
const width
|
|
1656
|
-
|
|
1681
|
+
const { width, height } = clampCreateNodeSize(
|
|
1682
|
+
'graph',
|
|
1683
|
+
input.width ?? GRAPH_NODE_SIZE.width,
|
|
1684
|
+
input.heightPx ?? GRAPH_NODE_SIZE.height,
|
|
1685
|
+
input.strictSize,
|
|
1686
|
+
);
|
|
1657
1687
|
const position =
|
|
1658
1688
|
input.x !== undefined && input.y !== undefined
|
|
1659
1689
|
? { x: input.x, y: input.y }
|
|
@@ -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',
|
|
@@ -175,6 +175,8 @@ export interface CanvasNodeState {
|
|
|
175
175
|
| 'ledger'
|
|
176
176
|
| 'trace'
|
|
177
177
|
| 'file'
|
|
178
|
+
| 'diff'
|
|
179
|
+
| 'mermaid'
|
|
178
180
|
| 'image'
|
|
179
181
|
| 'html'
|
|
180
182
|
| 'group';
|
|
@@ -368,6 +370,28 @@ class CanvasStateManager {
|
|
|
368
370
|
};
|
|
369
371
|
}
|
|
370
372
|
|
|
373
|
+
// ── Work-item change listener (single slot, for live workboard refresh) ──
|
|
374
|
+
private _workItemsChangedListener: (() => void) | null = null;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Register THE work-item change listener (single slot, last-write-wins).
|
|
378
|
+
* Fired after addWorkItem/updateWorkItem completes (including the node
|
|
379
|
+
* status mirror), so live views like the workboard can rebuild from the
|
|
380
|
+
* fresh work-item list.
|
|
381
|
+
*/
|
|
382
|
+
setWorkItemsChangedListener(listener: (() => void) | null): void {
|
|
383
|
+
this._workItemsChangedListener = listener;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private notifyWorkItemsChanged(): void {
|
|
387
|
+
if (!this._workItemsChangedListener) return;
|
|
388
|
+
try {
|
|
389
|
+
this._workItemsChangedListener();
|
|
390
|
+
} catch (error) {
|
|
391
|
+
logCanvasStateWarning('work-items-changed listener failed', error);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
371
395
|
private notifyChange(type: CanvasChangeType): void {
|
|
372
396
|
// Iterate a snapshot: a listener (e.g. a blocking-wait via onChange) may dispose
|
|
373
397
|
// itself synchronously here, and splicing the live array mid-iteration would skip
|
|
@@ -1716,18 +1740,63 @@ class CanvasStateManager {
|
|
|
1716
1740
|
}
|
|
1717
1741
|
|
|
1718
1742
|
addWorkItem(
|
|
1719
|
-
input: {
|
|
1743
|
+
input: {
|
|
1744
|
+
title: string;
|
|
1745
|
+
status?: PmxAxWorkItemStatus;
|
|
1746
|
+
detail?: string | null;
|
|
1747
|
+
nodeIds?: string[];
|
|
1748
|
+
agentId?: string | null;
|
|
1749
|
+
},
|
|
1720
1750
|
options: { source?: PmxAxSource } = {},
|
|
1721
1751
|
): PmxAxWorkItem {
|
|
1722
|
-
|
|
1752
|
+
const item = this.ax.addWorkItem(input, options);
|
|
1753
|
+
this.mirrorAxWorkStatusToNodes(item.nodeIds, [], item.status);
|
|
1754
|
+
this.notifyWorkItemsChanged();
|
|
1755
|
+
return item;
|
|
1723
1756
|
}
|
|
1724
1757
|
|
|
1725
1758
|
updateWorkItem(
|
|
1726
1759
|
id: string,
|
|
1727
|
-
patch: {
|
|
1760
|
+
patch: {
|
|
1761
|
+
title?: string;
|
|
1762
|
+
status?: PmxAxWorkItemStatus;
|
|
1763
|
+
detail?: string | null;
|
|
1764
|
+
nodeIds?: string[];
|
|
1765
|
+
agentId?: string | null;
|
|
1766
|
+
},
|
|
1728
1767
|
options: { source?: PmxAxSource } = {},
|
|
1729
1768
|
): PmxAxWorkItem | null {
|
|
1730
|
-
|
|
1769
|
+
const previousNodeIds = this.ax.getWorkItems().find((w) => w.id === id)?.nodeIds ?? [];
|
|
1770
|
+
const item = this.ax.updateWorkItem(id, patch, options);
|
|
1771
|
+
if (!item) return null;
|
|
1772
|
+
this.mirrorAxWorkStatusToNodes(item.nodeIds, previousNodeIds, item.status);
|
|
1773
|
+
this.notifyWorkItemsChanged();
|
|
1774
|
+
return item;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* Mirror a work item's status onto every linked canvas node's `data.axWorkStatus`,
|
|
1779
|
+
* through the standard node-update path (persistence + undo/redo, same as any other
|
|
1780
|
+
* node data change). Nodes unlinked by an updateWorkItem nodeIds change have the
|
|
1781
|
+
* mirrored status cleared.
|
|
1782
|
+
*/
|
|
1783
|
+
private mirrorAxWorkStatusToNodes(nodeIds: string[], previousNodeIds: string[], status: PmxAxWorkItemStatus): void {
|
|
1784
|
+
for (const nodeId of nodeIds) {
|
|
1785
|
+
const node = this.nodes.get(nodeId);
|
|
1786
|
+
if (!node) continue;
|
|
1787
|
+
// No-op guard: a title-/agentId-only work-item patch must not push
|
|
1788
|
+
// history entries and SSE churn for chips that aren't changing.
|
|
1789
|
+
if (node.data.axWorkStatus === status) continue;
|
|
1790
|
+
this.updateNode(nodeId, { data: { ...node.data, axWorkStatus: status } });
|
|
1791
|
+
}
|
|
1792
|
+
for (const nodeId of previousNodeIds) {
|
|
1793
|
+
if (nodeIds.includes(nodeId)) continue;
|
|
1794
|
+
const node = this.nodes.get(nodeId);
|
|
1795
|
+
if (!node || node.data.axWorkStatus === undefined) continue;
|
|
1796
|
+
const data = { ...node.data };
|
|
1797
|
+
delete data.axWorkStatus;
|
|
1798
|
+
this.updateNode(nodeId, { data });
|
|
1799
|
+
}
|
|
1731
1800
|
}
|
|
1732
1801
|
|
|
1733
1802
|
// ── Approval gates (canvas-bound) ─────────────────────────────────
|
|
@@ -1873,7 +1942,7 @@ class CanvasStateManager {
|
|
|
1873
1942
|
nodeIds?: string[];
|
|
1874
1943
|
data?: Record<string, unknown> | null;
|
|
1875
1944
|
},
|
|
1876
|
-
options: { source?: PmxAxSource } = {},
|
|
1945
|
+
options: { source?: PmxAxSource; agentId?: string | null } = {},
|
|
1877
1946
|
): PmxAxEvent {
|
|
1878
1947
|
return this.ax.recordAxEvent(input, options);
|
|
1879
1948
|
}
|
|
@@ -1892,7 +1961,10 @@ class CanvasStateManager {
|
|
|
1892
1961
|
return this.ax.addEvidence(input, options);
|
|
1893
1962
|
}
|
|
1894
1963
|
|
|
1895
|
-
recordSteeringMessage(
|
|
1964
|
+
recordSteeringMessage(
|
|
1965
|
+
message: string,
|
|
1966
|
+
options: { source?: PmxAxSource; agentId?: string | null; target?: string | null } = {},
|
|
1967
|
+
): PmxAxSteeringMessage {
|
|
1896
1968
|
return this.ax.recordSteeringMessage(message, options);
|
|
1897
1969
|
}
|
|
1898
1970
|
|
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
|
|
2
2
|
import { getCanvasNodeTitle } from './canvas-serialization.js';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Per-type minimum node size (0.4.5 report follow-up): agents frequently
|
|
6
|
+
* create nodes with explicit frames far too small for their content — clipped
|
|
7
|
+
* markdown, charts squeezed behind inner scrollbars. Explicit sizes below the
|
|
8
|
+
* floor are clamped UP at creation (canvas-operations.ts creators);
|
|
9
|
+
* `strictSize: true` is the escape hatch for a genuinely small fixed frame.
|
|
10
|
+
* Floors sit below every type default and above the point of unreadability.
|
|
11
|
+
* Creation-only: later updates are untouched (the browser's drag-resize
|
|
12
|
+
* enforces its own 200×100 floor client-side), but `validate` reports any
|
|
13
|
+
* node below its floor as an advisory sizeWarning. Absent types (trace,
|
|
14
|
+
* group, prompt/response) are intentionally unclamped — trace is small by
|
|
15
|
+
* design, groups size to their children.
|
|
16
|
+
*/
|
|
17
|
+
export const NODE_MIN_CREATE_SIZES: Partial<Record<CanvasNodeState['type'], { width: number; height: number }>> = {
|
|
18
|
+
markdown: { width: 360, height: 180 },
|
|
19
|
+
context: { width: 360, height: 180 },
|
|
20
|
+
file: { width: 360, height: 200 },
|
|
21
|
+
diff: { width: 420, height: 240 },
|
|
22
|
+
mermaid: { width: 360, height: 240 },
|
|
23
|
+
status: { width: 280, height: 120 },
|
|
24
|
+
ledger: { width: 320, height: 200 },
|
|
25
|
+
image: { width: 240, height: 180 },
|
|
26
|
+
html: { width: 420, height: 280 },
|
|
27
|
+
webpage: { width: 420, height: 280 },
|
|
28
|
+
'json-render': { width: 420, height: 280 },
|
|
29
|
+
graph: { width: 420, height: 280 },
|
|
30
|
+
'mcp-app': { width: 480, height: 320 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function clampCreateNodeSize(
|
|
34
|
+
type: CanvasNodeState['type'],
|
|
35
|
+
width: number,
|
|
36
|
+
height: number,
|
|
37
|
+
strictSize?: boolean,
|
|
38
|
+
): { width: number; height: number } {
|
|
39
|
+
if (strictSize === true) return { width, height };
|
|
40
|
+
const min = NODE_MIN_CREATE_SIZES[type];
|
|
41
|
+
if (!min) return { width, height };
|
|
42
|
+
return { width: Math.max(width, min.width), height: Math.max(height, min.height) };
|
|
43
|
+
}
|
|
44
|
+
|
|
4
45
|
export interface CanvasValidationPair {
|
|
5
46
|
aId: string;
|
|
6
47
|
aTitle: string | null;
|
|
@@ -15,12 +56,24 @@ export interface CanvasContainmentIssue {
|
|
|
15
56
|
childTitle: string | null;
|
|
16
57
|
}
|
|
17
58
|
|
|
59
|
+
export interface CanvasSizeWarning {
|
|
60
|
+
id: string;
|
|
61
|
+
title: string | null;
|
|
62
|
+
type: CanvasNodeState['type'];
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
65
|
+
minWidth: number;
|
|
66
|
+
minHeight: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
18
69
|
export interface CanvasValidationResult {
|
|
19
70
|
ok: boolean;
|
|
20
71
|
collisions: CanvasValidationPair[];
|
|
21
72
|
containments: CanvasContainmentIssue[];
|
|
22
73
|
containmentViolations: CanvasContainmentIssue[];
|
|
23
74
|
missingEdgeEndpoints: Array<{ edgeId: string; from: string; to: string }>;
|
|
75
|
+
/** Nodes below their type's readable minimum (advisory — does not fail `ok`). */
|
|
76
|
+
sizeWarnings: CanvasSizeWarning[];
|
|
24
77
|
summary: {
|
|
25
78
|
nodes: number;
|
|
26
79
|
edges: number;
|
|
@@ -28,6 +81,7 @@ export interface CanvasValidationResult {
|
|
|
28
81
|
containments: number;
|
|
29
82
|
containmentViolations: number;
|
|
30
83
|
missingEdgeEndpoints: number;
|
|
84
|
+
sizeWarnings: number;
|
|
31
85
|
};
|
|
32
86
|
}
|
|
33
87
|
|
|
@@ -109,12 +163,36 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
|
|
|
109
163
|
.filter((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to))
|
|
110
164
|
.map((edge) => ({ edgeId: edge.id, from: edge.from, to: edge.to }));
|
|
111
165
|
|
|
166
|
+
// Advisory: nodes below their type's readable floor (clipped/unreadable
|
|
167
|
+
// content — 0.4.5 report follow-up). Creation clamps these, but resized or
|
|
168
|
+
// strictSize-created nodes can still be undersized; surface them so an
|
|
169
|
+
// agent's validate pass sees the problem. Collapsed/docked nodes render as
|
|
170
|
+
// bars, and strictSize is the deliberate opt-out — all skipped.
|
|
171
|
+
const sizeWarnings: CanvasSizeWarning[] = layout.nodes
|
|
172
|
+
.filter((node) => !node.collapsed && node.dockPosition == null && node.data?.strictSize !== true)
|
|
173
|
+
.flatMap((node) => {
|
|
174
|
+
const min = NODE_MIN_CREATE_SIZES[node.type];
|
|
175
|
+
if (!min || (node.size.width >= min.width && node.size.height >= min.height)) return [];
|
|
176
|
+
return [
|
|
177
|
+
{
|
|
178
|
+
id: node.id,
|
|
179
|
+
title: getCanvasNodeTitle(node),
|
|
180
|
+
type: node.type,
|
|
181
|
+
width: node.size.width,
|
|
182
|
+
height: node.size.height,
|
|
183
|
+
minWidth: min.width,
|
|
184
|
+
minHeight: min.height,
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
});
|
|
188
|
+
|
|
112
189
|
return {
|
|
113
190
|
ok: collisions.length === 0 && containmentViolations.length === 0 && missingEdgeEndpoints.length === 0,
|
|
114
191
|
collisions,
|
|
115
192
|
containments,
|
|
116
193
|
containmentViolations,
|
|
117
194
|
missingEdgeEndpoints,
|
|
195
|
+
sizeWarnings,
|
|
118
196
|
summary: {
|
|
119
197
|
nodes: layout.nodes.length,
|
|
120
198
|
edges: layout.edges.length,
|
|
@@ -122,6 +200,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
|
|
|
122
200
|
containments: containments.length,
|
|
123
201
|
containmentViolations: containmentViolations.length,
|
|
124
202
|
missingEdgeEndpoints: missingEdgeEndpoints.length,
|
|
203
|
+
sizeWarnings: sizeWarnings.length,
|
|
125
204
|
},
|
|
126
205
|
};
|
|
127
206
|
}
|
|
@@ -211,6 +211,17 @@ function escapeSurfaceHtml(value: string): string {
|
|
|
211
211
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Author-HTML fragment for a `mermaid` node surface: the HTML-escaped diagram
|
|
216
|
+
* source in a hidden `<pre>` the entry script reads back via textContent, plus
|
|
217
|
+
* the separately-built renderer bundle (dist/canvas/mermaid-entry.js — kept out
|
|
218
|
+
* of the main SPA bundle). Escaping is mandatory: the source is arbitrary node
|
|
219
|
+
* data and must not be parsed as markup.
|
|
220
|
+
*/
|
|
221
|
+
export function buildMermaidSurfaceHtml(source: string): string {
|
|
222
|
+
return `<pre class="mermaid-source" style="display:none">${escapeSurfaceHtml(source)}</pre><script src="/canvas/mermaid-entry.js"></script>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
214
225
|
export interface HtmlSurfaceOptions {
|
|
215
226
|
theme: SurfaceTheme;
|
|
216
227
|
/**
|