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.
- package/.github/extensions/pmx-canvas/extension.mjs +67 -3
- package/CHANGELOG.md +73 -0
- package/Readme.md +35 -5
- package/dist/canvas/global.css +498 -28
- package/dist/canvas/index.js +110 -95
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +4 -0
- package/dist/types/cli/commands/backup.d.ts +1 -0
- package/dist/types/cli/commands/boards.d.ts +1 -0
- package/dist/types/cli/commands/export.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
- package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
- package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -0
- package/dist/types/client/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/boards-store.d.ts +79 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/state/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +3 -3
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +8 -0
- package/dist/types/mcp/canvas-access.d.ts +10 -3
- package/dist/types/mcp/context-uri-template.d.ts +6 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/attribution.d.ts +11 -0
- package/dist/types/server/ax-context.d.ts +3 -1
- package/dist/types/server/ax-interaction.d.ts +2 -2
- package/dist/types/server/ax-state.d.ts +13 -3
- package/dist/types/server/backup.d.ts +36 -0
- package/dist/types/server/board-export.d.ts +45 -0
- package/dist/types/server/canvas-db.d.ts +65 -9
- package/dist/types/server/canvas-operations.d.ts +45 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +146 -3
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/context-brief.d.ts +111 -0
- package/dist/types/server/context-reads.d.ts +55 -0
- package/dist/types/server/document-import.d.ts +66 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +107 -4
- package/dist/types/server/mutation-history.d.ts +1 -0
- package/dist/types/server/operations/invoker.d.ts +2 -0
- package/dist/types/server/operations/mcp.d.ts +7 -1
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
- package/dist/types/server/operations/ops/backup.d.ts +2 -0
- package/dist/types/server/operations/ops/boards.d.ts +10 -0
- package/dist/types/server/operations/ops/export.d.ts +2 -0
- package/dist/types/server/operations/ops/imports.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +4 -2
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/trace-manager.d.ts +7 -0
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/server/webpage-node.d.ts +22 -1
- package/dist/types/server/workbench-auth.d.ts +3 -0
- package/dist/types/shared/boards.d.ts +6 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +157 -0
- package/docs/http-api.md +277 -6
- package/docs/mcp.md +96 -7
- package/docs/node-types.md +106 -0
- package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
- package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
- package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
- package/docs/plans/plan-013-static-board-export.md +47 -0
- package/docs/plans/plan-014-connected-board-memory.md +140 -0
- package/docs/product-vision-2026-09.md +118 -18
- package/docs/sdk.md +50 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +62 -5
- package/skills/pmx-canvas/references/full-reference.md +44 -8
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +9 -0
- package/src/cli/commands/ax.ts +13 -0
- package/src/cli/commands/backup.ts +66 -0
- package/src/cli/commands/boards.ts +71 -0
- package/src/cli/commands/export.ts +30 -0
- package/src/cli/commands/nodes.ts +14 -1
- package/src/cli/commands/pins.ts +6 -2
- package/src/cli/commands/pump.ts +3 -0
- package/src/cli/commands/query.ts +33 -7
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/index.ts +21 -9
- package/src/client/App.tsx +28 -4
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/BoardCopyDialog.tsx +153 -0
- package/src/client/canvas/BoardFolderDialog.tsx +120 -0
- package/src/client/canvas/BoardLinkDialog.tsx +93 -0
- package/src/client/canvas/BoardSwitcher.tsx +149 -0
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +197 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +49 -7
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/icons.tsx +2 -0
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/AttachmentNode.tsx +148 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/nodes/ax-node-actions.ts +2 -5
- package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
- package/src/client/state/boards-store.ts +167 -0
- package/src/client/state/canvas-store.ts +22 -0
- package/src/client/state/intent-bridge.ts +30 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +8 -0
- package/src/client/state/sse-bridge.ts +52 -5
- package/src/client/theme/global.css +498 -28
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +45 -9
- package/src/mcp/canvas-access.ts +59 -22
- package/src/mcp/context-uri-template.ts +28 -0
- package/src/mcp/server.ts +122 -15
- package/src/server/agent-context.ts +116 -29
- package/src/server/agent-presence.ts +15 -9
- package/src/server/attribution.ts +29 -0
- package/src/server/ax-context.ts +4 -2
- package/src/server/ax-interaction.ts +1 -0
- package/src/server/ax-state-manager.ts +72 -3
- package/src/server/ax-state.ts +41 -0
- package/src/server/backup.ts +178 -0
- package/src/server/board-export.ts +686 -0
- package/src/server/canvas-db.ts +612 -105
- package/src/server/canvas-operations.ts +183 -31
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +22 -0
- package/src/server/canvas-state.ts +812 -48
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +670 -9
- package/src/server/document-import.ts +223 -0
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +243 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +29 -5
- package/src/server/operations/http.ts +9 -3
- package/src/server/operations/index.ts +8 -0
- package/src/server/operations/invoker.ts +14 -3
- package/src/server/operations/mcp.ts +50 -11
- package/src/server/operations/ops/app.ts +15 -0
- package/src/server/operations/ops/ax-read.ts +10 -6
- package/src/server/operations/ops/ax-timeline.ts +75 -0
- package/src/server/operations/ops/backup.ts +134 -0
- package/src/server/operations/ops/batch.ts +20 -4
- package/src/server/operations/ops/boards.ts +351 -0
- package/src/server/operations/ops/export.ts +74 -0
- package/src/server/operations/ops/ext-app.ts +4 -0
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/imports.ts +220 -0
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +99 -21
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +129 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +155 -8
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +11 -3
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/server/workboard.ts +3 -3
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
- package/src/shared/tour.ts +87 -0
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
* the one-shot boot migration into SQLite was retired.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { createHash } from 'node:crypto';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
15
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
16
|
+
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
17
|
+
import {
|
|
18
|
+
copyFileSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
unlinkSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { basename, isAbsolute, join, dirname, relative } from 'node:path';
|
|
18
28
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
19
29
|
import { normalizeCanvasNodeData } from './canvas-provenance.js';
|
|
20
30
|
import {
|
|
@@ -28,13 +38,41 @@ import {
|
|
|
28
38
|
renameSnapshotInDB,
|
|
29
39
|
writeBlobToDB,
|
|
30
40
|
readBlobFromDB,
|
|
31
|
-
isDbPopulated,
|
|
32
41
|
checkpointCanvasDb,
|
|
42
|
+
createBoardInDB,
|
|
43
|
+
createBoardWithStateInDB,
|
|
44
|
+
readContextBriefCursor,
|
|
45
|
+
advanceContextBriefCursor,
|
|
46
|
+
deleteBoardFromDB,
|
|
47
|
+
getActiveBoardIdFromDB,
|
|
48
|
+
getBoardFromDB,
|
|
49
|
+
listBoardsFromDB,
|
|
50
|
+
readMetaFromDB,
|
|
51
|
+
readThemeFromDB,
|
|
52
|
+
updateBoardInDB,
|
|
53
|
+
writeMetaToDB,
|
|
54
|
+
saveThemeToDB,
|
|
55
|
+
setActiveBoardIdInDB,
|
|
56
|
+
type CanvasBoard,
|
|
33
57
|
finalizeCanvasDbForClose,
|
|
34
58
|
type PersistedCanvasState,
|
|
35
59
|
type CanvasTheme,
|
|
36
60
|
type AxTimelineQuery,
|
|
37
61
|
} from './canvas-db.js';
|
|
62
|
+
import {
|
|
63
|
+
copyBoardAttachments,
|
|
64
|
+
createImport,
|
|
65
|
+
getAttachment,
|
|
66
|
+
getImport,
|
|
67
|
+
listImports,
|
|
68
|
+
readAttachmentBytes,
|
|
69
|
+
storeAttachment,
|
|
70
|
+
updateImport,
|
|
71
|
+
type DocumentImport,
|
|
72
|
+
type DocumentImportStatus,
|
|
73
|
+
type ImportSection,
|
|
74
|
+
type Attachment,
|
|
75
|
+
} from './document-import.js';
|
|
38
76
|
import { normalizeCanvasTheme } from './canvas-db.js';
|
|
39
77
|
import {
|
|
40
78
|
type CanvasPlacementRect,
|
|
@@ -73,6 +111,14 @@ import {
|
|
|
73
111
|
type PmxAxTimelineSummary,
|
|
74
112
|
} from './ax-state.js';
|
|
75
113
|
import { AxStateManager } from './ax-state-manager.js';
|
|
114
|
+
import { currentActor, unknownActor, type ActorAttribution } from './attribution.js';
|
|
115
|
+
import {
|
|
116
|
+
appendContextReadToDB,
|
|
117
|
+
loadContextReadsFromDB,
|
|
118
|
+
type ContextRead,
|
|
119
|
+
type ContextReadConsumerSummary,
|
|
120
|
+
type ContextReadInput,
|
|
121
|
+
} from './context-reads.js';
|
|
76
122
|
|
|
77
123
|
function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
78
124
|
console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
|
|
@@ -169,6 +215,7 @@ export interface CanvasNodeState {
|
|
|
169
215
|
| 'webpage'
|
|
170
216
|
| 'json-render'
|
|
171
217
|
| 'graph'
|
|
218
|
+
| 'board'
|
|
172
219
|
| 'prompt'
|
|
173
220
|
| 'response'
|
|
174
221
|
| 'status'
|
|
@@ -187,6 +234,30 @@ export interface CanvasNodeState {
|
|
|
187
234
|
collapsed: boolean;
|
|
188
235
|
pinned: boolean;
|
|
189
236
|
data: Record<string, unknown>;
|
|
237
|
+
/** Server-owned provenance. Legacy rows normalize to unknown. */
|
|
238
|
+
createdBy?: ActorAttribution;
|
|
239
|
+
lastEditedBy?: ActorAttribution;
|
|
240
|
+
/** Board-monotonic revision of this node's latest semantic content. */
|
|
241
|
+
contentRevision?: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface NodeDeletionTombstone {
|
|
245
|
+
nodeId: string;
|
|
246
|
+
revision: number;
|
|
247
|
+
deletedBy: ActorAttribution;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface BoardContentRevision {
|
|
251
|
+
revision: number;
|
|
252
|
+
/** Cursors below this value are expired and must perform a full reset. */
|
|
253
|
+
retentionFloor: number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface BoardContentDelta extends BoardContentRevision {
|
|
257
|
+
since: number;
|
|
258
|
+
reset: boolean;
|
|
259
|
+
nodes: CanvasNodeState[];
|
|
260
|
+
deleted: NodeDeletionTombstone[];
|
|
190
261
|
}
|
|
191
262
|
|
|
192
263
|
export interface ViewportState {
|
|
@@ -223,6 +294,7 @@ export interface CanvasAnnotation {
|
|
|
223
294
|
}
|
|
224
295
|
|
|
225
296
|
export interface CanvasLayout {
|
|
297
|
+
tour?: Tour;
|
|
226
298
|
viewport: ViewportState;
|
|
227
299
|
theme: CanvasTheme;
|
|
228
300
|
nodes: CanvasNodeState[];
|
|
@@ -239,6 +311,11 @@ export interface CanvasNodeUpdate {
|
|
|
239
311
|
|
|
240
312
|
export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
|
|
241
313
|
|
|
314
|
+
/** Name for a board nobody named yet: when it was made, to the minute. */
|
|
315
|
+
export function defaultBoardName(): string {
|
|
316
|
+
return `Board ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
242
319
|
export interface MutationRecordInfo {
|
|
243
320
|
operationType:
|
|
244
321
|
| 'addNode'
|
|
@@ -333,9 +410,13 @@ class CanvasStateManager {
|
|
|
333
410
|
private edges = new Map<string, CanvasEdge>();
|
|
334
411
|
private annotations = new Map<string, CanvasAnnotation>();
|
|
335
412
|
private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
|
|
413
|
+
private _tour: Tour | undefined;
|
|
336
414
|
private _theme: CanvasTheme = 'dark';
|
|
337
415
|
private _contextPinnedNodeIds = new Set<string>();
|
|
338
416
|
private _workspaceRoot = process.cwd();
|
|
417
|
+
private _contentRevision = 0;
|
|
418
|
+
private _revisionFloor = 0;
|
|
419
|
+
private _deletionTombstones: NodeDeletionTombstone[] = [];
|
|
339
420
|
|
|
340
421
|
// ── AX state (canvas-bound + timeline + host partitions) ──────────
|
|
341
422
|
// Extracted into a dedicated manager (plan-007 Slice A). CanvasStateManager
|
|
@@ -503,12 +584,58 @@ class CanvasStateManager {
|
|
|
503
584
|
}
|
|
504
585
|
|
|
505
586
|
private normalizeNode(node: CanvasNodeState): CanvasNodeState {
|
|
587
|
+
const data = { ...node.data };
|
|
588
|
+
delete data.createdBy;
|
|
589
|
+
delete data.lastEditedBy;
|
|
590
|
+
delete data.contentRevision;
|
|
506
591
|
return {
|
|
507
592
|
...node,
|
|
508
|
-
data: normalizeCanvasNodeData(node.type,
|
|
593
|
+
data: normalizeCanvasNodeData(node.type, data),
|
|
594
|
+
createdBy: node.createdBy ?? unknownActor(),
|
|
595
|
+
lastEditedBy: node.lastEditedBy ?? unknownActor(),
|
|
596
|
+
contentRevision: Number.isSafeInteger(node.contentRevision) ? node.contentRevision : 0,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private nextContentRevision(): number {
|
|
601
|
+
this._contentRevision += 1;
|
|
602
|
+
return this._contentRevision;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private revisionState() {
|
|
606
|
+
return {
|
|
607
|
+
revision: this._contentRevision,
|
|
608
|
+
floor: this._revisionFloor,
|
|
609
|
+
tombstones: structuredClone(this._deletionTombstones),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Stable delta cursor contract: revisions strictly increase per active board;
|
|
615
|
+
* geometry-only writes do not advance them. `reset` means `since` predates
|
|
616
|
+
* retained tombstones, so callers must replace their view with `nodes`.
|
|
617
|
+
*/
|
|
618
|
+
readContentDelta(since = this._contentRevision): BoardContentDelta {
|
|
619
|
+
const cursor = Math.max(0, Math.floor(since));
|
|
620
|
+
const reset = cursor < this._revisionFloor;
|
|
621
|
+
return {
|
|
622
|
+
since: cursor,
|
|
623
|
+
revision: this._contentRevision,
|
|
624
|
+
retentionFloor: this._revisionFloor,
|
|
625
|
+
reset,
|
|
626
|
+
nodes: Array.from(this.nodes.values())
|
|
627
|
+
.filter((node) => reset || (node.contentRevision ?? 0) > cursor)
|
|
628
|
+
.map((node) => structuredClone(this.nodeForRead(node))),
|
|
629
|
+
deleted: reset
|
|
630
|
+
? []
|
|
631
|
+
: this._deletionTombstones.filter((item) => item.revision > cursor).map((item) => structuredClone(item)),
|
|
509
632
|
};
|
|
510
633
|
}
|
|
511
634
|
|
|
635
|
+
getContentRevision(): BoardContentRevision {
|
|
636
|
+
return { revision: this._contentRevision, retentionFloor: this._revisionFloor };
|
|
637
|
+
}
|
|
638
|
+
|
|
512
639
|
private nodeForRead(node: CanvasNodeState): CanvasNodeState {
|
|
513
640
|
const resolved = this.resolveNodeDataBlobs(node);
|
|
514
641
|
return {
|
|
@@ -637,6 +764,7 @@ class CanvasStateManager {
|
|
|
637
764
|
|
|
638
765
|
// ── Persistence ────────────────────────────────────────────
|
|
639
766
|
private _db: import('bun:sqlite').Database | null = null;
|
|
767
|
+
private _dbPath: string | null = null;
|
|
640
768
|
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
641
769
|
|
|
642
770
|
/** Workspace root backing persistence and workspace-relative path resolution. */
|
|
@@ -674,7 +802,8 @@ class CanvasStateManager {
|
|
|
674
802
|
// 0.4.0 — the one-shot boot migration that copied them into SQLite and
|
|
675
803
|
// renamed the originals to `.bak` was retired.
|
|
676
804
|
try {
|
|
677
|
-
this._db = openCanvasDb(dbPath);
|
|
805
|
+
this._db = openCanvasDb(dbPath, { migratedBoardName: basename(workspaceRoot) || 'Board' });
|
|
806
|
+
this._dbPath = dbPath;
|
|
678
807
|
} catch (error) {
|
|
679
808
|
logCanvasStateWarning('open canvas database failed', error, { dbPath });
|
|
680
809
|
}
|
|
@@ -786,7 +915,7 @@ class CanvasStateManager {
|
|
|
786
915
|
}
|
|
787
916
|
|
|
788
917
|
private externalizeNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
|
|
789
|
-
if (node.type !== 'mcp-app') return node;
|
|
918
|
+
if (node.type !== 'mcp-app' && node.type !== 'html') return node;
|
|
790
919
|
let changed = false;
|
|
791
920
|
const data = { ...node.data };
|
|
792
921
|
for (const [key, value] of Object.entries(data)) {
|
|
@@ -800,7 +929,7 @@ class CanvasStateManager {
|
|
|
800
929
|
}
|
|
801
930
|
|
|
802
931
|
private resolveNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
|
|
803
|
-
if (node.type !== 'mcp-app') return node;
|
|
932
|
+
if (node.type !== 'mcp-app' && node.type !== 'html') return node;
|
|
804
933
|
let changed = false;
|
|
805
934
|
const data = { ...node.data };
|
|
806
935
|
for (const [key, value] of Object.entries(data)) {
|
|
@@ -840,22 +969,27 @@ class CanvasStateManager {
|
|
|
840
969
|
annotations: [],
|
|
841
970
|
contextPins: [],
|
|
842
971
|
ax: createEmptyAxState(),
|
|
972
|
+
revisionState: { revision: 0, floor: 0, tombstones: [] },
|
|
843
973
|
};
|
|
844
974
|
}
|
|
845
975
|
|
|
846
|
-
/**
|
|
976
|
+
/**
|
|
977
|
+
* Load the board that was open last. Call once on server startup. With no
|
|
978
|
+
* such board the workspace starts on Home (no board open).
|
|
979
|
+
*/
|
|
847
980
|
loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
|
|
848
981
|
// Host capability lives in its own table (not snapshotted / not in PmxAxState).
|
|
849
982
|
this.ax.loadHostCapabilityFromDb();
|
|
850
|
-
|
|
851
|
-
// retired with the 0.4.0 boot-migration removal).
|
|
852
|
-
if (this._db && isDbPopulated(this._db)) {
|
|
983
|
+
if (this._db) {
|
|
853
984
|
try {
|
|
854
|
-
const
|
|
855
|
-
|
|
985
|
+
const boardId = getActiveBoardIdFromDB(this._db);
|
|
986
|
+
const state = boardId ? loadStateFromDB(this._db, boardId) : null;
|
|
987
|
+
if (boardId && state) {
|
|
856
988
|
this.applyPersistedState(state);
|
|
989
|
+
this._activeBoardId = boardId;
|
|
857
990
|
return true;
|
|
858
991
|
}
|
|
992
|
+
this._theme = normalizeCanvasTheme(readThemeFromDB(this._db), this._theme);
|
|
859
993
|
} catch (error) {
|
|
860
994
|
logCanvasStateWarning('load state from sqlite failed', error, {});
|
|
861
995
|
}
|
|
@@ -867,6 +1001,434 @@ class CanvasStateManager {
|
|
|
867
1001
|
return false;
|
|
868
1002
|
}
|
|
869
1003
|
|
|
1004
|
+
// ── Library file (backup / restore) ─────────────────────────
|
|
1005
|
+
|
|
1006
|
+
/** The SQLite file holding every board, or null without a workspace database. */
|
|
1007
|
+
get databasePath(): string | null {
|
|
1008
|
+
return this._db ? this._dbPath : null;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
readWorkspaceMeta(key: string): string | null {
|
|
1012
|
+
return this._db ? readMetaFromDB(this._db, key) : null;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
writeWorkspaceMeta(key: string, value: string | null): void {
|
|
1016
|
+
if (this._db) writeMetaToDB(this._db, key, value);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** A consistent copy of the whole library, safe while the server runs. */
|
|
1020
|
+
backupDatabaseTo(path: string): void {
|
|
1021
|
+
if (!this._db) throw new Error('No workspace database to back up.');
|
|
1022
|
+
this.flushToDisk();
|
|
1023
|
+
this._db.run('VACUUM INTO ?', [path]);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Replace the library with `file`: the current file is kept beside it as
|
|
1028
|
+
* `<db>.before-restore`, then the restored file opens (migrating an older
|
|
1029
|
+
* layout) on the board it had open last.
|
|
1030
|
+
*/
|
|
1031
|
+
replaceDatabase(file: string): void {
|
|
1032
|
+
const dbPath = this.databasePath;
|
|
1033
|
+
if (!dbPath) throw new Error('No workspace database to restore into.');
|
|
1034
|
+
const root = this._workspaceRoot;
|
|
1035
|
+
const recoveryPath = `${dbPath}.before-restore`;
|
|
1036
|
+
const stagedPath = `${dbPath}.restore-${process.pid}-${Date.now()}`;
|
|
1037
|
+
this.flushToDisk();
|
|
1038
|
+
copyFileSync(file, stagedPath);
|
|
1039
|
+
let closed = false;
|
|
1040
|
+
let recoveryReady = false;
|
|
1041
|
+
try {
|
|
1042
|
+
this.close();
|
|
1043
|
+
closed = true;
|
|
1044
|
+
copyFileSync(dbPath, recoveryPath);
|
|
1045
|
+
recoveryReady = true;
|
|
1046
|
+
for (const suffix of ['-wal', '-shm']) rmSync(`${dbPath}${suffix}`, { force: true });
|
|
1047
|
+
copyFileSync(stagedPath, dbPath);
|
|
1048
|
+
this.setWorkspaceRoot(root);
|
|
1049
|
+
if (!this._db) throw new Error('Could not open the restored database.');
|
|
1050
|
+
const expectedBoard = getActiveBoardIdFromDB(this._db);
|
|
1051
|
+
const loaded = this.loadFromDisk({ clearExisting: true });
|
|
1052
|
+
if (expectedBoard && !loaded) throw new Error('Could not load the restored board.');
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
if (closed) {
|
|
1055
|
+
this.close();
|
|
1056
|
+
if (recoveryReady) {
|
|
1057
|
+
for (const suffix of ['-wal', '-shm']) rmSync(`${dbPath}${suffix}`, { force: true });
|
|
1058
|
+
copyFileSync(recoveryPath, dbPath);
|
|
1059
|
+
}
|
|
1060
|
+
this.setWorkspaceRoot(root);
|
|
1061
|
+
this.loadFromDisk({ clearExisting: true });
|
|
1062
|
+
}
|
|
1063
|
+
throw error;
|
|
1064
|
+
} finally {
|
|
1065
|
+
rmSync(stagedPath, { force: true });
|
|
1066
|
+
}
|
|
1067
|
+
this.notifyChange('nodes');
|
|
1068
|
+
this.notifyChange('pins');
|
|
1069
|
+
this.notifyChange('ax');
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ── Boards ──────────────────────────────────────────────────
|
|
1073
|
+
|
|
1074
|
+
private _activeBoardId: string | null = null;
|
|
1075
|
+
private _boardGeneration = 0;
|
|
1076
|
+
|
|
1077
|
+
/** Invalidates asynchronous work whenever the loaded board state is replaced. */
|
|
1078
|
+
get boardGeneration(): number {
|
|
1079
|
+
return this._boardGeneration;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** The open board, or null on Home. */
|
|
1083
|
+
get activeBoardId(): string | null {
|
|
1084
|
+
return this._activeBoardId;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
getActiveBoard(): CanvasBoard | null {
|
|
1088
|
+
if (!this._db || !this._activeBoardId) return null;
|
|
1089
|
+
const board = getBoardFromDB(this._db, this._activeBoardId);
|
|
1090
|
+
return board ? { ...board, nodeCount: this.nodes.size } : null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* One board's content without opening it: the open board from memory, any
|
|
1095
|
+
* other straight from SQLite. Null when there is no such board.
|
|
1096
|
+
*/
|
|
1097
|
+
readBoard(
|
|
1098
|
+
id: string,
|
|
1099
|
+
includeBlobs = true,
|
|
1100
|
+
): { board: CanvasBoard; state: PersistedCanvasState; layout: CanvasLayout } | null {
|
|
1101
|
+
if (!this._db) return null;
|
|
1102
|
+
const board = this.listBoards().find((entry) => entry.id === id);
|
|
1103
|
+
if (!board) return null;
|
|
1104
|
+
if (id === this._activeBoardId) {
|
|
1105
|
+
const layout = includeBlobs ? this.getLayout() : this.getLayoutForPersistence();
|
|
1106
|
+
if (!includeBlobs)
|
|
1107
|
+
layout.nodes = layout.nodes.map((node) => ({
|
|
1108
|
+
...node,
|
|
1109
|
+
pinned: node.pinned || this._contextPinnedNodeIds.has(node.id),
|
|
1110
|
+
}));
|
|
1111
|
+
return {
|
|
1112
|
+
board,
|
|
1113
|
+
layout,
|
|
1114
|
+
state: {
|
|
1115
|
+
version: 1,
|
|
1116
|
+
theme: this._theme,
|
|
1117
|
+
tour: layout.tour,
|
|
1118
|
+
ax: this.getAxState(),
|
|
1119
|
+
viewport: layout.viewport,
|
|
1120
|
+
nodes: layout.nodes,
|
|
1121
|
+
edges: layout.edges,
|
|
1122
|
+
annotations: layout.annotations,
|
|
1123
|
+
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1124
|
+
revisionState: this.revisionState(),
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
const state = loadStateFromDB(this._db, id);
|
|
1129
|
+
if (!state) return null;
|
|
1130
|
+
const pins = new Set(state.contextPins);
|
|
1131
|
+
state.nodes = state.nodes.map((node) => ({
|
|
1132
|
+
...(includeBlobs ? this.resolveNodeDataBlobs(node) : node),
|
|
1133
|
+
pinned: node.pinned || pins.has(node.id),
|
|
1134
|
+
}));
|
|
1135
|
+
return {
|
|
1136
|
+
board,
|
|
1137
|
+
state,
|
|
1138
|
+
layout: {
|
|
1139
|
+
tour: state.tour,
|
|
1140
|
+
theme: state.theme ?? this._theme,
|
|
1141
|
+
viewport: state.viewport,
|
|
1142
|
+
nodes: state.nodes,
|
|
1143
|
+
edges: state.edges,
|
|
1144
|
+
annotations: state.annotations ?? [],
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Every board in the workspace, most recently opened first. */
|
|
1150
|
+
listBoards(): CanvasBoard[] {
|
|
1151
|
+
if (!this._db) return [];
|
|
1152
|
+
return listBoardsFromDB(this._db).map((board) =>
|
|
1153
|
+
board.id === this._activeBoardId ? { ...board, nodeCount: this.nodes.size } : board,
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
storeAttachment(input: { boardId: string; name: string; mime: string; bytes: Uint8Array }): Attachment | null {
|
|
1158
|
+
if (!this._db) return null;
|
|
1159
|
+
if (input.boardId === this._activeBoardId) this.flushToDisk();
|
|
1160
|
+
if (!getBoardFromDB(this._db, input.boardId)) return null;
|
|
1161
|
+
return storeAttachment(this._db, input);
|
|
1162
|
+
}
|
|
1163
|
+
getAttachment(id: string): Attachment | null {
|
|
1164
|
+
return this._db ? getAttachment(this._db, id) : null;
|
|
1165
|
+
}
|
|
1166
|
+
readAttachmentBytes(id: string): Uint8Array | null {
|
|
1167
|
+
return this._db ? readAttachmentBytes(this._db, id) : null;
|
|
1168
|
+
}
|
|
1169
|
+
createDocumentImport(attachmentId: string, position: { x: number; y: number }): DocumentImport | null {
|
|
1170
|
+
return this._db ? createImport(this._db, attachmentId, position) : null;
|
|
1171
|
+
}
|
|
1172
|
+
getDocumentImport(id: string): DocumentImport | null {
|
|
1173
|
+
return this._db ? getImport(this._db, id) : null;
|
|
1174
|
+
}
|
|
1175
|
+
listDocumentImports(boardId?: string): DocumentImport[] {
|
|
1176
|
+
return this._db ? listImports(this._db, boardId) : [];
|
|
1177
|
+
}
|
|
1178
|
+
updateDocumentImport(
|
|
1179
|
+
id: string,
|
|
1180
|
+
status: DocumentImportStatus,
|
|
1181
|
+
fields?: {
|
|
1182
|
+
sections?: ImportSection[];
|
|
1183
|
+
warnings?: string[];
|
|
1184
|
+
agentDescription?: string;
|
|
1185
|
+
reason?: string;
|
|
1186
|
+
committedNodeIds?: string[];
|
|
1187
|
+
},
|
|
1188
|
+
): DocumentImport | null {
|
|
1189
|
+
return this._db ? updateImport(this._db, id, status, fields) : null;
|
|
1190
|
+
}
|
|
1191
|
+
commitDocumentImport(id: string, nodeIds: string[]): DocumentImport | null {
|
|
1192
|
+
if (!this._db) return null;
|
|
1193
|
+
if (this._saveTimer) {
|
|
1194
|
+
clearTimeout(this._saveTimer);
|
|
1195
|
+
this._saveTimer = null;
|
|
1196
|
+
}
|
|
1197
|
+
return this._db.transaction(() => {
|
|
1198
|
+
if (!this.saveToDisk()) throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
|
|
1199
|
+
return updateImport(this._db!, id, 'committed', { committedNodeIds: nodeIds });
|
|
1200
|
+
})();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
getContextBriefCursor(boardId: string, consumer: string): number | null {
|
|
1204
|
+
return this._db ? readContextBriefCursor(this._db, boardId, consumer) : null;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
advanceContextBriefCursor(boardId: string, consumer: string, revision: number): void {
|
|
1208
|
+
// A durable cursor must never get ahead of the board content it describes.
|
|
1209
|
+
if (boardId === this._activeBoardId) this.flushToDisk();
|
|
1210
|
+
if (this._db) advanceContextBriefCursor(this._db, boardId, consumer, revision);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Creates a board without opening it. */
|
|
1214
|
+
createBoard(name: string, category: string | null = null): CanvasBoard | null {
|
|
1215
|
+
if (!this._db) return null;
|
|
1216
|
+
return createBoardInDB(this._db, name, category);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
updateBoard(id: string, patch: { name?: string; category?: string | null }): boolean {
|
|
1220
|
+
return this._db ? updateBoardInDB(this._db, id, patch) : false;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
setBoardReadme(id: string, nodeId: string | null): boolean {
|
|
1224
|
+
if (!this._db) return false;
|
|
1225
|
+
const read = this.readBoard(id, false);
|
|
1226
|
+
if (!read) return false;
|
|
1227
|
+
if (nodeId && !read.layout.nodes.some((node) => node.id === nodeId && node.type === 'markdown')) return false;
|
|
1228
|
+
return updateBoardInDB(this._db, id, { readmeNodeId: nodeId });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Bounded inactive-board creation; never replaces the state the human has open. */
|
|
1232
|
+
createBoardFromBoard(input: {
|
|
1233
|
+
sourceBoardId: string;
|
|
1234
|
+
name: string;
|
|
1235
|
+
category?: string | null;
|
|
1236
|
+
nodeIds?: string[];
|
|
1237
|
+
includeReadme?: boolean;
|
|
1238
|
+
includeStructure?: boolean;
|
|
1239
|
+
}): CanvasBoard | null {
|
|
1240
|
+
if (!this._db) return null;
|
|
1241
|
+
const source = this.readBoard(input.sourceBoardId, true);
|
|
1242
|
+
if (!source) return null;
|
|
1243
|
+
const selected = new Set(input.nodeIds ?? []);
|
|
1244
|
+
if (input.includeReadme && source.board.readmeNodeId) selected.add(source.board.readmeNodeId);
|
|
1245
|
+
if (input.includeStructure) {
|
|
1246
|
+
for (const node of source.layout.nodes) {
|
|
1247
|
+
if (node.type === 'group') selected.add(node.id);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const copied = source.layout.nodes.filter(
|
|
1251
|
+
(node) => selected.has(node.id) && !['prompt', 'response', 'trace', 'mcp-app'].includes(node.type),
|
|
1252
|
+
);
|
|
1253
|
+
const ids = new Map(copied.map((node) => [node.id, randomUUID()]));
|
|
1254
|
+
const referenceKeys = new Set([
|
|
1255
|
+
'nodeId',
|
|
1256
|
+
'nodeIds',
|
|
1257
|
+
'sourceNodeId',
|
|
1258
|
+
'targetNodeId',
|
|
1259
|
+
'embeddedNodeId',
|
|
1260
|
+
'embeddedNodeIds',
|
|
1261
|
+
'embeddedGraphId',
|
|
1262
|
+
]);
|
|
1263
|
+
const remap = (value: unknown, key = ''): unknown => {
|
|
1264
|
+
if (typeof value === 'string') return referenceKeys.has(key) ? (ids.get(value) ?? null) : value;
|
|
1265
|
+
if (Array.isArray(value)) return value.map((item) => remap(item, key)).filter((item) => item !== null);
|
|
1266
|
+
if (value && typeof value === 'object') {
|
|
1267
|
+
return Object.fromEntries(
|
|
1268
|
+
Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, remap(item, key)]),
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
return value;
|
|
1272
|
+
};
|
|
1273
|
+
const actor = currentActor();
|
|
1274
|
+
const nodes: CanvasNodeState[] = copied.map((node, index) => {
|
|
1275
|
+
const data = remap(node.data) as Record<string, unknown>;
|
|
1276
|
+
if (node.type === 'graph' || node.type === 'json-render') {
|
|
1277
|
+
data.url = `/api/canvas/json-render/view?nodeId=${encodeURIComponent(ids.get(node.id)!)}`;
|
|
1278
|
+
}
|
|
1279
|
+
delete data.axStep;
|
|
1280
|
+
delete data.axWorkStatus;
|
|
1281
|
+
delete data.workItemId;
|
|
1282
|
+
delete data.approvalGateId;
|
|
1283
|
+
delete data.parentGroup;
|
|
1284
|
+
if (typeof node.data.parentGroup === 'string' && ids.has(node.data.parentGroup)) {
|
|
1285
|
+
data.parentGroup = ids.get(node.data.parentGroup);
|
|
1286
|
+
}
|
|
1287
|
+
if (node.type === 'group')
|
|
1288
|
+
data.children = Array.isArray(node.data.children)
|
|
1289
|
+
? node.data.children.flatMap((id) => (typeof id === 'string' && ids.has(id) ? [ids.get(id)!] : []))
|
|
1290
|
+
: [];
|
|
1291
|
+
return {
|
|
1292
|
+
...node,
|
|
1293
|
+
id: ids.get(node.id)!,
|
|
1294
|
+
pinned: false,
|
|
1295
|
+
data,
|
|
1296
|
+
createdBy: actor,
|
|
1297
|
+
lastEditedBy: actor,
|
|
1298
|
+
contentRevision: index + 1,
|
|
1299
|
+
};
|
|
1300
|
+
});
|
|
1301
|
+
nodes.push({
|
|
1302
|
+
id: randomUUID(),
|
|
1303
|
+
type: 'board' as const,
|
|
1304
|
+
position: { x: 40, y: Math.max(40, ...nodes.map((node) => node.position.y + node.size.height + 40)) },
|
|
1305
|
+
size: { width: 360, height: 160 },
|
|
1306
|
+
zIndex: 1,
|
|
1307
|
+
collapsed: false,
|
|
1308
|
+
pinned: false,
|
|
1309
|
+
data: { title: source.board.name, boardId: source.board.id },
|
|
1310
|
+
createdBy: actor,
|
|
1311
|
+
lastEditedBy: actor,
|
|
1312
|
+
contentRevision: nodes.length + 1,
|
|
1313
|
+
});
|
|
1314
|
+
const edges = source.layout.edges
|
|
1315
|
+
.filter((edge) => ids.has(edge.from) && ids.has(edge.to))
|
|
1316
|
+
.map((edge) => ({ ...edge, id: randomUUID(), from: ids.get(edge.from)!, to: ids.get(edge.to)! }));
|
|
1317
|
+
const state: PersistedCanvasState = {
|
|
1318
|
+
version: 1,
|
|
1319
|
+
theme: source.state.theme,
|
|
1320
|
+
viewport: { x: 0, y: 0, scale: 1 },
|
|
1321
|
+
nodes,
|
|
1322
|
+
edges,
|
|
1323
|
+
annotations: [],
|
|
1324
|
+
contextPins: [],
|
|
1325
|
+
ax: createEmptyAxState(),
|
|
1326
|
+
revisionState: { revision: nodes.length, floor: 0, tombstones: [] },
|
|
1327
|
+
};
|
|
1328
|
+
const board = createBoardWithStateInDB(
|
|
1329
|
+
this._db,
|
|
1330
|
+
input.name,
|
|
1331
|
+
input.category ?? null,
|
|
1332
|
+
this.externalizePersistedStateBlobs(state),
|
|
1333
|
+
input.includeReadme && source.board.readmeNodeId ? (ids.get(source.board.readmeNodeId) ?? null) : null,
|
|
1334
|
+
);
|
|
1335
|
+
const referencedAttachmentIds = new Set<string>();
|
|
1336
|
+
for (const node of nodes) {
|
|
1337
|
+
if (typeof node.data.attachmentId === 'string') referencedAttachmentIds.add(node.data.attachmentId);
|
|
1338
|
+
const provenance = node.data.source;
|
|
1339
|
+
if (provenance && typeof provenance === 'object') {
|
|
1340
|
+
const sourceAttachmentId = (provenance as Record<string, unknown>).attachmentId;
|
|
1341
|
+
if (typeof sourceAttachmentId === 'string') referencedAttachmentIds.add(sourceAttachmentId);
|
|
1342
|
+
}
|
|
1343
|
+
delete node.data.importId;
|
|
1344
|
+
}
|
|
1345
|
+
const attachmentIds = copyBoardAttachments(this._db, source.board.id, board.id, referencedAttachmentIds);
|
|
1346
|
+
if (attachmentIds.size > 0) {
|
|
1347
|
+
const createdImportAttachments = new Set<string>();
|
|
1348
|
+
for (const row of this._db
|
|
1349
|
+
.query<{ id: string; data: string }, [string]>('SELECT id,data FROM nodes WHERE board_id=?')
|
|
1350
|
+
.all(board.id)) {
|
|
1351
|
+
const data = JSON.parse(row.data) as Record<string, unknown>;
|
|
1352
|
+
const old = typeof data.attachmentId === 'string' ? data.attachmentId : null;
|
|
1353
|
+
const sourceData =
|
|
1354
|
+
data.source && typeof data.source === 'object' ? (data.source as Record<string, unknown>) : null;
|
|
1355
|
+
const sourceOld = typeof sourceData?.attachmentId === 'string' ? sourceData.attachmentId : null;
|
|
1356
|
+
if (old && attachmentIds.has(old)) data.attachmentId = attachmentIds.get(old);
|
|
1357
|
+
if (sourceOld && attachmentIds.has(sourceOld))
|
|
1358
|
+
data.source = { ...sourceData, attachmentId: attachmentIds.get(sourceOld) };
|
|
1359
|
+
delete data.importId;
|
|
1360
|
+
this._db.run('UPDATE nodes SET data=? WHERE board_id=? AND id=?', [JSON.stringify(data), board.id, row.id]);
|
|
1361
|
+
if (old && attachmentIds.has(old) && !createdImportAttachments.has(old)) {
|
|
1362
|
+
createImport(this._db, attachmentIds.get(old)!, { x: 40, y: 260 });
|
|
1363
|
+
createdImportAttachments.add(old);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return board;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Deletes a board and its snapshots; deleting the open board returns to Home first. */
|
|
1371
|
+
deleteBoard(id: string): boolean {
|
|
1372
|
+
if (!this._db) return false;
|
|
1373
|
+
if (id === this._activeBoardId && !this.switchBoard(null)) return false;
|
|
1374
|
+
return deleteBoardFromDB(this._db, id);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Save the open board, then open `id` (or Home for null). The canvas state is
|
|
1379
|
+
* replaced wholesale; callers own the runtime side (undo history, app
|
|
1380
|
+
* sessions, watchers, SSE) — see `openCanvasBoard` in canvas-operations.
|
|
1381
|
+
*/
|
|
1382
|
+
switchBoard(id: string | null): boolean {
|
|
1383
|
+
if (!this._db) return false;
|
|
1384
|
+
if (this._saveTimer) {
|
|
1385
|
+
clearTimeout(this._saveTimer);
|
|
1386
|
+
this._saveTimer = null;
|
|
1387
|
+
}
|
|
1388
|
+
if (!this.saveToDisk()) {
|
|
1389
|
+
throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state before switching boards.');
|
|
1390
|
+
}
|
|
1391
|
+
let next = this.emptyPersistedState();
|
|
1392
|
+
if (id) {
|
|
1393
|
+
const state = loadStateFromDB(this._db, id);
|
|
1394
|
+
if (!state) return false;
|
|
1395
|
+
next = state;
|
|
1396
|
+
}
|
|
1397
|
+
this.applyPersistedState(next);
|
|
1398
|
+
this._activeBoardId = id;
|
|
1399
|
+
setActiveBoardIdInDB(this._db, id);
|
|
1400
|
+
this.notifyChange('nodes');
|
|
1401
|
+
this.notifyChange('pins');
|
|
1402
|
+
this.notifyChange('ax');
|
|
1403
|
+
return true;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Content written while no board is open (Home) lands on a new board, which
|
|
1408
|
+
* opens: nothing the human was looking at is displaced.
|
|
1409
|
+
*/
|
|
1410
|
+
private ensureActiveBoard(): string | null {
|
|
1411
|
+
if (!this._db) return null;
|
|
1412
|
+
if (this._activeBoardId) return this._activeBoardId;
|
|
1413
|
+
const board = createBoardInDB(this._db, defaultBoardName());
|
|
1414
|
+
this._activeBoardId = board.id;
|
|
1415
|
+
setActiveBoardIdInDB(this._db, board.id);
|
|
1416
|
+
return board.id;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
private hasBoardContent(): boolean {
|
|
1420
|
+
const ax = this.getAxState();
|
|
1421
|
+
return (
|
|
1422
|
+
this._tour !== undefined ||
|
|
1423
|
+
this.nodes.size > 0 ||
|
|
1424
|
+
this.edges.size > 0 ||
|
|
1425
|
+
this.annotations.size > 0 ||
|
|
1426
|
+
ax.workItems.length > 0 ||
|
|
1427
|
+
ax.approvalGates.length > 0 ||
|
|
1428
|
+
ax.reviewAnnotations.length > 0
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
870
1432
|
/** Debounced save — coalesces rapid mutations into a single write. */
|
|
871
1433
|
private scheduleSave(): void {
|
|
872
1434
|
if (!this._db) return;
|
|
@@ -878,36 +1440,50 @@ class CanvasStateManager {
|
|
|
878
1440
|
}
|
|
879
1441
|
|
|
880
1442
|
flushToDisk(): void {
|
|
1443
|
+
if (!this._db) return;
|
|
881
1444
|
if (this._saveTimer) {
|
|
882
1445
|
clearTimeout(this._saveTimer);
|
|
883
1446
|
this._saveTimer = null;
|
|
884
1447
|
}
|
|
885
|
-
this.saveToDisk()
|
|
1448
|
+
if (!this.saveToDisk()) {
|
|
1449
|
+
throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
|
|
1450
|
+
}
|
|
886
1451
|
if (this._db) {
|
|
887
1452
|
try {
|
|
888
1453
|
checkpointCanvasDb(this._db);
|
|
889
1454
|
} catch (error) {
|
|
890
1455
|
logCanvasStateWarning('checkpoint database failed', error, {});
|
|
1456
|
+
throw error;
|
|
891
1457
|
}
|
|
892
1458
|
}
|
|
893
1459
|
}
|
|
894
1460
|
|
|
895
1461
|
/** Write current state to SQLite immediately. */
|
|
896
|
-
private saveToDisk():
|
|
897
|
-
if (!this._db) return;
|
|
1462
|
+
private saveToDisk(): boolean {
|
|
1463
|
+
if (!this._db) return false;
|
|
898
1464
|
try {
|
|
1465
|
+
if (!this._activeBoardId && !this.hasBoardContent()) {
|
|
1466
|
+
saveThemeToDB(this._db, this._theme);
|
|
1467
|
+
this._lastPersistenceError = null;
|
|
1468
|
+
return true;
|
|
1469
|
+
}
|
|
1470
|
+
const boardId = this.ensureActiveBoard();
|
|
1471
|
+
if (!boardId) return false;
|
|
899
1472
|
const payload = this.externalizePersistedStateBlobs({
|
|
900
1473
|
version: 1,
|
|
901
1474
|
theme: this._theme,
|
|
1475
|
+
tour: this._tour,
|
|
902
1476
|
viewport: this._viewport,
|
|
903
1477
|
nodes: Array.from(this.nodes.values()),
|
|
904
1478
|
edges: Array.from(this.edges.values()),
|
|
905
1479
|
annotations: Array.from(this.annotations.values()),
|
|
906
1480
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
907
1481
|
ax: this.getAxState(),
|
|
1482
|
+
revisionState: this.revisionState(),
|
|
908
1483
|
});
|
|
909
|
-
saveStateToDB(this._db, payload);
|
|
1484
|
+
saveStateToDB(this._db, boardId, payload);
|
|
910
1485
|
this._lastPersistenceError = null;
|
|
1486
|
+
return true;
|
|
911
1487
|
} catch (error) {
|
|
912
1488
|
// Persistence failures are otherwise warn-and-continue (the canvas keeps
|
|
913
1489
|
// working from memory) — record the failure so /health can report a
|
|
@@ -917,6 +1493,7 @@ class CanvasStateManager {
|
|
|
917
1493
|
at: new Date().toISOString(),
|
|
918
1494
|
};
|
|
919
1495
|
logCanvasStateWarning('save state to sqlite failed', error, {});
|
|
1496
|
+
return false;
|
|
920
1497
|
}
|
|
921
1498
|
}
|
|
922
1499
|
|
|
@@ -929,11 +1506,9 @@ class CanvasStateManager {
|
|
|
929
1506
|
|
|
930
1507
|
/** Close the SQLite database cleanly. Call on server shutdown. */
|
|
931
1508
|
close(): void {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
this.saveToDisk();
|
|
936
|
-
}
|
|
1509
|
+
// A failed debounce has no timer left, but its edits still need saving.
|
|
1510
|
+
// Leave the database and memory intact if the retry fails.
|
|
1511
|
+
if (this._saveTimer || this._lastPersistenceError) this.flushToDisk();
|
|
937
1512
|
if (this._db) {
|
|
938
1513
|
try {
|
|
939
1514
|
finalizeCanvasDbForClose(this._db);
|
|
@@ -947,6 +1522,8 @@ class CanvasStateManager {
|
|
|
947
1522
|
}
|
|
948
1523
|
this._db = null;
|
|
949
1524
|
}
|
|
1525
|
+
this._activeBoardId = null;
|
|
1526
|
+
this._boardGeneration += 1;
|
|
950
1527
|
}
|
|
951
1528
|
|
|
952
1529
|
// ── Snapshots ───────────────────────────────────────────────
|
|
@@ -957,11 +1534,16 @@ class CanvasStateManager {
|
|
|
957
1534
|
}
|
|
958
1535
|
|
|
959
1536
|
private applyPersistedState(state: PersistedCanvasState): void {
|
|
1537
|
+
this._boardGeneration += 1;
|
|
1538
|
+
this._tour = state.tour ? structuredClone(state.tour) : undefined;
|
|
960
1539
|
this.nodes.clear();
|
|
961
1540
|
this.edges.clear();
|
|
962
1541
|
this.annotations.clear();
|
|
963
1542
|
this._contextPinnedNodeIds.clear();
|
|
964
1543
|
this.ax.resetCanvasBound();
|
|
1544
|
+
this._contentRevision = state.revisionState?.revision ?? 0;
|
|
1545
|
+
this._revisionFloor = state.revisionState?.floor ?? 0;
|
|
1546
|
+
this._deletionTombstones = structuredClone(state.revisionState?.tombstones ?? []);
|
|
965
1547
|
|
|
966
1548
|
this._viewport = {
|
|
967
1549
|
x: state.viewport?.x ?? 0,
|
|
@@ -999,10 +1581,16 @@ class CanvasStateManager {
|
|
|
999
1581
|
snapshot: CanvasSnapshot;
|
|
1000
1582
|
state: PersistedCanvasState;
|
|
1001
1583
|
} | null {
|
|
1584
|
+
if (!this._activeBoardId) return null;
|
|
1585
|
+
|
|
1002
1586
|
// Try SQLite first
|
|
1003
1587
|
if (this._db) {
|
|
1004
|
-
const result = loadSnapshotFromDB(this._db, idOrName);
|
|
1588
|
+
const result = loadSnapshotFromDB(this._db, idOrName, this._activeBoardId);
|
|
1005
1589
|
if (result) return result;
|
|
1590
|
+
const ownedByAnotherBoard = this._db
|
|
1591
|
+
.query<{ found: number }, [string, string]>('SELECT 1 AS found FROM snapshots WHERE id = ? OR name = ? LIMIT 1')
|
|
1592
|
+
.get(idOrName, idOrName);
|
|
1593
|
+
if (ownedByAnotherBoard) return null;
|
|
1006
1594
|
}
|
|
1007
1595
|
|
|
1008
1596
|
// Fallback to filesystem (legacy on-disk snapshots are still readable in place)
|
|
@@ -1090,8 +1678,11 @@ class CanvasStateManager {
|
|
|
1090
1678
|
annotations: Array.from(this.annotations.values()),
|
|
1091
1679
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1092
1680
|
ax: this.getAxState(),
|
|
1681
|
+
revisionState: this.revisionState(),
|
|
1093
1682
|
});
|
|
1094
|
-
|
|
1683
|
+
const boardId = this.ensureActiveBoard();
|
|
1684
|
+
if (!boardId) return null;
|
|
1685
|
+
saveSnapshotToDB(this._db, boardId, snapshot, { ...payload, tour: this._tour });
|
|
1095
1686
|
snapshot.nodeCount = payload.nodes.length;
|
|
1096
1687
|
snapshot.edgeCount = payload.edges.length;
|
|
1097
1688
|
return snapshot;
|
|
@@ -1105,7 +1696,7 @@ class CanvasStateManager {
|
|
|
1105
1696
|
listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
|
|
1106
1697
|
if (this._db) {
|
|
1107
1698
|
try {
|
|
1108
|
-
return listSnapshotsFromDB(this._db, options);
|
|
1699
|
+
return this._activeBoardId ? listSnapshotsFromDB(this._db, this._activeBoardId, options) : [];
|
|
1109
1700
|
} catch (error) {
|
|
1110
1701
|
logCanvasStateWarning('list snapshots from db failed', error, {});
|
|
1111
1702
|
}
|
|
@@ -1176,6 +1767,7 @@ class CanvasStateManager {
|
|
|
1176
1767
|
|
|
1177
1768
|
const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
|
|
1178
1769
|
version: 1,
|
|
1770
|
+
tour: this._tour,
|
|
1179
1771
|
theme: this._theme,
|
|
1180
1772
|
viewport: structuredClone(this._viewport),
|
|
1181
1773
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
@@ -1183,9 +1775,11 @@ class CanvasStateManager {
|
|
|
1183
1775
|
annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
|
|
1184
1776
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1185
1777
|
ax: this.getAxState(),
|
|
1778
|
+
revisionState: this.revisionState(),
|
|
1186
1779
|
});
|
|
1187
1780
|
const nextState: PersistedCanvasState = {
|
|
1188
1781
|
version: 1,
|
|
1782
|
+
tour: resolved.state.tour,
|
|
1189
1783
|
theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
|
|
1190
1784
|
viewport: structuredClone(resolved.state.viewport),
|
|
1191
1785
|
nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
|
|
@@ -1195,10 +1789,13 @@ class CanvasStateManager {
|
|
|
1195
1789
|
: [],
|
|
1196
1790
|
contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
|
|
1197
1791
|
ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
|
|
1792
|
+
revisionState: resolved.state.revisionState ? structuredClone(resolved.state.revisionState) : undefined,
|
|
1198
1793
|
};
|
|
1199
1794
|
|
|
1200
1795
|
try {
|
|
1201
|
-
this.
|
|
1796
|
+
const revisionBeforeRestore = this._contentRevision;
|
|
1797
|
+
this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
|
|
1798
|
+
this.restampRestoredContent(previousState.nodes, revisionBeforeRestore);
|
|
1202
1799
|
this.scheduleSave();
|
|
1203
1800
|
this.notifyChange('nodes');
|
|
1204
1801
|
this.notifyChange('pins');
|
|
@@ -1207,14 +1804,20 @@ class CanvasStateManager {
|
|
|
1207
1804
|
operationType: 'restoreSnapshot',
|
|
1208
1805
|
description: `Restored snapshot "${resolved.snapshot.name}"`,
|
|
1209
1806
|
forward: this.suppressed(() => {
|
|
1210
|
-
this.
|
|
1807
|
+
const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
|
|
1808
|
+
const revisionBeforeReplay = this._contentRevision;
|
|
1809
|
+
this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
|
|
1810
|
+
this.restampRestoredContent(before, revisionBeforeReplay);
|
|
1211
1811
|
this.scheduleSave();
|
|
1212
1812
|
this.notifyChange('nodes');
|
|
1213
1813
|
this.notifyChange('pins');
|
|
1214
1814
|
this.notifyChange('ax');
|
|
1215
1815
|
}),
|
|
1216
1816
|
inverse: this.suppressed(() => {
|
|
1217
|
-
this.
|
|
1817
|
+
const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
|
|
1818
|
+
const revisionBeforeReplay = this._contentRevision;
|
|
1819
|
+
this.applyPersistedState({ ...previousState, revisionState: this.revisionState() });
|
|
1820
|
+
this.restampRestoredContent(before, revisionBeforeReplay);
|
|
1218
1821
|
this.scheduleSave();
|
|
1219
1822
|
this.notifyChange('nodes');
|
|
1220
1823
|
this.notifyChange('pins');
|
|
@@ -1233,9 +1836,13 @@ class CanvasStateManager {
|
|
|
1233
1836
|
}
|
|
1234
1837
|
|
|
1235
1838
|
/** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
|
|
1236
|
-
getSnapshotData(
|
|
1237
|
-
|
|
1238
|
-
|
|
1839
|
+
getSnapshotData(idOrName: string): {
|
|
1840
|
+
name: string;
|
|
1841
|
+
nodes: CanvasNodeState[];
|
|
1842
|
+
edges: CanvasEdge[];
|
|
1843
|
+
annotations: CanvasAnnotation[];
|
|
1844
|
+
tour?: Tour;
|
|
1845
|
+
} | null {
|
|
1239
1846
|
const resolved = this.readResolvedSnapshot(idOrName);
|
|
1240
1847
|
if (!resolved) return null;
|
|
1241
1848
|
const state = {
|
|
@@ -1246,6 +1853,7 @@ class CanvasStateManager {
|
|
|
1246
1853
|
};
|
|
1247
1854
|
return {
|
|
1248
1855
|
name: resolved.snapshot.name,
|
|
1856
|
+
...(state.tour ? { tour: structuredClone(state.tour) } : {}),
|
|
1249
1857
|
nodes: Array.isArray(state.nodes) ? state.nodes.map((node) => structuredClone(node)) : [],
|
|
1250
1858
|
edges: Array.isArray(state.edges) ? state.edges.map((edge) => structuredClone(edge)) : [],
|
|
1251
1859
|
annotations: Array.isArray(state.annotations)
|
|
@@ -1256,9 +1864,9 @@ class CanvasStateManager {
|
|
|
1256
1864
|
|
|
1257
1865
|
/** Delete a snapshot. */
|
|
1258
1866
|
renameSnapshot(id: string, name: string): boolean {
|
|
1259
|
-
if (!this._db) return false;
|
|
1867
|
+
if (!this._db || !this._activeBoardId) return false;
|
|
1260
1868
|
try {
|
|
1261
|
-
return renameSnapshotInDB(this._db, id, name);
|
|
1869
|
+
return renameSnapshotInDB(this._db, this._activeBoardId, id, name);
|
|
1262
1870
|
} catch (error) {
|
|
1263
1871
|
logCanvasStateWarning('rename snapshot failed', error, { id, name });
|
|
1264
1872
|
return false;
|
|
@@ -1266,12 +1874,20 @@ class CanvasStateManager {
|
|
|
1266
1874
|
}
|
|
1267
1875
|
|
|
1268
1876
|
deleteSnapshot(id: string): boolean {
|
|
1877
|
+
if (!this._activeBoardId) return false;
|
|
1269
1878
|
// Try SQLite first
|
|
1270
1879
|
if (this._db) {
|
|
1271
1880
|
try {
|
|
1272
|
-
|
|
1881
|
+
const snapshot = this._db
|
|
1882
|
+
.query<{ board_id: string }, [string]>('SELECT board_id FROM snapshots WHERE id = ?')
|
|
1883
|
+
.get(id);
|
|
1884
|
+
if (snapshot) {
|
|
1885
|
+
if (snapshot.board_id !== this._activeBoardId) return false;
|
|
1886
|
+
return deleteSnapshotFromDB(this._db, id);
|
|
1887
|
+
}
|
|
1273
1888
|
} catch (error) {
|
|
1274
1889
|
logCanvasStateWarning('delete snapshot from db failed', error, { id });
|
|
1890
|
+
return false;
|
|
1275
1891
|
}
|
|
1276
1892
|
}
|
|
1277
1893
|
|
|
@@ -1308,12 +1924,77 @@ class CanvasStateManager {
|
|
|
1308
1924
|
|
|
1309
1925
|
// ── Node CRUD ──────────────────────────────────────────────
|
|
1310
1926
|
|
|
1927
|
+
private restampRestoredContent(beforeNodes: CanvasNodeState[], minimumRevision: number): void {
|
|
1928
|
+
this._contentRevision = Math.max(this._contentRevision, minimumRevision);
|
|
1929
|
+
const before = new Map(beforeNodes.map((node) => [node.id, node]));
|
|
1930
|
+
const restoredIds = new Set(this.nodes.keys());
|
|
1931
|
+
for (const [id, node] of this.nodes) {
|
|
1932
|
+
const old = before.get(id);
|
|
1933
|
+
if (old && !this.nodeContentChanged(old, node)) {
|
|
1934
|
+
this.nodes.set(id, { ...node, contentRevision: old.contentRevision, lastEditedBy: old.lastEditedBy });
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1937
|
+
const revision = this.nextContentRevision();
|
|
1938
|
+
this.nodes.set(id, { ...node, lastEditedBy: currentActor(), contentRevision: revision });
|
|
1939
|
+
}
|
|
1940
|
+
for (const old of beforeNodes) {
|
|
1941
|
+
if (!restoredIds.has(old.id)) this.recordDeletion(old.id);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
private semanticData(data: Record<string, unknown>): Record<string, unknown> {
|
|
1946
|
+
const copy = { ...data };
|
|
1947
|
+
delete copy.strictSize;
|
|
1948
|
+
delete copy.arrangeLocked;
|
|
1949
|
+
return copy;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
private nodeContentChanged(a: CanvasNodeState, b: CanvasNodeState): boolean {
|
|
1953
|
+
return a.type !== b.type || JSON.stringify(this.semanticData(a.data)) !== JSON.stringify(this.semanticData(b.data));
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
private setSemanticNode(node: CanvasNodeState): void {
|
|
1957
|
+
const existing = this.nodes.get(node.id);
|
|
1958
|
+
if (!existing || !this.nodeContentChanged(existing, node)) {
|
|
1959
|
+
this.nodes.set(node.id, node);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
const data = { ...node.data };
|
|
1963
|
+
if (data.agentSummary === existing.data.agentSummary) delete data.agentSummary;
|
|
1964
|
+
if (data.summary === existing.data.summary) delete data.summary;
|
|
1965
|
+
this.nodes.set(node.id, {
|
|
1966
|
+
...node,
|
|
1967
|
+
data,
|
|
1968
|
+
createdBy: existing.createdBy,
|
|
1969
|
+
lastEditedBy: currentActor(),
|
|
1970
|
+
contentRevision: this.nextContentRevision(),
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
private recordDeletion(nodeId: string): void {
|
|
1975
|
+
const tombstone = { nodeId, revision: this.nextContentRevision(), deletedBy: currentActor() };
|
|
1976
|
+
this._deletionTombstones.push(tombstone);
|
|
1977
|
+
const limit = 500;
|
|
1978
|
+
if (this._deletionTombstones.length > limit) {
|
|
1979
|
+
const removed = this._deletionTombstones.splice(0, this._deletionTombstones.length - limit);
|
|
1980
|
+
this._revisionFloor = Math.max(this._revisionFloor, removed.at(-1)?.revision ?? 0);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1311
1984
|
get viewport(): ViewportState {
|
|
1312
1985
|
return structuredClone(this._viewport);
|
|
1313
1986
|
}
|
|
1314
1987
|
|
|
1315
1988
|
addNode(node: CanvasNodeState): void {
|
|
1316
|
-
const
|
|
1989
|
+
const actor = currentActor();
|
|
1990
|
+
const cloned = structuredClone(
|
|
1991
|
+
this.normalizeNode({
|
|
1992
|
+
...node,
|
|
1993
|
+
createdBy: actor,
|
|
1994
|
+
lastEditedBy: actor,
|
|
1995
|
+
contentRevision: this.nextContentRevision(),
|
|
1996
|
+
}),
|
|
1997
|
+
);
|
|
1317
1998
|
this.nodes.set(node.id, cloned);
|
|
1318
1999
|
this.scheduleSave();
|
|
1319
2000
|
this.notifyChange('nodes');
|
|
@@ -1336,11 +2017,43 @@ class CanvasStateManager {
|
|
|
1336
2017
|
updateNode(id: string, patch: Partial<CanvasNodeState>): void {
|
|
1337
2018
|
const existing = this.nodes.get(id);
|
|
1338
2019
|
if (!existing) return;
|
|
2020
|
+
if (patch.data) {
|
|
2021
|
+
const attachmentId = existing.data.attachmentId;
|
|
2022
|
+
if (typeof attachmentId === 'string' && patch.data.attachmentId !== attachmentId) {
|
|
2023
|
+
throw new Error('Attachment identity is immutable.');
|
|
2024
|
+
}
|
|
2025
|
+
const source = existing.data.source;
|
|
2026
|
+
const importedSource =
|
|
2027
|
+
source && typeof source === 'object' && typeof (source as Record<string, unknown>).attachmentId === 'string';
|
|
2028
|
+
if (importedSource && JSON.stringify(patch.data.source) !== JSON.stringify(source)) {
|
|
2029
|
+
throw new Error('Import provenance is immutable.');
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
1339
2032
|
const oldSnapshot = structuredClone(existing);
|
|
1340
2033
|
if (existing.type === 'group' && patch.position) {
|
|
1341
2034
|
this.translateGroupChildren(id, patch.position.x - existing.position.x, patch.position.y - existing.position.y);
|
|
1342
2035
|
}
|
|
1343
|
-
|
|
2036
|
+
let nextNode = this.normalizeNode({ ...existing, ...patch });
|
|
2037
|
+
if (this.nodeContentChanged(existing, nextNode)) {
|
|
2038
|
+
const data = { ...nextNode.data };
|
|
2039
|
+
const suppliedData = patch.data;
|
|
2040
|
+
if (!suppliedData || suppliedData.agentSummary === existing.data.agentSummary) delete data.agentSummary;
|
|
2041
|
+
if (!suppliedData || suppliedData.summary === existing.data.summary) delete data.summary;
|
|
2042
|
+
nextNode = {
|
|
2043
|
+
...nextNode,
|
|
2044
|
+
data,
|
|
2045
|
+
createdBy: existing.createdBy ?? unknownActor(),
|
|
2046
|
+
lastEditedBy: currentActor(),
|
|
2047
|
+
contentRevision: this.nextContentRevision(),
|
|
2048
|
+
};
|
|
2049
|
+
} else {
|
|
2050
|
+
nextNode = {
|
|
2051
|
+
...nextNode,
|
|
2052
|
+
createdBy: existing.createdBy,
|
|
2053
|
+
lastEditedBy: existing.lastEditedBy,
|
|
2054
|
+
contentRevision: existing.contentRevision,
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
1344
2057
|
this.nodes.set(id, nextNode);
|
|
1345
2058
|
const parentGroupId = existing.data.parentGroup as string | undefined;
|
|
1346
2059
|
if (parentGroupId) {
|
|
@@ -1358,7 +2071,16 @@ class CanvasStateManager {
|
|
|
1358
2071
|
description: `Updated node "${(existing.data.title as string) ?? id}"`,
|
|
1359
2072
|
forward: this.suppressed(() => this.updateNode(id, structuredClone(patch))),
|
|
1360
2073
|
inverse: this.suppressed(() => {
|
|
1361
|
-
|
|
2074
|
+
const restored = structuredClone(oldSnapshot);
|
|
2075
|
+
const current = this.nodes.get(id);
|
|
2076
|
+
if (current && this.nodeContentChanged(current, restored)) {
|
|
2077
|
+
restored.contentRevision = this.nextContentRevision();
|
|
2078
|
+
restored.lastEditedBy = currentActor();
|
|
2079
|
+
} else if (current) {
|
|
2080
|
+
restored.contentRevision = current.contentRevision;
|
|
2081
|
+
restored.lastEditedBy = current.lastEditedBy;
|
|
2082
|
+
}
|
|
2083
|
+
this.nodes.set(id, restored);
|
|
1362
2084
|
this.scheduleSave();
|
|
1363
2085
|
this.notifyChange('nodes');
|
|
1364
2086
|
}),
|
|
@@ -1379,6 +2101,7 @@ class CanvasStateManager {
|
|
|
1379
2101
|
let released: string[] = [];
|
|
1380
2102
|
|
|
1381
2103
|
if (existing) {
|
|
2104
|
+
this.recordDeletion(id);
|
|
1382
2105
|
const parentGroupId = existing.data.parentGroup as string | undefined;
|
|
1383
2106
|
const parent = parentGroupId ? this.nodes.get(parentGroupId) : undefined;
|
|
1384
2107
|
const enclosing = parent && parent.type === 'group' ? parent : null;
|
|
@@ -1391,13 +2114,13 @@ class CanvasStateManager {
|
|
|
1391
2114
|
const d = { ...child.data };
|
|
1392
2115
|
if (enclosing) d.parentGroup = enclosing.id;
|
|
1393
2116
|
else delete d.parentGroup;
|
|
1394
|
-
this.
|
|
2117
|
+
this.setSemanticNode({ ...child, data: d });
|
|
1395
2118
|
}
|
|
1396
2119
|
}
|
|
1397
2120
|
if (enclosing && enclosingBefore) {
|
|
1398
2121
|
// The removed node leaves the enclosing group; a dissolved group's children take its place.
|
|
1399
2122
|
const children = enclosingBefore.children.flatMap((cid) => (cid === id ? released : [cid]));
|
|
1400
|
-
this.
|
|
2123
|
+
this.setSemanticNode({ ...enclosing, data: { ...enclosing.data, children } });
|
|
1401
2124
|
}
|
|
1402
2125
|
}
|
|
1403
2126
|
|
|
@@ -1451,15 +2174,17 @@ class CanvasStateManager {
|
|
|
1451
2174
|
forward: this.suppressed(() => this.removeNode(id)),
|
|
1452
2175
|
inverse: this.suppressed(() => {
|
|
1453
2176
|
this.addNode(structuredClone(cloned));
|
|
2177
|
+
const restored = this.nodes.get(id);
|
|
2178
|
+
if (restored) this.nodes.set(id, { ...restored, createdBy: cloned.createdBy ?? unknownActor() });
|
|
1454
2179
|
// A restored group takes its children back; the enclosing group's membership is restored verbatim.
|
|
1455
2180
|
for (const cid of released) {
|
|
1456
2181
|
const child = this.nodes.get(cid);
|
|
1457
|
-
if (child) this.
|
|
2182
|
+
if (child) this.setSemanticNode({ ...child, data: { ...child.data, parentGroup: id } });
|
|
1458
2183
|
}
|
|
1459
2184
|
if (enclosingBefore) {
|
|
1460
2185
|
const enclosing = this.nodes.get(enclosingBefore.id);
|
|
1461
2186
|
if (enclosing) {
|
|
1462
|
-
this.
|
|
2187
|
+
this.setSemanticNode({
|
|
1463
2188
|
...enclosing,
|
|
1464
2189
|
data: { ...enclosing.data, children: enclosingBefore.children },
|
|
1465
2190
|
});
|
|
@@ -1620,6 +2345,7 @@ class CanvasStateManager {
|
|
|
1620
2345
|
|
|
1621
2346
|
getLayout(): CanvasLayout {
|
|
1622
2347
|
return {
|
|
2348
|
+
tour: this.getTour(),
|
|
1623
2349
|
viewport: structuredClone(this._viewport),
|
|
1624
2350
|
theme: this._theme,
|
|
1625
2351
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.nodeForRead(node))),
|
|
@@ -1630,6 +2356,7 @@ class CanvasStateManager {
|
|
|
1630
2356
|
|
|
1631
2357
|
getLayoutForPersistence(): CanvasLayout {
|
|
1632
2358
|
return {
|
|
2359
|
+
tour: this.getTour(),
|
|
1633
2360
|
viewport: structuredClone(this._viewport),
|
|
1634
2361
|
theme: this._theme,
|
|
1635
2362
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
|
|
@@ -1638,6 +2365,24 @@ class CanvasStateManager {
|
|
|
1638
2365
|
};
|
|
1639
2366
|
}
|
|
1640
2367
|
|
|
2368
|
+
getTour(): Tour | undefined {
|
|
2369
|
+
return this._tour ? structuredClone(this._tour) : undefined;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
setTour(tour: Tour | null): void {
|
|
2373
|
+
const previous = this.getTour();
|
|
2374
|
+
this._tour = tour === null ? undefined : tourSchema.parse(tour);
|
|
2375
|
+
const next = this.getTour();
|
|
2376
|
+
this.scheduleSave();
|
|
2377
|
+
this.notifyChange('nodes');
|
|
2378
|
+
this.recordMutation({
|
|
2379
|
+
operationType: 'viewport',
|
|
2380
|
+
description: 'Updated board tour',
|
|
2381
|
+
forward: this.suppressed(() => this.setTour(next ?? null)),
|
|
2382
|
+
inverse: this.suppressed(() => this.setTour(previous ?? null)),
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
|
|
1641
2386
|
applyUpdates(updates: CanvasNodeUpdate[], options: ApplyUpdatesOptions = {}): { applied: number; skipped: number } {
|
|
1642
2387
|
let applied = 0;
|
|
1643
2388
|
let skipped = 0;
|
|
@@ -2100,6 +2845,21 @@ class CanvasStateManager {
|
|
|
2100
2845
|
return this.ax.getAxTimelineSummary();
|
|
2101
2846
|
}
|
|
2102
2847
|
|
|
2848
|
+
/** Records one agent context read (diagnostics; never notifies, so a read cannot trigger reads). */
|
|
2849
|
+
recordContextRead(input: ContextReadInput, boardId: string | null = this._activeBoardId): ContextRead | null {
|
|
2850
|
+
if (!this._db) return null;
|
|
2851
|
+
try {
|
|
2852
|
+
return appendContextReadToDB(this._db, input, boardId);
|
|
2853
|
+
} catch (error) {
|
|
2854
|
+
logCanvasStateWarning('record context read failed', error);
|
|
2855
|
+
return null;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
getContextReads(limit?: number): { reads: ContextRead[]; summary: ContextReadConsumerSummary[] } {
|
|
2860
|
+
return this._db ? loadContextReadsFromDB(this._db, limit) : { reads: [], summary: [] };
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2103
2863
|
getAxTimeline(q: AxTimelineQuery = {}): {
|
|
2104
2864
|
events: PmxAxEvent[];
|
|
2105
2865
|
evidence: PmxAxEvidence[];
|
|
@@ -2156,10 +2916,10 @@ class CanvasStateManager {
|
|
|
2156
2916
|
}
|
|
2157
2917
|
|
|
2158
2918
|
// Apply
|
|
2159
|
-
this.
|
|
2919
|
+
this.setSemanticNode({ ...group, data: { ...group.data, children: merged } });
|
|
2160
2920
|
for (const id of validIds) {
|
|
2161
2921
|
const child = this.nodes.get(id)!;
|
|
2162
|
-
this.
|
|
2922
|
+
this.setSemanticNode({ ...child, data: { ...child.data, parentGroup: groupId } });
|
|
2163
2923
|
}
|
|
2164
2924
|
if (options.preservePositions === true) {
|
|
2165
2925
|
if (options.keepGroupFrame !== true && group.data.frameMode !== 'manual') {
|
|
@@ -2182,14 +2942,14 @@ class CanvasStateManager {
|
|
|
2182
2942
|
forward: this.suppressed(() => this.groupNodes(groupId, validIds, options)),
|
|
2183
2943
|
inverse: this.suppressed(() => {
|
|
2184
2944
|
const g = this.nodes.get(groupId);
|
|
2185
|
-
if (g) this.
|
|
2945
|
+
if (g) this.setSemanticNode({ ...g, data: { ...g.data, children: oldChildren } });
|
|
2186
2946
|
for (const [id, oldParent] of oldParents) {
|
|
2187
2947
|
const c = this.nodes.get(id);
|
|
2188
2948
|
if (!c) continue;
|
|
2189
2949
|
const d = { ...c.data };
|
|
2190
2950
|
if (oldParent) d.parentGroup = oldParent;
|
|
2191
2951
|
else delete d.parentGroup;
|
|
2192
|
-
this.
|
|
2952
|
+
this.setSemanticNode({ ...c, data: d });
|
|
2193
2953
|
}
|
|
2194
2954
|
this.scheduleSave();
|
|
2195
2955
|
this.notifyChange('nodes');
|
|
@@ -2225,13 +2985,13 @@ class CanvasStateManager {
|
|
|
2225
2985
|
|
|
2226
2986
|
const snapshot = childIds.slice();
|
|
2227
2987
|
|
|
2228
|
-
this.
|
|
2988
|
+
this.setSemanticNode({ ...group, data: { ...group.data, children: [] } });
|
|
2229
2989
|
for (const id of childIds) {
|
|
2230
2990
|
const child = this.nodes.get(id);
|
|
2231
2991
|
if (!child) continue;
|
|
2232
2992
|
const d = { ...child.data };
|
|
2233
2993
|
delete d.parentGroup;
|
|
2234
|
-
this.
|
|
2994
|
+
this.setSemanticNode({ ...child, data: d });
|
|
2235
2995
|
}
|
|
2236
2996
|
|
|
2237
2997
|
this.scheduleSave();
|
|
@@ -2246,12 +3006,15 @@ class CanvasStateManager {
|
|
|
2246
3006
|
}
|
|
2247
3007
|
|
|
2248
3008
|
clear(): void {
|
|
3009
|
+
const oldTour = this.getTour();
|
|
3010
|
+
this._tour = undefined;
|
|
2249
3011
|
const oldNodes = Array.from(this.nodes.values()).map((n) => structuredClone(n));
|
|
2250
3012
|
const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
|
|
2251
3013
|
const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
|
|
2252
3014
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
2253
3015
|
const oldAxState = this.getAxState();
|
|
2254
3016
|
const oldViewport = { ...this._viewport };
|
|
3017
|
+
for (const node of oldNodes) this.recordDeletion(node.id);
|
|
2255
3018
|
this.nodes.clear();
|
|
2256
3019
|
this.edges.clear();
|
|
2257
3020
|
this.annotations.clear();
|
|
@@ -2274,6 +3037,7 @@ class CanvasStateManager {
|
|
|
2274
3037
|
for (const e of oldEdges) this.addEdge(structuredClone(e));
|
|
2275
3038
|
for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
|
|
2276
3039
|
this.setContextPins(oldPins);
|
|
3040
|
+
this.setTour(oldTour ?? null);
|
|
2277
3041
|
this.ax.applyPersistedAx(oldAxState);
|
|
2278
3042
|
this.setViewport(oldViewport);
|
|
2279
3043
|
this.notifyChange('ax');
|