pmx-canvas 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { z } from 'zod';
34
34
  import { canvasState } from '../../canvas-state.js';
35
+ import { describeCreateSize, type CanvasSizeAdjustment } from '../../canvas-operations.js';
35
36
  import {
36
37
  closeMcpAppSession,
37
38
  openMcpApp as openExternalMcpApp,
@@ -75,6 +76,7 @@ export interface OpenMcpAppCoreResult {
75
76
  toolCallId: string;
76
77
  sessionId: string;
77
78
  resourceUri: string;
79
+ sizeAdjustment?: CanvasSizeAdjustment;
78
80
  }
79
81
 
80
82
  /**
@@ -105,6 +107,7 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
105
107
  throw new OperationError(`Node "${input.nodeId}" is not an external app node.`);
106
108
  }
107
109
 
110
+ const boardGeneration = canvasState.boardGeneration;
108
111
  const opened = await openExternalMcpApp({
109
112
  transport: input.transport,
110
113
  toolName: input.toolName,
@@ -112,6 +115,10 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
112
115
  ...(input.serverName ? { serverName: input.serverName } : {}),
113
116
  ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
114
117
  });
118
+ if (canvasState.boardGeneration !== boardGeneration) {
119
+ closeMcpAppSession(opened.sessionId);
120
+ throw new OperationError('The board changed while the MCP app was opening.', 409);
121
+ }
115
122
  const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
116
123
  const previousSessionId = targetNode?.data.appSessionId;
117
124
  if (typeof previousSessionId === 'string' && previousSessionId.trim().length > 0) {
@@ -121,6 +128,7 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
121
128
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
122
129
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
123
130
  : opened.toolResult;
131
+ const existingNodeIds = new Set(canvasState.getLayout().nodes.map((node) => node.id));
124
132
  ctx.emit('ext-app-open', {
125
133
  toolCallId,
126
134
  nodeId: nodeIdSeed,
@@ -158,6 +166,11 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
158
166
  getNode: (id) => canvasState.getNode(id),
159
167
  listNodes: () => canvasState.getLayout().nodes,
160
168
  });
169
+ const node = nodeId ? canvasState.getNode(nodeId) : undefined;
170
+ const sizeAdjustment =
171
+ node && !existingNodeIds.has(node.id)
172
+ ? describeCreateSize(input.width, input.height, node.size.width, node.size.height, node.size)
173
+ : undefined;
161
174
  return {
162
175
  ok: true,
163
176
  ...(nodeId ? { id: nodeId } : {}),
@@ -165,6 +178,7 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
165
178
  toolCallId,
166
179
  sessionId: opened.sessionId,
167
180
  resourceUri: opened.resourceUri,
181
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
168
182
  };
169
183
  }
170
184
 
@@ -388,6 +402,7 @@ function webArtifactEnvelope(result: WebArtifactCanvasBuildResult, includeLogs:
388
402
  ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
389
403
  nodeId: result.nodeId,
390
404
  url: result.url,
405
+ ...(result.sizeAdjustment ? { sizeAdjustment: result.sizeAdjustment } : {}),
391
406
  metadata: result.metadata,
392
407
  logs: result.logs,
393
408
  ...(includeLogs ? { stdout: result.stdout, stderr: result.stderr } : {}),
@@ -28,6 +28,7 @@ import {
28
28
  import { canvasState, type CanvasNodeState } from '../../canvas-state.js';
29
29
  import { buildCodeGraphSummary } from '../../code-graph.js';
30
30
  import { defineOperation, OperationError, type Operation, type OperationContext } from '../types.js';
31
+ import { readTargetBoard } from './boards.js';
31
32
  import { normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
32
33
  import { isRecord } from './nodes.js';
33
34
 
@@ -153,19 +154,22 @@ const axSurfaceSnapshotOperation = defineOperation<z.infer<typeof emptySchema>,
153
154
 
154
155
  // ── pinned-context.get ────────────────────────────────────────
155
156
 
156
- const pinnedContextOperation = defineOperation<z.infer<typeof emptySchema>, Record<string, unknown>>({
157
+ const pinnedContextShape = { board: z.unknown().optional().describe('Board id to read without opening it.') };
158
+ const pinnedContextSchema = z.looseObject(pinnedContextShape);
159
+ const pinnedContextOperation = defineOperation<z.infer<typeof pinnedContextSchema>, Record<string, unknown>>({
157
160
  name: 'pinned-context.get',
158
161
  mutates: false,
159
- input: emptySchema,
160
- inputShape: emptyShape,
162
+ input: pinnedContextSchema,
163
+ inputShape: pinnedContextShape,
161
164
  http: {
162
165
  method: 'GET',
163
166
  path: '/api/canvas/pinned-context',
164
167
  },
165
- handler: () => {
166
- const pinnedIds = Array.from(canvasState.contextPinnedNodeIds);
168
+ handler: ({ board }) => {
169
+ const target = readTargetBoard(board);
170
+ const pinnedIds = Array.from(target.pinnedNodeIds);
167
171
  const nodes = pinnedIds
168
- .map((id) => canvasState.getNode(id))
172
+ .map((id) => target.layout.nodes.find((node) => node.id === id))
169
173
  .filter((node): node is CanvasNodeState => node !== undefined);
170
174
  const preamble =
171
175
  pinnedIds.length > 0 ? buildAgentContextPreamble(nodes, { defaultTextLength: 700, webpageTextLength: 1600 }) : '';
@@ -51,6 +51,7 @@ import { canvasState } from '../../canvas-state.js';
51
51
  import { buildPendingAxActivity, isAxEventKind, isAxEvidenceKind } from '../../ax-state.js';
52
52
  import type { PmxAxEventKind, PmxAxEvidenceKind } from '../../ax-state.js';
53
53
  import { waitForAxCondition } from '../../ax-wait.js';
54
+ import { CONTEXT_READ_CHANNELS } from '../../context-reads.js';
54
55
  import { defineOperation, OperationError, type Operation } from '../types.js';
55
56
  import { isRecord } from './nodes.js';
56
57
  import {
@@ -462,6 +463,78 @@ const axCommandInvokeOperation = defineOperation<z.infer<typeof axCommandInvokeS
462
463
  },
463
464
  });
464
465
 
466
+ // ── ax.reads.list / ax.reads.record (context read instrumentation) ──
467
+
468
+ const axReadsListShape = {
469
+ limit: z.unknown().optional().describe('Max reads to return, newest first (default 50, max 500).'),
470
+ };
471
+ const axReadsListSchema = z.looseObject(axReadsListShape);
472
+
473
+ const axReadsListOperation = defineOperation<z.infer<typeof axReadsListSchema>, Record<string, unknown>>({
474
+ name: 'ax.reads.list',
475
+ mutates: false,
476
+ input: axReadsListSchema,
477
+ inputShape: axReadsListShape,
478
+ http: {
479
+ method: 'GET',
480
+ path: '/api/canvas/ax/context-reads',
481
+ },
482
+ mcp: {
483
+ toolName: 'canvas_get_context_reads',
484
+ description:
485
+ 'Read the context read log: which canvas context each agent read, and which pinned nodes were in what it received. Summary is per consumer.',
486
+ extraShape: {
487
+ limit: z.number().optional().describe('Max reads to return, newest first (default 50, max 500).'),
488
+ },
489
+ formatResult: axJsonResult,
490
+ },
491
+ handler: (input) => {
492
+ const limit = Number(input.limit ?? '');
493
+ return { ok: true, ...canvasState.getContextReads(Number.isFinite(limit) && limit > 0 ? limit : undefined) };
494
+ },
495
+ });
496
+
497
+ const axReadsRecordShape = {
498
+ channel: z.enum(CONTEXT_READ_CHANNELS).describe('How the read reached the agent.'),
499
+ resource: z.string().min(1).describe('Resource URI or operation name that was read.'),
500
+ consumer: z.string().nullable().optional().describe('Reader identity (MCP client name, adapter host).'),
501
+ agentId: z.string().nullable().optional().describe('Agent id when known.'),
502
+ source: z.string().optional().describe('Transport label (default "api").'),
503
+ pinnedNodeIds: z.array(z.string()).optional().describe('Pins at read time; defaults to the current pins.'),
504
+ deliveredNodeIds: z.array(z.string()).describe('Pinned ids present in what the agent received.'),
505
+ bytes: z.number().int().nonnegative().describe('Size of what the agent received.'),
506
+ boardId: z.string().nullable().optional().describe('Board that was read, captured before delivery.'),
507
+ };
508
+ const axReadsRecordSchema = z.object(axReadsRecordShape);
509
+
510
+ /** For proxies (an MCP server attached to this daemon, host adapters) that record the read the agent made. */
511
+ const axReadsRecordOperation = defineOperation<z.infer<typeof axReadsRecordSchema>, Record<string, unknown>>({
512
+ name: 'ax.reads.record',
513
+ mutates: false,
514
+ input: axReadsRecordSchema,
515
+ inputShape: axReadsRecordShape,
516
+ http: {
517
+ method: 'POST',
518
+ path: '/api/canvas/ax/context-reads',
519
+ },
520
+ handler: (input) => {
521
+ const read = canvasState.recordContextRead(
522
+ {
523
+ channel: input.channel,
524
+ resource: input.resource,
525
+ source: input.source ?? 'api',
526
+ consumer: input.consumer ?? null,
527
+ agentId: input.agentId ?? null,
528
+ pinnedNodeIds: input.pinnedNodeIds ?? [...canvasState.contextPinnedNodeIds],
529
+ deliveredNodeIds: input.deliveredNodeIds,
530
+ bytes: input.bytes,
531
+ },
532
+ input.boardId === undefined ? canvasState.activeBoardId : input.boardId,
533
+ );
534
+ return { ok: true, read };
535
+ },
536
+ });
537
+
465
538
  export const axTimelineOperations: Operation[] = [
466
539
  axEventRecordOperation,
467
540
  axEvidenceAddOperation,
@@ -470,4 +543,6 @@ export const axTimelineOperations: Operation[] = [
470
543
  axDeliveryPendingOperation,
471
544
  axDeliveryMarkOperation,
472
545
  axCommandInvokeOperation,
546
+ axReadsListOperation,
547
+ axReadsRecordOperation,
473
548
  ];
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Library backup (plan 012): backup.status / backup.run / backup.schedule /
3
+ * backup.restore. HTTP, CLI and SDK only — backing up and restoring are the
4
+ * human's maintenance, not agent tools, so there is no MCP surface.
5
+ *
6
+ * This module must never import server.ts or index.ts.
7
+ */
8
+ import { z } from 'zod';
9
+ import {
10
+ assertRestorableBackup,
11
+ getBackupStatus,
12
+ parseBackupInterval,
13
+ runBackup,
14
+ setBackupSchedule,
15
+ } from '../../backup.js';
16
+ import { restoreCanvasLibrary } from '../../canvas-operations.js';
17
+ import { canvasState } from '../../canvas-state.js';
18
+ import { defineOperation, OperationError, type Operation } from '../types.js';
19
+ import { boardsPayload } from './boards.js';
20
+
21
+ function requireDatabase(): void {
22
+ if (!canvasState.databasePath) throw new OperationError('Backups need a workspace database.', 409);
23
+ }
24
+
25
+ function positiveInteger(value: unknown, field: string): number | undefined {
26
+ if (value === undefined || value === null || value === '') return undefined;
27
+ const parsed = Number(value);
28
+ if (!Number.isInteger(parsed) || parsed < 1)
29
+ throw new OperationError(`${field} must be a whole number of 1 or more.`);
30
+ return parsed;
31
+ }
32
+
33
+ const statusShape = {};
34
+ const statusSchema = z.looseObject(statusShape);
35
+
36
+ const backupStatusOperation = defineOperation<z.infer<typeof statusSchema>, Record<string, unknown>>({
37
+ name: 'backup.status',
38
+ mutates: false,
39
+ input: statusSchema,
40
+ inputShape: statusShape,
41
+ http: { method: 'GET', path: '/api/canvas/backup' },
42
+ handler: () => {
43
+ requireDatabase();
44
+ return { ok: true, ...getBackupStatus() };
45
+ },
46
+ });
47
+
48
+ const runShape = {
49
+ to: z.unknown().optional().describe('Folder to write the backup to (default: the configured folder)'),
50
+ keep: z.unknown().optional().describe('How many backups to keep in that folder'),
51
+ };
52
+ const runSchema = z.looseObject(runShape);
53
+
54
+ const backupRunOperation = defineOperation<z.infer<typeof runSchema>, Record<string, unknown>>({
55
+ name: 'backup.run',
56
+ mutates: false,
57
+ input: runSchema,
58
+ inputShape: runShape,
59
+ http: { method: 'POST', path: '/api/canvas/backup' },
60
+ handler: (input) => {
61
+ requireDatabase();
62
+ const result = runBackup({
63
+ ...(typeof input.to === 'string' && input.to.trim() ? { folder: input.to.trim() } : {}),
64
+ ...(positiveInteger(input.keep, 'keep') ? { keep: positiveInteger(input.keep, 'keep') } : {}),
65
+ });
66
+ return { ok: true, ...result, status: getBackupStatus() };
67
+ },
68
+ });
69
+
70
+ const scheduleShape = {
71
+ every: z.unknown().optional().describe('Interval like "24h", "30m", "7d"; "off" or null stops scheduled backups'),
72
+ to: z.unknown().optional().describe('Backup folder'),
73
+ keep: z.unknown().optional().describe('How many backups to keep'),
74
+ };
75
+ const scheduleSchema = z.looseObject(scheduleShape);
76
+
77
+ const backupScheduleOperation = defineOperation<z.infer<typeof scheduleSchema>, Record<string, unknown>>({
78
+ name: 'backup.schedule',
79
+ mutates: false,
80
+ input: scheduleSchema,
81
+ inputShape: scheduleShape,
82
+ http: { method: 'POST', path: '/api/canvas/backup/schedule' },
83
+ handler: (input) => {
84
+ requireDatabase();
85
+ const off = input.every === null || input.every === 'off';
86
+ const everyMs = off ? null : parseBackupInterval(String(input.every ?? ''));
87
+ if (!off && everyMs === null) throw new OperationError('every must look like "30m", "24h" or "7d", or be "off".');
88
+ try {
89
+ const status = setBackupSchedule({
90
+ everyMs,
91
+ ...(typeof input.to === 'string' && input.to.trim() ? { folder: input.to.trim() } : {}),
92
+ ...(positiveInteger(input.keep, 'keep') ? { keep: positiveInteger(input.keep, 'keep') } : {}),
93
+ });
94
+ return { ok: true, ...status };
95
+ } catch (error) {
96
+ throw new OperationError(error instanceof Error ? error.message : String(error));
97
+ }
98
+ },
99
+ });
100
+
101
+ const restoreShape = {
102
+ file: z.unknown().optional().describe('Backup file to restore'),
103
+ };
104
+ const restoreSchema = z.looseObject(restoreShape);
105
+
106
+ const backupRestoreOperation = defineOperation<z.infer<typeof restoreSchema>, Record<string, unknown>>({
107
+ name: 'backup.restore',
108
+ mutates: false,
109
+ input: restoreSchema,
110
+ inputShape: restoreShape,
111
+ http: { method: 'POST', path: '/api/canvas/backup/restore' },
112
+ handler: (input, ctx) => {
113
+ requireDatabase();
114
+ const file = typeof input.file === 'string' ? input.file.trim() : '';
115
+ if (!file) throw new OperationError('file is required.');
116
+ try {
117
+ assertRestorableBackup(file);
118
+ } catch (error) {
119
+ throw new OperationError(error instanceof Error ? error.message : String(error));
120
+ }
121
+ const previous = `${canvasState.databasePath}.before-restore`;
122
+ restoreCanvasLibrary(file);
123
+ // `reloaded` tells open tabs to take the board fresh even when its id is unchanged.
124
+ ctx.emit('boards-changed', { ...boardsPayload(), reloaded: true });
125
+ return { ok: true, restored: file, previous, ...boardsPayload() };
126
+ },
127
+ });
128
+
129
+ export const backupOperations: Operation[] = [
130
+ backupStatusOperation,
131
+ backupRunOperation,
132
+ backupScheduleOperation,
133
+ backupRestoreOperation,
134
+ ];
@@ -145,7 +145,11 @@ function createInternalBatchNode(args: Record<string, unknown>): Record<string,
145
145
  defaultHeight: 200,
146
146
  });
147
147
 
148
- return { ok: true, ...serializeCanvasNodeCompact(created.node) };
148
+ return {
149
+ ok: true,
150
+ ...serializeCanvasNodeCompact(created.node),
151
+ ...(created.sizeAdjustment ? { sizeAdjustment: created.sizeAdjustment } : {}),
152
+ };
149
153
  }
150
154
 
151
155
  /**
@@ -165,7 +169,7 @@ function shapeBatchEntry(op: string, result: unknown): Record<string, unknown> {
165
169
  const id = typeof body.id === 'string' ? body.id : '';
166
170
  const node = id ? canvasState.getNode(id) : undefined;
167
171
  const extras: Record<string, unknown> = {};
168
- for (const key of ['fetch', 'error', 'url', 'spec']) {
172
+ for (const key of ['fetch', 'error', 'url', 'spec', 'sizeAdjustment']) {
169
173
  if (body[key] !== undefined) extras[key] = body[key];
170
174
  }
171
175
  if (node) return { ok: true, ...serializeCanvasNodeCompact(node), ...extras };
@@ -183,7 +187,11 @@ function shapeBatchEntry(op: string, result: unknown): Record<string, unknown> {
183
187
  if (op === 'group.add') {
184
188
  const groupId = typeof body.groupId === 'string' ? body.groupId : '';
185
189
  const group = groupId ? canvasState.getNode(groupId) : undefined;
186
- return { ok: true, ...(group ? serializeCanvasNode(group) : { id: groupId }) };
190
+ return {
191
+ ok: true,
192
+ ...(group ? serializeCanvasNode(group) : { id: groupId }),
193
+ ...(body.sizeAdjustment ? { sizeAdjustment: body.sizeAdjustment } : {}),
194
+ };
187
195
  }
188
196
 
189
197
  // pin.set/add/remove: legacy pushed { ok:true, ...{ count, nodeIds } }; wire shape drops nodeIds.
@@ -242,7 +250,14 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
242
250
  // ghost-cursor exemption for batch operations). No `fromWorkbench`: the
243
251
  // browser never issues batches, so inner writes are agent writes and the
244
252
  // scope fence applies to each of them.
245
- const raw = await executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
253
+ const pending = executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
254
+ // The first write may synchronously open a board from Home. Capture after
255
+ // dispatch, then refuse to continue a partially completed batch elsewhere.
256
+ const boardGeneration = canvasState.boardGeneration;
257
+ const raw = await pending;
258
+ if (canvasState.boardGeneration !== boardGeneration) {
259
+ throw new Error('The board changed while the batch was running.');
260
+ }
246
261
  const result = shapeBatchEntry(operation.op, raw);
247
262
  results.push(result);
248
263
  if (typeof operation.assign === 'string' && operation.assign.trim().length > 0) {
@@ -305,6 +320,7 @@ function compactBatchValue(value: unknown): unknown {
305
320
  'content',
306
321
  'position',
307
322
  'size',
323
+ 'sizeAdjustment',
308
324
  'fetch',
309
325
  'error',
310
326
  'from',