pmx-canvas 0.6.5 → 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 +54 -0
- package/Readme.md +29 -5
- package/dist/canvas/global.css +440 -24
- package/dist/canvas/index.js +90 -75
- package/dist/types/cli/agent.d.ts +3 -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/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/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- 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/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +6 -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 +63 -9
- package/dist/types/server/canvas-operations.d.ts +23 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +140 -3
- 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/index.d.ts +103 -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/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/nodes.d.ts +1 -1
- 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/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/docs/RELEASE.md +5 -0
- package/docs/cli.md +68 -0
- package/docs/http-api.md +198 -2
- package/docs/mcp.md +71 -6
- package/docs/node-types.md +26 -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 +53 -4
- package/skills/pmx-canvas/references/full-reference.md +43 -7
- 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 +4 -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 +4 -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/view.ts +7 -5
- package/src/cli/index.ts +19 -9
- package/src/client/App.tsx +21 -3
- 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/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/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +34 -18
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/icons.tsx +2 -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/MarkdownNode.tsx +47 -19
- 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/node-factory.ts +1 -0
- package/src/client/state/session-store.ts +5 -0
- package/src/client/state/sse-bridge.ts +49 -5
- package/src/client/theme/global.css +440 -24
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +16 -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/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 +599 -107
- package/src/server/canvas-operations.ts +68 -9
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +12 -0
- package/src/server/canvas-state.ts +774 -45
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +659 -2
- package/src/server/document-import.ts +223 -0
- package/src/server/index.ts +231 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +26 -4
- 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 +5 -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 +8 -1
- 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/imports.ts +220 -0
- package/src/server/operations/ops/nodes.ts +57 -5
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/registry.ts +123 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/server.ts +146 -2
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +2 -1
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
|
@@ -12,10 +12,19 @@
|
|
|
12
12
|
* the one-shot boot migration into SQLite was retired.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { createHash } from 'node:crypto';
|
|
15
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
16
16
|
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
17
|
-
import {
|
|
18
|
-
|
|
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';
|
|
19
28
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
20
29
|
import { normalizeCanvasNodeData } from './canvas-provenance.js';
|
|
21
30
|
import {
|
|
@@ -29,13 +38,41 @@ import {
|
|
|
29
38
|
renameSnapshotInDB,
|
|
30
39
|
writeBlobToDB,
|
|
31
40
|
readBlobFromDB,
|
|
32
|
-
isDbPopulated,
|
|
33
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,
|
|
34
57
|
finalizeCanvasDbForClose,
|
|
35
58
|
type PersistedCanvasState,
|
|
36
59
|
type CanvasTheme,
|
|
37
60
|
type AxTimelineQuery,
|
|
38
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';
|
|
39
76
|
import { normalizeCanvasTheme } from './canvas-db.js';
|
|
40
77
|
import {
|
|
41
78
|
type CanvasPlacementRect,
|
|
@@ -74,6 +111,14 @@ import {
|
|
|
74
111
|
type PmxAxTimelineSummary,
|
|
75
112
|
} from './ax-state.js';
|
|
76
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';
|
|
77
122
|
|
|
78
123
|
function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
79
124
|
console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
|
|
@@ -170,6 +215,7 @@ export interface CanvasNodeState {
|
|
|
170
215
|
| 'webpage'
|
|
171
216
|
| 'json-render'
|
|
172
217
|
| 'graph'
|
|
218
|
+
| 'board'
|
|
173
219
|
| 'prompt'
|
|
174
220
|
| 'response'
|
|
175
221
|
| 'status'
|
|
@@ -188,6 +234,30 @@ export interface CanvasNodeState {
|
|
|
188
234
|
collapsed: boolean;
|
|
189
235
|
pinned: boolean;
|
|
190
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[];
|
|
191
261
|
}
|
|
192
262
|
|
|
193
263
|
export interface ViewportState {
|
|
@@ -241,6 +311,11 @@ export interface CanvasNodeUpdate {
|
|
|
241
311
|
|
|
242
312
|
export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
|
|
243
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
|
+
|
|
244
319
|
export interface MutationRecordInfo {
|
|
245
320
|
operationType:
|
|
246
321
|
| 'addNode'
|
|
@@ -339,6 +414,9 @@ class CanvasStateManager {
|
|
|
339
414
|
private _theme: CanvasTheme = 'dark';
|
|
340
415
|
private _contextPinnedNodeIds = new Set<string>();
|
|
341
416
|
private _workspaceRoot = process.cwd();
|
|
417
|
+
private _contentRevision = 0;
|
|
418
|
+
private _revisionFloor = 0;
|
|
419
|
+
private _deletionTombstones: NodeDeletionTombstone[] = [];
|
|
342
420
|
|
|
343
421
|
// ── AX state (canvas-bound + timeline + host partitions) ──────────
|
|
344
422
|
// Extracted into a dedicated manager (plan-007 Slice A). CanvasStateManager
|
|
@@ -506,12 +584,58 @@ class CanvasStateManager {
|
|
|
506
584
|
}
|
|
507
585
|
|
|
508
586
|
private normalizeNode(node: CanvasNodeState): CanvasNodeState {
|
|
587
|
+
const data = { ...node.data };
|
|
588
|
+
delete data.createdBy;
|
|
589
|
+
delete data.lastEditedBy;
|
|
590
|
+
delete data.contentRevision;
|
|
509
591
|
return {
|
|
510
592
|
...node,
|
|
511
|
-
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,
|
|
512
597
|
};
|
|
513
598
|
}
|
|
514
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)),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
getContentRevision(): BoardContentRevision {
|
|
636
|
+
return { revision: this._contentRevision, retentionFloor: this._revisionFloor };
|
|
637
|
+
}
|
|
638
|
+
|
|
515
639
|
private nodeForRead(node: CanvasNodeState): CanvasNodeState {
|
|
516
640
|
const resolved = this.resolveNodeDataBlobs(node);
|
|
517
641
|
return {
|
|
@@ -640,6 +764,7 @@ class CanvasStateManager {
|
|
|
640
764
|
|
|
641
765
|
// ── Persistence ────────────────────────────────────────────
|
|
642
766
|
private _db: import('bun:sqlite').Database | null = null;
|
|
767
|
+
private _dbPath: string | null = null;
|
|
643
768
|
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
644
769
|
|
|
645
770
|
/** Workspace root backing persistence and workspace-relative path resolution. */
|
|
@@ -677,7 +802,8 @@ class CanvasStateManager {
|
|
|
677
802
|
// 0.4.0 — the one-shot boot migration that copied them into SQLite and
|
|
678
803
|
// renamed the originals to `.bak` was retired.
|
|
679
804
|
try {
|
|
680
|
-
this._db = openCanvasDb(dbPath);
|
|
805
|
+
this._db = openCanvasDb(dbPath, { migratedBoardName: basename(workspaceRoot) || 'Board' });
|
|
806
|
+
this._dbPath = dbPath;
|
|
681
807
|
} catch (error) {
|
|
682
808
|
logCanvasStateWarning('open canvas database failed', error, { dbPath });
|
|
683
809
|
}
|
|
@@ -789,7 +915,7 @@ class CanvasStateManager {
|
|
|
789
915
|
}
|
|
790
916
|
|
|
791
917
|
private externalizeNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
|
|
792
|
-
if (node.type !== 'mcp-app') return node;
|
|
918
|
+
if (node.type !== 'mcp-app' && node.type !== 'html') return node;
|
|
793
919
|
let changed = false;
|
|
794
920
|
const data = { ...node.data };
|
|
795
921
|
for (const [key, value] of Object.entries(data)) {
|
|
@@ -803,7 +929,7 @@ class CanvasStateManager {
|
|
|
803
929
|
}
|
|
804
930
|
|
|
805
931
|
private resolveNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
|
|
806
|
-
if (node.type !== 'mcp-app') return node;
|
|
932
|
+
if (node.type !== 'mcp-app' && node.type !== 'html') return node;
|
|
807
933
|
let changed = false;
|
|
808
934
|
const data = { ...node.data };
|
|
809
935
|
for (const [key, value] of Object.entries(data)) {
|
|
@@ -843,22 +969,27 @@ class CanvasStateManager {
|
|
|
843
969
|
annotations: [],
|
|
844
970
|
contextPins: [],
|
|
845
971
|
ax: createEmptyAxState(),
|
|
972
|
+
revisionState: { revision: 0, floor: 0, tombstones: [] },
|
|
846
973
|
};
|
|
847
974
|
}
|
|
848
975
|
|
|
849
|
-
/**
|
|
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
|
+
*/
|
|
850
980
|
loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
|
|
851
981
|
// Host capability lives in its own table (not snapshotted / not in PmxAxState).
|
|
852
982
|
this.ax.loadHostCapabilityFromDb();
|
|
853
|
-
|
|
854
|
-
// retired with the 0.4.0 boot-migration removal).
|
|
855
|
-
if (this._db && isDbPopulated(this._db)) {
|
|
983
|
+
if (this._db) {
|
|
856
984
|
try {
|
|
857
|
-
const
|
|
858
|
-
|
|
985
|
+
const boardId = getActiveBoardIdFromDB(this._db);
|
|
986
|
+
const state = boardId ? loadStateFromDB(this._db, boardId) : null;
|
|
987
|
+
if (boardId && state) {
|
|
859
988
|
this.applyPersistedState(state);
|
|
989
|
+
this._activeBoardId = boardId;
|
|
860
990
|
return true;
|
|
861
991
|
}
|
|
992
|
+
this._theme = normalizeCanvasTheme(readThemeFromDB(this._db), this._theme);
|
|
862
993
|
} catch (error) {
|
|
863
994
|
logCanvasStateWarning('load state from sqlite failed', error, {});
|
|
864
995
|
}
|
|
@@ -870,6 +1001,434 @@ class CanvasStateManager {
|
|
|
870
1001
|
return false;
|
|
871
1002
|
}
|
|
872
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
|
+
|
|
873
1432
|
/** Debounced save — coalesces rapid mutations into a single write. */
|
|
874
1433
|
private scheduleSave(): void {
|
|
875
1434
|
if (!this._db) return;
|
|
@@ -881,24 +1440,35 @@ class CanvasStateManager {
|
|
|
881
1440
|
}
|
|
882
1441
|
|
|
883
1442
|
flushToDisk(): void {
|
|
1443
|
+
if (!this._db) return;
|
|
884
1444
|
if (this._saveTimer) {
|
|
885
1445
|
clearTimeout(this._saveTimer);
|
|
886
1446
|
this._saveTimer = null;
|
|
887
1447
|
}
|
|
888
|
-
this.saveToDisk()
|
|
1448
|
+
if (!this.saveToDisk()) {
|
|
1449
|
+
throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
|
|
1450
|
+
}
|
|
889
1451
|
if (this._db) {
|
|
890
1452
|
try {
|
|
891
1453
|
checkpointCanvasDb(this._db);
|
|
892
1454
|
} catch (error) {
|
|
893
1455
|
logCanvasStateWarning('checkpoint database failed', error, {});
|
|
1456
|
+
throw error;
|
|
894
1457
|
}
|
|
895
1458
|
}
|
|
896
1459
|
}
|
|
897
1460
|
|
|
898
1461
|
/** Write current state to SQLite immediately. */
|
|
899
|
-
private saveToDisk():
|
|
900
|
-
if (!this._db) return;
|
|
1462
|
+
private saveToDisk(): boolean {
|
|
1463
|
+
if (!this._db) return false;
|
|
901
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;
|
|
902
1472
|
const payload = this.externalizePersistedStateBlobs({
|
|
903
1473
|
version: 1,
|
|
904
1474
|
theme: this._theme,
|
|
@@ -909,9 +1479,11 @@ class CanvasStateManager {
|
|
|
909
1479
|
annotations: Array.from(this.annotations.values()),
|
|
910
1480
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
911
1481
|
ax: this.getAxState(),
|
|
1482
|
+
revisionState: this.revisionState(),
|
|
912
1483
|
});
|
|
913
|
-
saveStateToDB(this._db, payload);
|
|
1484
|
+
saveStateToDB(this._db, boardId, payload);
|
|
914
1485
|
this._lastPersistenceError = null;
|
|
1486
|
+
return true;
|
|
915
1487
|
} catch (error) {
|
|
916
1488
|
// Persistence failures are otherwise warn-and-continue (the canvas keeps
|
|
917
1489
|
// working from memory) — record the failure so /health can report a
|
|
@@ -921,6 +1493,7 @@ class CanvasStateManager {
|
|
|
921
1493
|
at: new Date().toISOString(),
|
|
922
1494
|
};
|
|
923
1495
|
logCanvasStateWarning('save state to sqlite failed', error, {});
|
|
1496
|
+
return false;
|
|
924
1497
|
}
|
|
925
1498
|
}
|
|
926
1499
|
|
|
@@ -933,11 +1506,9 @@ class CanvasStateManager {
|
|
|
933
1506
|
|
|
934
1507
|
/** Close the SQLite database cleanly. Call on server shutdown. */
|
|
935
1508
|
close(): void {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
this.saveToDisk();
|
|
940
|
-
}
|
|
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();
|
|
941
1512
|
if (this._db) {
|
|
942
1513
|
try {
|
|
943
1514
|
finalizeCanvasDbForClose(this._db);
|
|
@@ -951,6 +1522,8 @@ class CanvasStateManager {
|
|
|
951
1522
|
}
|
|
952
1523
|
this._db = null;
|
|
953
1524
|
}
|
|
1525
|
+
this._activeBoardId = null;
|
|
1526
|
+
this._boardGeneration += 1;
|
|
954
1527
|
}
|
|
955
1528
|
|
|
956
1529
|
// ── Snapshots ───────────────────────────────────────────────
|
|
@@ -961,12 +1534,16 @@ class CanvasStateManager {
|
|
|
961
1534
|
}
|
|
962
1535
|
|
|
963
1536
|
private applyPersistedState(state: PersistedCanvasState): void {
|
|
1537
|
+
this._boardGeneration += 1;
|
|
964
1538
|
this._tour = state.tour ? structuredClone(state.tour) : undefined;
|
|
965
1539
|
this.nodes.clear();
|
|
966
1540
|
this.edges.clear();
|
|
967
1541
|
this.annotations.clear();
|
|
968
1542
|
this._contextPinnedNodeIds.clear();
|
|
969
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 ?? []);
|
|
970
1547
|
|
|
971
1548
|
this._viewport = {
|
|
972
1549
|
x: state.viewport?.x ?? 0,
|
|
@@ -1004,10 +1581,16 @@ class CanvasStateManager {
|
|
|
1004
1581
|
snapshot: CanvasSnapshot;
|
|
1005
1582
|
state: PersistedCanvasState;
|
|
1006
1583
|
} | null {
|
|
1584
|
+
if (!this._activeBoardId) return null;
|
|
1585
|
+
|
|
1007
1586
|
// Try SQLite first
|
|
1008
1587
|
if (this._db) {
|
|
1009
|
-
const result = loadSnapshotFromDB(this._db, idOrName);
|
|
1588
|
+
const result = loadSnapshotFromDB(this._db, idOrName, this._activeBoardId);
|
|
1010
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;
|
|
1011
1594
|
}
|
|
1012
1595
|
|
|
1013
1596
|
// Fallback to filesystem (legacy on-disk snapshots are still readable in place)
|
|
@@ -1095,8 +1678,11 @@ class CanvasStateManager {
|
|
|
1095
1678
|
annotations: Array.from(this.annotations.values()),
|
|
1096
1679
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1097
1680
|
ax: this.getAxState(),
|
|
1681
|
+
revisionState: this.revisionState(),
|
|
1098
1682
|
});
|
|
1099
|
-
|
|
1683
|
+
const boardId = this.ensureActiveBoard();
|
|
1684
|
+
if (!boardId) return null;
|
|
1685
|
+
saveSnapshotToDB(this._db, boardId, snapshot, { ...payload, tour: this._tour });
|
|
1100
1686
|
snapshot.nodeCount = payload.nodes.length;
|
|
1101
1687
|
snapshot.edgeCount = payload.edges.length;
|
|
1102
1688
|
return snapshot;
|
|
@@ -1110,7 +1696,7 @@ class CanvasStateManager {
|
|
|
1110
1696
|
listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
|
|
1111
1697
|
if (this._db) {
|
|
1112
1698
|
try {
|
|
1113
|
-
return listSnapshotsFromDB(this._db, options);
|
|
1699
|
+
return this._activeBoardId ? listSnapshotsFromDB(this._db, this._activeBoardId, options) : [];
|
|
1114
1700
|
} catch (error) {
|
|
1115
1701
|
logCanvasStateWarning('list snapshots from db failed', error, {});
|
|
1116
1702
|
}
|
|
@@ -1189,6 +1775,7 @@ class CanvasStateManager {
|
|
|
1189
1775
|
annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
|
|
1190
1776
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1191
1777
|
ax: this.getAxState(),
|
|
1778
|
+
revisionState: this.revisionState(),
|
|
1192
1779
|
});
|
|
1193
1780
|
const nextState: PersistedCanvasState = {
|
|
1194
1781
|
version: 1,
|
|
@@ -1202,10 +1789,13 @@ class CanvasStateManager {
|
|
|
1202
1789
|
: [],
|
|
1203
1790
|
contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
|
|
1204
1791
|
ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
|
|
1792
|
+
revisionState: resolved.state.revisionState ? structuredClone(resolved.state.revisionState) : undefined,
|
|
1205
1793
|
};
|
|
1206
1794
|
|
|
1207
1795
|
try {
|
|
1208
|
-
this.
|
|
1796
|
+
const revisionBeforeRestore = this._contentRevision;
|
|
1797
|
+
this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
|
|
1798
|
+
this.restampRestoredContent(previousState.nodes, revisionBeforeRestore);
|
|
1209
1799
|
this.scheduleSave();
|
|
1210
1800
|
this.notifyChange('nodes');
|
|
1211
1801
|
this.notifyChange('pins');
|
|
@@ -1214,14 +1804,20 @@ class CanvasStateManager {
|
|
|
1214
1804
|
operationType: 'restoreSnapshot',
|
|
1215
1805
|
description: `Restored snapshot "${resolved.snapshot.name}"`,
|
|
1216
1806
|
forward: this.suppressed(() => {
|
|
1217
|
-
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);
|
|
1218
1811
|
this.scheduleSave();
|
|
1219
1812
|
this.notifyChange('nodes');
|
|
1220
1813
|
this.notifyChange('pins');
|
|
1221
1814
|
this.notifyChange('ax');
|
|
1222
1815
|
}),
|
|
1223
1816
|
inverse: this.suppressed(() => {
|
|
1224
|
-
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);
|
|
1225
1821
|
this.scheduleSave();
|
|
1226
1822
|
this.notifyChange('nodes');
|
|
1227
1823
|
this.notifyChange('pins');
|
|
@@ -1268,9 +1864,9 @@ class CanvasStateManager {
|
|
|
1268
1864
|
|
|
1269
1865
|
/** Delete a snapshot. */
|
|
1270
1866
|
renameSnapshot(id: string, name: string): boolean {
|
|
1271
|
-
if (!this._db) return false;
|
|
1867
|
+
if (!this._db || !this._activeBoardId) return false;
|
|
1272
1868
|
try {
|
|
1273
|
-
return renameSnapshotInDB(this._db, id, name);
|
|
1869
|
+
return renameSnapshotInDB(this._db, this._activeBoardId, id, name);
|
|
1274
1870
|
} catch (error) {
|
|
1275
1871
|
logCanvasStateWarning('rename snapshot failed', error, { id, name });
|
|
1276
1872
|
return false;
|
|
@@ -1278,12 +1874,20 @@ class CanvasStateManager {
|
|
|
1278
1874
|
}
|
|
1279
1875
|
|
|
1280
1876
|
deleteSnapshot(id: string): boolean {
|
|
1877
|
+
if (!this._activeBoardId) return false;
|
|
1281
1878
|
// Try SQLite first
|
|
1282
1879
|
if (this._db) {
|
|
1283
1880
|
try {
|
|
1284
|
-
|
|
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
|
+
}
|
|
1285
1888
|
} catch (error) {
|
|
1286
1889
|
logCanvasStateWarning('delete snapshot from db failed', error, { id });
|
|
1890
|
+
return false;
|
|
1287
1891
|
}
|
|
1288
1892
|
}
|
|
1289
1893
|
|
|
@@ -1320,12 +1924,77 @@ class CanvasStateManager {
|
|
|
1320
1924
|
|
|
1321
1925
|
// ── Node CRUD ──────────────────────────────────────────────
|
|
1322
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
|
+
|
|
1323
1984
|
get viewport(): ViewportState {
|
|
1324
1985
|
return structuredClone(this._viewport);
|
|
1325
1986
|
}
|
|
1326
1987
|
|
|
1327
1988
|
addNode(node: CanvasNodeState): void {
|
|
1328
|
-
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
|
+
);
|
|
1329
1998
|
this.nodes.set(node.id, cloned);
|
|
1330
1999
|
this.scheduleSave();
|
|
1331
2000
|
this.notifyChange('nodes');
|
|
@@ -1348,11 +2017,43 @@ class CanvasStateManager {
|
|
|
1348
2017
|
updateNode(id: string, patch: Partial<CanvasNodeState>): void {
|
|
1349
2018
|
const existing = this.nodes.get(id);
|
|
1350
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
|
+
}
|
|
1351
2032
|
const oldSnapshot = structuredClone(existing);
|
|
1352
2033
|
if (existing.type === 'group' && patch.position) {
|
|
1353
2034
|
this.translateGroupChildren(id, patch.position.x - existing.position.x, patch.position.y - existing.position.y);
|
|
1354
2035
|
}
|
|
1355
|
-
|
|
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
|
+
}
|
|
1356
2057
|
this.nodes.set(id, nextNode);
|
|
1357
2058
|
const parentGroupId = existing.data.parentGroup as string | undefined;
|
|
1358
2059
|
if (parentGroupId) {
|
|
@@ -1370,7 +2071,16 @@ class CanvasStateManager {
|
|
|
1370
2071
|
description: `Updated node "${(existing.data.title as string) ?? id}"`,
|
|
1371
2072
|
forward: this.suppressed(() => this.updateNode(id, structuredClone(patch))),
|
|
1372
2073
|
inverse: this.suppressed(() => {
|
|
1373
|
-
|
|
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);
|
|
1374
2084
|
this.scheduleSave();
|
|
1375
2085
|
this.notifyChange('nodes');
|
|
1376
2086
|
}),
|
|
@@ -1391,6 +2101,7 @@ class CanvasStateManager {
|
|
|
1391
2101
|
let released: string[] = [];
|
|
1392
2102
|
|
|
1393
2103
|
if (existing) {
|
|
2104
|
+
this.recordDeletion(id);
|
|
1394
2105
|
const parentGroupId = existing.data.parentGroup as string | undefined;
|
|
1395
2106
|
const parent = parentGroupId ? this.nodes.get(parentGroupId) : undefined;
|
|
1396
2107
|
const enclosing = parent && parent.type === 'group' ? parent : null;
|
|
@@ -1403,13 +2114,13 @@ class CanvasStateManager {
|
|
|
1403
2114
|
const d = { ...child.data };
|
|
1404
2115
|
if (enclosing) d.parentGroup = enclosing.id;
|
|
1405
2116
|
else delete d.parentGroup;
|
|
1406
|
-
this.
|
|
2117
|
+
this.setSemanticNode({ ...child, data: d });
|
|
1407
2118
|
}
|
|
1408
2119
|
}
|
|
1409
2120
|
if (enclosing && enclosingBefore) {
|
|
1410
2121
|
// The removed node leaves the enclosing group; a dissolved group's children take its place.
|
|
1411
2122
|
const children = enclosingBefore.children.flatMap((cid) => (cid === id ? released : [cid]));
|
|
1412
|
-
this.
|
|
2123
|
+
this.setSemanticNode({ ...enclosing, data: { ...enclosing.data, children } });
|
|
1413
2124
|
}
|
|
1414
2125
|
}
|
|
1415
2126
|
|
|
@@ -1463,15 +2174,17 @@ class CanvasStateManager {
|
|
|
1463
2174
|
forward: this.suppressed(() => this.removeNode(id)),
|
|
1464
2175
|
inverse: this.suppressed(() => {
|
|
1465
2176
|
this.addNode(structuredClone(cloned));
|
|
2177
|
+
const restored = this.nodes.get(id);
|
|
2178
|
+
if (restored) this.nodes.set(id, { ...restored, createdBy: cloned.createdBy ?? unknownActor() });
|
|
1466
2179
|
// A restored group takes its children back; the enclosing group's membership is restored verbatim.
|
|
1467
2180
|
for (const cid of released) {
|
|
1468
2181
|
const child = this.nodes.get(cid);
|
|
1469
|
-
if (child) this.
|
|
2182
|
+
if (child) this.setSemanticNode({ ...child, data: { ...child.data, parentGroup: id } });
|
|
1470
2183
|
}
|
|
1471
2184
|
if (enclosingBefore) {
|
|
1472
2185
|
const enclosing = this.nodes.get(enclosingBefore.id);
|
|
1473
2186
|
if (enclosing) {
|
|
1474
|
-
this.
|
|
2187
|
+
this.setSemanticNode({
|
|
1475
2188
|
...enclosing,
|
|
1476
2189
|
data: { ...enclosing.data, children: enclosingBefore.children },
|
|
1477
2190
|
});
|
|
@@ -2132,6 +2845,21 @@ class CanvasStateManager {
|
|
|
2132
2845
|
return this.ax.getAxTimelineSummary();
|
|
2133
2846
|
}
|
|
2134
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
|
+
|
|
2135
2863
|
getAxTimeline(q: AxTimelineQuery = {}): {
|
|
2136
2864
|
events: PmxAxEvent[];
|
|
2137
2865
|
evidence: PmxAxEvidence[];
|
|
@@ -2188,10 +2916,10 @@ class CanvasStateManager {
|
|
|
2188
2916
|
}
|
|
2189
2917
|
|
|
2190
2918
|
// Apply
|
|
2191
|
-
this.
|
|
2919
|
+
this.setSemanticNode({ ...group, data: { ...group.data, children: merged } });
|
|
2192
2920
|
for (const id of validIds) {
|
|
2193
2921
|
const child = this.nodes.get(id)!;
|
|
2194
|
-
this.
|
|
2922
|
+
this.setSemanticNode({ ...child, data: { ...child.data, parentGroup: groupId } });
|
|
2195
2923
|
}
|
|
2196
2924
|
if (options.preservePositions === true) {
|
|
2197
2925
|
if (options.keepGroupFrame !== true && group.data.frameMode !== 'manual') {
|
|
@@ -2214,14 +2942,14 @@ class CanvasStateManager {
|
|
|
2214
2942
|
forward: this.suppressed(() => this.groupNodes(groupId, validIds, options)),
|
|
2215
2943
|
inverse: this.suppressed(() => {
|
|
2216
2944
|
const g = this.nodes.get(groupId);
|
|
2217
|
-
if (g) this.
|
|
2945
|
+
if (g) this.setSemanticNode({ ...g, data: { ...g.data, children: oldChildren } });
|
|
2218
2946
|
for (const [id, oldParent] of oldParents) {
|
|
2219
2947
|
const c = this.nodes.get(id);
|
|
2220
2948
|
if (!c) continue;
|
|
2221
2949
|
const d = { ...c.data };
|
|
2222
2950
|
if (oldParent) d.parentGroup = oldParent;
|
|
2223
2951
|
else delete d.parentGroup;
|
|
2224
|
-
this.
|
|
2952
|
+
this.setSemanticNode({ ...c, data: d });
|
|
2225
2953
|
}
|
|
2226
2954
|
this.scheduleSave();
|
|
2227
2955
|
this.notifyChange('nodes');
|
|
@@ -2257,13 +2985,13 @@ class CanvasStateManager {
|
|
|
2257
2985
|
|
|
2258
2986
|
const snapshot = childIds.slice();
|
|
2259
2987
|
|
|
2260
|
-
this.
|
|
2988
|
+
this.setSemanticNode({ ...group, data: { ...group.data, children: [] } });
|
|
2261
2989
|
for (const id of childIds) {
|
|
2262
2990
|
const child = this.nodes.get(id);
|
|
2263
2991
|
if (!child) continue;
|
|
2264
2992
|
const d = { ...child.data };
|
|
2265
2993
|
delete d.parentGroup;
|
|
2266
|
-
this.
|
|
2994
|
+
this.setSemanticNode({ ...child, data: d });
|
|
2267
2995
|
}
|
|
2268
2996
|
|
|
2269
2997
|
this.scheduleSave();
|
|
@@ -2286,6 +3014,7 @@ class CanvasStateManager {
|
|
|
2286
3014
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
2287
3015
|
const oldAxState = this.getAxState();
|
|
2288
3016
|
const oldViewport = { ...this._viewport };
|
|
3017
|
+
for (const node of oldNodes) this.recordDeletion(node.id);
|
|
2289
3018
|
this.nodes.clear();
|
|
2290
3019
|
this.edges.clear();
|
|
2291
3020
|
this.annotations.clear();
|