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
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
type PmxAxTimelineSummary,
|
|
85
85
|
} from './ax-state.js';
|
|
86
86
|
import type { CanvasChangeType, MutationRecordInfo } from './canvas-state.js';
|
|
87
|
+
import { currentActor, type ActorAttribution } from './attribution.js';
|
|
87
88
|
|
|
88
89
|
type Database = import('bun:sqlite').Database;
|
|
89
90
|
|
|
@@ -115,6 +116,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
115
116
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
function resolutionActor(source?: PmxAxSource): ActorAttribution {
|
|
120
|
+
if (source === 'system') return { actor: 'system', source: 'system' };
|
|
121
|
+
const actor = currentActor(source ?? 'sdk');
|
|
122
|
+
return source && actor.actor === 'agent' ? { ...actor, source } : actor;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function selfAnswered(requester: ActorAttribution | undefined, answerer: ActorAttribution): boolean {
|
|
126
|
+
return (
|
|
127
|
+
requester?.actor === 'agent' &&
|
|
128
|
+
answerer.actor === 'agent' &&
|
|
129
|
+
Boolean(requester.agentId) &&
|
|
130
|
+
requester.agentId === answerer.agentId
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
function logAxStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
119
135
|
console.warn(`[ax-state] ${action}`, { error, ...(details ?? {}) });
|
|
120
136
|
}
|
|
@@ -342,7 +358,12 @@ export class AxStateManager {
|
|
|
342
358
|
options: { source?: PmxAxSource } = {},
|
|
343
359
|
): PmxAxApprovalGate {
|
|
344
360
|
const oldAxState = this.getAxState();
|
|
345
|
-
const gate = createAxApprovalGate(
|
|
361
|
+
const gate = createAxApprovalGate(
|
|
362
|
+
input,
|
|
363
|
+
options.source ?? 'api',
|
|
364
|
+
this.deps.getNodeIds(),
|
|
365
|
+
resolutionActor(options.source),
|
|
366
|
+
);
|
|
346
367
|
this.applyAxState({ ...oldAxState, approvalGates: [...oldAxState.approvalGates, gate] });
|
|
347
368
|
const applied = this.getAxState();
|
|
348
369
|
this.deps.scheduleSave();
|
|
@@ -372,12 +393,15 @@ export class AxStateManager {
|
|
|
372
393
|
const oldAxState = this.getAxState();
|
|
373
394
|
const gate = oldAxState.approvalGates.find((g) => g.id === id);
|
|
374
395
|
if (!gate || gate.status !== 'pending') return null;
|
|
396
|
+
const resolvedBy = resolutionActor(options.source);
|
|
375
397
|
const resolved: PmxAxApprovalGate = {
|
|
376
398
|
...gate,
|
|
377
399
|
status: decision,
|
|
378
400
|
resolvedAt: new Date().toISOString(),
|
|
379
401
|
resolution: options.resolution ?? null,
|
|
380
402
|
source: options.source ?? gate.source,
|
|
403
|
+
resolvedBy,
|
|
404
|
+
selfAnswer: selfAnswered(gate.requestedBy, resolvedBy),
|
|
381
405
|
};
|
|
382
406
|
this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, resolved) });
|
|
383
407
|
const applied = this.getAxState();
|
|
@@ -397,6 +421,15 @@ export class AxStateManager {
|
|
|
397
421
|
this.deps.notifyChange('ax');
|
|
398
422
|
}),
|
|
399
423
|
});
|
|
424
|
+
this.recordAxEvent(
|
|
425
|
+
{
|
|
426
|
+
kind: 'approval',
|
|
427
|
+
summary: `${decision === 'approved' ? 'Approved' : decision === 'rejected' ? 'Rejected' : 'Held'} "${gate.title}"`,
|
|
428
|
+
nodeIds: gate.nodeIds,
|
|
429
|
+
data: { gateId: id, decision, resolvedBy, selfAnswer: resolved.selfAnswer },
|
|
430
|
+
},
|
|
431
|
+
{ source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
|
|
432
|
+
);
|
|
400
433
|
return applied.approvalGates.find((g) => g.id === id) ?? null;
|
|
401
434
|
}
|
|
402
435
|
|
|
@@ -415,6 +448,8 @@ export class AxStateManager {
|
|
|
415
448
|
resolvedAt: null,
|
|
416
449
|
resolution: null,
|
|
417
450
|
source: options.source ?? gate.source,
|
|
451
|
+
resolvedBy: null,
|
|
452
|
+
selfAnswer: false,
|
|
418
453
|
};
|
|
419
454
|
this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, reopened) });
|
|
420
455
|
const applied = this.getAxState();
|
|
@@ -545,7 +580,12 @@ export class AxStateManager {
|
|
|
545
580
|
options: { source?: PmxAxSource } = {},
|
|
546
581
|
): PmxAxElicitation {
|
|
547
582
|
const oldAxState = this.getAxState();
|
|
548
|
-
const elicitation = createAxElicitation(
|
|
583
|
+
const elicitation = createAxElicitation(
|
|
584
|
+
input,
|
|
585
|
+
options.source ?? 'api',
|
|
586
|
+
this.deps.getNodeIds(),
|
|
587
|
+
resolutionActor(options.source),
|
|
588
|
+
);
|
|
549
589
|
this.applyAxState({ ...oldAxState, elicitations: [...oldAxState.elicitations, elicitation] });
|
|
550
590
|
const applied = this.getAxState();
|
|
551
591
|
this.deps.scheduleSave();
|
|
@@ -575,12 +615,15 @@ export class AxStateManager {
|
|
|
575
615
|
const oldAxState = this.getAxState();
|
|
576
616
|
const existing = oldAxState.elicitations.find((e) => e.id === id);
|
|
577
617
|
if (!existing || existing.status !== 'pending') return null;
|
|
618
|
+
const resolvedBy = resolutionActor(options.source);
|
|
578
619
|
const merged: PmxAxElicitation = {
|
|
579
620
|
...existing,
|
|
580
621
|
status: 'answered',
|
|
581
622
|
response,
|
|
582
623
|
resolvedAt: new Date().toISOString(),
|
|
583
624
|
source: options.source ?? existing.source,
|
|
625
|
+
resolvedBy,
|
|
626
|
+
selfAnswer: selfAnswered(existing.requestedBy, resolvedBy),
|
|
584
627
|
};
|
|
585
628
|
this.applyAxState({ ...oldAxState, elicitations: replaceById(oldAxState.elicitations, merged) });
|
|
586
629
|
const applied = this.getAxState();
|
|
@@ -600,6 +643,15 @@ export class AxStateManager {
|
|
|
600
643
|
this.deps.notifyChange('ax');
|
|
601
644
|
}),
|
|
602
645
|
});
|
|
646
|
+
this.recordAxEvent(
|
|
647
|
+
{
|
|
648
|
+
kind: 'approval',
|
|
649
|
+
summary: `Answered elicitation "${existing.prompt}"`,
|
|
650
|
+
nodeIds: existing.nodeIds,
|
|
651
|
+
data: { elicitationId: id, resolvedBy, selfAnswer: merged.selfAnswer },
|
|
652
|
+
},
|
|
653
|
+
{ source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
|
|
654
|
+
);
|
|
603
655
|
return applied.elicitations.find((e) => e.id === id) ?? null;
|
|
604
656
|
}
|
|
605
657
|
|
|
@@ -613,7 +665,12 @@ export class AxStateManager {
|
|
|
613
665
|
options: { source?: PmxAxSource } = {},
|
|
614
666
|
): PmxAxModeRequest {
|
|
615
667
|
const oldAxState = this.getAxState();
|
|
616
|
-
const request = createAxModeRequest(
|
|
668
|
+
const request = createAxModeRequest(
|
|
669
|
+
input,
|
|
670
|
+
options.source ?? 'api',
|
|
671
|
+
this.deps.getNodeIds(),
|
|
672
|
+
resolutionActor(options.source),
|
|
673
|
+
);
|
|
617
674
|
this.applyAxState({ ...oldAxState, modeRequests: [...oldAxState.modeRequests, request] });
|
|
618
675
|
const applied = this.getAxState();
|
|
619
676
|
this.deps.scheduleSave();
|
|
@@ -643,12 +700,15 @@ export class AxStateManager {
|
|
|
643
700
|
const oldAxState = this.getAxState();
|
|
644
701
|
const existing = oldAxState.modeRequests.find((m) => m.id === id);
|
|
645
702
|
if (!existing || existing.status !== 'pending') return null;
|
|
703
|
+
const resolvedBy = resolutionActor(options.source);
|
|
646
704
|
const merged: PmxAxModeRequest = {
|
|
647
705
|
...existing,
|
|
648
706
|
status: decision,
|
|
649
707
|
resolvedAt: new Date().toISOString(),
|
|
650
708
|
resolution: options.resolution ?? null,
|
|
651
709
|
source: options.source ?? existing.source,
|
|
710
|
+
resolvedBy,
|
|
711
|
+
selfAnswer: selfAnswered(existing.requestedBy, resolvedBy),
|
|
652
712
|
};
|
|
653
713
|
this.applyAxState({ ...oldAxState, modeRequests: replaceById(oldAxState.modeRequests, merged) });
|
|
654
714
|
const applied = this.getAxState();
|
|
@@ -668,6 +728,15 @@ export class AxStateManager {
|
|
|
668
728
|
this.deps.notifyChange('ax');
|
|
669
729
|
}),
|
|
670
730
|
});
|
|
731
|
+
this.recordAxEvent(
|
|
732
|
+
{
|
|
733
|
+
kind: 'approval',
|
|
734
|
+
summary: `${decision === 'approved' ? 'Approved' : 'Rejected'} ${existing.mode} mode request`,
|
|
735
|
+
nodeIds: existing.nodeIds,
|
|
736
|
+
data: { modeRequestId: id, decision, resolvedBy, selfAnswer: merged.selfAnswer },
|
|
737
|
+
},
|
|
738
|
+
{ source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
|
|
739
|
+
);
|
|
671
740
|
return applied.modeRequests.find((m) => m.id === id) ?? null;
|
|
672
741
|
}
|
|
673
742
|
|
package/src/server/ax-state.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
|
|
|
2
2
|
import { clampGateTtlMs, DEFAULT_GATE_TTL_MS } from '../shared/approval-gates.js';
|
|
3
3
|
import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../shared/ax-kinds.js';
|
|
4
4
|
import type { AgentContextNode } from './agent-context.js';
|
|
5
|
+
import type { ActorAttribution } from './attribution.js';
|
|
5
6
|
|
|
6
7
|
export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
|
|
7
8
|
|
|
@@ -48,6 +49,9 @@ export interface PmxAxApprovalGate {
|
|
|
48
49
|
resolvedAt: string | null;
|
|
49
50
|
resolution: string | null;
|
|
50
51
|
source: PmxAxSource | null;
|
|
52
|
+
requestedBy?: ActorAttribution;
|
|
53
|
+
resolvedBy?: ActorAttribution | null;
|
|
54
|
+
selfAnswer?: boolean;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
export interface PmxAxReviewRegion {
|
|
@@ -301,6 +305,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
301
305
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
302
306
|
}
|
|
303
307
|
|
|
308
|
+
function normalizeAttribution(value: unknown): ActorAttribution {
|
|
309
|
+
if (!isRecord(value)) return { actor: 'unknown', source: 'unknown' };
|
|
310
|
+
const actor = value.actor;
|
|
311
|
+
return {
|
|
312
|
+
actor: actor === 'human' || actor === 'agent' || actor === 'system' ? actor : 'unknown',
|
|
313
|
+
source: typeof value.source === 'string' ? value.source : 'unknown',
|
|
314
|
+
...(typeof value.agentId === 'string' ? { agentId: value.agentId } : {}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
304
318
|
function normalizeSource(value: unknown): PmxAxSource | null {
|
|
305
319
|
return typeof value === 'string' && AX_SOURCES.has(value as PmxAxSource) ? (value as PmxAxSource) : null;
|
|
306
320
|
}
|
|
@@ -548,6 +562,9 @@ export interface PmxAxElicitation {
|
|
|
548
562
|
createdAt: string;
|
|
549
563
|
resolvedAt: string | null;
|
|
550
564
|
source: PmxAxSource | null;
|
|
565
|
+
requestedBy?: ActorAttribution;
|
|
566
|
+
resolvedBy?: ActorAttribution | null;
|
|
567
|
+
selfAnswer?: boolean;
|
|
551
568
|
}
|
|
552
569
|
|
|
553
570
|
export function normalizeAxElicitation(input: unknown, validNodeIds?: Set<string>): PmxAxElicitation | null {
|
|
@@ -564,6 +581,9 @@ export function normalizeAxElicitation(input: unknown, validNodeIds?: Set<string
|
|
|
564
581
|
createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
|
|
565
582
|
resolvedAt: normalizeTimestamp(input.resolvedAt),
|
|
566
583
|
source: normalizeSource(input.source),
|
|
584
|
+
requestedBy: normalizeAttribution(input.requestedBy),
|
|
585
|
+
resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
|
|
586
|
+
selfAnswer: input.selfAnswer === true,
|
|
567
587
|
};
|
|
568
588
|
}
|
|
569
589
|
|
|
@@ -571,6 +591,7 @@ export function createAxElicitation(
|
|
|
571
591
|
input: { prompt: string; fields?: string[]; nodeIds?: string[] },
|
|
572
592
|
source: PmxAxSource | null,
|
|
573
593
|
validNodeIds?: Set<string>,
|
|
594
|
+
requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
|
|
574
595
|
): PmxAxElicitation {
|
|
575
596
|
return {
|
|
576
597
|
id: axId('elic'),
|
|
@@ -582,6 +603,9 @@ export function createAxElicitation(
|
|
|
582
603
|
createdAt: nowIso(),
|
|
583
604
|
resolvedAt: null,
|
|
584
605
|
source,
|
|
606
|
+
requestedBy,
|
|
607
|
+
resolvedBy: null,
|
|
608
|
+
selfAnswer: false,
|
|
585
609
|
};
|
|
586
610
|
}
|
|
587
611
|
|
|
@@ -602,6 +626,9 @@ export interface PmxAxModeRequest {
|
|
|
602
626
|
resolvedAt: string | null;
|
|
603
627
|
resolution: string | null;
|
|
604
628
|
source: PmxAxSource | null;
|
|
629
|
+
requestedBy?: ActorAttribution;
|
|
630
|
+
resolvedBy?: ActorAttribution | null;
|
|
631
|
+
selfAnswer?: boolean;
|
|
605
632
|
}
|
|
606
633
|
|
|
607
634
|
export function normalizeAxModeRequest(input: unknown, validNodeIds?: Set<string>): PmxAxModeRequest | null {
|
|
@@ -618,6 +645,9 @@ export function normalizeAxModeRequest(input: unknown, validNodeIds?: Set<string
|
|
|
618
645
|
resolvedAt: normalizeTimestamp(input.resolvedAt),
|
|
619
646
|
resolution: optionalString(input.resolution),
|
|
620
647
|
source: normalizeSource(input.source),
|
|
648
|
+
requestedBy: normalizeAttribution(input.requestedBy),
|
|
649
|
+
resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
|
|
650
|
+
selfAnswer: input.selfAnswer === true,
|
|
621
651
|
};
|
|
622
652
|
}
|
|
623
653
|
|
|
@@ -625,6 +655,7 @@ export function createAxModeRequest(
|
|
|
625
655
|
input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
|
|
626
656
|
source: PmxAxSource | null,
|
|
627
657
|
validNodeIds?: Set<string>,
|
|
658
|
+
requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
|
|
628
659
|
): PmxAxModeRequest {
|
|
629
660
|
return {
|
|
630
661
|
id: axId('mode'),
|
|
@@ -636,6 +667,9 @@ export function createAxModeRequest(
|
|
|
636
667
|
resolvedAt: null,
|
|
637
668
|
resolution: null,
|
|
638
669
|
source,
|
|
670
|
+
requestedBy,
|
|
671
|
+
resolvedBy: null,
|
|
672
|
+
selfAnswer: false,
|
|
639
673
|
};
|
|
640
674
|
}
|
|
641
675
|
|
|
@@ -715,6 +749,9 @@ export function normalizeAxApprovalGate(input: unknown, validNodeIds?: Set<strin
|
|
|
715
749
|
resolvedAt: normalizeTimestamp(input.resolvedAt),
|
|
716
750
|
resolution: optionalString(input.resolution),
|
|
717
751
|
source: normalizeSource(input.source),
|
|
752
|
+
requestedBy: normalizeAttribution(input.requestedBy),
|
|
753
|
+
resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
|
|
754
|
+
selfAnswer: input.selfAnswer === true,
|
|
718
755
|
};
|
|
719
756
|
}
|
|
720
757
|
|
|
@@ -856,6 +893,7 @@ export function createAxApprovalGate(
|
|
|
856
893
|
input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[]; ttlMs?: number },
|
|
857
894
|
source: PmxAxSource | null,
|
|
858
895
|
validNodeIds?: Set<string>,
|
|
896
|
+
requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
|
|
859
897
|
): PmxAxApprovalGate {
|
|
860
898
|
const created = Date.now();
|
|
861
899
|
return {
|
|
@@ -870,6 +908,9 @@ export function createAxApprovalGate(
|
|
|
870
908
|
resolvedAt: null,
|
|
871
909
|
resolution: null,
|
|
872
910
|
source,
|
|
911
|
+
requestedBy,
|
|
912
|
+
resolvedBy: null,
|
|
913
|
+
selfAnswer: false,
|
|
873
914
|
};
|
|
874
915
|
}
|
|
875
916
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library backup (plan 012): the whole workspace database — every board, its
|
|
3
|
+
* snapshots and blobs — copied with `VACUUM INTO`, which is safe while the
|
|
4
|
+
* server runs. Backups are plain files in a folder the human chooses; the
|
|
5
|
+
* schedule lives in the database and runs inside the server.
|
|
6
|
+
*/
|
|
7
|
+
import { Database } from 'bun:sqlite';
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
10
|
+
import { canvasState, PMX_CANVAS_DIR } from './canvas-state.js';
|
|
11
|
+
|
|
12
|
+
const FILE_PATTERN = /^canvas-(\d{8}T\d{9}Z)(?:-(\d+))?\.db$/;
|
|
13
|
+
const DEFAULT_KEEP = 14;
|
|
14
|
+
const MIN_INTERVAL_MS = 60_000;
|
|
15
|
+
const CHECK_INTERVAL_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
const META = {
|
|
18
|
+
folder: 'backup_folder',
|
|
19
|
+
everyMs: 'backup_every_ms',
|
|
20
|
+
keep: 'backup_keep',
|
|
21
|
+
lastAt: 'backup_last_at',
|
|
22
|
+
lastPath: 'backup_last_path',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export interface BackupFile {
|
|
26
|
+
path: string;
|
|
27
|
+
bytes: number;
|
|
28
|
+
at: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BackupStatus {
|
|
32
|
+
folder: string;
|
|
33
|
+
keep: number;
|
|
34
|
+
everyMs: number | null;
|
|
35
|
+
lastAt: string | null;
|
|
36
|
+
lastPath: string | null;
|
|
37
|
+
backups: BackupFile[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** "90s", "30m", "24h", "7d" → milliseconds. */
|
|
41
|
+
export function parseBackupInterval(value: string): number | null {
|
|
42
|
+
const match = value.trim().match(/^(\d+)\s*([smhd])$/i);
|
|
43
|
+
if (!match) return null;
|
|
44
|
+
const unit = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2].toLowerCase() as 's' | 'm' | 'h' | 'd'];
|
|
45
|
+
return Number(match[1]) * unit;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveFolder(folder: string): string {
|
|
49
|
+
return isAbsolute(folder) ? folder : resolve(canvasState.workspaceRoot, folder);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Beside the database: `.pmx-canvas/backups` unless PMX_CANVAS_DB_PATH moved it. */
|
|
53
|
+
function defaultFolder(): string {
|
|
54
|
+
const dbPath = canvasState.databasePath;
|
|
55
|
+
return resolve(
|
|
56
|
+
dbPath ? join(dirname(dbPath), 'backups') : join(canvasState.workspaceRoot, PMX_CANVAS_DIR, 'backups'),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function listBackupFiles(folder: string): BackupFile[] {
|
|
61
|
+
if (!existsSync(folder)) return [];
|
|
62
|
+
// Newest first by the name's timestamp, then its same-millisecond counter.
|
|
63
|
+
const order = (name: string): string => {
|
|
64
|
+
const match = name.match(FILE_PATTERN);
|
|
65
|
+
return `${match?.[1] ?? ''}-${(match?.[2] ?? '1').padStart(6, '0')}`;
|
|
66
|
+
};
|
|
67
|
+
return readdirSync(folder)
|
|
68
|
+
.filter((name) => FILE_PATTERN.test(name))
|
|
69
|
+
.sort((a, b) => order(b).localeCompare(order(a)))
|
|
70
|
+
.map((name) => {
|
|
71
|
+
const path = join(folder, name);
|
|
72
|
+
const stat = statSync(path);
|
|
73
|
+
return { path, bytes: stat.size, at: stat.mtime.toISOString() };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getBackupStatus(): BackupStatus {
|
|
78
|
+
const everyMs = Number(canvasState.readWorkspaceMeta(META.everyMs));
|
|
79
|
+
const folder = canvasState.readWorkspaceMeta(META.folder) ?? defaultFolder();
|
|
80
|
+
return {
|
|
81
|
+
folder,
|
|
82
|
+
keep: Number(canvasState.readWorkspaceMeta(META.keep)) || DEFAULT_KEEP,
|
|
83
|
+
everyMs: everyMs > 0 ? everyMs : null,
|
|
84
|
+
lastAt: canvasState.readWorkspaceMeta(META.lastAt),
|
|
85
|
+
lastPath: canvasState.readWorkspaceMeta(META.lastPath),
|
|
86
|
+
backups: listBackupFiles(folder),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Back up now; keeps the newest `keep` backups in the folder and removes older ones. */
|
|
91
|
+
export function runBackup(options: { folder?: string; keep?: number } = {}): {
|
|
92
|
+
backup: BackupFile;
|
|
93
|
+
removed: string[];
|
|
94
|
+
} {
|
|
95
|
+
const status = getBackupStatus();
|
|
96
|
+
const folder = options.folder ? resolveFolder(options.folder) : status.folder;
|
|
97
|
+
const keep = options.keep ?? status.keep;
|
|
98
|
+
mkdirSync(folder, { recursive: true });
|
|
99
|
+
|
|
100
|
+
const stamp = new Date().toISOString().replace(/[-:.]/g, '');
|
|
101
|
+
let path = join(folder, `canvas-${stamp}.db`);
|
|
102
|
+
for (let n = 2; existsSync(path); n++) path = join(folder, `canvas-${stamp}-${n}.db`);
|
|
103
|
+
canvasState.backupDatabaseTo(path);
|
|
104
|
+
|
|
105
|
+
const removed = listBackupFiles(folder)
|
|
106
|
+
.slice(keep)
|
|
107
|
+
.map((file) => file.path);
|
|
108
|
+
for (const file of removed) rmSync(file, { force: true });
|
|
109
|
+
|
|
110
|
+
const at = new Date().toISOString();
|
|
111
|
+
canvasState.writeWorkspaceMeta(META.lastAt, at);
|
|
112
|
+
canvasState.writeWorkspaceMeta(META.lastPath, path);
|
|
113
|
+
return { backup: { path, bytes: statSync(path).size, at }, removed };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Set the schedule (`everyMs` null turns it off). Folder and keep also apply to manual backups. */
|
|
117
|
+
export function setBackupSchedule(options: { everyMs: number | null; folder?: string; keep?: number }): BackupStatus {
|
|
118
|
+
if (options.everyMs !== null && options.everyMs < MIN_INTERVAL_MS) {
|
|
119
|
+
throw new Error('Back up at most once a minute.');
|
|
120
|
+
}
|
|
121
|
+
canvasState.writeWorkspaceMeta(META.everyMs, options.everyMs === null ? null : String(options.everyMs));
|
|
122
|
+
if (options.folder) canvasState.writeWorkspaceMeta(META.folder, resolveFolder(options.folder));
|
|
123
|
+
if (options.keep) canvasState.writeWorkspaceMeta(META.keep, String(options.keep));
|
|
124
|
+
return getBackupStatus();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Runs a scheduled backup when one is due. Returns whether it ran. */
|
|
128
|
+
export function runDueBackup(now = Date.now()): boolean {
|
|
129
|
+
if (!canvasState.databasePath) return false;
|
|
130
|
+
const { everyMs, lastAt } = getBackupStatus();
|
|
131
|
+
if (!everyMs) return false;
|
|
132
|
+
if (lastAt && now - Date.parse(lastAt) < everyMs) return false;
|
|
133
|
+
runBackup();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Rejects anything that is not a canvas database before the live file is touched. */
|
|
138
|
+
export function assertRestorableBackup(file: string): void {
|
|
139
|
+
if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`No backup file at ${file}.`);
|
|
140
|
+
if (resolve(file) === resolve(canvasState.databasePath ?? '')) {
|
|
141
|
+
throw new Error('That is the live database, not a backup.');
|
|
142
|
+
}
|
|
143
|
+
let tables: string[] = [];
|
|
144
|
+
try {
|
|
145
|
+
const db = new Database(file, { readonly: true });
|
|
146
|
+
tables = db
|
|
147
|
+
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
148
|
+
.all()
|
|
149
|
+
.map((row) => row.name);
|
|
150
|
+
db.close();
|
|
151
|
+
} catch {
|
|
152
|
+
throw new Error(`${file} is not a SQLite database.`);
|
|
153
|
+
}
|
|
154
|
+
if (!tables.includes('meta') || !tables.includes('nodes')) {
|
|
155
|
+
throw new Error(`${file} is not a PMX Canvas database.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
160
|
+
|
|
161
|
+
export function startBackupScheduler(): void {
|
|
162
|
+
stopBackupScheduler();
|
|
163
|
+
const check = () => {
|
|
164
|
+
try {
|
|
165
|
+
runDueBackup();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.warn('[backup] scheduled backup failed', error);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
check();
|
|
171
|
+
timer = setInterval(check, CHECK_INTERVAL_MS);
|
|
172
|
+
timer.unref?.();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function stopBackupScheduler(): void {
|
|
176
|
+
if (timer) clearInterval(timer);
|
|
177
|
+
timer = null;
|
|
178
|
+
}
|