Skip to content

Commit ae756ce

Browse files
feat(dashboard): single-window perspective capture + shape-only fingerprint (#14652) (#14697)
* feat(dashboard): single-window perspective capture + shape-only fingerprint (#14652) * fix(dashboard): fingerprint coherence + cycle guards — persisted-tree fingerprints, fail-closed cyclic walks (#14652)
1 parent 99da119 commit ae756ce

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

src/dashboard/DockZoneModel.mjs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,128 @@ class DockZoneModel extends Base {
722722
return errors
723723
}
724724

725+
/**
726+
* @summary Computes the shape-only fingerprint of a dock-zone document.
727+
*
728+
* The fingerprint describes topology SHAPE — node types, nesting, child arity, zone
729+
* occupancy — and deliberately contains no node ids, item ids, sizes, titles or window
730+
* identity, so two structurally identical layouts fingerprint identically regardless of
731+
* where or when they were captured (the persistence guardrail for `windowFingerprint`).
732+
* Deterministic by construction: child arrays keep document order, edge zones walk in the
733+
* fixed {@link #dockZoneEdgeKeys} order.
734+
* @param {Object} document The committed dock-zone document.
735+
* @returns {{fingerprint:(Object|null), errors:String[]}}
736+
* @static
737+
*/
738+
static computeShapeFingerprint(document) {
739+
let errors = [];
740+
741+
if (!DockZoneModel.isJsonRecord(document) || !DockZoneModel.isJsonRecord(document.nodes)) {
742+
return {fingerprint: null, errors: ['fingerprint requires a document with a nodes record']}
743+
}
744+
745+
const counts = {'edge-zone': 0, split: 0, tabs: 0},
746+
visited = new Set();
747+
748+
const walk = nodeId => {
749+
const node = document.nodes[nodeId];
750+
751+
if (!node) {
752+
errors.push(`fingerprint walk found no node for id "${nodeId}"`);
753+
return '?'
754+
}
755+
756+
// cycle guard: a node graph that references an ancestor would recurse forever —
757+
// fail closed through the errors path, never a RangeError out of the public API
758+
if (visited.has(nodeId)) {
759+
errors.push(`fingerprint walk detected a cycle at node "${nodeId}"`);
760+
return '?'
761+
}
762+
763+
visited.add(nodeId);
764+
765+
counts[node.type] = (counts[node.type] || 0) + 1;
766+
767+
switch (node.type) {
768+
case 'split':
769+
return `${node.orientation === 'horizontal' ? 'h' : 'v'}(${(node.children || []).map(walk).join(',')})`;
770+
case 'tabs':
771+
return `t${node.items?.length || 0}`;
772+
case 'edge-zone':
773+
return `e{${[...DockZoneModel.dockZoneEdgeKeys]
774+
.map(zone => node.zones?.[zone] ? `${zone}:${walk(node.zones[zone])}` : '')
775+
.filter(Boolean).join(',')}}`;
776+
default:
777+
errors.push(`fingerprint walk found unsupported node type "${node.type}"`);
778+
return '?'
779+
}
780+
};
781+
782+
const shape = walk(document.root);
783+
784+
if (errors.length) {
785+
return {fingerprint: null, errors}
786+
}
787+
788+
return {
789+
fingerprint: {
790+
schema : 'neo.harness.dockShape.v1',
791+
shape,
792+
nodeCounts: counts,
793+
itemCount : Object.keys(document.items || {}).length
794+
},
795+
errors
796+
}
797+
}
798+
799+
/**
800+
* @summary Captures the current window's dock document as a v2 saved-layout perspective.
801+
*
802+
* The single-window capture scope: layout truth only enters the record — the committed
803+
* document tree — never render projections, runtime handles or pane-internal state (panes
804+
* are layout-blind, so their internals are not the layout's to save).
805+
*
806+
* Fingerprint-coherence by construction: the wrapper is written FIRST (validate + normalize
807+
* through the one writer path), and the fingerprint is computed from the PERSISTED
808+
* `layout.dockZone` — never the raw input — so the stored fingerprint cannot describe a
809+
* tree the record does not contain (normalization collapses e.g. a single-child split to
810+
* its child; a pre-normalization fingerprint would immortalize the collapsed wrapper).
811+
* @param {Object} document The committed dock-zone document to capture.
812+
* @param {Object} [metadata={}] {layoutId, title, revision, metadata, perspectiveName}
813+
* @returns {{layout:(Object|null), errors:String[]}}
814+
* @static
815+
*/
816+
static capturePerspective(document, metadata={}) {
817+
// pre-probe the RAW input purely as the cycle/shape gate: the writer's normalize pass
818+
// recurses and must never see a cyclic graph; the probe's fingerprint is DISCARDED so
819+
// coherence with the persisted tree is never at risk
820+
const probe = DockZoneModel.computeShapeFingerprint(document);
821+
822+
if (probe.errors.length) {
823+
return {layout: null, errors: probe.errors}
824+
}
825+
826+
const written = DockZoneModel.createSavedLayout(document, {
827+
...metadata,
828+
captureScope : 'window',
829+
windowFingerprint: null
830+
});
831+
832+
if (written.errors.length) {
833+
return written
834+
}
835+
836+
const {fingerprint, errors} = DockZoneModel.computeShapeFingerprint(written.layout.dockZone);
837+
838+
if (errors.length) {
839+
return {layout: null, errors}
840+
}
841+
842+
written.layout.windowFingerprint = fingerprint;
843+
844+
return written
845+
}
846+
725847
/**
726848
* @summary Wraps a valid committed dock-zone document in a JSON-only saved-layout envelope.
727849
*

test/playwright/unit/dashboard/DockZoneModel.spec.mjs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,84 @@ test.describe('Neo.dashboard.DockZoneModel', () => {
217217
expect(DockZoneModel.restoreSavedLayout(layout).errors).toEqual([])
218218
});
219219

220+
test('capturePerspective emits a v2 window-scope record with a shape-only fingerprint', () => {
221+
const {layout, errors} = DockZoneModel.capturePerspective(doc(), {
222+
layoutId : 'capture-1',
223+
title : 'Capture One',
224+
perspectiveName: 'Morning Focus'
225+
});
226+
227+
expect(errors).toEqual([]);
228+
expect(layout.schema).toBe(DockZoneModel.LAYOUT_SCHEMA);
229+
expect(layout.captureScope).toBe('window');
230+
expect(layout.perspectiveName).toBe('Morning Focus');
231+
expect(layout.windowFingerprint.schema).toBe('neo.harness.dockShape.v1');
232+
expect(typeof layout.windowFingerprint.shape).toBe('string');
233+
expect(layout.windowFingerprint.itemCount).toBeGreaterThan(0);
234+
expect(DockZoneModel.restoreSavedLayout(layout).errors).toEqual([])
235+
});
236+
237+
test('stored fingerprint matches the PERSISTED document, not the pre-normalized input (single-child split collapse)', () => {
238+
// a split with one child normalizes away — the persisted root becomes the child tabs
239+
const d = splitDoc();
240+
d.nodes['main-split'].children = ['main-tabs'];
241+
d.nodes['main-split'].sizes = [1];
242+
delete d.nodes['side-tabs'];
243+
delete d.items.terminal;
244+
245+
const {layout, errors} = DockZoneModel.capturePerspective(d, {layoutId: 'c', title: 'C'});
246+
247+
expect(errors).toEqual([]);
248+
// the stored fingerprint must equal the persisted tree's fingerprint by construction…
249+
expect(layout.windowFingerprint)
250+
.toEqual(DockZoneModel.computeShapeFingerprint(layout.dockZone).fingerprint);
251+
// …and must NOT carry the collapsed split wrapper the raw input had
252+
expect(layout.windowFingerprint.shape)
253+
.not.toBe(DockZoneModel.computeShapeFingerprint(d).fingerprint.shape);
254+
expect(layout.windowFingerprint.shape).not.toContain('h(')
255+
});
256+
257+
test('cyclic node graphs fail closed through the public return shapes, never a throw', () => {
258+
const cyclic = doc();
259+
cyclic.nodes['loop-split'] = {type: 'split', orientation: 'horizontal', children: ['main-tabs', 'loop-split'], sizes: [0.5, 0.5]};
260+
cyclic.nodes.root.zones.center = 'loop-split';
261+
262+
const direct = DockZoneModel.computeShapeFingerprint(cyclic);
263+
expect(direct.fingerprint).toBe(null);
264+
expect(direct.errors.join(' ')).toContain('cycle');
265+
266+
const captured = DockZoneModel.capturePerspective(cyclic, {layoutId: 'x', title: 'X'});
267+
expect(captured.layout).toBe(null);
268+
expect(captured.errors.length).toBeGreaterThan(0)
269+
});
270+
271+
test('shape fingerprints are deterministic, id-free and shape-sensitive', () => {
272+
const a = DockZoneModel.computeShapeFingerprint(doc()),
273+
b = DockZoneModel.computeShapeFingerprint(doc());
274+
275+
expect(a.errors).toEqual([]);
276+
expect(a.fingerprint).toEqual(b.fingerprint);
277+
278+
// rename every node id — the shape must not change (id-freedom)
279+
const renamed = JSON.parse(JSON.stringify(doc()).replaceAll('main-tabs', 'renamed-tabs'));
280+
expect(DockZoneModel.computeShapeFingerprint(renamed).fingerprint.shape).toBe(a.fingerprint.shape);
281+
282+
// structural change → different shape term
283+
const mutated = doc();
284+
mutated.nodes['main-tabs'].items.push('extra-item');
285+
expect(DockZoneModel.computeShapeFingerprint(mutated).fingerprint.shape).not.toBe(a.fingerprint.shape)
286+
});
287+
288+
test('fingerprint walk fails closed on dangling node refs', () => {
289+
const broken = doc();
290+
broken.root = 'missing-node';
291+
292+
const {fingerprint, errors} = DockZoneModel.computeShapeFingerprint(broken);
293+
294+
expect(fingerprint).toBe(null);
295+
expect(errors.join(' ')).toContain('missing-node')
296+
});
297+
220298
test('fails closed on perspective-field contract violations', () => {
221299
const badScope = DockZoneModel.createSavedLayout(doc(), {layoutId: 'x', title: 'X', captureScope: 'galaxy'});
222300
expect(badScope.layout).toBe(null);

0 commit comments

Comments
 (0)