From c146e570c5328900008297d750ef9a680c60684a Mon Sep 17 00:00:00 2001 From: Diplow Date: Tue, 20 Jan 2026 14:27:23 +0100 Subject: [PATCH 01/27] feat(mapping): add LeafTraversalService for hierarchical leaf discovery Add new traversal subsystem that finds leaf tiles (tiles with no structural children) in a hexagonal hierarchy. Supports depth-first traversal in direction order (1-6), skipping composed children and hexplans. Key features: - getAllLeafTiles: Returns all leaves under a root coordinate - getNextIncompleteLeaf: Finds first incomplete leaf given a completion set - Handles meta-leaf pattern where tiles gain children between traversals Part of run orchestration feature (Plan 1 of 5). Co-Authored-By: Claude Opus 4.5 --- src/lib/domains/mapping/index.ts | 3 + .../services/_traversal-services/README.md | 90 +++ .../leaf-traversal.integration.test.ts | 520 ++++++++++++++++++ .../_leaf-traversal.service.ts | 195 +++++++ .../_traversal-services/dependencies.json | 8 + .../services/_traversal-services/index.ts | 5 + .../mapping/services/dependencies.json | 3 +- src/lib/domains/mapping/services/index.ts | 5 + 8 files changed, 828 insertions(+), 1 deletion(-) create mode 100644 src/lib/domains/mapping/services/_traversal-services/README.md create mode 100644 src/lib/domains/mapping/services/_traversal-services/__tests__/leaf-traversal.integration.test.ts create mode 100644 src/lib/domains/mapping/services/_traversal-services/_leaf-traversal.service.ts create mode 100644 src/lib/domains/mapping/services/_traversal-services/dependencies.json create mode 100644 src/lib/domains/mapping/services/_traversal-services/index.ts diff --git a/src/lib/domains/mapping/index.ts b/src/lib/domains/mapping/index.ts index 7b7158f1f..7540c8e91 100644 --- a/src/lib/domains/mapping/index.ts +++ b/src/lib/domains/mapping/index.ts @@ -23,7 +23,10 @@ export { ItemHistoryService, ItemContextService, MappingUtils, + LeafTraversalService, type HexecuteContext, + type NextLeafResult, + type LeafTraversalServiceDeps, } from '~/lib/domains/mapping/services'; // Infrastructure (server-only - contains database connections) diff --git a/src/lib/domains/mapping/services/_traversal-services/README.md b/src/lib/domains/mapping/services/_traversal-services/README.md new file mode 100644 index 000000000..2b2cf6193 --- /dev/null +++ b/src/lib/domains/mapping/services/_traversal-services/README.md @@ -0,0 +1,90 @@ +# Traversal Services + +## Mental Model + +Like a **tree walker with a checklist** - it systematically visits every leaf node in a hexagonal hierarchy, keeping track of which ones have been visited, and can report the next unvisited leaf at any time. + +## Responsibilities + +- Traverse tile hierarchies to find leaf tiles (tiles with no structural children) +- Return leaves in deterministic direction order (1, 2, 3, 4, 5, 6) +- Track completion status to find next incomplete leaf +- Handle dynamic hierarchies where leaves may gain children (meta-leaf pattern) + +## Non-Responsibilities + +- Executing leaf tiles - See `~/lib/domains/agentic` +- Managing run state - See `~/lib/domains/agentic/services/_run-services` +- Querying individual tiles - See `../item-services` +- Creating or modifying tiles - See `../item-services` + +## Key Concepts + +### Leaf Tile + +A tile is considered a "leaf" if it has no **structural children** (directions 1-6). The following are NOT considered when determining leaf status: + +- Composed children (negative directions -1 to -6) +- Hexplan children (direction 0) + +### Traversal Order + +Leaves are returned in depth-first, direction-ordered traversal: + +1. Visit children in direction order: 1 (NW), 2 (NE), 3 (E), 4 (SE), 5 (SW), 6 (W) +2. For each child, recursively visit its children first +3. Collect leaf coordinates as they're encountered + +### Meta-Leaf Pattern + +When a leaf tile gains children between traversal calls (e.g., an AI agent decomposed a task into subtasks), the traversal naturally handles this: + +- The former leaf is no longer returned (it has children now) +- Its new children become the leaves to execute +- Previously completed coordinates are simply skipped + +## Interface + +```typescript +interface NextLeafResult { + leafCoords: string | null; // Next incomplete leaf, or null if all complete + allLeafCoords: string[]; // All leaves in traversal order +} + +class LeafTraversalService { + constructor(deps: { itemQueryService: ItemQueryService }) + + getAllLeafTiles(rootCoords: string): Promise + + getNextIncompleteLeaf( + rootCoords: string, + completedCoords: Set + ): Promise +} +``` + +## Usage Example + +```typescript +import { LeafTraversalService } from '~/lib/domains/mapping/services'; + +const traversalService = new LeafTraversalService({ + itemQueryService: mappingService.items.query, +}); + +// Get all leaves under a root +const leaves = await traversalService.getAllLeafTiles('userId,0:1'); +// Returns: ['userId,0:1,1', 'userId,0:1,3', 'userId,0:1,6,2'] + +// Find next incomplete leaf +const completed = new Set(['userId,0:1,1']); +const { leafCoords, allLeafCoords } = await traversalService.getNextIncompleteLeaf( + 'userId,0:1', + completed +); +// leafCoords: 'userId,0:1,3' (next after the completed one) +``` + +## Dependencies + +See `dependencies.json` for allowed imports. diff --git a/src/lib/domains/mapping/services/_traversal-services/__tests__/leaf-traversal.integration.test.ts b/src/lib/domains/mapping/services/_traversal-services/__tests__/leaf-traversal.integration.test.ts new file mode 100644 index 000000000..c4331526b --- /dev/null +++ b/src/lib/domains/mapping/services/_traversal-services/__tests__/leaf-traversal.integration.test.ts @@ -0,0 +1,520 @@ +import { describe, beforeEach, it, expect } from "vitest"; +import { Direction, CoordSystem } from "~/lib/domains/mapping/utils"; +import { + type TestEnvironment, + _cleanupDatabase, + _createTestEnvironment, + _setupBasicMap, + _createTestCoordinates, + _createUniqueTestParams, + createTestItem, +} from "~/lib/domains/mapping/services/__tests__/helpers/_test-utilities"; +import { LeafTraversalService } from "~/lib/domains/mapping/services/_traversal-services"; + +/** + * Helper to convert string id from MapItemContract to number for parentId + */ +function toParentId(id: string): number { + return parseInt(id, 10); +} + +describe("LeafTraversalService [Integration - DB]", () => { + let testEnv: TestEnvironment; + let leafTraversalService: LeafTraversalService; + + beforeEach(async () => { + await _cleanupDatabase(); + testEnv = _createTestEnvironment(); + leafTraversalService = new LeafTraversalService({ + itemQueryService: testEnv.service.items.query, + }); + }); + + describe("getAllLeafTiles", () => { + it("returns empty array for root with no children", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootCoordId = rootMap.items[0]!.coords; + + const leaves = await leafTraversalService.getAllLeafTiles(rootCoordId); + + expect(leaves).toEqual([]); + }); + + it("returns leaf coords for root with direct leaf children", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create two direct children (leaves) + const child1Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + const child2Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.East], + }); + + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child1Coords, + title: "Child 1", + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child2Coords, + title: "Child 2", + }); + + const leaves = await leafTraversalService.getAllLeafTiles(rootCoordId); + + expect(leaves).toHaveLength(2); + expect(leaves).toContain(CoordSystem.createId(child1Coords)); + expect(leaves).toContain(CoordSystem.createId(child2Coords)); + }); + + it("returns leaves in direction order (1, 2, 3...)", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create children in reverse order to test ordering + const child3Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.East], // Direction 3 + }); + const child1Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], // Direction 1 + }); + const child2Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthEast], // Direction 2 + }); + + // Create in reverse order + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child3Coords, + title: "Child 3", + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child1Coords, + title: "Child 1", + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child2Coords, + title: "Child 2", + }); + + const leaves = await leafTraversalService.getAllLeafTiles(rootCoordId); + + // Should be ordered by direction: 1, 2, 3 + expect(leaves).toHaveLength(3); + expect(leaves[0]).toBe(CoordSystem.createId(child1Coords)); + expect(leaves[1]).toBe(CoordSystem.createId(child2Coords)); + expect(leaves[2]).toBe(CoordSystem.createId(child3Coords)); + }); + + it("recurses into parent tiles to find nested leaves", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create a parent child + const parentCoords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + const parentChild = await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: parentCoords, + title: "Parent", + }); + + // Create nested children under the parent + const nestedChild1Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest, Direction.East], + }); + const nestedChild2Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest, Direction.West], + }); + + await createTestItem(testEnv, { + parentId: toParentId(parentChild.id), + coords: nestedChild1Coords, + title: "Nested Child 1", + }); + await createTestItem(testEnv, { + parentId: toParentId(parentChild.id), + coords: nestedChild2Coords, + title: "Nested Child 2", + }); + + const leaves = await leafTraversalService.getAllLeafTiles(rootCoordId); + + // Parent is not a leaf (has children), only nested children are leaves + expect(leaves).toHaveLength(2); + expect(leaves).toContain(CoordSystem.createId(nestedChild1Coords)); + expect(leaves).toContain(CoordSystem.createId(nestedChild2Coords)); + expect(leaves).not.toContain(CoordSystem.createId(parentCoords)); + }); + + it("handles deep nesting (3+ levels)", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Level 1 + const level1Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + const level1 = await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: level1Coords, + title: "Level 1", + }); + + // Level 2 + const level2Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest, Direction.East], + }); + const level2 = await createTestItem(testEnv, { + parentId: toParentId(level1.id), + coords: level2Coords, + title: "Level 2", + }); + + // Level 3 (leaf) + const level3Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest, Direction.East, Direction.SouthWest], + }); + await createTestItem(testEnv, { + parentId: toParentId(level2.id), + coords: level3Coords, + title: "Level 3 Leaf", + }); + + const leaves = await leafTraversalService.getAllLeafTiles(rootCoordId); + + expect(leaves).toHaveLength(1); + expect(leaves[0]).toBe(CoordSystem.createId(level3Coords)); + }); + + it("ignores composed children (negative directions)", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create structural child (should be found) + const structuralCoords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: structuralCoords, + title: "Structural Child", + }); + + // Create composed child (should be ignored) + const composedCoords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.ComposedNorthWest], + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: composedCoords, + title: "Composed Child", + }); + + const leaves = await leafTraversalService.getAllLeafTiles(rootCoordId); + + expect(leaves).toHaveLength(1); + expect(leaves[0]).toBe(CoordSystem.createId(structuralCoords)); + }); + + it("ignores direction-0 (hexplan) children", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create structural child (should be found) + const structuralCoords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: structuralCoords, + title: "Structural Child", + }); + + // Create hexplan child at direction 0 (should be ignored) + const hexplanCoords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.Center], + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: hexplanCoords, + title: "Hexplan", + }); + + const leaves = await leafTraversalService.getAllLeafTiles(rootCoordId); + + expect(leaves).toHaveLength(1); + expect(leaves[0]).toBe(CoordSystem.createId(structuralCoords)); + }); + }); + + describe("getNextIncompleteLeaf", () => { + it("returns first leaf when completedCoords is empty", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create two children + const child1Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + const child2Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.East], + }); + + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child1Coords, + title: "Child 1", + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child2Coords, + title: "Child 2", + }); + + const result = await leafTraversalService.getNextIncompleteLeaf( + rootCoordId, + new Set() + ); + + expect(result.leafCoords).toBe(CoordSystem.createId(child1Coords)); + expect(result.allLeafCoords).toHaveLength(2); + }); + + it("skips completed leaves and returns next incomplete", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create two children + const child1Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + const child2Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.East], + }); + + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child1Coords, + title: "Child 1", + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child2Coords, + title: "Child 2", + }); + + const child1CoordId = CoordSystem.createId(child1Coords); + const completedCoords = new Set([child1CoordId]); + + const result = await leafTraversalService.getNextIncompleteLeaf( + rootCoordId, + completedCoords + ); + + expect(result.leafCoords).toBe(CoordSystem.createId(child2Coords)); + }); + + it("returns null when all leaves are completed", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create two children + const child1Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + const child2Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.East], + }); + + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child1Coords, + title: "Child 1", + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child2Coords, + title: "Child 2", + }); + + const completedCoords = new Set([ + CoordSystem.createId(child1Coords), + CoordSystem.createId(child2Coords), + ]); + + const result = await leafTraversalService.getNextIncompleteLeaf( + rootCoordId, + completedCoords + ); + + expect(result.leafCoords).toBeNull(); + expect(result.allLeafCoords).toHaveLength(2); + }); + + it("handles meta-leaf: tile that gained children is no longer a leaf", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create a tile that was previously a leaf but now has children + const formerLeafCoords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + const formerLeaf = await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: formerLeafCoords, + title: "Former Leaf (now parent)", + }); + + // Add children to the former leaf (making it a parent) + const newLeafCoords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest, Direction.East], + }); + await createTestItem(testEnv, { + parentId: toParentId(formerLeaf.id), + coords: newLeafCoords, + title: "New Leaf", + }); + + // The completed set still has the former leaf coord (from a previous run) + const completedCoords = new Set([ + CoordSystem.createId(formerLeafCoords), + ]); + + const result = await leafTraversalService.getNextIncompleteLeaf( + rootCoordId, + completedCoords + ); + + // Should return the new leaf, not the former leaf + expect(result.leafCoords).toBe(CoordSystem.createId(newLeafCoords)); + // The former leaf should NOT be in allLeafCoords since it has children now + expect(result.allLeafCoords).not.toContain( + CoordSystem.createId(formerLeafCoords) + ); + }); + + it("returns allLeafCoords alongside the next leaf", async () => { + const testParams = _createUniqueTestParams(); + const rootMap = await _setupBasicMap(testEnv.service, testParams); + const rootItem = rootMap.items[0]!; + const rootCoordId = rootItem.coords; + + // Create three children + const child1Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.NorthWest], + }); + const child2Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.East], + }); + const child3Coords = _createTestCoordinates({ + userId: testParams.userId, + groupId: testParams.groupId, + path: [Direction.West], + }); + + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child1Coords, + title: "Child 1", + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child2Coords, + title: "Child 2", + }); + await createTestItem(testEnv, { + parentId: toParentId(rootItem.id), + coords: child3Coords, + title: "Child 3", + }); + + const result = await leafTraversalService.getNextIncompleteLeaf( + rootCoordId, + new Set() + ); + + expect(result.allLeafCoords).toHaveLength(3); + expect(result.allLeafCoords).toContain(CoordSystem.createId(child1Coords)); + expect(result.allLeafCoords).toContain(CoordSystem.createId(child2Coords)); + expect(result.allLeafCoords).toContain(CoordSystem.createId(child3Coords)); + }); + }); +}); diff --git a/src/lib/domains/mapping/services/_traversal-services/_leaf-traversal.service.ts b/src/lib/domains/mapping/services/_traversal-services/_leaf-traversal.service.ts new file mode 100644 index 000000000..c6e59fa94 --- /dev/null +++ b/src/lib/domains/mapping/services/_traversal-services/_leaf-traversal.service.ts @@ -0,0 +1,195 @@ +import type { ItemQueryService } from "~/lib/domains/mapping/services/_item-services"; +import { CoordSystem, Direction } from "~/lib/domains/mapping/utils"; + +/** + * Result of finding the next incomplete leaf tile + */ +export interface NextLeafResult { + /** Coordinates of the next incomplete leaf, or null if all complete */ + leafCoords: string | null; + /** All leaf coordinates in traversal order */ + allLeafCoords: string[]; +} + +/** + * Dependencies required by LeafTraversalService + */ +export interface LeafTraversalServiceDeps { + itemQueryService: ItemQueryService; +} + +/** + * Service for traversing tile hierarchies to find leaf tiles. + * + * A leaf tile is a tile that has no structural children (directions 1-6). + * Composed children (directions -1 to -6) and hexplan children (direction 0) + * are not considered when determining if a tile is a leaf. + */ +export class LeafTraversalService { + private readonly itemQueryService: ItemQueryService; + + constructor(deps: LeafTraversalServiceDeps) { + this.itemQueryService = deps.itemQueryService; + } + + /** + * Get all leaf tiles under a root coordinate. + * + * Traverses the hierarchy depth-first in direction order (1, 2, 3, 4, 5, 6). + * Returns coordinates of tiles that have no structural children. + * The root itself is not included even if it has no children. + * + * @param rootCoords - The root coordinate ID to start traversal from + * @returns Array of leaf coordinate IDs in traversal order + */ + async getAllLeafTiles(rootCoords: string): Promise { + const leaves: string[] = []; + await this._collectLeavesUnderRoot(rootCoords, leaves); + return leaves; + } + + /** + * Get the next incomplete leaf tile. + * + * Traverses the hierarchy to find the first leaf that is not in the + * completedCoords set. + * + * @param rootCoords - The root coordinate ID to start traversal from + * @param completedCoords - Set of coordinate IDs that have been completed + * @returns Result containing the next leaf coords (or null) and all leaf coords + */ + async getNextIncompleteLeaf( + rootCoords: string, + completedCoords: Set + ): Promise { + const allLeafCoords = await this.getAllLeafTiles(rootCoords); + + // Find first leaf not in completed set + const nextLeaf = allLeafCoords.find( + (leafCoord) => !completedCoords.has(leafCoord) + ); + + return { + leafCoords: nextLeaf ?? null, + allLeafCoords, + }; + } + + /** + * Collect leaf tiles under a root coordinate. + * The root itself is not included in the results. + */ + private async _collectLeavesUnderRoot( + rootCoordId: string, + leaves: string[] + ): Promise { + const rootCoord = CoordSystem.parseId(rootCoordId); + const rootItem = await this.itemQueryService.getItemByCoords({ + coords: rootCoord, + }); + + // Get structural children of the root (id is string, convert to number) + const rootItemIdNum = parseInt(rootItem.id, 10); + const rootStructuralChildren = await this._getStructuralChildren(rootItemIdNum); + + // If root has no children, return empty (root itself is not a leaf) + if (rootStructuralChildren.length === 0) { + return; + } + + // Sort children by direction order and collect leaves from each + const sortedChildren = this._sortChildrenByDirection(rootStructuralChildren); + + for (const child of sortedChildren) { + await this._collectLeaves(child.coords, leaves); + } + } + + /** + * Recursively collect leaf tiles from a given coordinate. + * This coordinate IS included if it's a leaf. + */ + private async _collectLeaves( + coordId: string, + leaves: string[] + ): Promise { + const coord = CoordSystem.parseId(coordId); + const item = await this.itemQueryService.getItemByCoords({ coords: coord }); + + // Get structural children (directions 1-6 only) + const itemIdNum = parseInt(item.id, 10); + const structuralChildren = await this._getStructuralChildren(itemIdNum); + + if (structuralChildren.length === 0) { + // This is a leaf tile + leaves.push(coordId); + return; + } + + // Sort children by direction order and recurse + const sortedChildren = this._sortChildrenByDirection(structuralChildren); + + for (const child of sortedChildren) { + await this._collectLeaves(child.coords, leaves); + } + } + + /** + * Sort children by their direction value (1, 2, 3, 4, 5, 6). + */ + private _sortChildrenByDirection( + children: { id: string; coords: string }[] + ): { id: string; coords: string }[] { + return children.sort((childA, childB) => { + const coordA = CoordSystem.parseId(childA.coords); + const coordB = CoordSystem.parseId(childB.coords); + const directionA = coordA.path[coordA.path.length - 1] ?? 0; + const directionB = coordB.path[coordB.path.length - 1] ?? 0; + return directionA - directionB; + }); + } + + /** + * Get structural children (directions 1-6) for a tile. + * Excludes composed children (negative directions) and hexplan (direction 0). + */ + private async _getStructuralChildren( + itemId: number + ): Promise<{ id: string; coords: string }[]> { + const descendants = await this.itemQueryService.getDescendants({ + itemId, + includeComposition: false, + }); + + // Filter to only direct structural children (depth = parent depth + 1) + // and only positive directions 1-6 + const item = await this.itemQueryService.getItemById({ itemId }); + const parentCoord = CoordSystem.parseId(item.coords); + const parentDepth = parentCoord.path.length; + + return descendants + .filter((descendant) => { + const descendantCoord = CoordSystem.parseId(descendant.coords); + const descendantDepth = descendantCoord.path.length; + + // Must be exactly one level deeper + if (descendantDepth !== parentDepth + 1) { + return false; + } + + // Get the last direction (the one leading to this child) + const lastDirection = descendantCoord.path[descendantCoord.path.length - 1]; + + // Must be a structural direction (1-6) + return ( + lastDirection !== undefined && + lastDirection >= Direction.NorthWest && + lastDirection <= Direction.West + ); + }) + .map((descendant) => ({ + id: descendant.id, + coords: descendant.coords, + })); + } +} diff --git a/src/lib/domains/mapping/services/_traversal-services/dependencies.json b/src/lib/domains/mapping/services/_traversal-services/dependencies.json new file mode 100644 index 000000000..e1d387a40 --- /dev/null +++ b/src/lib/domains/mapping/services/_traversal-services/dependencies.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../../../../../scripts/checks/architecture/dependencies.schema.json", + "allowed": [ + "~/lib/domains/mapping/services/_item-services" + ], + "subsystems": [], + "exceptions": {} +} diff --git a/src/lib/domains/mapping/services/_traversal-services/index.ts b/src/lib/domains/mapping/services/_traversal-services/index.ts new file mode 100644 index 000000000..819d4274d --- /dev/null +++ b/src/lib/domains/mapping/services/_traversal-services/index.ts @@ -0,0 +1,5 @@ +export { + LeafTraversalService, + type NextLeafResult, + type LeafTraversalServiceDeps, +} from "~/lib/domains/mapping/services/_traversal-services/_leaf-traversal.service"; diff --git a/src/lib/domains/mapping/services/dependencies.json b/src/lib/domains/mapping/services/dependencies.json index b17a04247..89d905edc 100644 --- a/src/lib/domains/mapping/services/dependencies.json +++ b/src/lib/domains/mapping/services/dependencies.json @@ -10,7 +10,8 @@ "~/server/db" ], "subsystems": [ - "./_item-services" + "./_item-services", + "./_traversal-services" ], "exceptions": {} } \ No newline at end of file diff --git a/src/lib/domains/mapping/services/index.ts b/src/lib/domains/mapping/services/index.ts index f5afaa81e..11a6c2e94 100644 --- a/src/lib/domains/mapping/services/index.ts +++ b/src/lib/domains/mapping/services/index.ts @@ -6,4 +6,9 @@ export { ItemQueryService } from "~/lib/domains/mapping/services/_item-services" export { ItemHistoryService } from "~/lib/domains/mapping/services/_item-services"; export { ItemContextService, type HexecuteContext } from "~/lib/domains/mapping/services/_item-services"; export { MappingUtils } from "~/lib/domains/mapping/services/_mapping-utils"; +export { + LeafTraversalService, + type NextLeafResult, + type LeafTraversalServiceDeps, +} from "~/lib/domains/mapping/services/_traversal-services"; // export * from "./adapters"; From e2b3bf21f722af9d9cc3e3d323a7a692281189da Mon Sep 17 00:00:00 2001 From: Diplow Date: Tue, 20 Jan 2026 15:11:22 +0100 Subject: [PATCH 02/27] feat(agentic): add response parser and execution instructions for API orchestration - Add parseAgentResponse utility to extract structured status from agent responses - Update SYSTEM template with execution-instructions section and blockage context - Remove orchestrator template - orchestration now handled at API layer - Add wasBlocked/blockageReason fields to PromptData for resuming blocked runs - Update tests and documentation Plan 3 of run-orchestration feature. Co-Authored-By: Claude Opus 4.5 --- src/lib/domains/agentic/templates/README.md | 18 +-- .../__tests__/system-template.test.ts | 128 +++++++++++++++++ .../_hexrun-orchestrator-template.ts | 134 ------------------ .../agentic/templates/_internals/types.ts | 4 + .../agentic/templates/_prompt-builder.ts | 15 +- .../agentic/templates/_system-template.ts | 41 +++++- src/lib/domains/agentic/templates/index.ts | 8 -- src/lib/domains/agentic/utils/README.md | 100 +++++++++++++ .../utils/__tests__/prompt-builder.test.ts | 132 +++++------------ .../utils/__tests__/response-parser.test.ts | 131 +++++++++++++++++ .../domains/agentic/utils/_response-parser.ts | 94 ++++++++++++ src/lib/domains/agentic/utils/index.ts | 3 + 12 files changed, 548 insertions(+), 260 deletions(-) create mode 100644 src/lib/domains/agentic/templates/__tests__/system-template.test.ts delete mode 100644 src/lib/domains/agentic/templates/_hexrun-orchestrator-template.ts create mode 100644 src/lib/domains/agentic/utils/README.md create mode 100644 src/lib/domains/agentic/utils/__tests__/response-parser.test.ts create mode 100644 src/lib/domains/agentic/utils/_response-parser.ts diff --git a/src/lib/domains/agentic/templates/README.md b/src/lib/domains/agentic/templates/README.md index 0d863b41f..7fa9e0970 100644 --- a/src/lib/domains/agentic/templates/README.md +++ b/src/lib/domains/agentic/templates/README.md @@ -48,18 +48,15 @@ Template tiles use a special `itemType: "template"` and include: **SYSTEM Template** (`_system-template.ts`) - Used for executable task tiles -- Renders: hexrun-intro, ancestor-context, context, subtasks, task, hexplan +- Renders: hexrun-intro, execution-context (if blocked), ancestor-context, context, subtasks, task, hexplan, execution-instructions - Supports iterative hexrun execution pattern +- Includes status block instructions for API orchestration (agents report completion via `` blocks) **USER Template** (`_user-template.ts`) - Used for user root tiles (interlocutor mode) - Renders: user-intro, context, sections, recent-history, discussion, user-message - Optimized for conversational interaction -**HEXRUN Orchestrator Template** (`_hexrun-orchestrator-template.ts`) -- Triggered when SYSTEM tiles are executed via @-mention in chat -- Wraps task execution in an orchestration loop using MCP tools - ### Seeding Built-in Templates Templates are seeded to the database using a dedicated script: @@ -87,10 +84,6 @@ See `drizzle/seeds/templates.seed.ts` for implementation details. // Build execution-ready XML prompt from task data function buildPrompt(data: PromptData): string -// Orchestrator functions (for @-mention triggered execution) -function shouldUseOrchestrator(itemType: MapItemType, userMessage: string | undefined): boolean -function buildOrchestratorPrompt(data: OrchestratorPromptInput): string - // Input data structure interface PromptData { task: { title: string; content: string | undefined; coords: string } @@ -101,8 +94,10 @@ interface PromptData { mcpServerName: string allLeafTasks?: Array<{ title: string; coords: string }> itemType: MapItemType // Required - determines which template to use - discussion?: string // For USER tiles and orchestrator - userMessage?: string // Triggers orchestrator mode for SYSTEM tiles + discussion?: string // For USER tiles + userMessage?: string // Optional instruction for execution + wasBlocked?: boolean // Whether resuming from blocked state + blockageReason?: string // Reason for previous blockage } ``` @@ -118,7 +113,6 @@ templates/ ├── _prompt-builder.ts # Core implementation (template lookup, rendering) ├── _system-template.ts # SYSTEM tile template and data types ├── _user-template.ts # USER tile template and data types -├── _hexrun-orchestrator-template.ts # @-mention orchestration template ├── _pre-processor/ # {{@Template}} tag expansion │ ├── index.ts │ ├── _parser.ts diff --git a/src/lib/domains/agentic/templates/__tests__/system-template.test.ts b/src/lib/domains/agentic/templates/__tests__/system-template.test.ts new file mode 100644 index 000000000..a8b3e97fc --- /dev/null +++ b/src/lib/domains/agentic/templates/__tests__/system-template.test.ts @@ -0,0 +1,128 @@ +/** + * System Template Tests + * + * Tests for the SYSTEM template execution context and instructions sections. + */ + +import { describe, it, expect } from 'vitest' +import Mustache from 'mustache' +import { + SYSTEM_TEMPLATE, + type SystemTemplateData, + HEXRUN_INTRO, + EXECUTION_CONTEXT_SECTION, + EXECUTION_INSTRUCTIONS_SECTION +} from '~/lib/domains/agentic/templates/_system-template' + +describe('SYSTEM template', () => { + describe('execution context', () => { + it('renders blockage context when wasBlocked is true', () => { + const data: Partial = { + wasBlocked: true, + blockageReason: 'Missing API key' + } + + const rendered = Mustache.render(EXECUTION_CONTEXT_SECTION, data) + + expect(rendered).toContain('') + expect(rendered).toContain('') + expect(rendered).toContain('Missing API key') + expect(rendered).toContain('blocker has been addressed') + }) + + it('omits blockage context when wasBlocked is false', () => { + const data: Partial = { + wasBlocked: false, + blockageReason: '' + } + + const rendered = Mustache.render(EXECUTION_CONTEXT_SECTION, data) + + expect(rendered.trim()).toBe('') + }) + + it('escapes HTML in blockageReason', () => { + // Using triple braces {{{ }}} for raw output, so the caller must escape + // The template renders the value as-is, so we test with pre-escaped input + const escapedData: Partial = { + wasBlocked: true, + blockageReason: '<script>alert("xss")</script>' + } + + const rendered = Mustache.render(EXECUTION_CONTEXT_SECTION, escapedData) + + expect(rendered).toContain('<script>') + expect(rendered).not.toContain('