pmx-canvas 0.6.5 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +139 -14
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +32 -6
  4. package/dist/canvas/global.css +510 -31
  5. package/dist/canvas/index.js +92 -77
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/cli/daemon.d.ts +5 -5
  11. package/dist/types/cli/shared.d.ts +3 -1
  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/nodes/AttachmentNode.d.ts +6 -0
  19. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  20. package/dist/types/client/state/boards-store.d.ts +79 -0
  21. package/dist/types/client/state/canvas-store.d.ts +2 -0
  22. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  23. package/dist/types/client/state/session-store.d.ts +7 -0
  24. package/dist/types/client/types.d.ts +2 -2
  25. package/dist/types/json-render/catalog.d.ts +1 -1
  26. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  27. package/dist/types/json-render/server.d.ts +6 -0
  28. package/dist/types/mcp/canvas-access.d.ts +10 -3
  29. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  30. package/dist/types/server/agent-presence.d.ts +1 -1
  31. package/dist/types/server/attribution.d.ts +11 -0
  32. package/dist/types/server/ax-context.d.ts +3 -1
  33. package/dist/types/server/ax-interaction.d.ts +2 -2
  34. package/dist/types/server/ax-state.d.ts +13 -3
  35. package/dist/types/server/backup.d.ts +36 -0
  36. package/dist/types/server/board-export.d.ts +45 -0
  37. package/dist/types/server/canvas-db.d.ts +63 -9
  38. package/dist/types/server/canvas-operations.d.ts +23 -0
  39. package/dist/types/server/canvas-provenance.d.ts +1 -1
  40. package/dist/types/server/canvas-state.d.ts +140 -3
  41. package/dist/types/server/context-brief.d.ts +111 -0
  42. package/dist/types/server/context-reads.d.ts +55 -0
  43. package/dist/types/server/document-import.d.ts +66 -0
  44. package/dist/types/server/index.d.ts +103 -4
  45. package/dist/types/server/mutation-history.d.ts +1 -0
  46. package/dist/types/server/operations/invoker.d.ts +2 -0
  47. package/dist/types/server/operations/mcp.d.ts +7 -1
  48. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  49. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  50. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  51. package/dist/types/server/operations/ops/export.d.ts +2 -0
  52. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  53. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  54. package/dist/types/server/operations/ops/query.d.ts +2 -0
  55. package/dist/types/server/operations/registry.d.ts +9 -1
  56. package/dist/types/server/operations/types.d.ts +1 -1
  57. package/dist/types/server/server.d.ts +1 -0
  58. package/dist/types/server/trace-manager.d.ts +7 -0
  59. package/dist/types/server/webpage-node.d.ts +22 -1
  60. package/dist/types/server/workbench-auth.d.ts +3 -0
  61. package/dist/types/shared/boards.d.ts +6 -0
  62. package/docs/RELEASE.md +5 -0
  63. package/docs/cli.md +73 -0
  64. package/docs/http-api.md +198 -2
  65. package/docs/mcp.md +71 -6
  66. package/docs/node-types.md +26 -0
  67. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  68. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  69. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  70. package/docs/plans/plan-013-static-board-export.md +47 -0
  71. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  72. package/docs/product-vision-2026-09.md +118 -18
  73. package/docs/sdk.md +50 -0
  74. package/package.json +1 -1
  75. package/skills/pmx-canvas/SKILL.md +68 -11
  76. package/skills/pmx-canvas/evals/evals.json +130 -0
  77. package/skills/pmx-canvas/references/full-reference.md +43 -7
  78. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  79. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  80. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  81. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  82. package/skills/pmx-canvas-testing/SKILL.md +11 -2
  83. package/src/cli/agent.ts +4 -0
  84. package/src/cli/commands/apps.ts +1 -1
  85. package/src/cli/commands/ax.ts +13 -0
  86. package/src/cli/commands/backup.ts +66 -0
  87. package/src/cli/commands/boards.ts +71 -0
  88. package/src/cli/commands/export.ts +37 -0
  89. package/src/cli/commands/nodes.ts +4 -1
  90. package/src/cli/commands/pins.ts +6 -2
  91. package/src/cli/commands/pump.ts +3 -0
  92. package/src/cli/commands/query.ts +33 -7
  93. package/src/cli/commands/view.ts +7 -5
  94. package/src/cli/daemon.ts +37 -7
  95. package/src/cli/index.ts +19 -9
  96. package/src/cli/shared.ts +11 -2
  97. package/src/client/App.tsx +21 -4
  98. package/src/client/canvas/AttentionToast.tsx +5 -3
  99. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  100. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  101. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  102. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  103. package/src/client/canvas/CanvasNode.tsx +0 -1
  104. package/src/client/canvas/CanvasViewport.tsx +3 -0
  105. package/src/client/canvas/CommandPalette.tsx +107 -4
  106. package/src/client/canvas/ContextMenu.tsx +4 -1
  107. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  108. package/src/client/canvas/ExportDialog.tsx +199 -0
  109. package/src/client/canvas/HomeView.tsx +277 -0
  110. package/src/client/canvas/Presentation.tsx +39 -9
  111. package/src/client/canvas/SelectionBar.tsx +0 -2
  112. package/src/client/canvas/SessionPanel.tsx +28 -1
  113. package/src/client/canvas/ToolRail.tsx +38 -3
  114. package/src/client/canvas/TopBar.tsx +34 -18
  115. package/src/client/canvas/import-files.ts +19 -1
  116. package/src/client/canvas/kind-colors.ts +1 -0
  117. package/src/client/icons.tsx +2 -0
  118. package/src/client/nodes/AttachmentNode.tsx +145 -0
  119. package/src/client/nodes/BoardNode.tsx +35 -0
  120. package/src/client/nodes/FileNode.tsx +6 -0
  121. package/src/client/nodes/MarkdownNode.tsx +47 -19
  122. package/src/client/nodes/ax-node-actions.ts +2 -5
  123. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  124. package/src/client/state/attention-bridge.ts +3 -1
  125. package/src/client/state/boards-store.ts +167 -0
  126. package/src/client/state/canvas-store.ts +22 -0
  127. package/src/client/state/intent-bridge.ts +30 -2
  128. package/src/client/state/node-factory.ts +1 -0
  129. package/src/client/state/session-store.ts +5 -0
  130. package/src/client/state/sse-bridge.ts +52 -6
  131. package/src/client/theme/global.css +510 -31
  132. package/src/client/types.ts +2 -0
  133. package/src/json-render/server.ts +16 -9
  134. package/src/mcp/canvas-access.ts +59 -22
  135. package/src/mcp/context-uri-template.ts +28 -0
  136. package/src/mcp/server.ts +122 -15
  137. package/src/server/agent-context.ts +116 -29
  138. package/src/server/attribution.ts +29 -0
  139. package/src/server/ax-context.ts +4 -2
  140. package/src/server/ax-interaction.ts +1 -0
  141. package/src/server/ax-state-manager.ts +72 -3
  142. package/src/server/ax-state.ts +41 -0
  143. package/src/server/backup.ts +178 -0
  144. package/src/server/board-export.ts +686 -0
  145. package/src/server/canvas-db.ts +599 -107
  146. package/src/server/canvas-operations.ts +68 -9
  147. package/src/server/canvas-provenance.ts +1 -0
  148. package/src/server/canvas-schema.ts +12 -0
  149. package/src/server/canvas-state.ts +774 -45
  150. package/src/server/context-brief.ts +372 -0
  151. package/src/server/context-reads.ts +238 -0
  152. package/src/server/demo-state.json +659 -2
  153. package/src/server/document-import.ts +223 -0
  154. package/src/server/index.ts +231 -5
  155. package/src/server/mutation-history.ts +10 -13
  156. package/src/server/operations/composites.ts +26 -4
  157. package/src/server/operations/http.ts +9 -3
  158. package/src/server/operations/index.ts +8 -0
  159. package/src/server/operations/invoker.ts +14 -3
  160. package/src/server/operations/mcp.ts +50 -11
  161. package/src/server/operations/ops/app.ts +5 -0
  162. package/src/server/operations/ops/ax-read.ts +10 -6
  163. package/src/server/operations/ops/ax-timeline.ts +75 -0
  164. package/src/server/operations/ops/backup.ts +134 -0
  165. package/src/server/operations/ops/batch.ts +8 -1
  166. package/src/server/operations/ops/boards.ts +351 -0
  167. package/src/server/operations/ops/export.ts +74 -0
  168. package/src/server/operations/ops/ext-app.ts +4 -0
  169. package/src/server/operations/ops/imports.ts +236 -0
  170. package/src/server/operations/ops/nodes.ts +57 -5
  171. package/src/server/operations/ops/query.ts +206 -9
  172. package/src/server/operations/registry.ts +123 -31
  173. package/src/server/operations/types.ts +1 -1
  174. package/src/server/server.ts +146 -2
  175. package/src/server/spatial-analysis.ts +9 -17
  176. package/src/server/trace-manager.ts +19 -0
  177. package/src/server/web-artifacts.ts +2 -1
  178. package/src/server/webpage-node.ts +173 -13
  179. package/src/server/workbench-auth.ts +11 -0
  180. package/src/shared/boards.ts +18 -0
  181. package/src/shared/node-sizes.ts +1 -0
@@ -119,7 +119,7 @@ function buildHumanAnnotations(layout: CanvasLayout): PmxAxHumanAnnotation[] {
119
119
  });
120
120
  }
121
121
 
122
- export function buildCanvasAxContext(consumer?: string): PmxAxContext {
122
+ export function buildCanvasAxContext(consumer?: string): PmxAxContext & { boardId: string | null } {
123
123
  const layout = canvasState.getLayout();
124
124
  const ax = canvasState.getAxState();
125
125
  const focusNodes = ax.focus.nodeIds
@@ -130,7 +130,7 @@ export function buildCanvasAxContext(consumer?: string): PmxAxContext {
130
130
  // The FIFO claim/ack queue (getPendingSteering) stays oldest-first for processing.
131
131
  const pendingSteering = canvasState.getPendingSteeringForContext({ consumer, limit: AX_CONTEXT_STEERING_LIMIT });
132
132
  const totalPending = canvasState.getPendingSteeringCount(consumer);
133
- return buildAxContext({
133
+ const context = buildAxContext({
134
134
  layout,
135
135
  delivery: {
136
136
  pendingSteering,
@@ -151,4 +151,6 @@ export function buildCanvasAxContext(consumer?: string): PmxAxContext {
151
151
  timeline: canvasState.getAxTimelineSummary(),
152
152
  host: canvasState.getHostCapability(),
153
153
  });
154
+ // Capture attribution with the payload, before any transport awaits or board switches.
155
+ return { ...context, boardId: canvasState.activeBoardId };
154
156
  }
@@ -131,6 +131,7 @@ export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabili
131
131
  'ax.event.record',
132
132
  ]),
133
133
  graph: caps(true, ['ax.evidence.add', 'ax.focus.set', 'ax.event.record']),
134
+ board: caps(false, []),
134
135
  ledger: caps(true, ['ax.evidence.add', 'ax.event.record']),
135
136
  trace: caps(true, ['ax.evidence.add', 'ax.event.record']),
136
137
  image: caps(true, ['ax.evidence.add', 'ax.review.add']),
@@ -84,6 +84,7 @@ import {
84
84
  type PmxAxTimelineSummary,
85
85
  } from './ax-state.js';
86
86
  import type { CanvasChangeType, MutationRecordInfo } from './canvas-state.js';
87
+ import { currentActor, type ActorAttribution } from './attribution.js';
87
88
 
88
89
  type Database = import('bun:sqlite').Database;
89
90
 
@@ -115,6 +116,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
115
116
  return value !== null && typeof value === 'object' && !Array.isArray(value);
116
117
  }
117
118
 
119
+ function resolutionActor(source?: PmxAxSource): ActorAttribution {
120
+ if (source === 'system') return { actor: 'system', source: 'system' };
121
+ const actor = currentActor(source ?? 'sdk');
122
+ return source && actor.actor === 'agent' ? { ...actor, source } : actor;
123
+ }
124
+
125
+ function selfAnswered(requester: ActorAttribution | undefined, answerer: ActorAttribution): boolean {
126
+ return (
127
+ requester?.actor === 'agent' &&
128
+ answerer.actor === 'agent' &&
129
+ Boolean(requester.agentId) &&
130
+ requester.agentId === answerer.agentId
131
+ );
132
+ }
133
+
118
134
  function logAxStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
119
135
  console.warn(`[ax-state] ${action}`, { error, ...(details ?? {}) });
120
136
  }
@@ -342,7 +358,12 @@ export class AxStateManager {
342
358
  options: { source?: PmxAxSource } = {},
343
359
  ): PmxAxApprovalGate {
344
360
  const oldAxState = this.getAxState();
345
- const gate = createAxApprovalGate(input, options.source ?? 'api', this.deps.getNodeIds());
361
+ const gate = createAxApprovalGate(
362
+ input,
363
+ options.source ?? 'api',
364
+ this.deps.getNodeIds(),
365
+ resolutionActor(options.source),
366
+ );
346
367
  this.applyAxState({ ...oldAxState, approvalGates: [...oldAxState.approvalGates, gate] });
347
368
  const applied = this.getAxState();
348
369
  this.deps.scheduleSave();
@@ -372,12 +393,15 @@ export class AxStateManager {
372
393
  const oldAxState = this.getAxState();
373
394
  const gate = oldAxState.approvalGates.find((g) => g.id === id);
374
395
  if (!gate || gate.status !== 'pending') return null;
396
+ const resolvedBy = resolutionActor(options.source);
375
397
  const resolved: PmxAxApprovalGate = {
376
398
  ...gate,
377
399
  status: decision,
378
400
  resolvedAt: new Date().toISOString(),
379
401
  resolution: options.resolution ?? null,
380
402
  source: options.source ?? gate.source,
403
+ resolvedBy,
404
+ selfAnswer: selfAnswered(gate.requestedBy, resolvedBy),
381
405
  };
382
406
  this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, resolved) });
383
407
  const applied = this.getAxState();
@@ -397,6 +421,15 @@ export class AxStateManager {
397
421
  this.deps.notifyChange('ax');
398
422
  }),
399
423
  });
424
+ this.recordAxEvent(
425
+ {
426
+ kind: 'approval',
427
+ summary: `${decision === 'approved' ? 'Approved' : decision === 'rejected' ? 'Rejected' : 'Held'} "${gate.title}"`,
428
+ nodeIds: gate.nodeIds,
429
+ data: { gateId: id, decision, resolvedBy, selfAnswer: resolved.selfAnswer },
430
+ },
431
+ { source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
432
+ );
400
433
  return applied.approvalGates.find((g) => g.id === id) ?? null;
401
434
  }
402
435
 
@@ -415,6 +448,8 @@ export class AxStateManager {
415
448
  resolvedAt: null,
416
449
  resolution: null,
417
450
  source: options.source ?? gate.source,
451
+ resolvedBy: null,
452
+ selfAnswer: false,
418
453
  };
419
454
  this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, reopened) });
420
455
  const applied = this.getAxState();
@@ -545,7 +580,12 @@ export class AxStateManager {
545
580
  options: { source?: PmxAxSource } = {},
546
581
  ): PmxAxElicitation {
547
582
  const oldAxState = this.getAxState();
548
- const elicitation = createAxElicitation(input, options.source ?? 'api', this.deps.getNodeIds());
583
+ const elicitation = createAxElicitation(
584
+ input,
585
+ options.source ?? 'api',
586
+ this.deps.getNodeIds(),
587
+ resolutionActor(options.source),
588
+ );
549
589
  this.applyAxState({ ...oldAxState, elicitations: [...oldAxState.elicitations, elicitation] });
550
590
  const applied = this.getAxState();
551
591
  this.deps.scheduleSave();
@@ -575,12 +615,15 @@ export class AxStateManager {
575
615
  const oldAxState = this.getAxState();
576
616
  const existing = oldAxState.elicitations.find((e) => e.id === id);
577
617
  if (!existing || existing.status !== 'pending') return null;
618
+ const resolvedBy = resolutionActor(options.source);
578
619
  const merged: PmxAxElicitation = {
579
620
  ...existing,
580
621
  status: 'answered',
581
622
  response,
582
623
  resolvedAt: new Date().toISOString(),
583
624
  source: options.source ?? existing.source,
625
+ resolvedBy,
626
+ selfAnswer: selfAnswered(existing.requestedBy, resolvedBy),
584
627
  };
585
628
  this.applyAxState({ ...oldAxState, elicitations: replaceById(oldAxState.elicitations, merged) });
586
629
  const applied = this.getAxState();
@@ -600,6 +643,15 @@ export class AxStateManager {
600
643
  this.deps.notifyChange('ax');
601
644
  }),
602
645
  });
646
+ this.recordAxEvent(
647
+ {
648
+ kind: 'approval',
649
+ summary: `Answered elicitation "${existing.prompt}"`,
650
+ nodeIds: existing.nodeIds,
651
+ data: { elicitationId: id, resolvedBy, selfAnswer: merged.selfAnswer },
652
+ },
653
+ { source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
654
+ );
603
655
  return applied.elicitations.find((e) => e.id === id) ?? null;
604
656
  }
605
657
 
@@ -613,7 +665,12 @@ export class AxStateManager {
613
665
  options: { source?: PmxAxSource } = {},
614
666
  ): PmxAxModeRequest {
615
667
  const oldAxState = this.getAxState();
616
- const request = createAxModeRequest(input, options.source ?? 'api', this.deps.getNodeIds());
668
+ const request = createAxModeRequest(
669
+ input,
670
+ options.source ?? 'api',
671
+ this.deps.getNodeIds(),
672
+ resolutionActor(options.source),
673
+ );
617
674
  this.applyAxState({ ...oldAxState, modeRequests: [...oldAxState.modeRequests, request] });
618
675
  const applied = this.getAxState();
619
676
  this.deps.scheduleSave();
@@ -643,12 +700,15 @@ export class AxStateManager {
643
700
  const oldAxState = this.getAxState();
644
701
  const existing = oldAxState.modeRequests.find((m) => m.id === id);
645
702
  if (!existing || existing.status !== 'pending') return null;
703
+ const resolvedBy = resolutionActor(options.source);
646
704
  const merged: PmxAxModeRequest = {
647
705
  ...existing,
648
706
  status: decision,
649
707
  resolvedAt: new Date().toISOString(),
650
708
  resolution: options.resolution ?? null,
651
709
  source: options.source ?? existing.source,
710
+ resolvedBy,
711
+ selfAnswer: selfAnswered(existing.requestedBy, resolvedBy),
652
712
  };
653
713
  this.applyAxState({ ...oldAxState, modeRequests: replaceById(oldAxState.modeRequests, merged) });
654
714
  const applied = this.getAxState();
@@ -668,6 +728,15 @@ export class AxStateManager {
668
728
  this.deps.notifyChange('ax');
669
729
  }),
670
730
  });
731
+ this.recordAxEvent(
732
+ {
733
+ kind: 'approval',
734
+ summary: `${decision === 'approved' ? 'Approved' : 'Rejected'} ${existing.mode} mode request`,
735
+ nodeIds: existing.nodeIds,
736
+ data: { modeRequestId: id, decision, resolvedBy, selfAnswer: merged.selfAnswer },
737
+ },
738
+ { source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
739
+ );
671
740
  return applied.modeRequests.find((m) => m.id === id) ?? null;
672
741
  }
673
742
 
@@ -2,6 +2,7 @@ import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
2
  import { clampGateTtlMs, DEFAULT_GATE_TTL_MS } from '../shared/approval-gates.js';
3
3
  import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../shared/ax-kinds.js';
4
4
  import type { AgentContextNode } from './agent-context.js';
5
+ import type { ActorAttribution } from './attribution.js';
5
6
 
6
7
  export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
7
8
 
@@ -48,6 +49,9 @@ export interface PmxAxApprovalGate {
48
49
  resolvedAt: string | null;
49
50
  resolution: string | null;
50
51
  source: PmxAxSource | null;
52
+ requestedBy?: ActorAttribution;
53
+ resolvedBy?: ActorAttribution | null;
54
+ selfAnswer?: boolean;
51
55
  }
52
56
 
53
57
  export interface PmxAxReviewRegion {
@@ -301,6 +305,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
301
305
  return value !== null && typeof value === 'object' && !Array.isArray(value);
302
306
  }
303
307
 
308
+ function normalizeAttribution(value: unknown): ActorAttribution {
309
+ if (!isRecord(value)) return { actor: 'unknown', source: 'unknown' };
310
+ const actor = value.actor;
311
+ return {
312
+ actor: actor === 'human' || actor === 'agent' || actor === 'system' ? actor : 'unknown',
313
+ source: typeof value.source === 'string' ? value.source : 'unknown',
314
+ ...(typeof value.agentId === 'string' ? { agentId: value.agentId } : {}),
315
+ };
316
+ }
317
+
304
318
  function normalizeSource(value: unknown): PmxAxSource | null {
305
319
  return typeof value === 'string' && AX_SOURCES.has(value as PmxAxSource) ? (value as PmxAxSource) : null;
306
320
  }
@@ -548,6 +562,9 @@ export interface PmxAxElicitation {
548
562
  createdAt: string;
549
563
  resolvedAt: string | null;
550
564
  source: PmxAxSource | null;
565
+ requestedBy?: ActorAttribution;
566
+ resolvedBy?: ActorAttribution | null;
567
+ selfAnswer?: boolean;
551
568
  }
552
569
 
553
570
  export function normalizeAxElicitation(input: unknown, validNodeIds?: Set<string>): PmxAxElicitation | null {
@@ -564,6 +581,9 @@ export function normalizeAxElicitation(input: unknown, validNodeIds?: Set<string
564
581
  createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
565
582
  resolvedAt: normalizeTimestamp(input.resolvedAt),
566
583
  source: normalizeSource(input.source),
584
+ requestedBy: normalizeAttribution(input.requestedBy),
585
+ resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
586
+ selfAnswer: input.selfAnswer === true,
567
587
  };
568
588
  }
569
589
 
@@ -571,6 +591,7 @@ export function createAxElicitation(
571
591
  input: { prompt: string; fields?: string[]; nodeIds?: string[] },
572
592
  source: PmxAxSource | null,
573
593
  validNodeIds?: Set<string>,
594
+ requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
574
595
  ): PmxAxElicitation {
575
596
  return {
576
597
  id: axId('elic'),
@@ -582,6 +603,9 @@ export function createAxElicitation(
582
603
  createdAt: nowIso(),
583
604
  resolvedAt: null,
584
605
  source,
606
+ requestedBy,
607
+ resolvedBy: null,
608
+ selfAnswer: false,
585
609
  };
586
610
  }
587
611
 
@@ -602,6 +626,9 @@ export interface PmxAxModeRequest {
602
626
  resolvedAt: string | null;
603
627
  resolution: string | null;
604
628
  source: PmxAxSource | null;
629
+ requestedBy?: ActorAttribution;
630
+ resolvedBy?: ActorAttribution | null;
631
+ selfAnswer?: boolean;
605
632
  }
606
633
 
607
634
  export function normalizeAxModeRequest(input: unknown, validNodeIds?: Set<string>): PmxAxModeRequest | null {
@@ -618,6 +645,9 @@ export function normalizeAxModeRequest(input: unknown, validNodeIds?: Set<string
618
645
  resolvedAt: normalizeTimestamp(input.resolvedAt),
619
646
  resolution: optionalString(input.resolution),
620
647
  source: normalizeSource(input.source),
648
+ requestedBy: normalizeAttribution(input.requestedBy),
649
+ resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
650
+ selfAnswer: input.selfAnswer === true,
621
651
  };
622
652
  }
623
653
 
@@ -625,6 +655,7 @@ export function createAxModeRequest(
625
655
  input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
626
656
  source: PmxAxSource | null,
627
657
  validNodeIds?: Set<string>,
658
+ requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
628
659
  ): PmxAxModeRequest {
629
660
  return {
630
661
  id: axId('mode'),
@@ -636,6 +667,9 @@ export function createAxModeRequest(
636
667
  resolvedAt: null,
637
668
  resolution: null,
638
669
  source,
670
+ requestedBy,
671
+ resolvedBy: null,
672
+ selfAnswer: false,
639
673
  };
640
674
  }
641
675
 
@@ -715,6 +749,9 @@ export function normalizeAxApprovalGate(input: unknown, validNodeIds?: Set<strin
715
749
  resolvedAt: normalizeTimestamp(input.resolvedAt),
716
750
  resolution: optionalString(input.resolution),
717
751
  source: normalizeSource(input.source),
752
+ requestedBy: normalizeAttribution(input.requestedBy),
753
+ resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
754
+ selfAnswer: input.selfAnswer === true,
718
755
  };
719
756
  }
720
757
 
@@ -856,6 +893,7 @@ export function createAxApprovalGate(
856
893
  input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[]; ttlMs?: number },
857
894
  source: PmxAxSource | null,
858
895
  validNodeIds?: Set<string>,
896
+ requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
859
897
  ): PmxAxApprovalGate {
860
898
  const created = Date.now();
861
899
  return {
@@ -870,6 +908,9 @@ export function createAxApprovalGate(
870
908
  resolvedAt: null,
871
909
  resolution: null,
872
910
  source,
911
+ requestedBy,
912
+ resolvedBy: null,
913
+ selfAnswer: false,
873
914
  };
874
915
  }
875
916
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Library backup (plan 012): the whole workspace database — every board, its
3
+ * snapshots and blobs — copied with `VACUUM INTO`, which is safe while the
4
+ * server runs. Backups are plain files in a folder the human chooses; the
5
+ * schedule lives in the database and runs inside the server.
6
+ */
7
+ import { Database } from 'bun:sqlite';
8
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
9
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
10
+ import { canvasState, PMX_CANVAS_DIR } from './canvas-state.js';
11
+
12
+ const FILE_PATTERN = /^canvas-(\d{8}T\d{9}Z)(?:-(\d+))?\.db$/;
13
+ const DEFAULT_KEEP = 14;
14
+ const MIN_INTERVAL_MS = 60_000;
15
+ const CHECK_INTERVAL_MS = 60_000;
16
+
17
+ const META = {
18
+ folder: 'backup_folder',
19
+ everyMs: 'backup_every_ms',
20
+ keep: 'backup_keep',
21
+ lastAt: 'backup_last_at',
22
+ lastPath: 'backup_last_path',
23
+ } as const;
24
+
25
+ export interface BackupFile {
26
+ path: string;
27
+ bytes: number;
28
+ at: string;
29
+ }
30
+
31
+ export interface BackupStatus {
32
+ folder: string;
33
+ keep: number;
34
+ everyMs: number | null;
35
+ lastAt: string | null;
36
+ lastPath: string | null;
37
+ backups: BackupFile[];
38
+ }
39
+
40
+ /** "90s", "30m", "24h", "7d" → milliseconds. */
41
+ export function parseBackupInterval(value: string): number | null {
42
+ const match = value.trim().match(/^(\d+)\s*([smhd])$/i);
43
+ if (!match) return null;
44
+ const unit = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2].toLowerCase() as 's' | 'm' | 'h' | 'd'];
45
+ return Number(match[1]) * unit;
46
+ }
47
+
48
+ function resolveFolder(folder: string): string {
49
+ return isAbsolute(folder) ? folder : resolve(canvasState.workspaceRoot, folder);
50
+ }
51
+
52
+ /** Beside the database: `.pmx-canvas/backups` unless PMX_CANVAS_DB_PATH moved it. */
53
+ function defaultFolder(): string {
54
+ const dbPath = canvasState.databasePath;
55
+ return resolve(
56
+ dbPath ? join(dirname(dbPath), 'backups') : join(canvasState.workspaceRoot, PMX_CANVAS_DIR, 'backups'),
57
+ );
58
+ }
59
+
60
+ function listBackupFiles(folder: string): BackupFile[] {
61
+ if (!existsSync(folder)) return [];
62
+ // Newest first by the name's timestamp, then its same-millisecond counter.
63
+ const order = (name: string): string => {
64
+ const match = name.match(FILE_PATTERN);
65
+ return `${match?.[1] ?? ''}-${(match?.[2] ?? '1').padStart(6, '0')}`;
66
+ };
67
+ return readdirSync(folder)
68
+ .filter((name) => FILE_PATTERN.test(name))
69
+ .sort((a, b) => order(b).localeCompare(order(a)))
70
+ .map((name) => {
71
+ const path = join(folder, name);
72
+ const stat = statSync(path);
73
+ return { path, bytes: stat.size, at: stat.mtime.toISOString() };
74
+ });
75
+ }
76
+
77
+ export function getBackupStatus(): BackupStatus {
78
+ const everyMs = Number(canvasState.readWorkspaceMeta(META.everyMs));
79
+ const folder = canvasState.readWorkspaceMeta(META.folder) ?? defaultFolder();
80
+ return {
81
+ folder,
82
+ keep: Number(canvasState.readWorkspaceMeta(META.keep)) || DEFAULT_KEEP,
83
+ everyMs: everyMs > 0 ? everyMs : null,
84
+ lastAt: canvasState.readWorkspaceMeta(META.lastAt),
85
+ lastPath: canvasState.readWorkspaceMeta(META.lastPath),
86
+ backups: listBackupFiles(folder),
87
+ };
88
+ }
89
+
90
+ /** Back up now; keeps the newest `keep` backups in the folder and removes older ones. */
91
+ export function runBackup(options: { folder?: string; keep?: number } = {}): {
92
+ backup: BackupFile;
93
+ removed: string[];
94
+ } {
95
+ const status = getBackupStatus();
96
+ const folder = options.folder ? resolveFolder(options.folder) : status.folder;
97
+ const keep = options.keep ?? status.keep;
98
+ mkdirSync(folder, { recursive: true });
99
+
100
+ const stamp = new Date().toISOString().replace(/[-:.]/g, '');
101
+ let path = join(folder, `canvas-${stamp}.db`);
102
+ for (let n = 2; existsSync(path); n++) path = join(folder, `canvas-${stamp}-${n}.db`);
103
+ canvasState.backupDatabaseTo(path);
104
+
105
+ const removed = listBackupFiles(folder)
106
+ .slice(keep)
107
+ .map((file) => file.path);
108
+ for (const file of removed) rmSync(file, { force: true });
109
+
110
+ const at = new Date().toISOString();
111
+ canvasState.writeWorkspaceMeta(META.lastAt, at);
112
+ canvasState.writeWorkspaceMeta(META.lastPath, path);
113
+ return { backup: { path, bytes: statSync(path).size, at }, removed };
114
+ }
115
+
116
+ /** Set the schedule (`everyMs` null turns it off). Folder and keep also apply to manual backups. */
117
+ export function setBackupSchedule(options: { everyMs: number | null; folder?: string; keep?: number }): BackupStatus {
118
+ if (options.everyMs !== null && options.everyMs < MIN_INTERVAL_MS) {
119
+ throw new Error('Back up at most once a minute.');
120
+ }
121
+ canvasState.writeWorkspaceMeta(META.everyMs, options.everyMs === null ? null : String(options.everyMs));
122
+ if (options.folder) canvasState.writeWorkspaceMeta(META.folder, resolveFolder(options.folder));
123
+ if (options.keep) canvasState.writeWorkspaceMeta(META.keep, String(options.keep));
124
+ return getBackupStatus();
125
+ }
126
+
127
+ /** Runs a scheduled backup when one is due. Returns whether it ran. */
128
+ export function runDueBackup(now = Date.now()): boolean {
129
+ if (!canvasState.databasePath) return false;
130
+ const { everyMs, lastAt } = getBackupStatus();
131
+ if (!everyMs) return false;
132
+ if (lastAt && now - Date.parse(lastAt) < everyMs) return false;
133
+ runBackup();
134
+ return true;
135
+ }
136
+
137
+ /** Rejects anything that is not a canvas database before the live file is touched. */
138
+ export function assertRestorableBackup(file: string): void {
139
+ if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`No backup file at ${file}.`);
140
+ if (resolve(file) === resolve(canvasState.databasePath ?? '')) {
141
+ throw new Error('That is the live database, not a backup.');
142
+ }
143
+ let tables: string[] = [];
144
+ try {
145
+ const db = new Database(file, { readonly: true });
146
+ tables = db
147
+ .query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
148
+ .all()
149
+ .map((row) => row.name);
150
+ db.close();
151
+ } catch {
152
+ throw new Error(`${file} is not a SQLite database.`);
153
+ }
154
+ if (!tables.includes('meta') || !tables.includes('nodes')) {
155
+ throw new Error(`${file} is not a PMX Canvas database.`);
156
+ }
157
+ }
158
+
159
+ let timer: ReturnType<typeof setInterval> | null = null;
160
+
161
+ export function startBackupScheduler(): void {
162
+ stopBackupScheduler();
163
+ const check = () => {
164
+ try {
165
+ runDueBackup();
166
+ } catch (error) {
167
+ console.warn('[backup] scheduled backup failed', error);
168
+ }
169
+ };
170
+ check();
171
+ timer = setInterval(check, CHECK_INTERVAL_MS);
172
+ timer.unref?.();
173
+ }
174
+
175
+ export function stopBackupScheduler(): void {
176
+ if (timer) clearInterval(timer);
177
+ timer = null;
178
+ }