feat(catalog): repair page hierarchy cycles and duplicate sibling page order
CI / check (push) Successful in 1m3s
CI / deploy (push) Successful in 1m23s
CI / publish-container (push) Successful in 47s

The Arcturus errors "page hierarchy contains a cycle page 354 and 357" and
"sibling order 1 is used more than once (111 problems)" come from
catalog_pages, not catalog_items: pages 354/357 point at themselves, and
many parents have child pages sharing the same order_num. Extend the
emulator catalog scan + fix to detect both: pages whose parent chain loops
back get detached (parent_id = 0 on the highest cycle member) and every
affected parent's children are renumbered sequentially, preserving their
current relative order.

Add scripts/diag-emulator.ts to inspect the live catalog state.
This commit is contained in:
openhands committed 2026-09-10 11:50:27 +02:00
1 parent acbe54fcb7
commit cea88eaa57
3 files changed
+252 -5

No files matched your search

+131 -3
View File
@@ -208,7 +208,9 @@ export interface EmulatorCatalogIssue {
| "limited_stack_vs_sells"
| "missing_page"
| "missing_item"
| "duplicate_order";
| "duplicate_order"
| "page_cycle"
| "sibling_page_order";
id: number;
pageId?: number;
detail: string;
@@ -249,8 +251,11 @@ export async function listEmulatorIssues(): Promise<EmulatorCatalogIssue[]> {
unknown,
];
const [pages] = (await db.execute(
sql`SELECT id FROM catalog_pages`,
)) as unknown as [Array<{ id: number }>, unknown];
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM catalog_pages`,
)) as unknown as [
Array<{ id: number; parentId: number | string; orderNum: number }>,
unknown,
];
const [bases] = (await db.execute(
sql`SELECT id FROM items_base`,
)) as unknown as [Array<{ id: number }>, unknown];
@@ -343,6 +348,62 @@ export async function listEmulatorIssues(): Promise<EmulatorCatalogIssue[]> {
}
}
// Page hierarchy cycles: a page (or a chain of pages) whose parent chain
// loops back on itself breaks the catalog tree ("page hierarchy contains a
// cycle"). Flag every page that is a member of such a cycle.
const parentOf = new Map<number, number>();
for (const row of pages) parentOf.set(Number(row.id), Number(row.parentId));
const inCycle = new Set<number>();
const walked = new Set<number>();
for (const row of pages) {
const start = Number(row.id);
if (walked.has(start)) continue;
const path: number[] = [];
const at = new Map<number, number>();
let cur = start;
while (cur !== 0 && !at.has(cur)) {
at.set(cur, path.length);
path.push(cur);
cur = parentOf.get(cur) ?? 0;
}
for (const node of path) walked.add(node);
if (at.has(cur)) {
for (let i = at.get(cur) ?? path.length; i < path.length; i++)
inCycle.add(path[i]);
}
}
for (const id of [...inCycle].sort((a, b) => a - b)) {
issues.push({
type: "page_cycle",
id,
detail: `catalog page #${id} (parent ${parentOf.get(id)}) is part of a parent hierarchy cycle`,
});
}
// Sibling page order: child pages of the same parent must have a unique
// order_num ("sibling order N is used more than once"). Flag every page
// whose (parent_id, order_num) is shared with at least one sibling.
const pageOrderGroups = new Map<string, number[]>();
for (const row of pages) {
const parentId = Number(row.parentId);
const order = Number(row.orderNum);
const key = `${parentId}:${order}`;
const arr = pageOrderGroups.get(key) ?? [];
arr.push(Number(row.id));
pageOrderGroups.set(key, arr);
}
for (const [, ids] of pageOrderGroups) {
if (ids.length <= 1) continue;
for (const id of ids) {
issues.push({
type: "sibling_page_order",
id,
pageId: parentOf.get(id) ?? 0,
detail: `catalog page #${id} shares order_num with a sibling under the same parent`,
});
}
}
return issues;
}
@@ -468,6 +529,73 @@ export async function fixEmulatorIssues(): Promise<FixEmulatorResult> {
fixed += dupOrders.length;
detail.push(`Re-numbered sibling order numbers on affected pages`);
}
// Page hierarchy cycles -> detach the highest member of each cycle so
// the parent chain can never loop back on itself.
const cyclePages = issues.filter((i) => i.type === "page_cycle");
if (cyclePages.length > 0) {
const cycleIds = cyclePages.map((i) => i.id);
const [cycleRows] = (await tx.execute(sql`
SELECT id, parent_id AS parentId FROM catalog_pages WHERE id IN (${sql.join(cycleIds, sql`, `)})
`)) as unknown as [
Array<{ id: number | string; parentId: number | string }>,
unknown,
];
const parentOf = new Map(
cycleRows.map((r) => [Number(r.id), Number(r.parentId)]),
);
const broken = new Set<number>();
for (const id of cycleIds) {
if (broken.has(id)) continue;
// Walk the cycle's parent chain to collect every member.
let cur = id;
const chain = new Set<number>();
while (cur !== 0 && !chain.has(cur)) {
chain.add(cur);
cur = parentOf.get(cur) ?? 0;
}
const member = Math.max(...chain);
await tx.execute(
sql`UPDATE catalog_pages SET parent_id = 0 WHERE id = ${member}`,
);
for (const node of chain) broken.add(node);
}
fixed += cyclePages.length;
detail.push(
`Detached ${new Set([...broken]).size} page(s) from parent hierarchy cycles`,
);
}
// Sibling page order -> re-number children sequentially under each
// affected parent, preserving the existing relative order.
const pageOrderIssues = issues.filter(
(i) => i.type === "sibling_page_order",
);
if (pageOrderIssues.length > 0) {
const parents = [...new Set(pageOrderIssues.map((i) => i.pageId))];
for (const parent of parents) {
const [children] = (await tx.execute(sql`
SELECT id, order_num AS orderNum FROM catalog_pages
WHERE parent_id = ${String(parent)} ORDER BY order_num, id
`)) as unknown as [
Array<{ id: number | string; orderNum: number }>,
unknown,
];
let next = 1;
for (const child of children) {
const newOrder = next++;
if (newOrder !== Number(child.orderNum)) {
await tx.execute(
sql`UPDATE catalog_pages SET order_num = ${newOrder} WHERE id = ${Number(child.id)}`,
);
}
}
}
fixed += pageOrderIssues.length;
detail.push(
`Re-numbered sibling page order under ${parents.length} parent(s)`,
);
}
});
return { fixed, removed, detail };