Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions assistant/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11697,6 +11697,48 @@ paths:
type: object
properties: {}
additionalProperties: false
/v1/memory/v3/tree:
post:
operationId: memory_v3_tree_post
summary: Return a serializable view of the memory v3 tree DAG (read-only)
description:
Returns the v3 tree root id plus every node and its ordered child refs (page:/node:) as a JSON-serializable
projection of the in-memory TreeIndex. Read-only; the CLI uses it to print an indented tree with shared-DAG
re-entries marked.
tags:
- memory
responses:
"200":
description: Successful response
requestBody:
required: true
content:
application/json:
schema:
type: object
properties: {}
additionalProperties: false
/v1/memory/v3/validate:
post:
operationId: memory_v3_validate_post
summary: Validate the memory v3 tree structure (read-only)
description:
Read-only structural validation of the hand-authored v3 tree DAG. Reports dangling child refs, orphan
pages, cycles, stale compositional indexes, and unknown edge targets. Writes nothing and runs no LLM — operators
dry-run it while the v2 → v3 migration is in flight.
tags:
- memory
responses:
"200":
description: Successful response
requestBody:
required: true
content:
application/json:
schema:
type: object
properties: {}
additionalProperties: false
/v1/messages:
get:
operationId: messages_get
Expand Down
164 changes: 164 additions & 0 deletions assistant/src/cli/commands/__tests__/memory-v3-render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, expect, test } from "bun:test";

import type {
MemoryV3TreeResult,
MemoryV3ValidateResult,
} from "../../../runtime/routes/memory-v3-routes.js";
import {
renderTree,
renderValidationReport,
reportHasDefects,
} from "../memory-v3-render.js";

function cleanReport(): MemoryV3ValidateResult {
return {
danglingChildRefs: [],
danglingChildRefCount: 0,
orphanPages: [],
orphanPageCount: 0,
cycles: [],
cycleCount: 0,
staleIndex: [],
staleIndexCount: 0,
unknownEdgeTargets: [],
unknownEdgeTargetCount: 0,
};
}

describe("memory v3 — renderValidationReport", () => {
test("renders 'none' for every empty category", () => {
const out = renderValidationReport(cleanReport());
expect(out).toContain("Memory v3 Tree Validation");
expect(out).toContain("Dangling child refs: none");
expect(out).toContain("Orphan pages: none");
expect(out).toContain("Cycles: none");
expect(out).toContain("Stale index: none");
expect(out).toContain("Unknown edge targets: none");
});

test("renders counts and offending ids for each defect category", () => {
const report: MemoryV3ValidateResult = {
danglingChildRefs: [{ node: "people", ref: "ghost", kind: "node" }],
danglingChildRefCount: 1,
orphanPages: ["stray-page"],
orphanPageCount: 1,
cycles: [{ from: "a", to: "b" }],
cycleCount: 1,
staleIndex: [
{ node: "root", child: "people", nodeMtimeMs: 1, childMtimeMs: 2 },
],
staleIndexCount: 1,
unknownEdgeTargets: [{ from: "p1", to: "missing" }],
unknownEdgeTargetCount: 1,
};
const out = renderValidationReport(report);
expect(out).toContain("Dangling child refs: 1");
expect(out).toContain("people → node:ghost");
expect(out).toContain("Orphan pages: 1");
expect(out).toContain("- stray-page");
expect(out).toContain("Cycles: 1");
expect(out).toContain("a → b");
expect(out).toContain("Stale index: 1");
expect(out).toContain("root (older than child people)");
expect(out).toContain("Unknown edge targets: 1");
expect(out).toContain("p1 → missing");
});
});

describe("memory v3 — reportHasDefects", () => {
test("false for a clean report", () => {
expect(reportHasDefects(cleanReport())).toBe(false);
});

test("true when any single category is non-empty", () => {
const report = cleanReport();
report.orphanPageCount = 1;
report.orphanPages = ["x"];
expect(reportHasDefects(report)).toBe(true);
});
});

describe("memory v3 — renderTree", () => {
test("renders an indented tree descending node and page children", () => {
const view: MemoryV3TreeResult = {
root: "_root",
nodes: [
{
id: "_root",
children: [
{ kind: "node", ref: "people" },
{ kind: "page", ref: "overview" },
],
},
{
id: "people",
children: [{ kind: "page", ref: "alice" }],
},
],
};
const out = renderTree(view);
expect(out).toBe(
["node:_root", " node:people", " page:alice", " page:overview"].join(
"\n",
),
);
});

test("marks a shared DAG sub-node as a re-entry rather than re-expanding", () => {
const view: MemoryV3TreeResult = {
root: "_root",
nodes: [
{
id: "_root",
children: [
{ kind: "node", ref: "a" },
{ kind: "node", ref: "b" },
],
},
{ id: "a", children: [{ kind: "node", ref: "shared" }] },
{ id: "b", children: [{ kind: "node", ref: "shared" }] },
{ id: "shared", children: [{ kind: "page", ref: "leaf" }] },
],
};
const out = renderTree(view);
// First reach under `a` expands; second reach under `b` is a marked re-entry.
expect(out).toContain(" node:a\n node:shared\n page:leaf");
expect(out).toContain("node:shared (↑ already shown)");
// The leaf page is expanded exactly once.
expect(out.match(/page:leaf/g)?.length).toBe(1);
});

test("bounds output on a cycle instead of looping forever", () => {
const view: MemoryV3TreeResult = {
root: "_root",
nodes: [
{ id: "_root", children: [{ kind: "node", ref: "a" }] },
{ id: "a", children: [{ kind: "node", ref: "_root" }] },
],
};
const out = renderTree(view);
expect(out).toContain("node:_root (↑ already shown)");
});

test("flags a child ref whose target node is missing", () => {
const view: MemoryV3TreeResult = {
root: "_root",
nodes: [{ id: "_root", children: [{ kind: "node", ref: "ghost" }] }],
};
const out = renderTree(view);
expect(out).toContain("node:ghost (missing)");
});

test("lists nodes unreachable from the root", () => {
const view: MemoryV3TreeResult = {
root: "_root",
nodes: [
{ id: "_root", children: [] },
{ id: "floating", children: [] },
],
};
const out = renderTree(view);
expect(out).toContain("Unreachable nodes (1):");
expect(out).toContain("- node:floating");
});
});
133 changes: 133 additions & 0 deletions assistant/src/cli/commands/memory-v3-render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Text rendering for `assistant memory v3 validate` and `... tree`.
*
* Both functions are pure presentation: they take the daemon route's response
* shape and return a terminal-ready string. They live CLI-side (mirroring
* `memory-v2-compare-render.ts`) and import only the response *types* from the
* daemon route — `cli/no-daemon-internals` permits type-only imports but
* forbids pulling in daemon runtime modules.
*/

import type {
MemoryV3TreeResult,
MemoryV3ValidateResult,
} from "../../runtime/routes/memory-v3-routes.js";

/**
* Render a {@link MemoryV3ValidateResult} into a counts summary plus the
* offending ids for each non-empty category. Categories with zero entries
* print `none` so a clean tree reads at a glance.
*/
export function renderValidationReport(report: MemoryV3ValidateResult): string {
const lines: string[] = [
"Memory v3 Tree Validation",
"=========================",
`Dangling child refs: ${report.danglingChildRefCount || "none"}`,
];
for (const d of report.danglingChildRefs) {
lines.push(` - ${d.node} → ${d.kind}:${d.ref}`);
}

lines.push(`Orphan pages: ${report.orphanPageCount || "none"}`);
for (const slug of report.orphanPages) {
lines.push(` - ${slug}`);
}

lines.push(`Cycles: ${report.cycleCount || "none"}`);
for (const c of report.cycles) {
lines.push(` - ${c.from} → ${c.to}`);
}

lines.push(`Stale index: ${report.staleIndexCount || "none"}`);
for (const s of report.staleIndex) {
lines.push(` - ${s.node} (older than child ${s.child})`);
}

lines.push(
`Unknown edge targets: ${report.unknownEdgeTargetCount || "none"}`,
);
for (const e of report.unknownEdgeTargets) {
lines.push(` - ${e.from} → ${e.to}`);
}

return lines.join("\n");
}

/**
* Whether the validation report has any defect in any category. The CLI uses
* this to set a non-zero exit code so `validate` is scriptable as a check.
*/
export function reportHasDefects(report: MemoryV3ValidateResult): boolean {
return (
report.danglingChildRefCount > 0 ||
report.orphanPageCount > 0 ||
report.cycleCount > 0 ||
report.staleIndexCount > 0 ||
report.unknownEdgeTargetCount > 0
);
}

/**
* Render a {@link MemoryV3TreeResult} as an indented tree rooted at `view.root`,
* descending `node:` children depth-first. A node reached more than once
* (shared DAG sub-node) is printed once with a `(↑ …)` re-entry marker rather
* than re-expanded, which also bounds output when the structure contains a
* cycle. `page:` children are printed as leaves under their parent node.
*/
export function renderTree(view: MemoryV3TreeResult): string {
const childrenById = new Map<string, MemoryV3TreeResult["nodes"][number]>();
for (const node of view.nodes) {
childrenById.set(node.id, node);
}

const lines: string[] = [];
const expanded = new Set<string>();

const walk = (nodeId: string, depth: number): void => {
const indent = " ".repeat(depth);
const node = childrenById.get(nodeId);

if (!node) {
lines.push(`${indent}node:${nodeId} (missing)`);
return;
}

if (expanded.has(nodeId)) {
// Shared DAG sub-node (or a cycle's back-edge): print the reference but
// do not re-expand, so output stays finite and the re-entry is visible.
lines.push(`${indent}node:${nodeId} (↑ already shown)`);
return;
}
expanded.add(nodeId);
lines.push(`${indent}node:${nodeId}`);

for (const child of node.children) {
if (child.kind === "page") {
lines.push(`${" ".repeat(depth + 1)}page:${child.ref}`);
} else {
walk(child.ref, depth + 1);
}
}
};

walk(view.root, 0);

if (lines.length === 0) {
lines.push("(empty tree)");
}

// Surface nodes that exist on disk but were never reached from the root —
// they would otherwise be invisible in a root-anchored print.
const unreached = view.nodes
.map((n) => n.id)
.filter((id) => !expanded.has(id))
.sort();
if (unreached.length > 0) {
lines.push("", `Unreachable nodes (${unreached.length}):`);
for (const id of unreached) {
lines.push(` - node:${id}`);
}
}

return lines.join("\n");
}
Loading
Loading