diff --git a/.claude/commands/README.md b/.claude/commands/README.md deleted file mode 100644 index 67fcb3584..000000000 --- a/.claude/commands/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Cycle Execution Phase - -## Purpose -Do the actual work - implement features, fix bugs, write tests, document changes. - -## Duration -Days to weeks - the longest phase of the cycle. - -## Critical Context: Current Plan - -**Before executing, review the plan from Planification phase:** -- Check `cycle-{date}.md` for task breakdown -- Check `.workflow/current.json` β†’ `progress.planification.issue_ids` -- Remember: Every task serves the priorities, which serve the high-level goal - -## Execution Check-in - -Regular progress assessment: - -- **Tasks completed**: X/Y from the plan -- **Current focus**: [Active task/issue] -- **Blockers**: [Any impediments] -- **Next action**: [Immediate next step] - -Key questions: -- Are you on track with the plan? -- Do priorities need adjustment based on discoveries? -- Any unexpected technical challenges? -- Is the work still serving the high-level goal? - -## Available Commands - -During execution, use these commands: -- `/implementation` - Code implementation guidance -- `/refactor` - Improve code quality -- `/refactor-clarity` - Apply Rule of 6 refactoring -- `/walkthrough` - Document how things work - -## Staying Focused - -The AI will help you stay on track by: -- Detecting when you're drifting from current priority -- Suggesting whether to defer meta-work to retrospective -- Capturing ideas without acting on them immediately -- Providing gentle reminders about current goals - -These behaviors are defined in CLAUDE.md and applied automatically. - -## Daily Practice - -1. Check current task in workflow state -2. Use appropriate execution command -3. Update progress in workflow state -4. Note any blockers or discoveries -5. Prepare for next task - -## Output -- Completed code changes -- Updated documentation -- Test coverage -- Progress updates in `.workflow/current.json` - -## Next Phase -When cycle tasks are complete β†’ Move to **Retrospective** to learn from the cycle \ No newline at end of file diff --git a/.claude/commands/fix-architecture.md b/.claude/commands/fix-architecture.md deleted file mode 100644 index aba51e5c2..000000000 --- a/.claude/commands/fix-architecture.md +++ /dev/null @@ -1,166 +0,0 @@ -# Fix Architecture Command - -You are an architecture compliance specialist. Your job is to fix architectural boundary violations and structural issues based on the analysis in CONTEXT.md. - -## Mission - -Execute the architecture fixes planned by `plan-quality-fix`, ensuring proper subsystem boundaries, dependencies, and architectural patterns are established. - -## Prerequisites - -- CONTEXT.md exists in the target folder with architecture analysis -- `pnpm check:lint && pnpm typecheck && pnpm check:deadcode [folder]` passes -- Git working directory is clean - -## Process - -### 1. Read Documentation & Context - -First, read relevant documentation: -- `scripts/checks/deadcode/README.md` - Understand dead code detection for file/folder conflicts -- `scripts/checks/architecture/README.md` - Understand rules, error types, and requirements - -Then read `[folder]/CONTEXT.md` to understand: -- Target violations to fix in this session -- Files to modify and their interdependencies -- Architectural context and subsystem structure - -Also read the dependency schema: -- `schemas/dependencies.schema.json` - Understand proper format for dependencies.json files - -### 2. Execute Architecture Fixes - -Work through violations in this **architectural priority order**: - -#### A. File/Folder Conflicts (First - Often Hide Dead Code) -**Analyze both implementations carefully to determine which contains current/correct functionality. Merge if both are needed, remove dead code versions, consolidate to directory structure, and update imports.** - -**Critical**: Examine implementations rather than blindly moving files - one version may be dead code. - -#### B. Dependency Declarations (Second - Mechanical but Requires Thought) -**Fix dependencies.json format while minimizing surface exposure. Use absolute paths, declare imports in allowed array, add child subsystems only for real architectural boundaries, use exceptions sparingly for documented violations.** - -**Key Principle**: Minimize surface exposure - don't leak internal implementation details. - -#### C. Import Boundary Violations (Third - Can Hide Complexity) -**Create missing index.ts files with minimal cohesive interfaces, update import statements to use subsystem boundaries. Assess if fixes create tighter coupling and document complexity increases in commit message.** - -**Warning**: Fixing import boundaries can sometimes increase coupling. Acknowledge this trade-off. - -#### D. Complexity Requirements (Last - Major Architectural Decisions) -**Subsystem creation usually involves refactoring, not just documentation**: - -1. **Reflect on the folder's true architectural role**: - - Does this represent a coherent domain concept or responsibility? - - Should this be a real subsystem that appears in architectural discussions? - - Even if the folder "accidentally" became a good boundary, consciously decide if it should be a subsystem - -2. **Most often, create NEW subsystems through refactoring**: - - Extract related functionality into a new dedicated subsystem folder - - Move cohesive responsibilities from the current folder into the new subsystem - - Establish clear interfaces between the original folder and new subsystem - - Update dependencies and imports to reflect the new structure - - **Key benefit**: This naturally reduces the parent folder's line count, solving the complexity violation through proper architectural separation - -3. **Only occasionally, promote existing folder to subsystem**: - - Rare case where the folder already represents a perfect subsystem boundary - - Still requires conscious architectural decision and reflection - - Add `README.md`, `dependencies.json`, and `ARCHITECTURE.md` to formalize it - -**Critical**: Creating a subsystem is a significant architectural decision that usually involves refactoring code structure, not just adding documentation files. - -### 3. Incremental Verification - -After each major change: - -```bash -pnpm check:lint && pnpm typecheck && pnpm check:deadcode [folder] -``` - -Fix any issues immediately before proceeding. - -### 4. Final Validation - -Run full verification suite: - -```bash -pnpm check:lint -pnpm typecheck -pnpm check:deadcode [folder] -pnpm check:architecture [folder] # Should show improvement -``` - -### 5. Git Commit - -Create a single focused commit: - -```bash -git add [modified-files] -git commit -m "refactor: fix architecture violations in [folder-name] - -- Add [count] missing dependencies.json files -- Create [count] subsystem index.ts interfaces -- Fix [count] import boundary violations -- Resolve [count] file/folder conflicts - -πŸ€– Generated with Claude Code" -``` - -## Error Recovery - -If verification fails after changes: -1. Check specific TypeScript errors for import path issues -2. Verify dependencies.json syntax matches schema -3. Ensure all reexports are properly typed -4. Use `git checkout -- [file]` to revert problematic changes -5. Make smaller, incremental changes - -## Success Criteria - -- All target violations from CONTEXT.md are addressed -- `pnpm check:lint && pnpm typecheck && pnpm check:deadcode [folder]` passes -- `pnpm check:architecture [folder]` shows measurable improvement -- Single clean commit with descriptive message -- No functionality broken, only architectural structure improved -- New dependencies.json files follow schema exactly - -## Exception Handling Strategy - -When no good architectural solution exists, document exceptions rather than force poor abstractions: - -**For complexity thresholds** βœ… **Now Available**: -Create `.architecture-exceptions` file in project root or any parent directory: -``` -# Folder-level complexity exceptions with higher thresholds -src/complex-legacy-folder: 2000 # Justified because [specific reason] -src/payments/legacy-processor: 1500 # Legacy system pending Q2 2024 rewrite - -# Comments explaining reasoning are required -# TODO items encouraged for planning refactor timeline -``` - -The architecture checker will: -- Walk up directory tree to find the most specific exception file -- Apply custom threshold instead of default 1000/500 limits -- Report exception usage in console: `πŸ”§ Using custom thresholds from .architecture-exceptions` -- Include exception details in JSON output with `custom_threshold`, `exception_source`, and `justification` -- Validate that paths exist and require justification comments - -**For import violations**: Use `exceptions` in dependencies.json: -```json -{ - "exceptions": { - "~/legacy/old-system": "TEMPORARY: Remove when migration complete (Q1 2024)" - } -} -``` - -**Principle**: Better to explicitly acknowledge exceptions with clear reasoning than force inappropriate architectural solutions. - -## Key Principles - -- **Schema compliance**: Dependencies.json must follow schema exactly -- **Incremental progress**: Fix one violation type at a time -- **Interface preservation**: Maintain existing public APIs -- **Clear boundaries**: Establish proper subsystem encapsulation -- **Exception transparency**: Document architectural exceptions rather than hide complexity \ No newline at end of file diff --git a/.claude/commands/fix-deadcode.md b/.claude/commands/fix-deadcode.md deleted file mode 100644 index d2e03d0c2..000000000 --- a/.claude/commands/fix-deadcode.md +++ /dev/null @@ -1,165 +0,0 @@ -# Fix Dead Code Command - -You are a dead code elimination specialist. Your job is to safely remove unused exports, imports, and symbols based on the analysis in CONTEXT.md. - -## Mission - -Execute the dead code removal plan created by `plan-quality-fix`, ensuring all changes are safe and contained in a single focused commit. - -## Prerequisites - -- CONTEXT.md exists in the target folder with dead code analysis -- `pnpm check:lint && pnpm typecheck` passes -- Git working directory is clean - -## Process - -### 1. Read Documentation & Context - -First, read the dead code checker documentation: -- `scripts/checks/deadcode/README.md` - Understand how detection works and AI-friendly commands - -Then read `[folder]/CONTEXT.md` to understand: -- Target violations to fix in this session -- Files to modify and their interdependencies -- Suspected false positives to avoid - -### 2. Execute Removals - -Work through the target violations systematically: - -**Unused Imports** (safest first): -- Remove unused import statements -- Clean up empty import blocks - -**Unused Local Symbols**: -- Remove unused variables, functions, types -- Verify they're not used in dynamic ways (reflection, string refs) - -**Unused Exports**: -- Remove unused exported functions, variables, types -- Double-check they're not part of public APIs -- Verify removal won't break external consumers - -**βœ… DELETING ENTIRE FILES (requires immediate validation)**: -- **Run `pnpm typecheck` immediately after each file deletion** -- Use TypeScript errors as your guide to find and fix broken imports -- Delete one file at a time, validate, fix imports, then proceed -- TypeScript will show you exactly which import statements to clean up - -### 3. βœ… ESSENTIAL: Incremental Verification - -**πŸ’‘ ALWAYS VALIDATE WITH TYPECHECK AFTER FILE CHANGES** - -After **EVERY** file deletion or major export removal: - -```bash -pnpm typecheck -``` - -**Why this works**: TypeScript immediately shows you which imports need updating when files are deleted. This turns potential errors into clear, actionable guidance. - -**Example workflow**: -``` -βœ… Delete file: mock-storage.ts -βœ… Run: pnpm typecheck -πŸ“ TypeScript shows: Cannot find module '~/storage/mock-storage' -πŸ“ Location: storage-operations.ts:13 -βœ… Fix: Remove the import line from storage-operations.ts -βœ… Verify: pnpm typecheck passes -``` - -Then run full checks: -```bash -pnpm check:lint && pnpm typecheck -``` - -Fix any issues immediately before proceeding to next file. - -### 4. Final Validation - -Run full verification suite: - -```bash -pnpm check:lint -pnpm typecheck -pnpm check:dead-code [folder] # Should show improvement -``` - -### 5. Git Commit - -Create a single focused commit: - -```bash -git add [modified-files] -git commit -m "refactor: remove dead code from [folder-name] - -- Remove [count] unused imports -- Remove [count] unused exports -- Remove [count] unused local symbols - -πŸ€– Generated with Claude Code" -``` - -## Safety Guidelines - -### Always Keep -- Public API exports (even if unused internally) -- Framework-required exports (Next.js pages, components) -- Test utilities that support testing patterns -- Type definitions representing domain concepts - -### Verify Before Removing -- Dynamic imports via string concatenation -- Reflection-based usage -- Build-time or test-only references -- Framework conventions you might not recognize - -### When in Doubt -- Keep the code and document why in commit message -- Add to `.deadcode-ignore` if this should be permanently excluded -- Create a separate issue to investigate further -- Err on the side of caution - -### Using .deadcode-ignore - -Add patterns to `.deadcode-ignore` when code appears unused but should be kept: - -``` -# Framework requirements -src/env.mjs -src/app/**/page.tsx - -# Test utilities -**/*.test.* -**/*.stories.* - -# Public API exports -src/lib/api/index.ts - -# Dynamic imports -src/components/dynamic/** -``` - -## Error Recovery - -If verification fails after changes: -1. Use `git status` to see what's broken -2. Fix immediately or `git checkout -- [file]` to revert -3. Re-read CONTEXT.md to understand what you missed -4. Try smaller, safer changes - -## Success Criteria - -- All target violations from CONTEXT.md are addressed -- `pnpm check:lint && pnpm typecheck` passes -- `pnpm check:dead-code [folder]` shows measurable improvement -- Single clean commit with descriptive message -- No functionality broken (focus on truly unused code) - -## Key Principles - -- **Safety first**: When uncertain, keep the code -- **Incremental progress**: Verify after each file change -- **Single responsibility**: Only remove dead code, don't refactor -- **Clear documentation**: Commit message explains what was removed and why \ No newline at end of file diff --git a/.claude/commands/fix-ruleof6.md b/.claude/commands/fix-ruleof6.md deleted file mode 100644 index 493e6f43e..000000000 --- a/.claude/commands/fix-ruleof6.md +++ /dev/null @@ -1,181 +0,0 @@ -# Fix Rule of 6 Command - -You are a Rule of 6 enforcement specialist. Your job is to fix cognitive complexity violations through intelligent refactoring based on the analysis in CONTEXT.md. - -## Mission - -Execute Rule of 6 fixes planned by `plan-quality-fix`, reducing cognitive load through meaningful abstractions and proper organization while avoiding over-engineering. - -## Prerequisites - -- CONTEXT.md exists in the target folder with Rule of 6 analysis -- `pnpm check:lint && pnpm typecheck && pnpm check:deadcode [folder] && pnpm check:architecture [folder]` passes -- Git working directory is clean - -## Process - -### 1. Read Documentation & Context - -First, read relevant documentation: -- `scripts/checks/deadcode/README.md` - Understand dead code detection (refactoring may create dead code) -- `scripts/checks/architecture/README.md` - Understand architectural boundaries (refactoring may affect them) -- `scripts/checks/ruleof6/README.md` - Understand philosophy, rules, and violation types - -Then read `[folder]/CONTEXT.md` to understand: -- Target violations to fix in this session -- Files to modify and their interdependencies -- Architectural context for making refactoring decisions - -### Understanding Domain-Aware Rule of 6 - -**New Behavior**: The Rule of 6 now separates domain items from generic infrastructure: - -**Generic Infrastructure (Excluded from Count)**: -- **Folders**: docs/, types/, utils/, components/, hooks/, __tests__/, tests/, fixtures/, mocks/, stories/ -- **Files**: README.md, index.ts/tsx, page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, *.config.*, *.test.*, *.spec.*, *.stories.*, dependencies.json - -**Domain Items (Count Toward Rule of 6)**: -- Business logic folders and files -- Feature-specific modules -- Domain-specific abstractions - -**Example**: A directory with 15 total items might only have 4 domain items (within limits) if the other 11 are generic infrastructure. - -**Impact on Refactoring**: Focus refactoring efforts on organizing domain items meaningfully rather than moving generic infrastructure files around. - -### 2. Execute Rule of 6 Fixes - -Work through violations in **impact and safety order**: - -#### A. Directory Items (First - Organizational) -**Group related domain items into meaningful subdirectories based on domain responsibility, not arbitrary criteria.** - -**Note**: Generic infrastructure (docs/, types/, utils/, hooks/, README.md, index.ts, *.test.*, etc.) is automatically excluded from the Rule of 6 count. Focus on organizing domain-specific folders and files that represent meaningful business abstractions. - -#### B. Functions per File (Second - Extract Cohesive Modules) -**Move related functions to focused modules based on domain responsibility or shared purpose, not arbitrary technical patterns.** - -#### C. Large Functions (Third - Careful Extraction) -**Extract functions only when they have multiple clear responsibilities at different abstraction levels. Keep intact when function performs sequential steps at consistent abstraction level.** - -#### D. Function Arguments (Last - Parameter Objects) -**Group related parameters into cohesive objects when they represent the same domain concept and would naturally be passed together.** - -### 3. βœ… ESSENTIAL: Incremental Verification - -**πŸ’‘ VALIDATE AFTER EACH FILE MOVE OR MAJOR EXTRACTION** - -After each major refactoring (especially file moves or function extractions): - -```bash -pnpm typecheck # Quick check for broken imports first -``` - -If typecheck passes, run full validation: -```bash -pnpm check:lint && pnpm typecheck && pnpm check:deadcode [folder] && pnpm check:architecture [folder] -``` - -**Why this works**: TypeScript immediately catches broken import paths from file moves, making issues easy to spot and fix before they accumulate. - -### 4. Final Validation - -Run full verification suite: - -```bash -pnpm check:lint -pnpm typecheck -pnpm check:deadcode [folder] # Refactoring may create dead code -pnpm check:architecture [folder] # Refactoring may affect boundaries -pnpm check:ruleof6 [folder] # Should show improvement (note: updated command name) -``` - -### 5. Git Commit - -Create a single focused commit: - -```bash -git add [modified-files] -git commit -m "refactor: fix Rule of 6 violations in [folder-name] - -- Organize [count] domain folders/files into meaningful groups -- Extract [count] functions into focused modules -- Refactor [count] functions with parameter objects -- Split [count] large functions with clear responsibilities - -Note: Generic infrastructure (docs/, utils/, *.test.*) excluded from count - -πŸ€– Generated with Claude Code" -``` - -## Refactoring Decision Framework - -### For Directory Organization -**Focus on domain items only** - generic infrastructure is automatically excluded. Group domain items by natural business boundaries (business domain, feature areas, domain concepts) rather than arbitrary splits. Don't create subdirectories just to move generic files like README.md or index.ts. - -### For Function Extraction -Extract only when function has multiple clear responsibilities or mixes abstraction levels. Keep intact for sequential implementation details at consistent abstraction level. - -### For Parameter Reduction -Create parameter objects when parameters belong to same domain concept and would naturally be passed together. Keep separate when parameters represent different concerns or are used independently. - -## Error Recovery - -If verification fails after refactoring: -1. Check for broken import paths from file moves -2. Verify new parameter objects don't break existing calls -3. Ensure extracted functions maintain proper typing -4. Use `git checkout -- [file]` to revert problematic changes -5. Make smaller, more focused refactors - -## Success Criteria - -- All target violations from CONTEXT.md are addressed -- All verification steps pass (lint, typecheck, deadcode, architecture, rule-of-6) -- Single clean commit with descriptive message -- Code is more readable and maintainable, not just compliant -- No artificial abstractions or over-engineering introduced -- Refactoring creates meaningful improvements to code organization - -## Exception Handling Strategy - -When no meaningful refactoring solution exists, use the **built-in exception handling system** to document complexity rather than create artificial abstractions: - -**Create `.ruleof6-exceptions` file** with custom thresholds: -``` -# Function-specific exceptions with custom thresholds -src/math/hex-calculations.ts:calculatePoints: 150 # Mathematical algorithm -src/legacy/parser.ts:parseComplexFormat: 200 # Legacy format parser - -# Directory-specific exceptions -src/components/forms: 12 # Cohesive form component library - -# Justification: Low-level mathematical functions with sequential logic -# Breaking apart would reduce clarity and create artificial abstractions -# TODO: Refactor when math library is updated -``` - -**Validation Features**: -- βœ… **Automatic validation**: Exception files are validated against actual code -- βœ… **Function existence**: Function-specific exceptions verify the function exists -- βœ… **Directory existence**: Directory paths are validated -- βœ… **Console output**: Custom thresholds are clearly marked with 🎯 indicator -- βœ… **JSON metadata**: Exception source and thresholds included in reports - -**Exception Philosophy**: -- Use for mathematical algorithms requiring sequential logic -- Use for framework-imposed patterns (e.g., route handlers) -- Use for cohesive domain collections that belong together -- Don't use to avoid beneficial refactoring -- Don't use to create artificial permission for complex code - -**Principle**: Better to explicitly acknowledge complexity with clear reasoning than create meaningless abstractions that reduce code clarity. - -## Key Principles - -- **Cognitive load reduction**: Make code easier for humans to understand -- **Meaningful abstractions**: Only create abstractions that add real value -- **Domain-driven organization**: Group by business domain, not arbitrary criteria -- **Single responsibility**: Each extracted unit should have one clear purpose -- **Preserve functionality**: Refactor structure, never behavior -- **Exception transparency**: Document complexity exceptions rather than hide them with poor abstractions \ No newline at end of file diff --git a/.claude/commands/hexframe.md b/.claude/commands/hexframe.md deleted file mode 100644 index 4965dfcb8..000000000 --- a/.claude/commands/hexframe.md +++ /dev/null @@ -1,25 +0,0 @@ -# Hexframe Tile Command - -## Purpose -Read a Hexframe tile by coordinates and process its content with additional instructions. - -## Syntax -``` -/hexframe -``` - -Coordinates use format `userId,groupId:path` (e.g., `1,0:5,4` or `1,0` for root). - -## Examples -``` -/hexframe 1,0 Summarize this tile -/hexframe 1,0:5,4 1,0:3 -``` -The second example reads tile at `1,0:5,4` and applies the content from tile `1,0:3` as instructions. - -## Process - -1. Parse coordinates from the first argument -2. Fetch the tile content using `mcp__hexframe__getItemByCoords` -3. Treat the tile content as the command/prompt -4. Apply remaining arguments as input to that command diff --git a/.claude/commands/hexrun.md b/.claude/commands/hexrun.md deleted file mode 100644 index d56f741ed..000000000 --- a/.claude/commands/hexrun.md +++ /dev/null @@ -1,135 +0,0 @@ -# Hexrun - Execute Hexframe Plans Step by Step - -## Purpose -Execute a hexframe plan one step at a time, spawning subagents for each step until completion or error. - -## Syntax -``` -/hexrun [instruction] -``` - -Coordinates use format `userId,groupId:path` (e.g., `abc123,0:6`). - -## Examples -``` -/hexrun fZRHqrORpUkoV14TRmtW0GA5kFV7UN0X,0:6 -/hexrun abc123,0:4 Use the debughexframe MCP server -``` - -## Key Concept: Root Coordinates - -The `` argument is the **root coord** for this execution session. Store it at the start - you'll need it throughout: -- The root hexplan lives at `{root_coords},0` -- All leaf tasks in the root hexplan are relative to this root -- The sync agent needs both root_coords and the last completed step coords - -## Process - -Execute this loop until complete or blocked: - -### 1. Get the next step prompt -Call `mcp__hexframe__hexecute` (or `mcp__debughexframe__hexecute` if specified) with the root coordinates. - -**Why hexecute**: The `hexecute` tool is responsible for gathering all necessary context from the tile hierarchy - it reads the task tile, its context children, subtask previews, and the hexplan state. The resulting prompt contains everything the subagent needs to execute one step. - -### 2. Check the response status -The response will contain one of: -- `COMPLETE` β†’ Stop, report success -- `BLOCKED` β†’ Stop, report blocker to user -- `` β†’ Continue to step 3 - -### 3. Spawn a subagent to execute the step -Use the Task tool with `subagent_type: "general-purpose"` to execute the prompt. Use opus model. - -**CRITICAL**: Pass the EXACT prompt returned by hexecute to the subagent. Do NOT summarize, interpret, or craft your own prompt. The hexecute output already includes `` that tell the subagent to execute only one step. - -The subagent will: -- Execute ONE step from the hexplan (the first πŸ“‹ step) -- Update the leaf's hexplan tile (mark that step βœ… or πŸ”΄) -- Return a summary including the coords of the step it executed - -### 4. Run the Sync Agent -After each step completes, spawn the sync agent to update the root hexplan: - -1. Call hexecute on the sync agent tile with `deleteHexplan: true` to ensure fresh execution. Use the SAME MCP server prefix that you used for step execution: - ```javascript - mcp__{mcp_prefix}__hexecute({ - taskCoords: "fZRHqrORpUkoV14TRmtW0GA5kFV7UN0X,0:1,3", - instruction: "root_coords={root_coords} last_step_coords={step_coords_from_subagent} hexframe_mcp={mcp_prefix}", - deleteHexplan: true - }) - ``` - Where `{mcp_prefix}` is either `hexframe` (default) or `debughexframe` based on the MCP server being used. - -2. Spawn a subagent with the sync agent prompt (use haiku model - sync is lightweight) - -The sync agent: -- Reads the leaf's hexplan to see what happened -- Updates the root hexplan with progress -- Detects meta-leaf expansions and adds new tasks -- Returns a brief sync summary - -### 5. Check results and repeat -- If sync agent reports blocked β†’ Stop and report to user -- If error β†’ Stop and report to user -- Otherwise β†’ Go back to step 1 - -Continue until `COMPLETE` or an error occurs. - -## User Control Points - -Between each step, the user can: -- Press Ctrl+C to stop execution -- Review the hexplan to see progress -- Edit the hexplan to modify upcoming steps -- Resume by running `/hexrun` again with the same coordinates - -## Handling User Input (Hexrun Feedback Loop) - -A HEXRUN is an iterative execution loop where the same tile may be executed multiple times. Between hexruns, the hexplan evolves with feedback and progress updates. The executing agent is instructed (via `` in the prompt) to look for "Feedback from last HEXRUN:" notes in the hexplan. - -When the user provides feedback or wants to change the agent's behavior: - -### 1. Edit the leaf's hexplan with feedback -Find the step that needs adjustment and update its hexplan tile at `{step_coords},0`: -- Add feedback using the prefix: `Feedback from last HEXRUN: ` -- The agent will read this on the next hexrun and incorporate the guidance - -Example: If step `[1,2,3]` needs adjustment, edit the tile at `[1,2,3,0]` to include the feedback. - -### 2. Reset the step in the root hexplan -Edit the root hexplan at `{root_coords},0` to mark the completed step (βœ…) back to pending (πŸ“‹): -- This tells hexecute to re-run that step -- Without this, the step would be skipped as "already done" - -Example workflow: -```text -Feedback: "Actually, use TypeScript instead of JavaScript for step 3" - -1. Read root hexplan at {root_coords},0 -2. Find "βœ… Step 3: Generate code" and change to "πŸ“‹ Step 3: Generate code" -3. Edit leaf hexplan at {step_3_coords},0 to add: "Feedback from last HEXRUN: Use TypeScript instead of JavaScript" -4. Resume with /hexrun {root_coords} -``` - -The agent will re-execute the step, see the feedback in the hexplan, and incorporate it. - -## MCP Server Selection - -By default, uses `mcp__hexframe__*` tools. To use the debug server, include it in the instruction: -``` -/hexrun abc123,0:6 Use the debughexframe MCP server -``` - -**Important**: When using a specific MCP server, use it consistently throughout: -- Step execution: `mcp__{mcp_prefix}__hexecute` -- Sync agent: Pass `hexframe_mcp={mcp_prefix}` in the instruction so it uses the same server - -## Implementation Notes - -- Each step runs in a separate subagent (Claude Code limitation: no nested subagents) -- Progress is persisted in hexplan tiles, making execution resumable -- Root hexplan lists ALL leaf tasks for single-pass execution tracking -- Sync agent runs after each step to propagate status to root hexplan -- Sync agent uses haiku (cheap/fast), step execution uses opus (capable) -- Meta-leaf expansion: if a leaf creates children, sync agent adds them to root hexplan diff --git a/.claude/commands/plan-quality-fix.md b/.claude/commands/plan-quality-fix.md deleted file mode 100644 index 52cb860ce..000000000 --- a/.claude/commands/plan-quality-fix.md +++ /dev/null @@ -1,153 +0,0 @@ -# Plan Quality Fix Command - -You are a code quality orchestrator that continuously improves folder quality through automated child command dispatch. - -## Mission - -Analyze a folder's quality violations and execute focused fixes through specialist child agents until all errors are eliminated or maximum iterations reached. - -**Fully Automated**: This command runs iteratively, dispatching appropriate child commands and updating context until the folder is clean or problems are detected with the fixing process. - -## Process - -### Initial Setup - -**Read Check Documentation** (once at start): -- `scripts/checks/deadcode/README.md` -- `scripts/checks/architecture/README.md` -- `scripts/checks/ruleof6/README.md` - -### Automated Iteration Loop - -Execute the following cycle up to 10 iterations or until all quality checks pass: - -#### 1. Pre-Quality Check - -Ensure basic code standards before proceeding: - -```bash -pnpm check:lint && pnpm typecheck -``` - -If these fail, stop and report the issues. - -#### 2. Git History Review - -Analyze recent commits to track progress: - -```bash -git log --oneline -10 [folder] -``` - -#### 3. Sequential Quality Assessment - -Run checks **in priority order** and stop at first category with errors: - -```bash -pnpm check:deadcode [folder] # Priority 1: Stop here if errors found -pnpm check:architecture [folder] # Priority 2: Only if dead code is clean -pnpm check:ruleof6 [folder] # Priority 3: Only if architecture is clean -``` - -If all checks pass, declare victory and exit. - -**Volume Check**: If total violations exceed 20, break the loop and recommend starting with a focused subfolder instead (see Subfolder Strategy below). - -#### 5. Analysis & Child Dispatch - -**Verify Check Results**: Review violations for false positives or check script bugs. - -**Create/Update Context**: Generate `[folder]/CONTEXT.md` with: - -```markdown -# Quality Fix Context: [Folder Name] - Iteration [N] - -## Folder Responsibilities -[Describe the primary purpose and responsibilities of this folder] - -## Subsystem Architecture -[Explain how different subsystems/components in this folder work together] - -## Previous Quality Fix Commits -- [commit-hash]: [commit message] - -## Current Target: [deadcode/architecture/ruleof6] - -### Target Violations -- [List specific violations to fix in this session] -- [Group related violations that should be fixed together] - -### Files to Modify -- [List specific files that need changes] -- [Note any interdependencies between changes] - -### Verification Steps -1. `pnpm check:lint` - Must pass -2. `pnpm typecheck` - Must pass -3. `pnpm check:[target-type] [folder]` - Must show improvement -``` - -**Dispatch Child Agent**: Use the Task tool to invoke the appropriate child command: -- `fix-deadcode` for dead code violations -- `fix-architecture` for architecture violations -- `fix-ruleof6` for Rule of 6 violations - -#### 6. Monitor Child Results - -After child completes: -- Verify the child made progress (fewer violations) -- If child failed to improve violations, stop and report the problem -- If child succeeded, continue to next iteration - -### Termination Conditions - -Stop the iteration loop when: -1. **Success**: All quality checks pass -2. **Max iterations**: 10 iterations reached -3. **Child failure**: Child agent fails to improve violations -4. **Lint/typecheck failure**: Basic code standards broken -5. **Too many violations**: >20 violations found (recommend subfolder approach) - -### Subfolder Strategy - -When violations exceed 20, identify the best subfolder candidate: - -1. **Check for identified subsystems**: Look for folders with `dependencies.json`, `README.md`, or `ARCHITECTURE.md` -2. **Analyze violation distribution**: Find subfolders with moderate violation counts (5-15) -3. **Avoid extremes**: Skip subfolders with too few violations (<5) or too many (>15) - -**Recommendation format**: -``` -Too many violations found (N total). Recommend starting with focused subfolder: - -Suggested target: [folder]/[subfolder-name] -- Estimated violations: [count] -- Reasoning: [identified subsystem/moderate complexity/etc.] -- Command: Run plan-quality-fix on [folder]/[subfolder-name] first - -After completing this subfolder, return to full folder cleanup. -``` - -### Final Report - -Provide summary of: -- Total iterations performed -- Violations fixed per category -- Final quality status -- Any remaining issues or recommendations - -## Key Principles - -- **Automated orchestration**: Continuously dispatch child agents until completion -- **Sequential priority**: Dead code β†’ Architecture β†’ Rule of 6 (later fixes create earlier issues) -- **Progress verification**: Each child must improve violations or process stops -- **Context preservation**: Track progress and architecture understanding across iterations -- **Fail-safe limits**: Maximum 10 iterations to prevent infinite loops - -## Success Criteria - -Command succeeds when: -1. All quality checks pass (dead code, architecture, Rule of 6) -2. Each child agent makes measurable progress on violations -3. Basic code standards maintained throughout (lint + typecheck) -4. Context and architecture understanding documented for future reference \ No newline at end of file diff --git a/.claude/commands/refactor-clarity.md b/.claude/commands/refactor-clarity.md deleted file mode 100644 index 4a66e5491..000000000 --- a/.claude/commands/refactor-clarity.md +++ /dev/null @@ -1,525 +0,0 @@ -# Refactor for Clarity Guide - -This guide provides principles and practices for refactoring code to improve clarity and readability. - -## The Fundamental Rule - -For any function: -- **The name** is enough to understand **what it does** -- **The arguments** are enough to understand **what it needs** to do what it does -- **The content** is enough to understand **how it does** what it does with what it needs - -This creates a clear hierarchy of understanding: -1. Read the name β†’ Know the purpose -2. Read the signature β†’ Know the dependencies -3. Read the body β†’ Know the implementation - -## The Hexframe Code Structure - -Apply the hexframe's hierarchical model to code organization: - -- **Folder = Frame**: Contains max 6 child folders + max 6 files - - Child folders represent child Tiles - - One primary file (e.g., page.tsx, index.ts) acts as the CenterTile - - Other files (layout.tsx, README.md, types.ts) are metadata "inside" the CenterTile -- **File = Tile**: Contains max 6 functions -- **Function = Child Tile**: Contains max 50 lines (relaxed from 6) -- **Arguments = Child Properties**: Max 6 arguments (prefer 3, or 1 object with max 6 keys) -- **Line = Leaf Tile**: Single responsibility statement - -This creates consistent depth and breadth limits throughout the codebase, mirroring the visual hierarchy of hexframe maps. - -**Key insight**: Just as a Frame has a CenterTile with surrounding children, a folder has a primary file (its center) with supporting metadata files that are "composed" with it (like layout.tsx composing with page.tsx). - -### Benefits of the Rule of 6: -- Prevents overwhelming complexity at any level -- Forces decomposition and better organization -- Makes navigation predictable and intuitive -- Aligns code structure with the hexframe mental model - -## Core Principles - -### 1. Naming is Communication -- Use descriptive, intention-revealing names -- Avoid abbreviations and acronyms -- Names should answer "what" and "why", not "how" - -### 2. Establish Domain Language -- When complexity reveals important concepts, they need precise definitions -- Complex naming decisions should be validated with the team/user -- Document new terms in the relevant domain or component README (e.g., `src/app/map/Canvas/README.md`) -- Refactoring is the perfect time to identify missing domain concepts - -**Signs a concept needs definition:** -- Multiple functions/files dealing with the same complex idea -- Difficulty naming something clearly in a few words -- Repeated explanatory comments about the same concept -- Confusion or ambiguity in code reviews - -### 3. Single Responsibility -- Each function/component should do one thing well -- If you need "and" to describe what it does, it's doing too much - -### 4. Reduce Cognitive Load -- Minimize the amount of context needed to understand code -- Make dependencies explicit -- Group related functionality together - -### 5. Know Your Domains -- Read domain README files to understand capabilities -- Only use domains that have documentation (README.md) -- Reuse existing concepts before creating new ones -- Link to domain capabilities rather than reimplementing - -## Practical Guidelines - -### Function Refactoring -- Extract complex conditions into well-named boolean functions -- Replace magic numbers/strings with named constants -- Keep functions short (follow the 50-line rule from CLAUDE.md) - -### Component Refactoring -- Separate concerns: UI, logic, and data fetching -- Extract reusable patterns into custom hooks or utilities -- Use composition over complex conditional rendering - -### Data Flow Clarity -- Make data transformations explicit and traceable -- Avoid hidden side effects -- Prefer immutable operations - -## Examples - -### Applying the Fundamental Rule - -**Good Example:** -```typescript -// Name tells you WHAT: gets a color -// Arguments tell you WHAT IT NEEDS: coordinates -// Body tells you HOW: (implementation details) -getColor(coords) -``` - -**Needs Refactoring:** -```typescript -// This function violates the rule - it does too many things -async function StaticCreateItemForm({...}: Props) { - // 1. Formats display strings - // 2. Calculates colors - // 3. Fetches hierarchy data - // 4. Transforms data - // 5. Renders UI - // The name doesn't tell us it's doing all these things! -} -``` - -**Better Approach:** -```typescript -// Each function does one thing, clearly named -async function fetchHierarchyData(rootItemId: string) { ... } -function formatCoordinatesDisplay(coords: HexCoord): string { ... } -function buildTileData(items: MapItemAPIContract[]): Record { ... } -function CreateItemForm({hierarchy, displayCoords, ...}: Props) { ... } -``` - -### Applying the Rule of 6 to Arguments - -**Too Many Arguments:** -```typescript -// BAD: Too many arguments -function createTile(id: string, name: string, x: number, y: number, - color: string, size: number, parentId: string, - description: string) { ... } -``` - -**Good: Group into Logical Objects (max 6 keys each):** -```typescript -interface TileConfig { - name: string; - description: string; - color: string; - size: number; -} - -interface TilePosition { - id: string; - x: number; - y: number; - parentId: string; -} - -// Now max 2 arguments, each object has max 6 properties -function createTile(config: TileConfig, position: TilePosition) { ... } -``` - -**Even Better: Single Object When All Related:** -```typescript -interface CreateTileParams { - id: string; - name: string; - position: { x: number; y: number }; // Nested objects count as 1 key - appearance: { color: string; size: number }; - relationships: { parentId: string }; - metadata: { description: string }; -} - -// Single argument with clear structure (6 top-level keys) -function createTile(params: CreateTileParams) { ... } -``` - -### Applying the Hexframe Structure - -**Current Structure (violates Rule of 6):** -``` -create/ - create-item.tsx (269 lines, 1 giant function) -``` - -**Refactored Structure:** -``` -create/ - # Primary file (CenterTile) - page.tsx (main component, <50 lines) - - # Metadata files (inside CenterTile, max 6) - layout.tsx (composes with page.tsx) - types.ts (type definitions) - validation.utils.ts (validation logic) - - # Child folders (child Tiles, max 6) - _components/ - form-fields.tsx - submit-button.tsx - _hooks/ - use-hierarchy.ts - use-coordinates.ts - _utils/ - data-fetcher.ts - formatters.ts -``` - -Each folder is a Frame with its own CenterTile (primary file) and supporting metadata. The structure mirrors hexframe's visual hierarchy. - -### Identifying Concepts During Refactoring - -Looking at `create-item.tsx`, we see opportunities to use existing concepts: -```typescript -// Line 33-34: Coordinate formatting -// CURRENT: Manual string concatenation -const coordsDisplay = `${targetCoords.userId},${targetCoords.groupId}:${targetCoords.path.join(",")}`; -const coordId = CoordSystem.createId(targetCoords); - -// BETTER: Both operations use existing CoordSystem -const coordId = CoordSystem.createId(targetCoords); -const coordsDisplay = coordId; // CoordSystem.createId already formats properly! - -// Line 96: Hierarchy building -// CURRENT: Using existing utility -hierarchy = _getParentHierarchy(coordId, items); -// This is already using the right domain concept! - -// Lines 62-94: Data transformation -// This is a gap - no existing transformer from MapItemContract to TileData -// This might need to become a new domain concept -``` - -## Handling Rule of 6 Violations - -When you exceed the limits: - -### Too many items in a folder (>6 folders + >6 files) -- Remember: Up to 6 child folders AND up to 6 files is acceptable -- Child folders are like child Tiles -- Files are metadata belonging to the folder's CenterTile -- If exceeding either limit: - - Group related folders into subfolders - - Extract common files into a shared module - - Consider if the folder is doing too much - -### Too many functions in a file (>6) -- Split into multiple files by responsibility -- Prefix internal/helper functions with "_" -- Move utilities to a separate utils file - -### Function too long (>50 lines) -- Extract logical blocks into separate functions -- Look for repeated patterns to abstract -- Consider if the function has multiple responsibilities - -### Too many arguments (>6, ideally >3) -- Group related arguments into an object -- Ensure the object has max 6 keys -- Consider if some arguments should be configuration options -- Use clear property names that maintain the "what it needs" clarity - -## Exceptions to the Rules - -### The 50-Line Exception - -The 50-line limit can be exceeded when: - -1. **Low-level implementation details** - The deeper in the abstraction hierarchy, the more lines are acceptable -2. **Simple, sequential operations** - When the function does one clear thing with straightforward steps -3. **Creating abstraction would reduce clarity** - When extracting would create confusing indirection -4. **AI-handleable complexity** - When the entire function is simple enough for AI to understand and modify as a unit - -**Example of Acceptable Longer Function:** -```typescript -// This is fine at 70+ lines because it's low-level DOM manipulation -// Each step is clear and extracting would create unnecessary abstraction -function _renderHexagonalGrid(canvas: HTMLCanvasElement, tiles: Tile[]) { - const ctx = canvas.getContext('2d'); - // ... 70 lines of straightforward canvas drawing operations - // Each line does one simple thing, extraction adds no value -} -``` - -**Key Principle**: The abstraction level determines flexibility: -- **High-level orchestration**: Strict 50-line limit (coordinates other functions) -- **Mid-level business logic**: Moderate flexibility (up to 75 lines if clear) -- **Low-level implementation**: Most flexibility (up to 100 lines if sequential) - -This mirrors hexframe philosophy: Once you reach a level simple enough for AI to handle atomically, further decomposition may reduce rather than improve clarity. - -## Pre-Refactoring Analysis - -### Step 1: Start Refactor Session -- Create a new file in current cycle: `.workflow/cycles/[current]/refactor-.md` -- Begin with identifying the code to refactor and its current state -- Document your pre-refactoring analysis as you discover concepts -- Update the file with user feedback and validation - -### Step 2: Discover Existing Domain Concepts -Before creating new concepts, understand what already exists: - -1. **Read domain README files** - Only consider domains with documentation - - Check `src/lib/domains/*/README.md` files - - If no README exists, ignore that domain during refactoring - - READMEs explain the domain's architecture and available capabilities - - Example: `mapping` domain README details CoordSystem in utils, MapContract types, etc. - -2. **Look for existing implementations** of your needed concept: - ```typescript - // BAD: Creating new concept that already exists - const coordsDisplay = CoordinateFormatter.toDisplayString(coords); - - // GOOD: Using existing domain concept - const coordsDisplay = CoordSystem.createId(coords); - ``` - -### Step 3: Identify gaps and new concepts -After understanding what exists: -1. **Core responsibilities** not covered by existing domains -2. **Repeated patterns** that might need names -3. **Complex concepts** currently expressed inline -4. **Domain terms** that appear but aren't defined - -### Present findings to user BEFORE refactoring: -``` -"I'm about to refactor create-item.tsx. - -From reading domain READMEs, I'll use these existing concepts: -- From mapping domain (has README): - - CoordSystem (utils/hex-coordinates.ts) for coordinate operations - - MapItemContract (types/contracts.ts) for data structures - - mapItemDomainToContractAdapter patterns -- From app/map utilities: - - _getParentHierarchy for hierarchy traversal - -New concepts that might need definition: -1. TileDataTransformer - converts MapItemContract to TileData format - (Gap: mapping domain has Contractβ†’Domain adapters but not Contractβ†’TileData) -2. HierarchyRenderer - manages visual hierarchy display logic - -Should these new concepts be added to UBIQUITOUS.md?" -``` - -### Step 4: Document and Validate -Document your findings in the refactor session file and present to the user for validation. - -### After validation, proceed with the complete refactoring independently - -## Refactor Session Documentation - -Each refactor session file (`.workflow/cycles/[current]/refactor-<title>.md`) should include: - -### Initial Section -- **Target Code**: File(s) to be refactored and current line count -- **Refactoring Goal**: Clear statement of what needs improvement -- **Current State Analysis**: Brief description of current code structure and issues - -### Pre-Refactoring Analysis -- **Existing Domain Concepts Found**: List of reusable concepts from documented domains -- **New Concepts Identified**: Potential new domain concepts that need naming -- **Structural Issues**: Rule of 6 violations, clarity problems, etc. -- **Proposed Changes**: High-level plan for the refactoring - -### User Validation -- **Concepts Approved**: Which new concepts were validated for documentation -- **Documentation Location**: Which README file(s) will contain the new concepts -- **Naming Decisions**: Any specific naming choices made with user -- **Scope Adjustments**: Any changes to the refactoring scope - -### Post-Refactoring Summary -- **Changes Applied**: Summary of structural and naming changes -- **Concepts Introduced**: New domain concepts added to the codebase -- **Before/After Metrics**: Line counts, function counts, clarity improvements -- **Future Considerations**: Any follow-up refactoring opportunities identified - -## Refactoring Workflow - -### Step 1: Pre-Refactoring Analysis -1. Create refactor session file in `.workflow/cycles/[current]/refactor-<title>.md` -2. Analyze the code to identify responsibilities and patterns -3. Document findings in the refactor session file -4. List potential domain concepts that need naming -5. Present findings to user for validation -6. Update the refactor session file with user feedback -7. Get approval on which concepts should be documented in the relevant README - -### Step 2: Execute Refactoring -1. Apply all validated concepts consistently -2. Follow the Fundamental Rule and Rule of 6 -3. Maintain single levels of abstraction -4. Complete the entire refactoring independently - -### Step 3: Post-Refactoring Review -Ask yourself: -1. Would a new developer understand this immediately? -2. Are the intentions clear from the names alone? -3. Is there any "clever" code that could be simpler? -4. Are there any implicit assumptions that should be explicit? -5. Does the structure follow the Rule of 6? -6. Can I read function names and understand the entire flow? -7. Did I apply all validated domain concepts consistently? - -## Git Workflow for Refactoring - -### Branch Management -1. **Create Refactor Branch**: Based on develop or current feature branch - ```bash - # From develop branch (for general refactoring) - git checkout develop - git pull origin develop - git checkout -b refactor/explicit-description - - # OR from a feature branch (if refactoring within that feature) - git checkout feature/current-feature - git pull origin feature/current-feature - git checkout -b refactor/clarity-in-feature - - # Examples: - # refactor/create-item-clarity - # refactor/apply-rule-of-6-to-components - # refactor/extract-domain-concepts - ``` - -2. **Commit Strategy**: Group related changes logically - ```bash - # Structural changes first - git add src/app/map/create/ - git commit -m "refactor: restructure create-item into Rule of 6 layout - - - Split 269-line component into 6 focused functions - - Created _components, _hooks, and _utils folders - - Each file now has max 6 functions" - - # Then concept extraction - git add src/lib/domains/mapping/ - git commit -m "refactor: extract TileDataTransformer domain concept - - - Added transformer for MapItemContract to TileData - - Reused existing CoordSystem utilities - - Documented in mapping domain README" - ``` - -### Safe Refactoring Practices -1. **Test First**: Ensure tests pass before refactoring - ```bash - pnpm test - pnpm test:e2e - git commit -m "test: add tests to cover refactoring scope" - ``` - -2. **Incremental Changes**: Refactor in small, verifiable steps - ```bash - # Step 1: Extract without changing behavior - git commit -m "refactor: extract coordinate formatting logic" - - # Step 2: Improve naming - git commit -m "refactor: rename functions for clarity" - - # Step 3: Apply Rule of 6 - git commit -m "refactor: split large functions per Rule of 6" - ``` - -3. **Verify No Behavior Changes**: Run tests after each commit - ```bash - pnpm test - pnpm check:lint - pnpm typecheck - ``` - -### Documentation Updates -```bash -# Update relevant README with new concepts -git add src/app/map/Canvas/README.md -git commit -m "docs: add optimistic update concepts to Canvas documentation" - -# Update domain READMEs -git add src/lib/domains/mapping/README.md -git commit -m "docs: document new transformer utilities in mapping domain" -``` - -### Creating Pull Request -1. **Push Refactor Branch**: Push to GitHub - ```bash - git push origin refactor/explicit-description - ``` - -2. **Create PR**: Open pull request to base branch - ```bash - # To develop (if refactoring from develop) - gh pr create --base develop --title "Refactor: [Description]" \ - --body-file .workflow/cycles/[current]/refactor-title.md - - # To feature branch (if refactoring within feature) - gh pr create --base feature/current-feature --title "Refactor: [Description]" \ - --body-file .workflow/cycles/[current]/refactor-title.md - ``` - -3. **PR Description**: Use entire refactor document - - Copy complete content of `.workflow/cycles/[current]/refactor-title.md` - - This includes: - - Pre-refactoring analysis - - Domain concepts identified - - User validation received - - Changes applied - - Metrics and improvements - -### Managing Large Refactors -For refactors spanning multiple files: - -1. **Feature Branch Protection**: Keep refactor isolated - ```bash - # Create tracking issue - git commit --allow-empty -m "refactor: tracking issue for large refactor - - Scope: - - [ ] Refactor component A - - [ ] Refactor component B - - [ ] Update documentation" - ``` - -2. **Stacked Commits**: One commit per logical change - ```bash - # Use interactive rebase to organize commits - git rebase -i origin/main - ``` - -3. **Regular Rebasing**: Keep up with base branch - ```bash - git fetch origin - git rebase origin/develop # or origin/feature/current-feature - # Resolve conflicts carefully to maintain refactor integrity - ``` \ No newline at end of file diff --git a/.claude/commands/walkthrough.md b/.claude/commands/walkthrough.md deleted file mode 100644 index d8e119630..000000000 --- a/.claude/commands/walkthrough.md +++ /dev/null @@ -1,128 +0,0 @@ -# Walkthrough Command - -## Purpose -Systematically investigate a problem by walking through the code step-by-step, building a clear mental model of how things work and identifying potential issues without immediately fixing them. - -## Command -``` -/walkthrough "problem description" -``` - -## Process - -### 1. Problem Statement -- Clearly restate the problem -- Identify observable symptoms -- Note what works vs what doesn't work - -### 2. Initial Assumptions -- State what parts of the codebase are likely relevant -- State what parts are likely NOT relevant -- Explain reasoning for these assumptions - -### 3. Code Investigation -Walk through the code systematically, starting broad and narrowing down: - -#### Investigation Layers: -1. **Integration Layer**: How is the component used? - - Where is it imported and rendered? - - What props/context does it receive? - - What's the component hierarchy? - -2. **Component Layer**: How does the component work? - - Overall structure and organization - - State management and interactions - - Subcomponents and their relationships - -3. **Implementation Layer**: Specific feature implementation - - How is the specific feature (e.g., styling) implemented? - - What utilities/helpers are used? - - Dependencies and assumptions - -4. **Detail Layer**: Deep dive into problem area - - Specific classes/styles/logic - - Edge cases and special handling - -#### For each file/component: -1. **Purpose**: What is this supposed to do? -2. **Structure**: How is it organized? -3. **Key Logic**: How does it work? (focus on problem-relevant parts) -4. **Dependencies**: What does it depend on? -5. **Potential Issues**: Flag anything suspicious (but don't fix yet) - -#### Progressive Narrowing: -- Start from where the component is used (page level) -- Move to the component itself -- Then to specific features -- Finally to implementation details -- At decision points, ask: "Should we explore [X] or note it for later?" - -### 4. Interactive Exploration -When reaching branches in investigation: -``` -"We have two paths here: -A) Investigate how [component X] handles [behavior Y] -B) Check if [component Z] might be interfering - -Would you like to explore one of these now, or should I note them and continue with [current path]?" -``` - -### 5. Building Understanding -As we go, maintain: -- **Working Theory**: Current understanding of how things work -- **Questions**: Things that need clarification -- **Anomalies**: Things that don't match expectations - -### 6. Final Summary - -#### How It Works -- Clear explanation of the relevant system -- Flow diagram if helpful -- Key components and their responsibilities - -#### Potential Issues Identified -1. **Issue**: [Description] - - **Location**: [File:line] - - **Why Suspicious**: [Reasoning] - - **Impact**: [How it might cause the problem] - -2. **Issue**: [Description] - ... - -#### Recommended Investigation Order -Prioritized list of what to investigate/fix first - -## Example Usage - -``` -/walkthrough "In dark mode, the focus ring on toolbox buttons is invisible" -``` - -Would lead to: -1. Examining how focus rings are styled -2. Checking CSS variable definitions -3. Investigating Tailwind class generation -4. Looking for overflow/clipping issues -5. Comparing with working focus rings elsewhere - -## Key Principles - -1. **Don't Fix During Walkthrough**: Just observe and document -2. **Ask for Direction**: When multiple paths exist, ask the user -3. **State Assumptions**: Be explicit about what you're assuming -4. **Focus on Understanding**: Build a mental model before solutions -5. **Document Everything**: Keep a clear trail of investigation - -## Output Format - -The walkthrough should produce: -1. A clear understanding of how the system works -2. A list of potential issues with evidence -3. A recommended path forward -4. Questions that need answers - -This systematic approach helps avoid: -- Jumping to conclusions -- Missing important context -- Fixing symptoms instead of causes -- Creating new issues while fixing old ones \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6d574e36..d2ef671a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,43 +92,6 @@ jobs: env: SKIP_ENV_VALIDATION: true - # Dead code detection job (informational only, does not block CI) - deadcode: - name: Dead Code Check (Optional) - needs: setup - runs-on: ubuntu-latest - continue-on-error: true - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Setup Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 - run_install: false - - - name: Restore node_modules cache - uses: actions/cache@v4 - with: - path: node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('**/pnpm-lock.yaml') }} - - - name: Run dead code check - run: pnpm check:deadcode - env: - SKIP_ENV_VALIDATION: true - # Architecture validation job architecture: name: Architecture Check @@ -137,6 +100,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: recursive - name: Setup Node.js uses: actions/setup-node@v4 @@ -174,6 +139,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: recursive - name: Setup Node.js uses: actions/setup-node@v4 @@ -405,7 +372,7 @@ jobs: # Note: deadcode and ruleof6 are optional (informational only) ci-status: name: CI Status Check - needs: [eslint, deadcode, architecture, ruleof6, typecheck, test-phase1, test-phase2, build] + needs: [eslint, architecture, ruleof6, typecheck, test-phase1, test-phase2, build] runs-on: ubuntu-latest if: always() steps: @@ -423,9 +390,6 @@ jobs: fi # Optional checks (informational only, logged but don't fail CI) - if [[ "${{ needs.deadcode.result }}" != "success" ]]; then - echo "⚠️ Dead code check found issues (optional, not blocking)" - fi if [[ "${{ needs.ruleof6.result }}" != "success" ]]; then echo "⚠️ Rule of 6 check found issues (optional, not blocking)" fi diff --git a/.gitignore b/.gitignore index 90291cecb..bb9a12def 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ __pycache__/ .idea .cursor/mcp.json .claude/mcp/ +.claude/archive/ drizzle/meta/* .cursorignore .env.test diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..88ad60030 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "scripts/checks/architecture"] + path = scripts/checks/architecture + url = https://github.com/Diplow/subsystem-architecture.git diff --git a/CLAUDE.md b/CLAUDE.md index c90f8db85..fe7ee98d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,50 +2,186 @@ This file provides guidance to AI agents and developpers when working with code in this repository. -## πŸ“š HIERARCHICAL DOCUMENTATION NAVIGATION +## Subsystem Architecture -**When understanding any part of the codebase, read documentation hierarchically:** +The codebase is organized into ~80 **subsystems** β€” directories containing a `dependencies.json` file. Each subsystem has: +- `dependencies.json` β€” declares allowed imports and subsystem type +- `index.ts` β€” public API (all external imports must go through this) +- `README.md` β€” mental model, responsibilities, child subsystems -1. **Start here** with the root CLAUDE.md for project overview -2. **Navigate to relevant subsystem** README.md files: - - `src/lib/domains/README.md` - For business logic and data persistence - - `src/app/map/README.md` - For frontend/UI questions - - `src/server/README.md` - For backend/API questions -3. **Drill deeper** into specific subsystem README.md files as needed -4. **Read before acting** - Always read the relevant README.md before modifying code +**Architectural constraints** (enforced by `pnpm check:architecture`): +- Import only through a subsystem's `index.ts`, never reach into internals +- Only import dependencies declared in `dependencies.json` +- No cross-domain imports (domains are isolated) +- Subsystems exceeding 1000 LoC must have a `README.md` -Each README.md should contain: -- **Mental Model**: How to think about this subsystem -- **Responsibilities**: What this subsystem handles -- **Subsystems**: Child components and their purposes +**Discovery:** +```bash +pnpm subsystem-tree # ASCII tree with types and LoC +pnpm subsystem-tree -- --format json # JSON output +pnpm subsystem-tree -- src/lib/domains # Subtree only +``` -## Project Overview +### Workflow: Feature Planning -Hexframe transforms visions into living systems through AI-powered hexagonal maps. +1. Run `pnpm subsystem-tree` to map the landscape +2. Identify impacted subsystems, read each one's `README.md` +3. Structure the plan with one step per impacted subsystem +4. When delegating a step to a subagent (Task tool), include the subsystem's `README.md` content as context in the prompt +5. Check `index.ts` exports and `dependencies.json` before writing code +6. Consider whether new subsystems should be introduced (Rule of 6 exceeded, >1000 LoC, natural boundary) -### Core Documentation -- **Mission & Vision**: `docs/company/MISSION.md` - Why Hexframe exists -- **Culture & Values**: `docs/company/CULTURE.md` - The tensions that guide us -- **Target User**: `docs/company/TARGET_USER.md` - Who we serve (system thinkers) -- **Main page**: `src/app/map/README.md` - The interface (web page) to the HexFrame system -- **Domain Model**: `src/lib/domains/README.md` - Core domain structure -- **System Philosophy**: `src/app/SYSTEM.md` - What systems mean in Hexframe +### Workflow: Impact Analysis + +1. Read the changed subsystem's `index.ts` to see public surface +2. Grep for consumers: `grep -rn "from.*~/path/to/subsystem['\"]" src --include="*.ts" --include="*.tsx"` (reliable because architecture enforcement forces all imports through `index.ts`) +3. Group results by subsystem, check transitive impact + +### Workflow: New Subsystem Introduction + +When to create: >6 files (Rule of 6), >1000 LoC, natural concern boundary. + +Checklist: +1. Create `dependencies.json` with type and allowed dependencies +2. Create `index.ts` as the public API +3. Create `README.md` with mental model, responsibilities, and subsystems +4. Add to parent's `"subsystems"` array in its `dependencies.json` +5. Run `pnpm check:architecture` to validate + +### Rule of 6 + +The codebase follows the **Rule of 6** for consistent organization (enforced by `pnpm check:ruleof6`): + +- **Subsystems**: Max 6 declared child subsystems per parent. Group related children into a router subsystem. +- **Files**: Max 6 functions per file. Move extras to other files. Prefix internal functions with `_`. +- **Functions**: Max 50 lines (warning), 100 lines (error). Refactor into max 6 function calls at the same abstraction level. +- **Arguments**: Max 6 arguments per function, or 1 object with max 6 keys at the same abstraction level. + +Custom thresholds via `.ruleof6-exceptions` files: +``` +# Function-specific: file:function:threshold +src/path/file.ts:complexFunction: 150 # Justification for exception + +# File-specific: file:threshold +src/path/file.ts: 10 # Justification for exception +``` + +## Product Presentation + +*Structure your expertise. Let AI execute. Get paid.* + +Hexframe helps experts β€” in sales, coaching, research, strategy β€” turn what they know into AI-powered systems they can run, refine, and sell. What you're building when you automate your expertise is a system. Hexframe gives you the tools to build systems well, informed by decades of software engineering practice, without the engineering background. + +The core experience: + +1. **Create** β€” Break your expertise into tasks and subtasks, top-down, until each piece is simple enough to trust AI with. Attach context where needed. Compose systems from other systems for advanced use cases. + +2. **Activate** β€” Run your system. Hexframe orchestrates AI agents for each tile, delivering the right context. When something fails, you see exactly where. Fix that tile. Run again. + +3. **Sell** β€” Let others use your systems. Set your pricing on top of AI execution costs. Hexframe handles metering and billing. Your expertise generates revenue. + +4. **Share** β€” Open your systems for others to discover, fork, and build upon. The open-source side of the ecosystem β€” grow the commons, learn from what others have built. + +5. **Monitor** β€” Track usage, health, and revenue across all your systems. See what works, what breaks, and where to focus. + +The structure you maintain β€” your Hexframe β€” is a living map of your expertise. It captures how you decompose problems, what context matters, and where you trust AI. It evolves as you learn. + +### The journey + +Building AI systems that work means learning three things: + +**Your system is never finished.** There's always an edge case, a shifting environment, a new problem that surfaces because your solution changed the landscape. The moment you stop learning is the moment you fall behind. + +**Speed of learning is everything.** The only way to improve is to put your system in front of real users and listen. The faster you can run, observe, fix, and run again, the faster you learn. Iterate faster than your competitors. + +**Simplicity is your best weapon.** As your system grows, complexity compounds. AI chokes on ambiguity faster than humans do. The antidote is relentless simplicity: clear instructions, well-defined boundaries, reusable building blocks. -## Key Principles +Hexframe is built around this journey: ship something that works, learn from real usage, iterate, and keep things simple as you grow. -### The Hexframe Thesis -System thinkers can either become great visionaries or frustrated geniuses β€” most end up frustrated. The AI revolution changes this: AI can leverage systems better than humans, do the grunt work, and needs exactly the structured context that system thinkers naturally create. +### Create + +Creating in Hexframe means externalizing your expertise into a structure AI can execute. + +The core actions: +1. **Decompose** β€” Break goals into subtasks, top-down, until each is simple enough to delegate +2. **Get help** β€” Let AI propose decompositions that you review and refine +3. **Attach context** β€” Add constraints, examples, and reference materials where needed + +The result is a hierarchy of tiles where: +- Each tile represents a unit of work +- Parent tiles orchestrate, leaf tiles execute +- Context flows down from ancestors to descendants + +For advanced use cases, **compose** systems by using other systems as building blocks β€” reference existing systems as tools within tiles, share context across hierarchies, and build layered capabilities from simple parts. + +### Activate + +Activating turns your structure into execution. + +The core actions: +1. **Run** β€” Point at a tile and execute. Hexframe orchestrates AI agents automatically. +2. **Observe** β€” Watch agents work through your structure, see progress tile by tile +3. **Refine** β€” When something fails, see exactly where. Fix that tile. Run again. + +What makes this powerful: +- The hierarchy IS the orchestration β€” no separate workflow to maintain +- Context flows automatically β€” each agent sees what's relevant based on position +- Failures are localized β€” you know exactly which tile to fix + +The feedback loop (run β†’ observe β†’ fix β†’ run) is how your system gets better over time. + +### Sell + +Your systems have value. Hexframe lets you capture it. + +How selling works: +- **Set pricing** β€” Add your margin on top of AI execution costs (token-based billing) +- **Control access** β€” Decide who can use your systems, offer trial credits +- **Keep your methods** β€” Users get results without seeing your system internals +- **Track revenue** β€” See what you earn across all your systems + +Why this matters: there's no path from "it works for me" to "it works for others" with raw prompts. Hexframe provides the distribution and billing infrastructure so you can focus on making your systems better. + +### Share + +Not everything needs to be monetized. You can open your systems for the community. + +What sharing enables: +- **Publish** β€” Make a system public so others can discover and use it +- **Fork** β€” Others can copy your system and adapt it to their needs +- **Learn** β€” Browse systems others have built to learn new approaches +- **Collaborate** β€” Work together on shared systems + +This is the open-source side of the Hexframe ecosystem. Good structures get reused. Patterns emerge. The community discovers what works. + +### Monitor + +As you build more systems, you need visibility into the whole portfolio. + +What monitoring shows you: +- **Usage** β€” Which systems are being run, how often, by whom +- **Health** β€” Which systems succeed vs. fail frequently +- **Revenue** β€” What's earning, what's not +- **Maintenance** β€” Which systems need attention or have gone stale + +Focus attention where it counts β€” improve high-use, high-failure systems first. Prune what's unused. See patterns across your systems. + +### Core Documentation + +- **Culture & Values**: `docs/company/CULTURE.md` - The tensions that guide us +- **Main page**: `src/app/map/README.md` - The interface (web page) to the HexFrame system +- **Domain Model**: `src/lib/domains/README.md` - Core domain structure ## Development Commands ### Core Development ```bash -pnpm check:lint # Run ESLint -pnpm typecheck # TypeScript type checking -pnpm test # Run all tests with AI-friendly JSON output -pnpm check:deadcode -pnpm check:architecture -pnpm check:ruleof6 +pnpm check:lint # Run ESLint +pnpm typecheck # TypeScript type checking +pnpm test # Run all tests with AI-friendly JSON output +pnpm check:architecture # Validate subsystem boundaries +pnpm check:ruleof6 # Check Rule of 6 compliance +pnpm subsystem-tree # Show subsystem hierarchy with types and LoC ``` ## Code Quality @@ -53,31 +189,20 @@ pnpm check:ruleof6 ### Architecture Enforcement Use `pnpm check:architecture` to validate architectural boundaries and coding standards. See `scripts/checks/architecture/README.md` for comprehensive documentation on rules, error types, and AI-friendly filtering commands. -### Dead Code Detection -Use `pnpm check:deadcode [path]` to identify unused exports, files, and transitive dead code. See `scripts/checks/deadcode/README.md` for detection logic and AI-friendly JSON filtering commands. Always review before removing - false positives can occur with dynamic imports and framework patterns. +### Subsystem Navigation +Use `pnpm subsystem-tree` to visualize the full subsystem hierarchy. Every directory with a `dependencies.json` is a subsystem with enforced boundaries. See `scripts/checks/architecture/README.md` for options (JSON output, subtree filtering). ## Architecture Overview -### Frontend -- **Next.js 15 App Router** with progressive enhancement -- Static β†’ Progressive β†’ Dynamic component patterns -- localStorage caching for performance -- See: `/src/app/map/README.md` - -### Backend -- **tRPC** for type-safe API -- Server-side caching and optimizations -- See: `/src/server/README.md` +**Tech stack:** Next.js 15 App Router, tRPC, Drizzle ORM + PostgreSQL, Vitest. -### Domain Layer -- **Domain-Driven Design** in `/src/lib/domains/` -- Clear boundaries between mapping, IAM, and other domains -- See: `/src/lib/domains/README.md` +| Root Subsystem | Type | Role | Key README | +|----------------|------|------|------------| +| `src/app/` | app | Next.js pages and UI components | `src/app/map/README.md` | +| `src/lib/` | router | Domain layer (DDD) | `src/lib/domains/README.md` | +| `src/server/` | router | tRPC API, cross-domain orchestration | `src/server/README.md` | -### Data Layer -- **Drizzle ORM + PostgreSQL** -- Migrations in `/drizzle/migrations/` -- localStorage for performance caching +The domain layer (`src/lib/domains/`) contains isolated domains (mapping, IAM, agentic, etc.) with no cross-domain imports. Each domain follows DDD patterns with services, repositories, and infrastructure layers. ## Tile Hierarchy Architecture @@ -92,13 +217,24 @@ This clean separation eliminates ambiguity about what an agent should do when ex ### Tile Types (MapItemType) -Every tile has a semantic type that guides agent behavior: +Every tile has a semantic type that guides agent behavior. The system supports both built-in enum types and custom string types. +#### Built-in Types - **USER**: Root tile for each user's map. The only tile type that can have no parent. Exactly one per user, at the center of their map. - **ORGANIZATIONAL**: Structural grouping tiles (e.g., "Plans", "Interests"). Used for navigation and categorization. Always visible to help orient users and agents. - **CONTEXT**: Reference material tiles to explore on-demand (default for new tiles). Background knowledge that agents should explore when relevant, not preload eagerly. - **SYSTEM**: Executable capability tiles that can be invoked like a skill. Agents can invoke these via hexecute when needed. +#### Custom Types +Beyond built-in types, arbitrary string values can be used as custom item types (e.g., "template", "project", "workflow"). This enables domain-specific semantic classification. + +**Reserved types**: The `user` type is reserved for system-created root tiles and cannot be used via API. + +**Type utilities** (in `src/lib/domains/mapping/infrastructure/map-item/item-type-utils.ts`): +- `isBuiltInItemType()` - Type guard for MapItemType enum values +- `isReservedItemType()` - Check if type is reserved +- `isCustomItemType()` - Check if type is custom (non-built-in) + **Migration note**: Previously there was only USER and BASE. BASE has been split into ORGANIZATIONAL, CONTEXT, and SYSTEM for semantic agent behavior. Tiles with null itemType should be treated as unclassified legacy tiles. ### Direction Values diff --git a/docs/company/MISSION.md b/docs/company/MISSION.md deleted file mode 100644 index 78b525bfc..000000000 --- a/docs/company/MISSION.md +++ /dev/null @@ -1,64 +0,0 @@ -# Hexframe Mission: Systems That Live - -## The Hexframe Thesis - -System thinkers can either become great visionaries or frustrated geniuses β€” but most of us end up in the second category. We create brilliant systems that die unused. - -The AI revolution changes everything. Why? Because: -1. **AI can leverage systems better than humans** - It never forgets the process, never skips steps, never gets tired -2. **AI can do the grunt work** - The energy-draining execution that exhausts system thinkers -3. **The perfect match** - AI needs structured context to be useful; system thinkers naturally create exactly that structure - -The systems we love to create are the exact context AI needs to be transformative. Hexframe bridges this gap. - -## Core Mission - -Hexframe enables deliberate change by transforming visions into living systems. - -System thinkers face a universal problem: they create brilliant systems that die unused. They love designing, thinking, refining β€” but their systems remain ideas instead of becoming practice. - -Hexframe solves this by making your system the interface to AI. You create a core system about how YOU work toward your goal. Then your daily practice becomes simple: talk to your system's AI to know what's next. - -The AI knows your system's context and challenges you when you derail. For instance, if your system includes a "goal clarification" step, the AI tracks whether you've completed it, remembers why you wanted to do it, and can challenge you if you're skipping it. As you work, your system grows β€” adding subsystems for specific tasks, connecting to specialized agents for different domains. Your system becomes alive through daily dialogue with AI that understands your intentions. - -## What Hexframe Does - -Hexframe enables system thinkers to: -1. **Create** systems that capture hard-won experience (e.g., "Effective Code Reviews at Hexframe" capturing what truly matters for quality) -2. **Share** systems for others to discuss, challenge, and fork (e.g., adapting someone's "Technical Hiring" system to your startup's context) -3. **Compose** systems by augmenting tiles with other systems (e.g., enhancing "Running an AI Company" by composing its "Product Development" tile with a specialized "Agile for AI Products" system) -4. **Activate** systems through actual use β€” unused systems die (e.g., that perfect onboarding guide that no new employee ever reads is a failed system) -5. **Monitor** which systems are actively used and which become foundations for others (revealing which ideas actually shape behavior and deserve scrutiny for bias) - -## The Product Vision: Where We're Going - -### Create: From Idea to System in Minutes -**Today**: Basic tile creation with text and hierarchy -**Near Future**: AI-assisted system generation from conversations. Describe your goal, and AI helps structure it into a hexagonal system. Can search the web to import information and format it via Hexframe. -**Vision**: Your thoughts become systems as you speak. Voice-to-system creation. Domain-specific templates. Version control for system evolution. - -### Share: A Living Library of Human Experience -**Today**: Systems exist but aren't yet shareable -**Near Future**: Public/private systems. Forking and attribution. Comments and discussions on tiles. System discovery through tags. -**Vision**: The world's repository of practical wisdom. Find how others solved your exact problem. See lineage of ideas through fork trees. Reputation based on system usage and impact. - -### Compose: Systems Building on Systems -**Today**: Manual hierarchy creation -**Near Future**: Drag-and-drop composition. Subagents system decomposition. System marketplace. Compatibility indicators between systems. -**Vision**: Like LEGO for knowledge. Any tile can become a gateway to another expert's system. AI suggests relevant systems to compose based on your goals. Seamless integration of specialized domains. - -Drag and drop composition example: compose a generic Domain Driven Architect with your domains documentations. - -### Activate: Your System Comes Alive Through Use -**Today**: Static display of systems -**Near Future**: MCP integration. Progress tracking per tile. AI chat that knows your system context. Daily check-ins guided by your system. Integration with calendar and task tools. -**Vision**: Your system becomes your AI copilot. It reminds you of your own best practices. It challenges deviations from your stated intentions. It learns from your usage patterns and suggests improvements. - -MCP integration example: start a Claude session that will automatically call your HexFrame system. - -### Monitor: See What Actually Works -**Today**: No analytics yet -**Near Future**: Usage statistics. Fork and adaptation statistics. Success metrics tied to systems. -**Vision**: Evidence-based system evolution. See which mental models actually drive results. Identify systemic biases through usage patterns. A/B test different approaches to the same goal. - -Usage statistics example: see your different systems usage (MCP calls, chat interactions...) via a specific chat widget. \ No newline at end of file diff --git a/docs/features/PROMPT_PROVENANCE_UI.md b/docs/features/PROMPT_PROVENANCE_UI.md new file mode 100644 index 000000000..64c920f3b --- /dev/null +++ b/docs/features/PROMPT_PROVENANCE_UI.md @@ -0,0 +1,294 @@ +# Prompt Provenance UI + +## Summary + +Show users exactly which tiles and templates contributed to each part of a generated prompt, enabling transparency and debuggability. + +## Motivation + +**Current state:** The Chat can display the generated prompt, but it's an opaque text blob. Users cannot see: +- Which tile contributed which section +- Which template rendered it +- How to modify a specific part of the output + +**Problem:** +- Debugging prompts requires mental reconstruction +- Users can't learn how their tile structure affects prompts +- No easy path from "I don't like this part" to "here's where to change it" + +**Hexframe principle:** Transparency. Users should understand exactly how their system produces outputs. + +## Relationship to Templates as Tiles + +This feature builds on [Templates as Tiles](./TEMPLATES_AS_TILES.md): + +| Templates as Tiles provides | This feature uses it for | +|----------------------------|--------------------------| +| Templates have coordinates | Link prompt sections to template source | +| Sub-templates are children | Show template composition hierarchy | +| Dynamic tile types | Show which type produced each section | + +## Core Design + +### 1. Annotated Prompt Structure + +The prompt builder returns both: +- **Raw prompt**: The text sent to the LLM (unchanged) +- **Annotated prompt**: Structured representation with provenance + +```typescript +interface AnnotatedPrompt { + raw: string; + sections: PromptSection[]; +} + +interface PromptSection { + content: string; + source: { + tileCoords: string; // e.g., "userId,0:1,3" + tileTitle: string; + templateCoords?: string; // Template that rendered this tile + templateName?: string; + }; + children?: PromptSection[]; // Nested sections (sub-templates) +} +``` + +### 2. Section Boundaries + +Each discrete prompt section tracks its origin: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ <hexrun-intro> β”‚ +β”‚ This prompt was generated from Hexframe tiles... β”‚ +β”‚ β”‚ +β”‚ Source: Template "system" β†’ HexrunIntro section β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ <context title="API Reference" coords="user,0:1,-2"> β”‚ +β”‚ The API uses REST conventions... β”‚ +β”‚ </context> β”‚ +β”‚ β”‚ +β”‚ Source: Tile "API Reference" at user,0:1,-2 β”‚ +β”‚ Rendered by: GenericTile primitive β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ <task> β”‚ +β”‚ <goal>Implement authentication</goal> β”‚ +β”‚ Add JWT-based auth to the API endpoints... β”‚ +β”‚ </task> β”‚ +β”‚ β”‚ +β”‚ Source: Tile "Implement authentication" at user,0:1,3 β”‚ +β”‚ Rendered by: Template "system" β†’ TaskSection β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### 3. UI Presentation Options + +#### Option A: Highlighted Blocks + +Prompt displayed as text with colored/bordered blocks. Hovering shows source info. + +``` +β”Œβ”€ Template: system ──────────────────────────────────────────┐ +β”‚ <hexrun-intro> β”‚ +β”‚ This prompt was generated from Hexframe tiles... β”‚ +β”‚ </hexrun-intro> β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +β”Œβ”€ Tile: API Reference (user,0:1,-2) ─────────────────────────┐ +β”‚ <context title="API Reference"> β”‚ +β”‚ The API uses REST conventions... β”‚ +β”‚ </context> β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Interactions:** +- Hover: Show source tile/template info +- Click: Navigate to source tile in map +- Right-click: "Edit this tile" / "View template" + +#### Option B: Side Panel + +Prompt text on left, source tree on right. Selecting text highlights corresponding source. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ <hexrun-intro> β”‚ β–Ό Prompt Structure β”‚ +β”‚ This prompt was generated β”‚ β”œβ”€ HexrunIntro (template) β”‚ +β”‚ from Hexframe tiles... β”‚ β”œβ”€ Context β”‚ +β”‚ </hexrun-intro> β”‚ β”‚ └─ API Reference β”‚ +β”‚ β”‚ β”œβ”€ Subtasks β”‚ +β”‚ <context title="API Ref"> β”‚ β”‚ β”œβ”€ Task 1 β”‚ +β”‚ The API uses REST... β”‚ β”‚ └─ Task 2 β”‚ +β”‚ </context> β”‚ β”œβ”€ Task β”‚ +β”‚ β”‚ β”‚ └─ Implement auth β”‚ +β”‚ <subtasks> β”‚ └─ HexPlan (template) β”‚ +β”‚ ... β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Option C: Inline Annotations (Collapsible) + +Each section has a small annotation bar that expands on click. + +``` +β–Έ Template: system/hexrun-intro ───────────────────────────── +<hexrun-intro> +This prompt was generated from Hexframe tiles... +</hexrun-intro> + +β–Έ Tile: API Reference ─────────────────────────────────────── +<context title="API Reference"> +The API uses REST conventions... +</context> +``` + +### 4. Implementation Approach + +#### Phase 1: Track Provenance in Prompt Builder + +Modify `buildPrompt()` to return `AnnotatedPrompt`: + +```typescript +// Before +function buildPrompt(data: PromptData): string + +// After +function buildPrompt(data: PromptData): AnnotatedPrompt +// or +function buildAnnotatedPrompt(data: PromptData): AnnotatedPrompt +``` + +Each section builder tracks its source: + +```typescript +function _buildContextSection(composedChildren, ...): PromptSection[] { + return composedChildren.map(child => ({ + content: GenericTile(child, ['title', 'content'], 'context'), + source: { + tileCoords: child.coords, + tileTitle: child.title, + // templateCoords if rendered by a tile-based template + } + })); +} +``` + +#### Phase 2: Store Provenance with Chat Messages + +The annotated prompt is stored alongside messages: + +```typescript +interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + promptProvenance?: AnnotatedPrompt; // For system messages +} +``` + +#### Phase 3: Render in Chat UI + +Add prompt viewer component with provenance display: + +```typescript +<PromptViewer + prompt={message.promptProvenance} + onTileClick={(coords) => navigateToTile(coords)} + onTemplateClick={(coords) => navigateToTemplate(coords)} +/> +``` + +## Data Flow + +``` +Tile Hierarchy + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ buildPrompt() │──────────────────────────────────────────┐ +β”‚ β”‚ β”‚ +β”‚ For each section: β”‚ +β”‚ - Render content β”‚ +β”‚ - Track source tile coords β”‚ +β”‚ - Track template coords (if applicable) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Raw Prompt β”‚ β”‚ Annotated Prompt β”‚ +β”‚ (sent to LLM) β”‚ β”‚ (for UI display) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Chat UI β”‚ + β”‚ Prompt Viewer β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Rendering Primitive Provenance + +For code-based primitives (`GenericTile`, `Folder`, etc.), track which primitive rendered the content: + +```typescript +interface PromptSection { + source: { + tileCoords: string; + tileTitle: string; + templateCoords?: string; // If tile-based template + templateName?: string; + primitive?: string; // "GenericTile", "Folder", etc. + }; +} +``` + +This enables: +- "This was rendered by the GenericTile primitive with fields: title, content" +- Future: link to primitive documentation + +## Interactive Features + +### 1. Click to Navigate +Click any section β†’ navigate to source tile in the map view. + +### 2. Edit in Place +"Edit this tile" button β†’ opens tile editor, changes reflect in next prompt. + +### 3. Template Inspection +"View template" β†’ shows the template tile that rendered this section. + +### 4. Diff View (Future) +Compare two prompt versions to see what changed after tile edits. + +## Migration Considerations + +### Backward Compatibility +- Existing `buildPrompt()` API continues to work (returns raw string) +- New `buildAnnotatedPrompt()` for provenance-aware callers +- Or: `buildPrompt()` returns `AnnotatedPrompt`, with `.raw` for the string + +### Chat History +- Existing messages don't have provenance data +- New messages store provenance +- UI gracefully handles missing provenance (shows raw text only) + +## Open Questions + +1. **Granularity:** How fine-grained should sections be? Per-tile? Per XML tag? Per line? + +2. **Performance:** Does tracking provenance add significant overhead? Benchmark needed. + +3. **Storage:** Store full annotated prompt, or reconstruct from tile versions on demand? + +4. **Nesting display:** How to show deeply nested template composition without overwhelming the UI? + +## Non-Goals (For This Feature) + +- Real-time prompt preview as you edit tiles (separate feature) +- Prompt diffing between versions +- Template debugging/stepping +- Prompt optimization suggestions diff --git a/docs/features/TEMPLATES_AS_TILES.md b/docs/features/TEMPLATES_AS_TILES.md new file mode 100644 index 000000000..eb2bf0ccd --- /dev/null +++ b/docs/features/TEMPLATES_AS_TILES.md @@ -0,0 +1,233 @@ +# Templates as Tiles + +## Summary + +Store prompt templates as tiles rather than TypeScript code, enabling user-created templates and transparent prompt inspection. + +## Motivation + +**Current state:** Templates are hardcoded in TypeScript (`SYSTEM_TEMPLATE`, `USER_TEMPLATE`, etc.). Users cannot customize how their tiles are rendered into prompts. + +**Problem:** +- No transparency into prompt generation +- Users cannot create custom tile types with custom rendering +- Templates are invisible code artifacts, not first-class data + +**Hexframe principle:** Transparency. Prompts should always be inspectable. If a user shares a tile, others should understand how it renders. + +## Core Design Decisions + +### 1. Dynamic Tile Types + +**Change:** Replace `MapItemType` enum with a string field. + +```typescript +// Before +enum MapItemType { USER, ORGANIZATIONAL, CONTEXT, SYSTEM } + +// After +itemType: string // "user", "organizational", "context", "system", "my-custom-type" +``` + +**Constraints:** +- Built-in types (`user`, `organizational`, `context`, `system`) are reserved +- Unique constraint on `itemType` (for now) +- Future: `(userId, itemType)` unique constraint to allow per-user type names + +### 2. Templates as Tiles + +A template is a tile of type `template` containing Mustache markup in its content field. + +**Template tile structure:** +- `itemType`: `"template"` +- `title`: Human-readable name (e.g., "System Task Template") +- `content`: Mustache template markup +- `templateName`: Identifier used in lookups (e.g., "system", "my-agent") + +**Example template content:** +```mustache +{{{hexrunIntro}}} +{{#hasComposedChildren}} + +{{{contextSection}}} +{{/hasComposedChildren}} + +<task> +<goal>{{{task.title}}}</goal> +{{#task.hasContent}} +{{{task.content}}} +{{/task.hasContent}} +</task> + +{{@HexPlan}} +``` + +### 3. Sub-templates as Structural Children + +Template composition follows tile hierarchy. A template's reusable components are its structural children (directions 1-6). + +``` +Template "my-agent-template" +β”œβ”€β”€ [1] Sub-template "HeaderSection" +β”œβ”€β”€ [2] Sub-template "ContextBlock" +└── [3] Sub-template "TaskSection" +``` + +**Pre-processor lookup:** +- `{{@HeaderSection}}` β†’ finds child tile with `templateName: "HeaderSection"` +- Renders that child template with current context +- Inserts result into parent + +**Benefits:** +- Template structure visible in the map +- Self-contained: copy template tile β†’ get all sub-templates +- Overridable: fork and modify individual sub-templates + +### 4. Rendering Primitives Stay as Code + +Functions like `GenericTile`, `Folder`, `HexPlan` remain TypeScript: + +```typescript +// Still available to all templates +GenericTile(tile, ['title', 'content'], 'context') +Folder(tile, ['title', 'preview'], 3) +``` + +**Rationale:** These encode escaping logic, recursion, and field selection. Keeping them as code: +- Maintains security (XML escaping is critical) +- Avoids complex declarative DSL design +- Gives users powerful primitives without reimplementation + +**Future consideration:** Declarative rendering primitives as tiles (post-MVP). + +### 5. Template-Type Binding + +Each custom tile type maps to exactly one template: +- Type `"system"` β†’ renders with template `templateName: "system"` +- Type `"my-agent"` β†’ renders with template `templateName: "my-agent"` + +**Creating a new template effectively creates a new tile type.** + +### 6. User Allowlist for Templates + +Users maintain an allowlist of templates they can execute: + +```typescript +interface UserTemplateConfig { + allowedTemplates: string[] // e.g., ["system", "user", "my-agent"] +} +``` + +**Behavior:** +- `hexecute` checks if tile's type has an allowed template +- Unknown/disallowed template β†’ error, execution stops +- Prevents arbitrary template execution + +### 7. Transparency Principle + +**Rule:** If a template is private, tiles using that template type must also be private. + +**Rationale:** A shared tile's prompt should be reconstructable. If someone can see a tile but not its template, they cannot understand how it will execute. + +**Implementation:** +- When sharing a tile publicly, validate its template is also public +- When making a template private, warn about affected public tiles + +## Data Model Changes + +### Template Name Column + +```sql +ALTER TABLE map_items ADD COLUMN template_name VARCHAR(100) NULL; +ALTER TABLE map_items ADD CONSTRAINT unique_template_name UNIQUE (template_name); +``` + +**Decision:** Nullable column. Simple, DB-validated, sufficient for this use case. + +## Migration Path + +### Phase 1: Add Infrastructure +1. Change `itemType` from enum to string +2. Add `templateName` field (or metadata column) +3. Add user template allowlist storage + +### Phase 2: Migrate Built-in Templates +1. Create template tiles for `SYSTEM_TEMPLATE`, `USER_TEMPLATE` +2. Store at well-known coordinates (TBD) +3. Update `buildPrompt()` to read from tiles instead of code + +### Phase 3: Enable Custom Templates +1. UI for creating template tiles +2. UI for managing template allowlist +3. Template validation (syntax checking) + +## Pre-processor Availability + +Users have access to the same pre-processor tags: + +| Tag | Behavior | +|-----|----------| +| `{{@HexPlan}}` | Expands hexplan section with status handling | +| `{{@ChildTemplateName}}` | Renders structural child template | +| `{{{variable}}}` | Mustache triple-brace (unescaped) | +| `{{#condition}}...{{/condition}}` | Mustache conditional | + +## Default Behavior for Template Tiles + +When `hexecute` encounters a `template` type tile: +- If user has explicitly allowed it: render using its own template (meta!) +- If not allowed (default): error + +**Default template for template tiles** (if allowed): Render as context tile, showing the template content as reference material. + +## Example: User Creates Custom Agent Template + +``` +1. User creates tile: + - type: "template" + - title: "Research Agent" + - templateName: "research-agent" + - content: (Mustache markup) + +2. User creates sub-template children: + - [1] "SourcesSection" + - [2] "FindingsSection" + +3. User adds "research-agent" to their allowlist + +4. User creates tiles of type "research-agent" + +5. hexecute on those tiles: + - Finds template by templateName + - Pre-processes {{@SourcesSection}}, {{@FindingsSection}} + - Renders Mustache with tile data + - Returns prompt +``` + +## Relationship to Second Feature (Prompt Rendering UI) + +This feature enables the second planned feature: showing users which prompt parts come from which tiles/templates. + +With templates as tiles: +- Each prompt section has a source coordinate +- UI can highlight "this came from template at [coords]" +- Users can click through to inspect/edit templates + +## Design Decisions (Resolved) + +1. **Template storage location:** System templates stored at `D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK,0:1,2` (organizational tile "Templates"). Template tiles are structural children of this tile. + +2. **Template versioning:** Leverages existing tile versioning. Templates are tiles, tiles have versions. + +3. **Template inheritance:** No inheritance. Extension only via sub-templates (compose, don't inherit). + +## Open Questions + +1. **Validation:** How do we validate template syntax before save? Show preview of rendered output? + +## Non-Goals (For This Feature) + +- Template marketplace/sharing between users +- Visual template editor (WYSIWYG) +- Template versioning/history +- Declarative rendering primitives as tiles (future extension) diff --git a/docs/plans/user-template-pool-dispatch.md b/docs/plans/user-template-pool-dispatch.md new file mode 100644 index 000000000..f31f299eb --- /dev/null +++ b/docs/plans/user-template-pool-dispatch.md @@ -0,0 +1,125 @@ +# Plan: Update User Interlocutor Template to Use Pool-Based Dispatch + +## Goal +Update the User Interlocutor Template at `D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK,0:1,2,2` to follow the same pool-based dispatch pattern as the System Task Template at `[1,2,1]`. + +## Current State +The User Interlocutor Template currently uses: +- `{{{userIntro}}}` - hardcoded intro variable +- `{{{contextSection}}}` - pre-rendered composed children +- `{{{sectionsSection}}}` - pre-rendered structural children +- `{{{recentHistory}}}` - session history (like hexplan) +- `{{{discussion}}}` - conversation history +- `{{{userMessage}}}` - current user message + +No sub-templates exist (empty children). + +## Target State +Match the System Task Template pattern: +- Pool-based dispatch via `{{@RenderChildren}}` +- Template context via `{{{template[-1].content}}}` +- Direction-0 for recent-history (like hexplan) + +## Tasks + +| # | Task | Details | +|---|------|---------| +| 1 | **Add sub-templates (structural children 1-6)** | Create template pool for USER tiles | +| | `[1]` organizational | `<folder>` with recursive rendering | +| | `[2]` context | `<item>` with content | +| | `[3]` generic | Fallback `<tile>` | +| | `[4]` section | `<section>` for structural children | +| | `[5]` recent-history | For direction-0 (like hexplan) | +| 2 | **Add user-intro as composed child (-1)** | Move USER_INTRO content to template context | +| 3 | **Update template content** | Use RenderChildren + template context | +| 4 | **Update UserTemplateData interface** | Add fields similar to SystemTemplateData | +| 5 | **Update prompt builder** | Ensure USER template gets same data flow | +| 6 | **Run tests and verify** | Ensure backward compatibility | + +## New Template Content +```mustache +{{{template[-1].content}}} + +<context> +{{@RenderChildren range=[-6..-1] fallback='generic'}} +</context> + +<sections> +{{@RenderChildren range=[1..6] fallback='section'}} +</sections> + +{{@RenderChildren range=[0..0] fallback='recent-history'}} + +{{#hasDiscussion}} +<discussion> +Previous messages in this conversation: +{{{discussion}}} +</discussion> +{{/hasDiscussion}} + +{{#hasUserMessage}} +<user-message> +The user's current message that you need to respond to: +{{{userMessage}}} +</user-message> +{{/hasUserMessage}} +``` + +Note: `discussion` and `userMessage` remain as Mustache variables since they're conversation-level data, not tile children. + +## Sub-Template Contents + +### `[1]` organizational +```mustache +<folder title="{{title}}" coords="{{coords}}"> +{{@RenderChildren range=[-6..-1] fallback='generic'}} +</folder> +``` + +### `[2]` context +```mustache +<item title="{{title}}" coords="{{coords}}"> +{{content}} +</item> +``` + +### `[3]` generic +```mustache +<tile coords="{{coords}}">{{title}}</tile> +``` + +### `[4]` section +```mustache +<section title="{{title}}" coords="{{coords}}"> +{{preview}} +</section> +``` + +### `[5]` recent-history +```mustache +<recent-history coords="{{coords}}"> +This section tracks the user's recent goals and session state. Update it when the user expresses a goal, so future sessions can quickly resume context. + +{{content}} +</recent-history> +``` + +## User-Intro Content (composed child -1) +```xml +<user-intro> +You are the default interlocutor for a Hexframe user. Your role is to help the user accomplish what they want to do with Hexframe. + +Hexframe is a hexagonal knowledge mapping system where users organize their thoughts, plans, and reference materials as interconnected tiles. You can help users: +- Navigate and understand their tile map +- Create, update, or reorganize tiles +- Execute tasks defined in SYSTEM tiles +- Find information in their knowledge base + +The sections below show the user's available tiles. ORGANIZATIONAL tiles (marked as folders) group related items. You can explore deeper into any section by using the hexecute tool with the tile's coordinates. + +If the user has expressed a goal in a previous session, check the <recent-history> section to quickly catch up on context. +</user-intro> +``` + +## Reference +See System Task Template implementation at `[1,2,1]` for the pattern to follow. diff --git a/drizzle/migrations/0013_alter_tile_favorites_mapitemid_to_integer.sql b/drizzle/migrations/0013_alter_tile_favorites_mapitemid_to_integer.sql index 95f880eb0..b28af5d04 100644 --- a/drizzle/migrations/0013_alter_tile_favorites_mapitemid_to_integer.sql +++ b/drizzle/migrations/0013_alter_tile_favorites_mapitemid_to_integer.sql @@ -1,43 +1,46 @@ -- Migration: Convert tile_favorites.map_item_id from coordinate string (text) to database ID (integer) -- This enables proper foreign key relationship to vde_map_items -- --- The old format stored coordinate strings like "userId,groupId:path" --- The new format stores the integer ID from vde_map_items - --- First, drop the existing foreign key constraint if it exists -ALTER TABLE "tile_favorites" DROP CONSTRAINT IF EXISTS "tile_favorites_map_item_id_vde_map_items_id_fk"; - --- Drop the index to allow column changes -DROP INDEX IF EXISTS "tile_favorites_map_item_id_idx"; - --- Add a new integer column for the map_item_id -ALTER TABLE "tile_favorites" ADD COLUMN "map_item_id_new" integer; - --- Migrate data by parsing the coordinate string and looking up the actual ID --- Format: 'userId,groupId:path' e.g., 'fZRHqrORpUkoV14TRmtW0GA5kFV7UN0X,0:6' -UPDATE "tile_favorites" f -SET "map_item_id_new" = m.id -FROM "vde_map_items" m -WHERE - -- Parse the userId (everything before the first comma) - m.coord_user_id = split_part(f.map_item_id, ',', 1) - -- Parse the groupId (between first comma and colon) - AND m.coord_group_id = split_part(split_part(f.map_item_id, ',', 2), ':', 1)::integer - -- Parse the path (everything after the colon) - AND m.path = split_part(f.map_item_id, ':', 2); - --- Drop the old text column -ALTER TABLE "tile_favorites" DROP COLUMN "map_item_id"; - --- Rename the new column -ALTER TABLE "tile_favorites" RENAME COLUMN "map_item_id_new" TO "map_item_id"; - --- Make it NOT NULL -ALTER TABLE "tile_favorites" ALTER COLUMN "map_item_id" SET NOT NULL; - --- Add the foreign key constraint -ALTER TABLE "tile_favorites" ADD CONSTRAINT "tile_favorites_map_item_id_vde_map_items_id_fk" - FOREIGN KEY ("map_item_id") REFERENCES "public"."vde_map_items"("id") ON DELETE cascade ON UPDATE no action; - --- Re-create the index -CREATE INDEX IF NOT EXISTS "tile_favorites_map_item_id_idx" ON "tile_favorites" ("map_item_id"); +-- NOTE: This migration is now a NO-OP because migration 0012 was updated to create +-- map_item_id as integer from the start. This migration is kept for journal consistency +-- but only runs the conversion if the column is still text type. + +DO $$ +BEGIN + -- Only run conversion if map_item_id is text type (not already integer) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tile_favorites' + AND column_name = 'map_item_id' + AND data_type = 'text' + ) THEN + -- Drop existing constraints + ALTER TABLE "tile_favorites" DROP CONSTRAINT IF EXISTS "tile_favorites_map_item_id_vde_map_items_id_fk"; + DROP INDEX IF EXISTS "tile_favorites_map_item_id_idx"; + + -- Add new integer column + ALTER TABLE "tile_favorites" ADD COLUMN "map_item_id_new" integer; + + -- Migrate data by parsing coordinate strings + UPDATE "tile_favorites" f + SET "map_item_id_new" = m.id + FROM "vde_map_items" m + WHERE m.coord_user_id = split_part(f.map_item_id::text, ',', 1) + AND m.coord_group_id = split_part(split_part(f.map_item_id::text, ',', 2), ':', 1)::integer + AND m.path = split_part(f.map_item_id::text, ':', 2); + + -- Swap columns + ALTER TABLE "tile_favorites" DROP COLUMN "map_item_id"; + ALTER TABLE "tile_favorites" RENAME COLUMN "map_item_id_new" TO "map_item_id"; + ALTER TABLE "tile_favorites" ALTER COLUMN "map_item_id" SET NOT NULL; + + -- Re-add constraints + ALTER TABLE "tile_favorites" ADD CONSTRAINT "tile_favorites_map_item_id_vde_map_items_id_fk" + FOREIGN KEY ("map_item_id") REFERENCES "public"."vde_map_items"("id") ON DELETE cascade ON UPDATE no action; + CREATE INDEX IF NOT EXISTS "tile_favorites_map_item_id_idx" ON "tile_favorites" ("map_item_id"); + + RAISE NOTICE 'Migration 0013: Converted map_item_id from text to integer'; + ELSE + RAISE NOTICE 'Migration 0013: map_item_id is already integer, skipping conversion'; + END IF; +END $$; diff --git a/drizzle/migrations/0015_add_template_name_column.sql b/drizzle/migrations/0015_add_template_name_column.sql new file mode 100644 index 000000000..dff991432 --- /dev/null +++ b/drizzle/migrations/0015_add_template_name_column.sql @@ -0,0 +1,13 @@ +-- Migration: Add template_name column to map_items +-- +-- This column stores the name of the template a tile was created from. +-- When a tile is instantiated from a template, the template's name is recorded +-- to track provenance and enable features like "created from template X". +-- +-- The column is nullable because most tiles are not created from templates. + +ALTER TABLE vde_map_items +ADD COLUMN template_name VARCHAR(255); + +-- Optional: Add an index for querying tiles by template +-- CREATE INDEX IF NOT EXISTS map_item_template_name_idx ON vde_map_items(template_name); diff --git a/drizzle/migrations/0016_fix_template_name_constraints.sql b/drizzle/migrations/0016_fix_template_name_constraints.sql new file mode 100644 index 000000000..1b191992d --- /dev/null +++ b/drizzle/migrations/0016_fix_template_name_constraints.sql @@ -0,0 +1,16 @@ +-- Migration: Fix template_name column constraints +-- +-- The template_name column was initially created with VARCHAR(255) and no unique constraint. +-- Per TEMPLATES_AS_TILES.md spec, it should be VARCHAR(100) with a unique constraint. +-- +-- This migration: +-- 1. Adds a UNIQUE constraint on template_name +-- 2. Changes the column length from 255 to 100 + +-- Add unique constraint (NULL values are allowed and don't violate uniqueness) +ALTER TABLE vde_map_items +ADD CONSTRAINT unique_template_name UNIQUE (template_name); + +-- Change column length from 255 to 100 +ALTER TABLE vde_map_items +ALTER COLUMN template_name TYPE VARCHAR(100); diff --git a/drizzle/migrations/0017_add_user_template_allowlist.sql b/drizzle/migrations/0017_add_user_template_allowlist.sql new file mode 100644 index 000000000..a046e61b8 --- /dev/null +++ b/drizzle/migrations/0017_add_user_template_allowlist.sql @@ -0,0 +1,19 @@ +-- Migration: Add user_template_allowlist table +-- +-- Stores per-user template allowlists for hexecute validation. +-- Users can only execute templates that are in their allowlist. +-- Built-in templates (system, user, organizational, context) are always allowed. +-- +-- The allowed_templates column stores a JSON array of template names. + +CREATE TABLE IF NOT EXISTS user_template_allowlist ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES "users"(id) ON DELETE CASCADE, + allowed_templates TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT unique_user_allowlist UNIQUE (user_id) +); + +-- Index for user lookups +CREATE INDEX IF NOT EXISTS user_template_allowlist_user_id_idx ON user_template_allowlist(user_id); diff --git a/drizzle/migrations/0018_curious_power_man.sql b/drizzle/migrations/0018_curious_power_man.sql new file mode 100644 index 000000000..d6c39252c --- /dev/null +++ b/drizzle/migrations/0018_curious_power_man.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS "vde_runs" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "root_coords" text NOT NULL, + "status" text NOT NULL, + "blockage_reason" text, + "execution_log" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_user_runs" ON "vde_runs" USING btree ("user_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_root_coords_status" ON "vde_runs" USING btree ("root_coords","status"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "unique_open_run" ON "vde_runs" USING btree ("root_coords") WHERE status = 'open'; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "vde_run_hexplans" ( + "run_id" text NOT NULL, + "coords" text NOT NULL, + "content" text DEFAULT '' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "vde_run_hexplans_run_id_coords_pk" PRIMARY KEY("run_id","coords") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "vde_run_hexplans" ADD CONSTRAINT "vde_run_hexplans_run_id_vde_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."vde_runs"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_run_hexplans_run_id" ON "vde_run_hexplans" USING btree ("run_id"); \ No newline at end of file diff --git a/drizzle/migrations/meta/0007_snapshot.json b/drizzle/migrations/meta/0007_snapshot.json index 328d2ce8d..50c5bb3fd 100644 --- a/drizzle/migrations/meta/0007_snapshot.json +++ b/drizzle/migrations/meta/0007_snapshot.json @@ -4,8 +4,8 @@ "version": "7", "dialect": "postgresql", "tables": { - "public.vde_base_items": { - "name": "vde_base_items", + "public.vde_base_item_versions": { + "name": "vde_base_item_versions", "schema": "", "columns": { "id": { @@ -14,17 +14,23 @@ "primaryKey": true, "notNull": true, "identity": { - "name": "vde_base_items_id_seq", + "type": "byDefault", + "name": "vde_base_item_versions_id_seq", + "schema": "public", "increment": "1", + "startWith": "1", "minValue": "1", "maxValue": "2147483647", - "startWith": "1", "cache": "1", - "cycle": false, - "schema": "public", - "type": "byDefault" + "cycle": false } }, + "base_item_id": { + "name": "base_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, "title": { "name": "title", "type": "text", @@ -49,6 +55,12 @@ "primaryKey": false, "notNull": false }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -56,24 +68,56 @@ "notNull": true, "default": "now()" }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", + "updated_by": { + "name": "updated_by", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false + } + }, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { + "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", + "tableFrom": "vde_base_item_versions", + "tableTo": "vde_base_items", + "columnsFrom": [ + "base_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, - "indexes": {}, - "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, - "public.vde_base_item_versions": { - "name": "vde_base_item_versions", + "public.vde_base_items": { + "name": "vde_base_items", "schema": "", "columns": { "id": { @@ -82,23 +126,17 @@ "primaryKey": true, "notNull": true, "identity": { - "name": "vde_base_item_versions_id_seq", + "type": "byDefault", + "name": "vde_base_items_id_seq", + "schema": "public", "increment": "1", + "startWith": "1", "minValue": "1", "maxValue": "2147483647", - "startWith": "1", "cache": "1", - "cycle": false, - "schema": "public", - "type": "byDefault" + "cycle": false } }, - "base_item_id": { - "name": "base_item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, "title": { "name": "title", "type": "text", @@ -123,11 +161,11 @@ "primaryKey": false, "notNull": false }, - "version_number": { - "name": "version_number", + "origin_id": { + "name": "origin_id", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": false }, "created_at": { "name": "created_at", @@ -136,59 +174,48 @@ "notNull": true, "default": "now()" }, - "updated_by": { - "name": "updated_by", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "timestamp", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" } }, "indexes": { - "vde_base_item_versions_base_item_id_idx": { - "name": "vde_base_item_versions_base_item_id_idx", + "base_item_origin_id_idx": { + "name": "base_item_origin_id_idx", "columns": [ { - "expression": "base_item_id", + "expression": "origin_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { - "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { - "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", - "tableFrom": "vde_base_item_versions", + "vde_base_items_origin_id_vde_base_items_id_fk": { + "name": "vde_base_items_origin_id_vde_base_items_id_fk", + "tableFrom": "vde_base_items", + "tableTo": "vde_base_items", "columnsFrom": [ - "base_item_id" + "origin_id" ], - "tableTo": "vde_base_items", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "vde_base_item_versions_base_item_id_version_number_unique": { - "name": "vde_base_item_versions_base_item_id_version_number_unique", - "columns": [ - "base_item_id", - "version_number" - ], - "nullsNotDistinct": false - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_map_items": { "name": "vde_map_items", @@ -200,20 +227,20 @@ "primaryKey": true, "notNull": true, "identity": { + "type": "byDefault", "name": "vde_map_items_id_seq", + "schema": "public", "increment": "1", + "startWith": "1", "minValue": "1", "maxValue": "2147483647", - "startWith": "1", "cache": "1", - "cycle": false, - "schema": "public", - "type": "byDefault" + "cycle": false } }, "coord_user_id": { "name": "coord_user_id", - "type": "integer", + "type": "text", "primaryKey": false, "notNull": true }, @@ -237,11 +264,12 @@ "primaryKey": false, "notNull": true }, - "origin_id": { - "name": "origin_id", - "type": "integer", + "visibility": { + "name": "visibility", + "type": "varchar(20)", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'private'" }, "parent_id": { "name": "parent_id", @@ -255,6 +283,12 @@ "primaryKey": false, "notNull": true }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -288,9 +322,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "map_item_item_type_idx": { "name": "map_item_item_type_idx", @@ -303,9 +337,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "map_item_parent_idx": { "name": "map_item_parent_idx", @@ -318,9 +367,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "map_item_ref_item_idx": { "name": "map_item_ref_item_idx", @@ -333,9 +382,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "map_item_unique_coords_idx": { "name": "map_item_unique_coords_idx", @@ -360,57 +409,56 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "vde_map_items_origin_id_vde_map_items_id_fk": { - "name": "vde_map_items_origin_id_vde_map_items_id_fk", - "tableFrom": "vde_map_items", - "columnsFrom": [ - "origin_id" - ], - "tableTo": "vde_map_items", - "columnsTo": [ - "id" - ], - "onUpdate": "no action", - "onDelete": "set null" - }, "vde_map_items_parent_id_vde_map_items_id_fk": { "name": "vde_map_items_parent_id_vde_map_items_id_fk", "tableFrom": "vde_map_items", + "tableTo": "vde_map_items", "columnsFrom": [ "parent_id" ], - "tableTo": "vde_map_items", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "vde_map_items_ref_item_id_vde_base_items_id_fk": { "name": "vde_map_items_ref_item_id_vde_base_items_id_fk", "tableFrom": "vde_map_items", + "tableTo": "vde_base_items", "columnsFrom": [ "ref_item_id" ], - "tableTo": "vde_base_items", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.users": { "name": "users", @@ -468,15 +516,12 @@ "uniqueConstraints": { "users_email_unique": { "name": "users_email_unique", + "nullsNotDistinct": false, "columns": [ "email" - ], - "nullsNotDistinct": false + ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.accounts": { "name": "accounts", @@ -568,22 +613,19 @@ "accounts_user_id_users_id_fk": { "name": "accounts_user_id_users_id_fk", "tableFrom": "accounts", + "tableTo": "users", "columnsFrom": [ "user_id" ], - "tableTo": "users", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.sessions": { "name": "sessions", @@ -645,30 +687,27 @@ "sessions_user_id_users_id_fk": { "name": "sessions_user_id_users_id_fk", "tableFrom": "sessions", + "tableTo": "users", "columnsFrom": [ "user_id" ], - "tableTo": "users", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "sessions_token_unique": { "name": "sessions_token_unique", + "nullsNotDistinct": false, "columns": [ "token" - ], - "nullsNotDistinct": false + ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.verification_tokens": { "name": "verification_tokens", @@ -719,15 +758,12 @@ "uniqueConstraints": { "verification_tokens_value_unique": { "name": "verification_tokens_value_unique", + "nullsNotDistinct": false, "columns": [ "value" - ], - "nullsNotDistinct": false + ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.apikey": { "name": "apikey", @@ -870,56 +906,55 @@ "apikey_user_id_users_id_fk": { "name": "apikey_user_id_users_id_fk", "tableFrom": "apikey", + "tableTo": "users", "columnsFrom": [ "user_id" ], - "tableTo": "users", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, - "public.vde_user_mapping": { - "name": "vde_user_mapping", + "public.internal_api_key": { + "name": "internal_api_key", "schema": "", "columns": { "id": { "name": "id", - "type": "integer", + "type": "text", "primaryKey": true, - "notNull": true, - "identity": { - "name": "vde_user_mapping_id_seq", - "increment": "1", - "minValue": "1", - "maxValue": "2147483647", - "startWith": "1", - "cache": "1", - "cycle": false, - "schema": "public", - "type": "byDefault" - } + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true }, - "auth_user_id": { - "name": "auth_user_id", + "purpose": { + "name": "purpose", "type": "text", "primaryKey": false, "notNull": true }, - "mapping_user_id": { - "name": "mapping_user_id", - "type": "integer", + "encrypted_key": { + "name": "encrypted_key", + "type": "text", "primaryKey": false, "notNull": true }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -927,81 +962,268 @@ "notNull": true, "default": "now()" }, - "updated_at": { - "name": "updated_at", + "last_used_at": { + "name": "last_used_at", "type": "timestamp", "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { - "user_mapping_auth_user_id_idx": { - "name": "user_mapping_auth_user_id_idx", + "unique_user_shortcut": { + "name": "unique_user_shortcut", "columns": [ { - "expression": "auth_user_id", + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, - "user_mapping_mapping_user_id_idx": { - "name": "user_mapping_mapping_user_id_idx", + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", "columns": [ { - "expression": "mapping_user_id", + "expression": "map_item_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { - "vde_user_mapping_auth_user_id_users_id_fk": { - "name": "vde_user_mapping_auth_user_id_users_id_fk", - "tableFrom": "vde_user_mapping", + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", "columnsFrom": [ - "auth_user_id" + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" ], - "tableTo": "users", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "vde_user_mapping_auth_user_id_unique": { - "name": "vde_user_mapping_auth_user_id_unique", + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", "columns": [ - "auth_user_id" + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "nullsNotDistinct": false + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} }, - "vde_user_mapping_mapping_user_id_unique": { - "name": "vde_user_mapping_mapping_user_id_unique", + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", "columns": [ - "mapping_user_id" + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" ], - "nullsNotDistinct": false + "onDelete": "cascade", + "onUpdate": "no action" } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "compositePrimaryKeys": {}, + "uniqueConstraints": {} }, "public.llm_job_results": { "name": "llm_job_results", @@ -1082,9 +1304,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "idx_user_jobs": { "name": "idx_user_jobs", @@ -1103,9 +1325,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": {}, @@ -1113,23 +1335,137 @@ "uniqueConstraints": { "llm_job_results_job_id_unique": { "name": "llm_job_results_job_id_unique", + "nullsNotDistinct": false, "columns": [ "job_id" + ] + } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "nullsNotDistinct": false + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} } }, "enums": {}, "schemas": {}, - "views": {}, "sequences": {}, - "roles": {}, - "policies": {}, "_meta": { "columns": {}, "schemas": {}, diff --git a/drizzle/migrations/meta/0008_snapshot.json b/drizzle/migrations/meta/0008_snapshot.json index 1866e76b5..cdb1847cd 100644 --- a/drizzle/migrations/meta/0008_snapshot.json +++ b/drizzle/migrations/meta/0008_snapshot.json @@ -1,5 +1,5 @@ { - "id": "2f6a3b20-f8e8-4d91-bbe0-c4f5854a3005", + "id": "a8b9c0d1-e2f3-4567-89ab-cdef01234567", "prevId": "869db4c9-25f1-43d4-8f32-8be51693fc5c", "version": "7", "dialect": "postgresql", @@ -75,7 +75,29 @@ "notNull": false } }, - "indexes": {}, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", @@ -92,10 +114,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_base_items": { "name": "vde_base_items", @@ -196,15 +215,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "base_item_no_self_reference": { - "name": "base_item_no_self_reference", - "value": "\"vde_base_items\".\"origin_id\" IS NULL OR \"vde_base_items\".\"origin_id\" != \"vde_base_items\".\"id\"" - } - }, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_map_items": { "name": "vde_map_items", @@ -229,7 +240,7 @@ }, "coord_user_id": { "name": "coord_user_id", - "type": "integer", + "type": "text", "primaryKey": false, "notNull": true }, @@ -253,6 +264,13 @@ "primaryKey": false, "notNull": true }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, "parent_id": { "name": "parent_id", "type": "integer", @@ -265,6 +283,12 @@ "primaryKey": false, "notNull": true }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -317,6 +341,21 @@ "method": "btree", "with": {} }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "map_item_parent_idx": { "name": "map_item_parent_idx", "columns": [ @@ -373,6 +412,21 @@ "concurrently": false, "method": "btree", "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -404,19 +458,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "user_item_parent_constraint": { - "name": "user_item_parent_constraint", - "value": "(\"vde_map_items\".\"item_type\" = 'USER' AND \"vde_map_items\".\"parent_id\" IS NULL) OR \"vde_map_items\".\"item_type\" != 'USER'" - }, - "null_parent_is_user_constraint": { - "name": "null_parent_is_user_constraint", - "value": "(\"vde_map_items\".\"parent_id\" IS NULL AND \"vde_map_items\".\"item_type\" = 'USER') OR \"vde_map_items\".\"parent_id\" IS NOT NULL" - } - }, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.users": { "name": "users", @@ -479,10 +521,7 @@ "email" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.accounts": { "name": "accounts", @@ -586,10 +625,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.sessions": { "name": "sessions", @@ -671,10 +707,7 @@ "token" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.verification_tokens": { "name": "verification_tokens", @@ -730,10 +763,7 @@ "value" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.apikey": { "name": "apikey", @@ -888,44 +918,43 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, - "public.vde_user_mapping": { - "name": "vde_user_mapping", + "public.internal_api_key": { + "name": "internal_api_key", "schema": "", "columns": { "id": { "name": "id", - "type": "integer", + "type": "text", "primaryKey": true, - "notNull": true, - "identity": { - "type": "byDefault", - "name": "vde_user_mapping_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } + "notNull": true }, - "auth_user_id": { - "name": "auth_user_id", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, - "mapping_user_id": { - "name": "mapping_user_id", - "type": "integer", + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", "primaryKey": false, "notNull": true }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -933,20 +962,124 @@ "notNull": true, "default": "now()" }, - "updated_at": { - "name": "updated_at", + "last_used_at": { + "name": "last_used_at", "type": "timestamp", "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { - "user_mapping_auth_user_id_idx": { - "name": "user_mapping_auth_user_id_idx", + "unique_user_shortcut": { + "name": "unique_user_shortcut", "columns": [ { - "expression": "auth_user_id", + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" @@ -957,11 +1090,11 @@ "method": "btree", "with": {} }, - "user_mapping_mapping_user_id_idx": { - "name": "user_mapping_mapping_user_id_idx", + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", "columns": [ { - "expression": "mapping_user_id", + "expression": "map_item_id", "isExpression": false, "asc": true, "nulls": "last" @@ -974,40 +1107,123 @@ } }, "foreignKeys": { - "vde_user_mapping_auth_user_id_users_id_fk": { - "name": "vde_user_mapping_auth_user_id_users_id_fk", - "tableFrom": "vde_user_mapping", + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", "tableTo": "users", "columnsFrom": [ - "auth_user_id" + "user_id" ], "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "vde_user_mapping_auth_user_id_unique": { - "name": "vde_user_mapping_auth_user_id_unique", - "nullsNotDistinct": false, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", "columns": [ - "auth_user_id" - ] + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} }, - "vde_user_mapping_mapping_user_id_unique": { - "name": "vde_user_mapping_mapping_user_id_unique", - "nullsNotDistinct": false, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", "columns": [ - "mapping_user_id" - ] + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} }, "public.llm_job_results": { "name": "llm_job_results", @@ -1124,18 +1340,132 @@ "job_id" ] } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} } }, "enums": {}, "schemas": {}, "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, "_meta": { "columns": {}, "schemas": {}, diff --git a/drizzle/migrations/meta/0009_snapshot.json b/drizzle/migrations/meta/0009_snapshot.json index aff256455..ef3b1195e 100644 --- a/drizzle/migrations/meta/0009_snapshot.json +++ b/drizzle/migrations/meta/0009_snapshot.json @@ -1,6 +1,6 @@ { - "id": "9009-migrate-composition-negative-directions", - "prevId": "2f6a3b20-f8e8-4d91-bbe0-c4f5854a3005", + "id": "b9c0d1e2-f3a4-5678-9abc-def012345678", + "prevId": "a8b9c0d1-e2f3-4567-89ab-cdef01234567", "version": "7", "dialect": "postgresql", "tables": { @@ -75,7 +75,29 @@ "notNull": false } }, - "indexes": {}, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", @@ -92,10 +114,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_base_items": { "name": "vde_base_items", @@ -196,15 +215,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "base_item_no_self_reference": { - "name": "base_item_no_self_reference", - "value": "\"vde_base_items\".\"origin_id\" IS NULL OR \"vde_base_items\".\"origin_id\" != \"vde_base_items\".\"id\"" - } - }, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_map_items": { "name": "vde_map_items", @@ -229,7 +240,7 @@ }, "coord_user_id": { "name": "coord_user_id", - "type": "integer", + "type": "text", "primaryKey": false, "notNull": true }, @@ -253,6 +264,13 @@ "primaryKey": false, "notNull": true }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, "parent_id": { "name": "parent_id", "type": "integer", @@ -265,6 +283,12 @@ "primaryKey": false, "notNull": true }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -317,6 +341,21 @@ "method": "btree", "with": {} }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "map_item_parent_idx": { "name": "map_item_parent_idx", "columns": [ @@ -373,6 +412,21 @@ "concurrently": false, "method": "btree", "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -404,19 +458,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "user_item_parent_constraint": { - "name": "user_item_parent_constraint", - "value": "(\"vde_map_items\".\"item_type\" = 'USER' AND \"vde_map_items\".\"parent_id\" IS NULL) OR \"vde_map_items\".\"item_type\" != 'USER'" - }, - "null_parent_is_user_constraint": { - "name": "null_parent_is_user_constraint", - "value": "(\"vde_map_items\".\"parent_id\" IS NULL AND \"vde_map_items\".\"item_type\" = 'USER') OR \"vde_map_items\".\"parent_id\" IS NOT NULL" - } - }, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.users": { "name": "users", @@ -479,10 +521,7 @@ "email" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.accounts": { "name": "accounts", @@ -586,10 +625,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.sessions": { "name": "sessions", @@ -671,10 +707,7 @@ "token" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.verification_tokens": { "name": "verification_tokens", @@ -730,10 +763,7 @@ "value" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.apikey": { "name": "apikey", @@ -888,44 +918,43 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, - "public.vde_user_mapping": { - "name": "vde_user_mapping", + "public.internal_api_key": { + "name": "internal_api_key", "schema": "", "columns": { "id": { "name": "id", - "type": "integer", + "type": "text", "primaryKey": true, - "notNull": true, - "identity": { - "type": "byDefault", - "name": "vde_user_mapping_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } + "notNull": true }, - "auth_user_id": { - "name": "auth_user_id", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, - "mapping_user_id": { - "name": "mapping_user_id", - "type": "integer", + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", "primaryKey": false, "notNull": true }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -933,20 +962,124 @@ "notNull": true, "default": "now()" }, - "updated_at": { - "name": "updated_at", + "last_used_at": { + "name": "last_used_at", "type": "timestamp", "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { - "user_mapping_auth_user_id_idx": { - "name": "user_mapping_auth_user_id_idx", + "unique_user_shortcut": { + "name": "unique_user_shortcut", "columns": [ { - "expression": "auth_user_id", + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" @@ -957,11 +1090,11 @@ "method": "btree", "with": {} }, - "user_mapping_mapping_user_id_idx": { - "name": "user_mapping_mapping_user_id_idx", + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", "columns": [ { - "expression": "mapping_user_id", + "expression": "map_item_id", "isExpression": false, "asc": true, "nulls": "last" @@ -974,40 +1107,123 @@ } }, "foreignKeys": { - "vde_user_mapping_auth_user_id_users_id_fk": { - "name": "vde_user_mapping_auth_user_id_users_id_fk", - "tableFrom": "vde_user_mapping", + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", "tableTo": "users", "columnsFrom": [ - "auth_user_id" + "user_id" ], "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "vde_user_mapping_auth_user_id_unique": { - "name": "vde_user_mapping_auth_user_id_unique", - "nullsNotDistinct": false, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", "columns": [ - "auth_user_id" - ] + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} }, - "vde_user_mapping_mapping_user_id_unique": { - "name": "vde_user_mapping_mapping_user_id_unique", - "nullsNotDistinct": false, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", "columns": [ - "mapping_user_id" - ] + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} }, "public.llm_job_results": { "name": "llm_job_results", @@ -1124,18 +1340,132 @@ "job_id" ] } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} } }, "enums": {}, "schemas": {}, "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, "_meta": { "columns": {}, "schemas": {}, diff --git a/drizzle/migrations/meta/0010_snapshot.json b/drizzle/migrations/meta/0010_snapshot.json index 2f5bb70c8..19afdce54 100644 --- a/drizzle/migrations/meta/0010_snapshot.json +++ b/drizzle/migrations/meta/0010_snapshot.json @@ -1,6 +1,6 @@ { - "id": "9010-convert-userid-to-text", - "prevId": "9009-migrate-composition-negative-directions", + "id": "c0d1e2f3-a4b5-6789-abcd-ef0123456789", + "prevId": "b9c0d1e2-f3a4-5678-9abc-def012345678", "version": "7", "dialect": "postgresql", "tables": { @@ -75,7 +75,29 @@ "notNull": false } }, - "indexes": {}, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", @@ -92,10 +114,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_base_items": { "name": "vde_base_items", @@ -196,15 +215,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "base_item_no_self_reference": { - "name": "base_item_no_self_reference", - "value": "\"vde_base_items\".\"origin_id\" IS NULL OR \"vde_base_items\".\"origin_id\" != \"vde_base_items\".\"id\"" - } - }, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_map_items": { "name": "vde_map_items", @@ -229,7 +240,7 @@ }, "coord_user_id": { "name": "coord_user_id", - "type": "varchar(255)", + "type": "text", "primaryKey": false, "notNull": true }, @@ -253,6 +264,13 @@ "primaryKey": false, "notNull": true }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, "parent_id": { "name": "parent_id", "type": "integer", @@ -265,6 +283,12 @@ "primaryKey": false, "notNull": true }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -317,6 +341,21 @@ "method": "btree", "with": {} }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "map_item_parent_idx": { "name": "map_item_parent_idx", "columns": [ @@ -373,6 +412,21 @@ "concurrently": false, "method": "btree", "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -404,19 +458,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "user_item_parent_constraint": { - "name": "user_item_parent_constraint", - "value": "(\"vde_map_items\".\"item_type\" = 'USER' AND \"vde_map_items\".\"parent_id\" IS NULL) OR \"vde_map_items\".\"item_type\" != 'USER'" - }, - "null_parent_is_user_constraint": { - "name": "null_parent_is_user_constraint", - "value": "(\"vde_map_items\".\"parent_id\" IS NULL AND \"vde_map_items\".\"item_type\" = 'USER') OR \"vde_map_items\".\"parent_id\" IS NOT NULL" - } - }, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.users": { "name": "users", @@ -479,10 +521,7 @@ "email" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.accounts": { "name": "accounts", @@ -586,10 +625,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.sessions": { "name": "sessions", @@ -671,10 +707,7 @@ "token" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.verification_tokens": { "name": "verification_tokens", @@ -730,10 +763,7 @@ "value" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.apikey": { "name": "apikey", @@ -888,10 +918,312 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} }, "public.llm_job_results": { "name": "llm_job_results", @@ -1008,21 +1340,135 @@ "job_id" ] } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} } }, "enums": {}, "schemas": {}, "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0011_snapshot.json b/drizzle/migrations/meta/0011_snapshot.json index 72b07ea10..8e4567819 100644 --- a/drizzle/migrations/meta/0011_snapshot.json +++ b/drizzle/migrations/meta/0011_snapshot.json @@ -1,6 +1,6 @@ { - "id": "9011-add-visibility-column", - "prevId": "9010-convert-userid-to-text", + "id": "d1e2f3a4-b5c6-789a-bcde-f01234567890", + "prevId": "c0d1e2f3-a4b5-6789-abcd-ef0123456789", "version": "7", "dialect": "postgresql", "tables": { @@ -75,7 +75,29 @@ "notNull": false } }, - "indexes": {}, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", @@ -92,10 +114,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_base_items": { "name": "vde_base_items", @@ -196,15 +215,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "base_item_no_self_reference": { - "name": "base_item_no_self_reference", - "value": "\"vde_base_items\".\"origin_id\" IS NULL OR \"vde_base_items\".\"origin_id\" != \"vde_base_items\".\"id\"" - } - }, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.vde_map_items": { "name": "vde_map_items", @@ -229,7 +240,7 @@ }, "coord_user_id": { "name": "coord_user_id", - "type": "varchar(255)", + "type": "text", "primaryKey": false, "notNull": true }, @@ -253,6 +264,13 @@ "primaryKey": false, "notNull": true }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, "parent_id": { "name": "parent_id", "type": "integer", @@ -265,6 +283,12 @@ "primaryKey": false, "notNull": true }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -278,13 +302,6 @@ "primaryKey": false, "notNull": true, "default": "now()" - }, - "visibility": { - "name": "visibility", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'private'" } }, "indexes": { @@ -324,6 +341,21 @@ "method": "btree", "with": {} }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "map_item_parent_idx": { "name": "map_item_parent_idx", "columns": [ @@ -381,17 +413,17 @@ "method": "btree", "with": {} }, - "map_item_visibility_idx": { - "name": "map_item_visibility_idx", + "unique_template_name": { + "name": "unique_template_name", "columns": [ { - "expression": "visibility", + "expression": "template_name", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} @@ -426,19 +458,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "user_item_parent_constraint": { - "name": "user_item_parent_constraint", - "value": "(\"vde_map_items\".\"item_type\" = 'USER' AND \"vde_map_items\".\"parent_id\" IS NULL) OR \"vde_map_items\".\"item_type\" != 'USER'" - }, - "null_parent_is_user_constraint": { - "name": "null_parent_is_user_constraint", - "value": "(\"vde_map_items\".\"parent_id\" IS NULL AND \"vde_map_items\".\"item_type\" = 'USER') OR \"vde_map_items\".\"parent_id\" IS NOT NULL" - } - }, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.users": { "name": "users", @@ -501,10 +521,7 @@ "email" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.accounts": { "name": "accounts", @@ -608,10 +625,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} }, "public.sessions": { "name": "sessions", @@ -693,10 +707,7 @@ "token" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.verification_tokens": { "name": "verification_tokens", @@ -752,10 +763,7 @@ "value" ] } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + } }, "public.apikey": { "name": "apikey", @@ -910,10 +918,312 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} }, "public.llm_job_results": { "name": "llm_job_results", @@ -1030,21 +1340,135 @@ "job_id" ] } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} } }, "enums": {}, "schemas": {}, "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0012_snapshot.json b/drizzle/migrations/meta/0012_snapshot.json new file mode 100644 index 000000000..2011482bf --- /dev/null +++ b/drizzle/migrations/meta/0012_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "e2f3a4b5-c6d7-89ab-cdef-012345678901", + "prevId": "d1e2f3a4-b5c6-789a-bcde-f01234567890", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.vde_base_item_versions": { + "name": "vde_base_item_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_item_versions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "base_item_id": { + "name": "base_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { + "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", + "tableFrom": "vde_base_item_versions", + "tableTo": "vde_base_items", + "columnsFrom": [ + "base_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_base_items": { + "name": "vde_base_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "base_item_origin_id_idx": { + "name": "base_item_origin_id_idx", + "columns": [ + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_items_origin_id_vde_base_items_id_fk": { + "name": "vde_base_items_origin_id_vde_base_items_id_fk", + "tableFrom": "vde_base_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "origin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_map_items": { + "name": "vde_map_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_map_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "coord_user_id": { + "name": "coord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coord_group_id": { + "name": "coord_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "path": { + "name": "path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_type": { + "name": "item_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ref_item_id": { + "name": "ref_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "map_item_coord_user_group_idx": { + "name": "map_item_coord_user_group_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_item_type_idx": { + "name": "map_item_item_type_idx", + "columns": [ + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_parent_idx": { + "name": "map_item_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_ref_item_idx": { + "name": "map_item_ref_item_idx", + "columns": [ + { + "expression": "ref_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_unique_coords_idx": { + "name": "map_item_unique_coords_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_map_items_parent_id_vde_map_items_id_fk": { + "name": "vde_map_items_parent_id_vde_map_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_map_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vde_map_items_ref_item_id_vde_base_items_id_fk": { + "name": "vde_map_items_ref_item_id_vde_base_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "ref_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_value_unique": { + "name": "verification_tokens_value_unique", + "nullsNotDistinct": false, + "columns": [ + "value" + ] + } + } + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_users_id_fk": { + "name": "apikey_user_id_users_id_fk", + "tableFrom": "apikey", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.llm_job_results": { + "name": "llm_job_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_job_status": { + "name": "idx_job_status", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_jobs": { + "name": "idx_user_jobs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "llm_job_results_job_id_unique": { + "name": "llm_job_results_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0013_snapshot.json b/drizzle/migrations/meta/0013_snapshot.json new file mode 100644 index 000000000..93fb9b884 --- /dev/null +++ b/drizzle/migrations/meta/0013_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "f3a4b5c6-d7e8-9abc-def0-123456789012", + "prevId": "e2f3a4b5-c6d7-89ab-cdef-012345678901", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.vde_base_item_versions": { + "name": "vde_base_item_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_item_versions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "base_item_id": { + "name": "base_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { + "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", + "tableFrom": "vde_base_item_versions", + "tableTo": "vde_base_items", + "columnsFrom": [ + "base_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_base_items": { + "name": "vde_base_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "base_item_origin_id_idx": { + "name": "base_item_origin_id_idx", + "columns": [ + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_items_origin_id_vde_base_items_id_fk": { + "name": "vde_base_items_origin_id_vde_base_items_id_fk", + "tableFrom": "vde_base_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "origin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_map_items": { + "name": "vde_map_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_map_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "coord_user_id": { + "name": "coord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coord_group_id": { + "name": "coord_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "path": { + "name": "path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_type": { + "name": "item_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ref_item_id": { + "name": "ref_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "map_item_coord_user_group_idx": { + "name": "map_item_coord_user_group_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_item_type_idx": { + "name": "map_item_item_type_idx", + "columns": [ + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_parent_idx": { + "name": "map_item_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_ref_item_idx": { + "name": "map_item_ref_item_idx", + "columns": [ + { + "expression": "ref_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_unique_coords_idx": { + "name": "map_item_unique_coords_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_map_items_parent_id_vde_map_items_id_fk": { + "name": "vde_map_items_parent_id_vde_map_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_map_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vde_map_items_ref_item_id_vde_base_items_id_fk": { + "name": "vde_map_items_ref_item_id_vde_base_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "ref_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_value_unique": { + "name": "verification_tokens_value_unique", + "nullsNotDistinct": false, + "columns": [ + "value" + ] + } + } + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_users_id_fk": { + "name": "apikey_user_id_users_id_fk", + "tableFrom": "apikey", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.llm_job_results": { + "name": "llm_job_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_job_status": { + "name": "idx_job_status", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_jobs": { + "name": "idx_user_jobs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "llm_job_results_job_id_unique": { + "name": "llm_job_results_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0014_snapshot.json b/drizzle/migrations/meta/0014_snapshot.json new file mode 100644 index 000000000..2a26413f4 --- /dev/null +++ b/drizzle/migrations/meta/0014_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "a4b5c6d7-e8f9-abcd-ef01-234567890123", + "prevId": "f3a4b5c6-d7e8-9abc-def0-123456789012", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.vde_base_item_versions": { + "name": "vde_base_item_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_item_versions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "base_item_id": { + "name": "base_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { + "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", + "tableFrom": "vde_base_item_versions", + "tableTo": "vde_base_items", + "columnsFrom": [ + "base_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_base_items": { + "name": "vde_base_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "base_item_origin_id_idx": { + "name": "base_item_origin_id_idx", + "columns": [ + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_items_origin_id_vde_base_items_id_fk": { + "name": "vde_base_items_origin_id_vde_base_items_id_fk", + "tableFrom": "vde_base_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "origin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_map_items": { + "name": "vde_map_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_map_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "coord_user_id": { + "name": "coord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coord_group_id": { + "name": "coord_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "path": { + "name": "path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_type": { + "name": "item_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ref_item_id": { + "name": "ref_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "map_item_coord_user_group_idx": { + "name": "map_item_coord_user_group_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_item_type_idx": { + "name": "map_item_item_type_idx", + "columns": [ + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_parent_idx": { + "name": "map_item_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_ref_item_idx": { + "name": "map_item_ref_item_idx", + "columns": [ + { + "expression": "ref_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_unique_coords_idx": { + "name": "map_item_unique_coords_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_map_items_parent_id_vde_map_items_id_fk": { + "name": "vde_map_items_parent_id_vde_map_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_map_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vde_map_items_ref_item_id_vde_base_items_id_fk": { + "name": "vde_map_items_ref_item_id_vde_base_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "ref_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_value_unique": { + "name": "verification_tokens_value_unique", + "nullsNotDistinct": false, + "columns": [ + "value" + ] + } + } + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_users_id_fk": { + "name": "apikey_user_id_users_id_fk", + "tableFrom": "apikey", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.llm_job_results": { + "name": "llm_job_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_job_status": { + "name": "idx_job_status", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_jobs": { + "name": "idx_user_jobs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "llm_job_results_job_id_unique": { + "name": "llm_job_results_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0015_snapshot.json b/drizzle/migrations/meta/0015_snapshot.json new file mode 100644 index 000000000..95d357947 --- /dev/null +++ b/drizzle/migrations/meta/0015_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "b5c6d7e8-f9a0-bcde-f012-345678901234", + "prevId": "a4b5c6d7-e8f9-abcd-ef01-234567890123", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.vde_base_item_versions": { + "name": "vde_base_item_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_item_versions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "base_item_id": { + "name": "base_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { + "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", + "tableFrom": "vde_base_item_versions", + "tableTo": "vde_base_items", + "columnsFrom": [ + "base_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_base_items": { + "name": "vde_base_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "base_item_origin_id_idx": { + "name": "base_item_origin_id_idx", + "columns": [ + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_items_origin_id_vde_base_items_id_fk": { + "name": "vde_base_items_origin_id_vde_base_items_id_fk", + "tableFrom": "vde_base_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "origin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_map_items": { + "name": "vde_map_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_map_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "coord_user_id": { + "name": "coord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coord_group_id": { + "name": "coord_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "path": { + "name": "path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_type": { + "name": "item_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ref_item_id": { + "name": "ref_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "map_item_coord_user_group_idx": { + "name": "map_item_coord_user_group_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_item_type_idx": { + "name": "map_item_item_type_idx", + "columns": [ + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_parent_idx": { + "name": "map_item_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_ref_item_idx": { + "name": "map_item_ref_item_idx", + "columns": [ + { + "expression": "ref_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_unique_coords_idx": { + "name": "map_item_unique_coords_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_map_items_parent_id_vde_map_items_id_fk": { + "name": "vde_map_items_parent_id_vde_map_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_map_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vde_map_items_ref_item_id_vde_base_items_id_fk": { + "name": "vde_map_items_ref_item_id_vde_base_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "ref_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_value_unique": { + "name": "verification_tokens_value_unique", + "nullsNotDistinct": false, + "columns": [ + "value" + ] + } + } + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_users_id_fk": { + "name": "apikey_user_id_users_id_fk", + "tableFrom": "apikey", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.llm_job_results": { + "name": "llm_job_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_job_status": { + "name": "idx_job_status", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_jobs": { + "name": "idx_user_jobs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "llm_job_results_job_id_unique": { + "name": "llm_job_results_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0016_snapshot.json b/drizzle/migrations/meta/0016_snapshot.json new file mode 100644 index 000000000..37e7b2b09 --- /dev/null +++ b/drizzle/migrations/meta/0016_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "c6d7e8f9-a0b1-cdef-0123-456789012345", + "prevId": "b5c6d7e8-f9a0-bcde-f012-345678901234", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.vde_base_item_versions": { + "name": "vde_base_item_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_item_versions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "base_item_id": { + "name": "base_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { + "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", + "tableFrom": "vde_base_item_versions", + "tableTo": "vde_base_items", + "columnsFrom": [ + "base_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_base_items": { + "name": "vde_base_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "base_item_origin_id_idx": { + "name": "base_item_origin_id_idx", + "columns": [ + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_items_origin_id_vde_base_items_id_fk": { + "name": "vde_base_items_origin_id_vde_base_items_id_fk", + "tableFrom": "vde_base_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "origin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_map_items": { + "name": "vde_map_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_map_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "coord_user_id": { + "name": "coord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coord_group_id": { + "name": "coord_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "path": { + "name": "path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_type": { + "name": "item_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ref_item_id": { + "name": "ref_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "map_item_coord_user_group_idx": { + "name": "map_item_coord_user_group_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_item_type_idx": { + "name": "map_item_item_type_idx", + "columns": [ + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_parent_idx": { + "name": "map_item_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_ref_item_idx": { + "name": "map_item_ref_item_idx", + "columns": [ + { + "expression": "ref_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_unique_coords_idx": { + "name": "map_item_unique_coords_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_map_items_parent_id_vde_map_items_id_fk": { + "name": "vde_map_items_parent_id_vde_map_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_map_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vde_map_items_ref_item_id_vde_base_items_id_fk": { + "name": "vde_map_items_ref_item_id_vde_base_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "ref_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_value_unique": { + "name": "verification_tokens_value_unique", + "nullsNotDistinct": false, + "columns": [ + "value" + ] + } + } + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_users_id_fk": { + "name": "apikey_user_id_users_id_fk", + "tableFrom": "apikey", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.llm_job_results": { + "name": "llm_job_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_job_status": { + "name": "idx_job_status", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_jobs": { + "name": "idx_user_jobs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "llm_job_results_job_id_unique": { + "name": "llm_job_results_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0017_snapshot.json b/drizzle/migrations/meta/0017_snapshot.json new file mode 100644 index 000000000..c015a50de --- /dev/null +++ b/drizzle/migrations/meta/0017_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "d7e8f9a0-b1c2-def0-1234-567890123456", + "prevId": "c6d7e8f9-a0b1-cdef-0123-456789012345", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.vde_base_item_versions": { + "name": "vde_base_item_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_item_versions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "base_item_id": { + "name": "base_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { + "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", + "tableFrom": "vde_base_item_versions", + "tableTo": "vde_base_items", + "columnsFrom": [ + "base_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_base_items": { + "name": "vde_base_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "base_item_origin_id_idx": { + "name": "base_item_origin_id_idx", + "columns": [ + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_items_origin_id_vde_base_items_id_fk": { + "name": "vde_base_items_origin_id_vde_base_items_id_fk", + "tableFrom": "vde_base_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "origin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_map_items": { + "name": "vde_map_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_map_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "coord_user_id": { + "name": "coord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coord_group_id": { + "name": "coord_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "path": { + "name": "path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_type": { + "name": "item_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ref_item_id": { + "name": "ref_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "map_item_coord_user_group_idx": { + "name": "map_item_coord_user_group_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_item_type_idx": { + "name": "map_item_item_type_idx", + "columns": [ + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_parent_idx": { + "name": "map_item_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_ref_item_idx": { + "name": "map_item_ref_item_idx", + "columns": [ + { + "expression": "ref_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_unique_coords_idx": { + "name": "map_item_unique_coords_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_map_items_parent_id_vde_map_items_id_fk": { + "name": "vde_map_items_parent_id_vde_map_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_map_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vde_map_items_ref_item_id_vde_base_items_id_fk": { + "name": "vde_map_items_ref_item_id_vde_base_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "ref_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_value_unique": { + "name": "verification_tokens_value_unique", + "nullsNotDistinct": false, + "columns": [ + "value" + ] + } + } + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_users_id_fk": { + "name": "apikey_user_id_users_id_fk", + "tableFrom": "apikey", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.llm_job_results": { + "name": "llm_job_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_job_status": { + "name": "idx_job_status", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_jobs": { + "name": "idx_user_jobs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "llm_job_results_job_id_unique": { + "name": "llm_job_results_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0018_snapshot.json b/drizzle/migrations/meta/0018_snapshot.json new file mode 100644 index 000000000..af8e0008c --- /dev/null +++ b/drizzle/migrations/meta/0018_snapshot.json @@ -0,0 +1,1555 @@ +{ + "id": "dad11acd-7cdf-4470-9df7-ae07a7da94cb", + "prevId": "d7e8f9a0-b1c2-def0-1234-567890123456", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.vde_base_item_versions": { + "name": "vde_base_item_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_item_versions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "base_item_id": { + "name": "base_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "base_item_versions_unique_version_idx": { + "name": "base_item_versions_unique_version_idx", + "columns": [ + { + "expression": "base_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_item_versions_base_item_id_vde_base_items_id_fk": { + "name": "vde_base_item_versions_base_item_id_vde_base_items_id_fk", + "tableFrom": "vde_base_item_versions", + "tableTo": "vde_base_items", + "columnsFrom": [ + "base_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_base_items": { + "name": "vde_base_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_base_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "base_item_origin_id_idx": { + "name": "base_item_origin_id_idx", + "columns": [ + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_base_items_origin_id_vde_base_items_id_fk": { + "name": "vde_base_items_origin_id_vde_base_items_id_fk", + "tableFrom": "vde_base_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "origin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_map_items": { + "name": "vde_map_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "vde_map_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "coord_user_id": { + "name": "coord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coord_group_id": { + "name": "coord_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "path": { + "name": "path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_type": { + "name": "item_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ref_item_id": { + "name": "ref_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "map_item_coord_user_group_idx": { + "name": "map_item_coord_user_group_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_item_type_idx": { + "name": "map_item_item_type_idx", + "columns": [ + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_visibility_idx": { + "name": "map_item_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_parent_idx": { + "name": "map_item_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_ref_item_idx": { + "name": "map_item_ref_item_idx", + "columns": [ + { + "expression": "ref_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "map_item_unique_coords_idx": { + "name": "map_item_unique_coords_idx", + "columns": [ + { + "expression": "coord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coord_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_template_name": { + "name": "unique_template_name", + "columns": [ + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_map_items_parent_id_vde_map_items_id_fk": { + "name": "vde_map_items_parent_id_vde_map_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_map_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vde_map_items_ref_item_id_vde_base_items_id_fk": { + "name": "vde_map_items_ref_item_id_vde_base_items_id_fk", + "tableFrom": "vde_map_items", + "tableTo": "vde_base_items", + "columnsFrom": [ + "ref_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_value_unique": { + "name": "verification_tokens_value_unique", + "nullsNotDistinct": false, + "columns": [ + "value" + ] + } + } + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_users_id_fk": { + "name": "apikey_user_id_users_id_fk", + "tableFrom": "apikey", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.internal_api_key": { + "name": "internal_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_key_per_user_purpose": { + "name": "unique_active_key_per_user_purpose", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"internal_api_key\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "internal_api_key_user_id_users_id_fk": { + "name": "internal_api_key_user_id_users_id_fk", + "tableFrom": "internal_api_key", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tile_favorites": { + "name": "tile_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_item_id": { + "name": "map_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortcut_name": { + "name": "shortcut_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_shortcut": { + "name": "unique_user_shortcut", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shortcut_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_user_id_idx": { + "name": "tile_favorites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tile_favorites_map_item_id_idx": { + "name": "tile_favorites_map_item_id_idx", + "columns": [ + { + "expression": "map_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tile_favorites_user_id_users_id_fk": { + "name": "tile_favorites_user_id_users_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tile_favorites_map_item_id_vde_map_items_id_fk": { + "name": "tile_favorites_map_item_id_vde_map_items_id_fk", + "tableFrom": "tile_favorites", + "tableTo": "vde_map_items", + "columnsFrom": [ + "map_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_template_allowlist": { + "name": "user_template_allowlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_templates": { + "name": "allowed_templates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_allowlist": { + "name": "unique_user_allowlist", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_template_allowlist_user_id_idx": { + "name": "user_template_allowlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_template_allowlist_user_id_users_id_fk": { + "name": "user_template_allowlist_user_id_users_id_fk", + "tableFrom": "user_template_allowlist", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.llm_job_results": { + "name": "llm_job_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_job_status": { + "name": "idx_job_status", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_jobs": { + "name": "idx_user_jobs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "llm_job_results_job_id_unique": { + "name": "llm_job_results_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + } + } + }, + "public.vde_runs": { + "name": "vde_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_coords": { + "name": "root_coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockage_reason": { + "name": "blockage_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_log": { + "name": "execution_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_runs": { + "name": "idx_user_runs", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_root_coords_status": { + "name": "idx_root_coords_status", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_open_run": { + "name": "unique_open_run", + "columns": [ + { + "expression": "root_coords", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.vde_run_hexplans": { + "name": "vde_run_hexplans", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coords": { + "name": "coords", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_run_hexplans_run_id": { + "name": "idx_run_hexplans_run_id", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vde_run_hexplans_run_id_vde_runs_id_fk": { + "name": "vde_run_hexplans_run_id_vde_runs_id_fk", + "tableFrom": "vde_run_hexplans", + "tableTo": "vde_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vde_run_hexplans_run_id_coords_pk": { + "name": "vde_run_hexplans_run_id_coords_pk", + "columns": [ + "run_id", + "coords" + ] + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 9fb80628e..0efcdba6e 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -92,6 +92,48 @@ "when": 1765500000000, "tag": "0012_add_tile_favorites", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1765600000000, + "tag": "0013_alter_tile_favorites_mapitemid_to_integer", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1765700000000, + "tag": "0014_migrate_item_types", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1765800000000, + "tag": "0015_add_template_name_column", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1765900000000, + "tag": "0016_fix_template_name_constraints", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1766000000000, + "tag": "0017_add_user_template_allowlist", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1769093003851, + "tag": "0018_curious_power_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/seeds/templates.seed.ts b/drizzle/seeds/templates.seed.ts new file mode 100644 index 000000000..6c6b098e2 --- /dev/null +++ b/drizzle/seeds/templates.seed.ts @@ -0,0 +1,371 @@ +/** + * Template Tiles Seed Script + * + * Seeds the built-in template tiles (system, user) at well-known coordinates. + * This script is idempotent - running it multiple times will not create duplicates. + * + * Usage: + * pnpm tsx drizzle/seeds/templates.seed.ts + * + * Or with dotenv for local environment: + * dotenv -e .env -e .env.local -- tsx drizzle/seeds/templates.seed.ts + * + * Design reference: docs/features/TEMPLATES_AS_TILES.md + */ + +import postgres from 'postgres' +import { SYSTEM_TEMPLATE } from '~/lib/domains/agentic/templates/_system-template' +import { USER_TEMPLATE } from '~/lib/domains/agentic/templates/_user-template' + +// ==================== CONFIGURATION ==================== + +/** + * Well-known user ID for system-owned templates. + * This is Hexframe's internal system user that owns built-in templates. + */ +const SYSTEM_USER_ID = 'D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK' + +/** + * Well-known coordinates for the Templates organizational tile. + * Built-in templates are stored as children of this tile. + */ +const TEMPLATES_PARENT_PATH = [1, 2] as const + +/** + * Item type for template tiles. + * This is a custom item type (not part of the MapItemType enum). + */ +const TEMPLATE_ITEM_TYPE = 'template' as const + +// ==================== TYPES ==================== + +interface BuiltinTemplateSpec { + templateName: string + title: string + content: string + direction: number +} + +interface SeedResult { + created: string[] + updated: string[] + skipped: string[] +} + +interface ExistingTemplate { + id: number + coord_user_id: string + path: string + template_name: string + ref_item_id: number + content: string +} + +/** + * Built-in template specifications. + * Each template is a child of the Templates organizational tile at TEMPLATES_PARENT_PATH. + */ +const BUILTIN_TEMPLATE_SPECS: readonly BuiltinTemplateSpec[] = [ + { + templateName: 'system', + title: 'System Task Template', + content: SYSTEM_TEMPLATE, + direction: 1, + }, + { + templateName: 'user', + title: 'User Interlocutor Template', + content: USER_TEMPLATE, + direction: 2, + }, +] as const + +// ==================== DATABASE CONNECTION ==================== + +const DATABASE_URL = process.env.DATABASE_URL +if (!DATABASE_URL) { + console.error('DATABASE_URL environment variable is required') + process.exit(1) +} + +const sql = postgres(DATABASE_URL) + +// ==================== SEED FUNCTIONS ==================== + +/** + * Find or create the grandparent tile at path [1] if needed. + */ +async function _ensureGrandparentExists(): Promise<number> { + const grandparentPath = TEMPLATES_PARENT_PATH.slice(0, 1).join(',') + + // Check if grandparent exists + const existingResult = await sql<Array<{ id: number }>>` + SELECT id FROM vde_map_items + WHERE coord_user_id = ${SYSTEM_USER_ID} + AND coord_group_id = 0 + AND path = ${grandparentPath} + LIMIT 1 + ` + + if (existingResult[0]) { + return existingResult[0].id + } + + // Find root tile + const rootResult = await sql<Array<{ id: number }>>` + SELECT id FROM vde_map_items + WHERE coord_user_id = ${SYSTEM_USER_ID} + AND coord_group_id = 0 + AND path = '' + LIMIT 1 + ` + const rootId = rootResult[0]?.id ?? null + + // Create grandparent organizational tile + const baseResult = await sql<Array<{ id: number }>>` + INSERT INTO vde_base_items (title, content, created_at, updated_at) + VALUES ('System', 'System-level organizational tiles', NOW(), NOW()) + RETURNING id + ` + const baseItemId = baseResult[0]?.id + if (!baseItemId) throw new Error('Failed to create grandparent base item') + + const mapResult = await sql<Array<{ id: number }>>` + INSERT INTO vde_map_items ( + coord_user_id, coord_group_id, path, item_type, visibility, + parent_id, ref_item_id, created_at, updated_at + ) + VALUES ( + ${SYSTEM_USER_ID}, 0, ${grandparentPath}, 'organizational', 'public', + ${rootId}, ${baseItemId}, NOW(), NOW() + ) + RETURNING id + ` + + const grandparentId = mapResult[0]?.id + if (!grandparentId) throw new Error('Failed to create grandparent map item') + + console.log(` Created grandparent organizational tile at path: ${grandparentPath}`) + return grandparentId +} + +/** + * Find or create the Templates organizational tile at TEMPLATES_PARENT_PATH. + */ +async function _ensureTemplatesParentExists(): Promise<number> { + const pathString = TEMPLATES_PARENT_PATH.join(',') + + // Check if parent exists + const existingResult = await sql<Array<{ id: number }>>` + SELECT id FROM vde_map_items + WHERE coord_user_id = ${SYSTEM_USER_ID} + AND coord_group_id = 0 + AND path = ${pathString} + LIMIT 1 + ` + + if (existingResult[0]) { + return existingResult[0].id + } + + // Ensure grandparent exists first + const grandparentId = await _ensureGrandparentExists() + + // Create Templates organizational tile + const baseResult = await sql<Array<{ id: number }>>` + INSERT INTO vde_base_items (title, content, created_at, updated_at) + VALUES ('Templates', 'Built-in template tiles for prompt rendering', NOW(), NOW()) + RETURNING id + ` + const baseItemId = baseResult[0]?.id + if (!baseItemId) throw new Error('Failed to create Templates base item') + + const mapResult = await sql<Array<{ id: number }>>` + INSERT INTO vde_map_items ( + coord_user_id, coord_group_id, path, item_type, visibility, + parent_id, ref_item_id, created_at, updated_at + ) + VALUES ( + ${SYSTEM_USER_ID}, 0, ${pathString}, 'organizational', 'public', + ${grandparentId}, ${baseItemId}, NOW(), NOW() + ) + RETURNING id + ` + + const parentId = mapResult[0]?.id + if (!parentId) throw new Error('Failed to create Templates map item') + + console.log(` Created Templates organizational tile at path: ${pathString}`) + return parentId +} + +/** + * Find context needed for seeding: parent ID and existing templates. + */ +async function _findSeedContext(): Promise<{ + parentId: number + existingTemplates: Map<string, ExistingTemplate> +}> { + // Ensure Templates parent exists (creates if missing) + const parentId = await _ensureTemplatesParentExists() + + const templatesResult = await sql<ExistingTemplate[]>` + SELECT + m.id, + m.coord_user_id, + m.path, + m.template_name, + m.ref_item_id, + b.content + FROM vde_map_items m + JOIN vde_base_items b ON m.ref_item_id = b.id + WHERE m.coord_user_id = ${SYSTEM_USER_ID} + AND m.coord_group_id = 0 + AND m.item_type = ${TEMPLATE_ITEM_TYPE} + AND m.template_name IS NOT NULL + ` + + const existingTemplates = new Map<string, ExistingTemplate>() + for (const template of templatesResult) { + existingTemplates.set(template.template_name, template) + } + + return { parentId, existingTemplates } +} + +/** + * Create a new template tile (base item + map item). + */ +async function _createTemplate( + spec: BuiltinTemplateSpec, + parentId: number +): Promise<void> { + const pathString = [...TEMPLATES_PARENT_PATH, spec.direction].join(',') + + const baseResult = await sql<Array<{ id: number }>>` + INSERT INTO vde_base_items (title, content, created_at, updated_at) + VALUES (${spec.title}, ${spec.content}, NOW(), NOW()) + RETURNING id + ` + + const baseItemId = baseResult[0]?.id + if (baseItemId === undefined) { + throw new Error(`Failed to create base item for template: ${spec.templateName}`) + } + + await sql` + INSERT INTO vde_map_items ( + coord_user_id, coord_group_id, path, item_type, visibility, + parent_id, ref_item_id, template_name, created_at, updated_at + ) + VALUES ( + ${SYSTEM_USER_ID}, 0, ${pathString}, ${TEMPLATE_ITEM_TYPE}, 'public', + ${parentId}, ${baseItemId}, ${spec.templateName}, NOW(), NOW() + ) + ` +} + +/** + * Seed a single template: create new or update existing if content changed. + */ +async function _seedTemplate( + spec: BuiltinTemplateSpec, + existingTemplates: Map<string, ExistingTemplate>, + parentId: number +): Promise<'created' | 'updated' | 'skipped'> { + const existing = existingTemplates.get(spec.templateName) + + if (!existing) { + await _createTemplate(spec, parentId) + return 'created' + } + + if (existing.content === spec.content) { + return 'skipped' + } + + await sql` + UPDATE vde_base_items + SET content = ${spec.content}, + title = ${spec.title}, + updated_at = NOW() + WHERE id = ${existing.ref_item_id} + ` + + return 'updated' +} + +/** + * Main seed function: seeds all built-in templates. + */ +async function seedTemplates(): Promise<SeedResult> { + const result: SeedResult = { created: [], updated: [], skipped: [] } + + console.log('Finding seed context...') + const { parentId, existingTemplates } = await _findSeedContext() + + console.log(`Templates parent tile ID: ${parentId}`) + console.log(`Found ${existingTemplates.size} existing template tiles\n`) + + console.log('Seeding templates...') + for (const spec of BUILTIN_TEMPLATE_SPECS) { + const status = await _seedTemplate(spec, existingTemplates, parentId) + + switch (status) { + case 'created': + result.created.push(spec.templateName) + console.log(` + Created: ${spec.templateName}`) + break + case 'updated': + result.updated.push(spec.templateName) + console.log(` ~ Updated: ${spec.templateName}`) + break + case 'skipped': + result.skipped.push(spec.templateName) + console.log(` - Skipped: ${spec.templateName} (unchanged)`) + break + } + } + + return result +} + +/** + * Entry point. + */ +async function main() { + console.log('='.repeat(60)) + console.log('Template Tiles Seed Script') + console.log('='.repeat(60)) + console.log() + console.log(`System User ID: ${SYSTEM_USER_ID}`) + console.log(`Templates Parent Path: ${TEMPLATES_PARENT_PATH.join(',')}`) + console.log(`Templates to seed: ${BUILTIN_TEMPLATE_SPECS.map(s => s.templateName).join(', ')}`) + console.log() + + try { + const result = await seedTemplates() + + console.log() + console.log('='.repeat(60)) + console.log('Summary') + console.log('='.repeat(60)) + console.log(` Created: ${result.created.length} (${result.created.join(', ') || 'none'})`) + console.log(` Updated: ${result.updated.length} (${result.updated.join(', ') || 'none'})`) + console.log(` Skipped: ${result.skipped.length} (${result.skipped.join(', ') || 'none'})`) + console.log() + + if (result.created.length > 0 || result.updated.length > 0) { + console.log('Seed completed successfully!') + } else { + console.log('No changes needed. All templates are up to date.') + } + } catch (error) { + console.error('Seed failed:', error) + process.exit(1) + } finally { + await sql.end() + } +} + +main() diff --git a/package.json b/package.json index 7f50da177..8e44d4a70 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,12 @@ "lint:colors": "node scripts/validate-colors.mjs", "check:architecture": "python3 -m scripts.checks.architecture.main", "check:architecture:map": "python3 -m scripts.checks.architecture.main src/app/map", - "check:deadcode": "python3 -m scripts.checks.deadcode.main", - "check:ruleof6": "python3 scripts/checks/ruleof6/cli.py", + "check:ruleof6": "python3 -m scripts.checks.architecture.ruleof6.main", "check:lint": "python3 scripts/checks/lint/main.py", "check:lint:verbose": "python3 scripts/checks/lint/main.py --verbose", "check:lint:errors": "python3 scripts/checks/lint/main.py --errors-only", - "check:quality": "pnpm check:deadcode && pnpm check:architecture && pnpm check:ruleof6 && pnpm check:lint", + "check:quality": "pnpm check:architecture && pnpm check:ruleof6 && pnpm check:lint", + "subsystem-tree": "python3 scripts/checks/run-subsystem-tree.py", "test:checkers": "python3 scripts/checks/run-tests.py", "lint:all": "pnpm check:lint && pnpm check:quality", "check:architecture:fix": "node scripts/fix-dependencies.cjs", diff --git a/scripts/checks/architecture b/scripts/checks/architecture new file mode 160000 index 000000000..5f5662529 --- /dev/null +++ b/scripts/checks/architecture @@ -0,0 +1 @@ +Subproject commit 5f5662529dfa90e97ab47886e6b63b5f33060665 diff --git a/scripts/checks/architecture/README-STRUCTURE.md b/scripts/checks/architecture/README-STRUCTURE.md deleted file mode 100644 index 3e0d9f9c4..000000000 --- a/scripts/checks/architecture/README-STRUCTURE.md +++ /dev/null @@ -1,62 +0,0 @@ -# README.md Structure Guide - -## Purpose -Each subsystem must have a README.md that serves as the single source of truth for understanding that part of the codebase. This replaces the previous dual README/ARCHITECTURE approach. - -## Required Structure - -Every README.md should follow this template: - -```markdown -# [Subsystem Name] - -## Mental Model -*How to think about this subsystem - use concrete allegories when possible* -Example: "The Map Cache is the 'database for the frontend' - it stores all tile data locally and syncs with the server like a distributed database would." - -## Responsibilities -*What this subsystem IS responsible for - bullet points* -- Specific responsibility 1 -- Specific responsibility 2 -- Specific responsibility 3 - -## Non-Responsibilities -*What this subsystem does NOT handle - delegate to the correct subsystem* -*Note: All direct child subsystems should be mentioned here* - -- Authentication β†’ See `src/lib/auth/README.md` -- Business logic β†’ See `src/lib/domains/mapping/README.md` -- UI rendering β†’ See `src/app/map/Canvas/README.md` -- [Child subsystem] β†’ See `./child-dir/README.md` - -## Interface -*See `index.ts` for the public API - the ONLY exports other subsystems can use* -*See `dependencies.json` for what this subsystem can import* - -Note: Child subsystems can import from parent freely, but all other subsystems MUST go through index.ts. The CI tool `pnpm check:architecture` enforces this boundary. -``` - -## Guidelines - -1. **Keep it concise** - Maximum 1 page when possible -2. **Use concrete allegories** - Mental models should use familiar concepts (e.g., "cache as database", "event bus as postal system") -3. **Complete non-responsibilities** - Every child subsystem must be listed in non-responsibilities -4. **Minimal interface exposure** - index.ts should export ONLY what's needed, hiding implementation details -5. **Enforce boundaries** - `pnpm check:architecture` ensures subsystem isolation -6. **Update when architecture changes** - Part of the change process - -## Why This Matters - -- **Maintainability**: Clear boundaries prevent spaghetti dependencies -- **AI-friendly**: Well-defined interfaces help AI understand what can be used where -- **Minimal surface area**: Less exposed = less to break when refactoring -- **Parent-child freedom**: Children can access parent internals, but siblings must use public APIs - -## Migration from ARCHITECTURE.md - -When consolidating ARCHITECTURE.md into README.md: -1. Extract a concrete mental model (avoid vague terms like "smart", "manager", "handler") -2. Convert subsystem listing to non-responsibilities section -3. Remove key concepts (they should have their own subsystems) -4. Point dependencies section to dependencies.json only -5. Ensure all child directories are referenced \ No newline at end of file diff --git a/scripts/checks/architecture/README.md b/scripts/checks/architecture/README.md deleted file mode 100644 index f12dbf39b..000000000 --- a/scripts/checks/architecture/README.md +++ /dev/null @@ -1,256 +0,0 @@ -# Architecture Checker - -The Architecture Checker enforces subsystem boundaries with clear interfaces and hierarchical independence. - -## Overview - -This tool validates that subsystems are properly documented, have explicit dependency declarations, and maintain encapsulation through index.ts interfaces. Subsystems serve as navigable entry points for understanding the codebase at a high level, with relative independence from each other and hierarchical relationships where child subsystems are only known within their parent. - -## Usage - -```bash -# Check entire src directory (default) -python3 scripts/check-architecture.py - -# Check specific directory -python3 scripts/check-architecture.py src/app/map - -# Show help -python3 scripts/check-architecture.py --help -``` - -## Architecture Rules - -### 1. Complexity Requirements - -**Folders over 1000 lines need (ERROR):** -- `dependencies.json` - Declares allowed imports and child subsystems -- `README.md` - Documents purpose, mental model, responsibilities, and subsystems - -**Folders over 500 lines should have (WARNING):** -- `README.md` - Basic documentation - -**Custom thresholds:** -- Create `.architecture-exceptions` file in project root or parent directory -- Format: `path/to/folder: 2000 # Justification required` -- Use sparingly with clear reasoning - -### 2. Import Boundaries - -**External files cannot import directly into subsystems:** -- ❌ `import { Foo } from '~/lib/domains/mapping/services/foo'` -- βœ… `import { Foo } from '~/lib/domains/mapping/services'` (via index.ts) - -**Subsystems must expose API through index.ts:** -- Each subsystem needs `index.ts` that reexports internal modules -- External imports must go through the index, not directly to files -- Child subsystems are only known within their parent subsystem -- Enforces hierarchical encapsulation and relative independence - -### 3. Domain Structure - -**Domain services are restricted:** -- Services can only be imported by API/server code -- Frontend code cannot import services directly -- Services must be in `/services/` directories with proper `dependencies.json` - -**Domain organization:** -- `_objects/` - Domain models and entities -- `_repositories/` - Data access interfaces -- `services/` - Business logic (API/server only) -- `infrastructure/` - Implementation details -- `utils/` - Pure utility functions - -### 4. Subsystem Types - -**Subsystems can declare their architectural role:** -```json -{ - "type": "boundary", - "allowed": [...], - "subsystems": [...] -} -``` - -**Available types:** -- `boundary` - Cohesive module with its own logic (default behavior) - - External imports must go through parent index - - Enforces encapsulation and abstraction boundaries - - Example: `Canvas`, `Cache`, `OperationOverlay` -- `router` - Just aggregates/re-exports children, no logic - - Direct child imports are allowed - - Acts as convenience layer for public API - - Example: `Services` (re-exports EventBus, DragAndDrop, etc.) -- `domain` - Domain-driven design module (auto-detected) - - Special cross-domain import rules apply - - Services restricted to API/server code -- `utility` - Stateless helper functions - - Can be imported from anywhere - - No state, pure functions only -- `page` - Next.js page route (isolated from other pages) - - Cannot import from other pages (use ~/lib for shared code) - - Direct subfolders of src/app with page.tsx must be subsystems - - Example: `src/app/map`, `src/app/auth` -- `app` - Next.js app root (isolated from non-app code) - - Nothing outside ~/app can import from ~/app - - Contains page subsystems - - Example: `src/app` - -**When to use each type:** -- Use `boundary` for subsystems that coordinate multiple components and have their own state/logic -- Use `router` for pure aggregation layers that just organize child subsystems -- Use `domain` (explicit or auto-detected) for DDD domain modules -- Use `utility` for pure, stateless helper function collections -- Use `page` for Next.js routes (any src/app subfolder with page.tsx) -- Use `app` for src/app root directory only - -**Router subsystem warnings:** -When a subsystem is marked as `"type": "router"`, the checker will generate **warnings** (not errors) for any imports from the router's index. The warnings suggest importing from specific child subsystems instead: -``` -⚠️ Consider importing from specific child instead: ~/app/map/Services/[EventBus, Operations] -``` -This encourages explicit dependency tracking while still allowing router imports when convenient. Use `pnpm check:architecture --include-warnings` to see these suggestions. - -### 5. Dependency Management - -**All imports must be declared in dependencies.json:** -```json -{ - "type": "boundary", - "allowed": ["~/lib/utils", "~/server/db"], - "allowedChildren": ["react", "next/navigation"], - "subsystems": ["./services", "./infrastructure"] -} -``` - -**Dependency arrays:** -- `type` - Architectural role: "boundary", "router", "domain", or "utility" (optional) -- `allowed` - Dependencies specific to this subsystem (use `~/` absolute paths) -- `allowedChildren` - Dependencies that cascade to child subsystems (use sparingly for truly ubiquitous dependencies like `react`) -- `subsystems` - Declared child subsystems (relative paths like `./Cache`) - -**Path requirements:** -- Use absolute paths with `~/` prefix in `allowed` and `allowedChildren` -- Use relative paths like `./childname` only in `subsystems` array -- No `../` paths anywhere - -**Note:** The `exceptions` object has been removed in favor of using the `type` field. If you need to import from a child subsystem directly, use `"type": "router"` instead of documenting it as an exception. - -### 6. Reexport Boundaries - -**Index.ts files can only reexport:** -- Internal files within same subsystem -- Declared child subsystems -- External libraries (not other subsystems) - -**EXCEPTION: Domain utils can reexport from sibling subsystems:** -- `domain/utils` subsystems (`src/lib/domains/*/utils`) are special -- They create a client-safe API by reexporting types from sibling subsystems -- This allows client code to import from `~/lib/domains/DOMAIN/utils` without pulling in server dependencies (like database connections) -- Example: `~/lib/domains/mapping/utils` can reexport types from `~/lib/domains/mapping/types` and `~/lib/domains/mapping/_objects` -- The main domain index (`~/lib/domains/mapping`) still imports server-side code and should NOT be imported by client components - -### 7. Naming Conflicts - -**No file/folder naming conflicts:** -- Cannot have both `foo.ts` and `foo/` directory -- Move file contents to `foo/index.ts` instead - -## Error Types - -| Type | Description | Recommendation | -|------|-------------|----------------| -| `complexity` | Missing documentation files | Create missing files: dependencies.json, README.md | -| `import_boundary` | Direct imports bypassing index.ts | Use subsystem interface via index.ts | -| `domain_import` | Services imported by non-API code | Move import to API/server or refactor to utility | -| `domain_structure` | Invalid domain organization | Follow domain structure pattern | -| `subsystem_structure` | Missing subsystem declarations | Add child to parent's subsystems array | -| `dependency_format` | Invalid path formats | Use absolute paths with ~/ prefix | -| `redundancy` | Duplicate dependency declarations | Remove redundant entries | -| `nonexistent_dependency` | Dependency pointing to non-existent path | Remove or create missing path | -| `reexport_boundary` | Invalid reexports | Remove external reexports | -| `file_conflict` | File/folder naming conflicts | Move to directory structure | - -## Quick Filters for AI Agents - -The tool outputs structured JSON to `test-results/architecture-check.json` for automated processing. - -### Filter by Error Type -```bash -# All import boundary violations -jq '.errors[] | select(.type == "import_boundary")' test-results/architecture-check.json - -# All domain import violations -jq '.errors[] | select(.type == "domain_import")' test-results/architecture-check.json - -# All complexity issues -jq '.errors[] | select(.type == "complexity")' test-results/architecture-check.json -``` - -### Filter by Subsystem -```bash -# All errors in mapping domain -jq '.errors[] | select(.subsystem | contains("mapping"))' test-results/architecture-check.json - -# All errors in specific subsystem -jq '.errors[] | select(.subsystem | contains("src/app/map/Canvas"))' test-results/architecture-check.json -``` - -### Filter by Recommendation -```bash -# All "add to allowed" recommendations -jq '.errors[] | select(.recommendation | contains("Add"))' test-results/architecture-check.json - -# All "create file" recommendations -jq '.errors[] | select(.recommendation | contains("Create"))' test-results/architecture-check.json - -# All "use index" recommendations -jq '.errors[] | select(.recommendation | contains("index.ts"))' test-results/architecture-check.json -``` - -### Filter by Severity -```bash -# Errors only -jq '.errors[] | select(.severity == "error")' test-results/architecture-check.json - -# Warnings only -jq '.errors[] | select(.severity == "warning")' test-results/architecture-check.json -``` - -### Summary Information -```bash -# Get summary statistics -jq '.summary' test-results/architecture-check.json - -# Count errors by type -jq '.summary.by_type' test-results/architecture-check.json - -# Count errors by subsystem -jq '.summary.by_subsystem' test-results/architecture-check.json - -# Count by recommendation type -jq '.summary.by_recommendation' test-results/architecture-check.json -``` - -### Human Readable Output -```bash -# File:line format for IDE integration -jq -r '.errors[] | "\(.file // "unknown"):\(.line // 0) \(.type): \(.message | split("\n")[0])"' test-results/architecture-check.json - -# Just error messages -jq -r '.errors[].message' test-results/architecture-check.json - -# Recommendations only -jq -r '.errors[].recommendation' test-results/architecture-check.json | sort | uniq -c | sort -nr -``` - -## Integration - -The architecture checker is integrated into: -- **CI/CD Pipeline:** Runs on every PR via GitHub Actions -- **Development Workflow:** Available via `pnpm check:architecture [path]` -- **Claude Commands:** Use `/plan-quality-fix` to analyze violations and `/fix-architecture` to execute fixes - -**No pre-commit hooks** - violations are caught in CI and fixed with AI assistance. - -For development guidance, see the main project documentation in `/CLAUDE.md`. \ No newline at end of file diff --git a/scripts/checks/architecture/__init__.py b/scripts/checks/architecture/__init__.py deleted file mode 100644 index 2f59c0821..000000000 --- a/scripts/checks/architecture/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Architecture checking package.""" - -from .models import ArchError, CheckResults, ErrorType, Severity, FileInfo, SubsystemInfo - -__all__ = [ - "ArchError", - "CheckResults", - "ErrorType", - "Severity", - "FileInfo", - "SubsystemInfo" -] \ No newline at end of file diff --git a/scripts/checks/architecture/checker.py b/scripts/checks/architecture/checker.py deleted file mode 100644 index 6f2375ac7..000000000 --- a/scripts/checks/architecture/checker.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 -""" -Main architecture checker orchestration. - -Coordinates all rule checkers and manages the overall checking process. -""" - -import time -from pathlib import Path -from typing import List - -from .models import CheckResults, SubsystemInfo, FileInfo -from .rules import ComplexityRuleChecker, SubsystemRuleChecker, ImportRuleChecker, DomainRuleChecker, AppPageRuleChecker -from .utils import FileCache, PathHelper, ExceptionHandler -from .utils.file_utils import find_typescript_files - - -class ArchitectureChecker: - """Main architecture checker that orchestrates all rule checking.""" - - def __init__(self, target_path: str = "src"): - self.path_helper = PathHelper(target_path) - self.file_cache = FileCache() - - # Initialize exception handler with project root - project_root = Path(target_path).resolve() - while project_root.parent != project_root and not (project_root / ".git").exists(): - project_root = project_root.parent - self.exception_handler = ExceptionHandler(project_root) - - # Initialize rule checkers - self.complexity_checker = ComplexityRuleChecker(self.path_helper, self.file_cache, self.exception_handler) - self.subsystem_checker = SubsystemRuleChecker(self.path_helper, self.file_cache) - self.import_checker = ImportRuleChecker(self.path_helper, self.file_cache) - self.domain_checker = DomainRuleChecker(self.path_helper, self.file_cache) - self.app_page_checker = AppPageRuleChecker(self.path_helper, self.file_cache) - - # Track subsystems for cross-rule coordination - self.subsystems: List[SubsystemInfo] = [] - - def run_all_checks(self) -> CheckResults: - """Run all architecture checks and return results.""" - start_time = time.time() - results = CheckResults(target_path=str(self.path_helper.target_path)) - - # print(f"πŸ—οΈ Checking architectural boundaries in {self.path_helper.target_path}...") - - # Single pass: find all subsystems and cache file info - self.subsystems = self._find_all_subsystems() - - # Find all index files for standalone checks - self.index_files = self._find_all_index_files() - - # Run all checks in logical order - self._run_complexity_checks(results) - self._run_subsystem_checks(results) - self._run_import_checks(results) - self._run_standalone_index_checks(results) - self._run_domain_checks(results) - self._run_app_page_checks(results) - - results.execution_time = time.time() - start_time - return results - - def _find_all_subsystems(self) -> List[SubsystemInfo]: - """Find all subsystems in target path.""" - subsystems = [] - - deps_files = self.path_helper.find_dependencies_files() - - for deps_file in deps_files: - subsystem_dir = deps_file.parent - - # Load subsystem info - dependencies = self.file_cache.load_dependencies_json(deps_file) - - # Find all TypeScript files in subsystem - files = self._find_subsystem_files(subsystem_dir) - total_lines = sum(f.lines for f in files) - - # Determine subsystem type - subsystem_type = dependencies.get("type") - # Auto-detect domain type if not specified - if not subsystem_type and self.path_helper.is_domain_path(subsystem_dir): - subsystem_type = "domain" - - subsystem = SubsystemInfo( - path=subsystem_dir, - name=subsystem_dir.name, - dependencies=dependencies, - files=files, - total_lines=total_lines, - parent_path=subsystem_dir.parent, - subsystem_type=subsystem_type - ) - - subsystems.append(subsystem) - - return subsystems - - def _find_all_index_files(self) -> List[Path]: - """Find all index.ts and index.tsx files in target path.""" - index_files = [] - - for pattern in ["**/index.ts", "**/index.tsx"]: - for index_file in self.path_helper.target_path.glob(pattern): - if not self._is_test_file(index_file): - index_files.append(index_file) - - return index_files - - def _find_subsystem_files(self, subsystem_dir: Path) -> List[FileInfo]: - """Find TypeScript files in subsystem, excluding child subsystems. - - This mirrors the logic of count_typescript_lines() to ensure consistency: - - Files directly in this directory belong to this subsystem - - Files in subdirectories without dependencies.json also belong to this subsystem - - Files in child subsystems (with dependencies.json) are excluded - """ - files = [] - - # Only include direct files in this directory - for pattern in ["*.ts", "*.tsx"]: - for ts_file in subsystem_dir.glob(pattern): # glob, not rglob! - if not self._is_test_file(ts_file): - file_info = self.file_cache.get_file_info(ts_file) - files.append(file_info) - - # Recurse into subdirectories that are NOT subsystems - for subdir in subsystem_dir.iterdir(): - if subdir.is_dir(): - deps_file = subdir / "dependencies.json" - if not deps_file.exists(): - # Not a subsystem, recurse to include its files - files.extend(self._find_subsystem_files(subdir)) - - return files - - def _is_test_file(self, file_path: Path) -> bool: - """Check if file is a test file.""" - name = file_path.name - return ".test." in name or ".spec." in name or "/__tests__/" in str(file_path) - - def _run_complexity_checks(self, results: CheckResults) -> None: - """Run complexity-based checks.""" - # Check complexity requirements for all directories - errors = self.complexity_checker.check_complexity_requirements() - for error in errors: - results.add_error(error) - - # Check subsystem completeness - errors = self.complexity_checker.check_subsystem_completeness(self.subsystems) - for error in errors: - results.add_error(error) - - def _run_subsystem_checks(self, results: CheckResults) -> None: - """Run subsystem-related checks.""" - # Check subsystem declarations - errors = self.subsystem_checker.check_subsystem_declarations(self.subsystems) - for error in errors: - results.add_error(error) - - # Check that declared subsystems actually exist - errors = self.subsystem_checker.check_declared_subsystems_exist(self.subsystems) - for error in errors: - results.add_error(error) - - # Check dependencies.json format - errors = self.subsystem_checker.check_dependencies_json_format(self.subsystems) - for error in errors: - results.add_error(error) - - # Check for redundancy - errors = self.subsystem_checker.check_hierarchical_redundancy(self.subsystems) - for error in errors: - results.add_error(error) - - errors = self.subsystem_checker.check_redundant_dependencies(self.subsystems) - for error in errors: - results.add_error(error) - - # Check for ancestor redundancy - errors = self.subsystem_checker.check_ancestor_redundancy(self.subsystems) - for error in errors: - results.add_error(error) - - # Check for domain utils redundancy - errors = self.subsystem_checker.check_domain_utils_redundancy(self.subsystems) - for error in errors: - results.add_error(error) - - # Check for nonexistent dependencies - errors = self.subsystem_checker.check_nonexistent_dependencies(self.subsystems) - for error in errors: - results.add_error(error) - - # Check file/folder conflicts - errors = self.subsystem_checker.check_file_folder_conflicts() - for error in errors: - results.add_error(error) - - def _run_import_checks(self, results: CheckResults) -> None: - """Run import-related checks.""" - # Check import boundaries - errors = self.import_checker.check_import_boundaries(self.subsystems) - for error in errors: - results.add_error(error) - - # Check reexport boundaries - errors = self.import_checker.check_reexport_boundaries(self.subsystems) - for error in errors: - results.add_error(error) - - # Check outbound dependencies - errors = self.import_checker.check_outbound_dependencies_parallel(self.subsystems) - for error in errors: - results.add_error(error) - - # Check router import patterns (warnings for importing from router index) - errors = self.import_checker.check_router_import_patterns(self.subsystems) - for error in errors: - results.add_error(error) - - # Check domain utils import patterns - errors = self.import_checker.check_domain_utils_import_patterns(self.subsystems) - for error in errors: - results.add_error(error) - - def _run_standalone_index_checks(self, results: CheckResults) -> None: - """Run checks on standalone index.ts files (not part of formal subsystems).""" - errors = self.import_checker.check_standalone_index_reexports(self.index_files) - for error in errors: - results.add_error(error) - - def _run_domain_checks(self, results: CheckResults) -> None: - """Run domain-specific checks.""" - # Check domain structure - errors = self.domain_checker.check_domain_structure() - for error in errors: - results.add_error(error) - - # Check domain import restrictions - errors = self.domain_checker.check_domain_import_restrictions() - for error in errors: - results.add_error(error) - - def _run_app_page_checks(self, results: CheckResults) -> None: - """Run app and page-specific checks.""" - # Check that app subfolders with page.tsx are subsystems - errors = self.app_page_checker.check_page_tsx_subsystems() - for error in errors: - results.add_error(error) - - # Check app isolation - errors = self.app_page_checker.check_app_isolation() - for error in errors: - results.add_error(error) - - # Check page isolation - errors = self.app_page_checker.check_page_isolation(self.subsystems) - for error in errors: - results.add_error(error) \ No newline at end of file diff --git a/scripts/checks/architecture/dependencies.schema.json b/scripts/checks/architecture/dependencies.schema.json deleted file mode 100644 index 1230c1d05..000000000 --- a/scripts/checks/architecture/dependencies.schema.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Subsystem Dependencies Declaration", - "description": "Declares allowed imports for a subsystem. Everything not listed is forbidden.", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["boundary", "router", "domain", "utility", "page", "app"], - "description": "Defines the architectural role of this subsystem: 'boundary' (cohesive module with logic - prefer parent imports), 'router' (aggregates children - child imports OK), 'domain' (special domain rules), 'utility' (stateless helpers - import anywhere), 'page' (Next.js page - isolated from other pages), 'app' (Next.js app root - isolated from non-app code)", - "examples": ["boundary", "router", "domain", "utility", "page", "app"] - }, - "allowed": { - "type": "array", - "description": "Patterns for allowed imports specific to this subsystem.", - "items": { - "type": "string" - }, - "examples": [ - "./", - "~/lib/domains/shared", - "~/server/db", - "react", - "zod", - "@trpc/server" - ] - }, - "allowedChildren": { - "type": "array", - "description": "Dependencies that cascade to all child subsystems, hiding them from child dependency declarations. Use sparingly - explicit dependencies in each subsystem are usually better for clarity. Only use for truly ubiquitous dependencies where listing them in every subsystem adds noise without value.", - "items": { - "type": "string" - }, - "examples": [ - "react", - "next/navigation", - "_objects" - ] - }, - "subsystems": { - "type": "array", - "description": "Child subsystems of this subsystem (relative paths to folders with dependencies.json).", - "items": { - "type": "string" - }, - "examples": [ - "./Cache", - "./Services/EventBus" - ] - }, - "exceptions": { - "type": "object", - "description": "Document dependencies that violate architectural principles but are temporarily accepted. Each exception should explain why this dependency exists and what the long-term solution should be. Normal, well-architected dependencies should NOT be listed here.", - "additionalProperties": { - "type": "string" - }, - "examples": [{ - "~/lib/domains/iam": "TEMPORARY: IAM domain should not depend on mapping domain. Long-term: API layer should compose both domains instead of direct domain-to-domain coupling.", - "~/legacy/old-utils": "TECHNICAL DEBT: Remove once migration to new utilities is complete in Q2 2024" - }] - } - }, - "required": ["allowed"] -} \ No newline at end of file diff --git a/scripts/checks/architecture/main.py b/scripts/checks/architecture/main.py deleted file mode 100644 index 787811d49..000000000 --- a/scripts/checks/architecture/main.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -""" -Main entry point for architecture checking. - -Usage: - python3 scripts/checks/architecture/main.py [path] - pnpm check:architecture [path] -""" - -import sys -import argparse - -from .checker import ArchitectureChecker -from .reporter import ArchitectureReporter - - -def main(): - """Main entry point for architecture checking.""" - parser = argparse.ArgumentParser(description="Check architecture boundaries and complexity requirements") - parser.add_argument('target_path', nargs='?', default='src', help='Target directory to check (default: src)') - parser.add_argument('--format', choices=['console', 'json'], default='console', help='Output format') - parser.add_argument('--include-warnings', action='store_true', help='Include warnings in output (default: errors only)') - - # Handle legacy flags - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: - parser.print_help() - sys.exit(0) - - args = parser.parse_args() - - # Run checks - checker = ArchitectureChecker(args.target_path) - results = checker.run_all_checks() - - # Save warning count before filtering - warning_count = len(results.warnings) - - # Filter out warnings if not requested - if not args.include_warnings: - results.warnings = [] - - # Report results - reporter = ArchitectureReporter() - success = reporter.report_results( - results, - format_type=args.format, - suppressed_warning_count=warning_count if not args.include_warnings else 0 - ) - - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/checks/architecture/models.py b/scripts/checks/architecture/models.py deleted file mode 100644 index ac40a7d33..000000000 --- a/scripts/checks/architecture/models.py +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env python3 -""" -Data models for architecture checking. - -Contains all data structures used throughout the architecture checking system. -""" - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional -from enum import Enum - - -class ErrorType(Enum): - """Types of architecture errors.""" - COMPLEXITY = "complexity" - SUBSYSTEM_STRUCTURE = "subsystem_structure" - IMPORT_BOUNDARY = "import_boundary" - REEXPORT_BOUNDARY = "reexport_boundary" - DEPENDENCY_FORMAT = "dependency_format" - REDUNDANCY = "redundancy" - NONEXISTENT_DEPENDENCY = "nonexistent_dependency" - FILE_CONFLICT = "file_conflict" - DOMAIN_STRUCTURE = "domain_structure" - DOMAIN_IMPORT = "domain_import" - - -class Severity(Enum): - """Error severity levels.""" - ERROR = "error" - WARNING = "warning" - - -class RecommendationType(Enum): - """Types of recommendations for fixing architecture errors.""" - # Documentation - CREATE_README = "Create README documentation" - CREATE_SUBSYSTEM_FILES = "Create missing subsystem files" - - # Dependencies - ADD_ALLOWED_DEPENDENCY = "Add to allowed dependencies" - ADD_ALLOWED_CHILDREN = "Add to allowedChildren" - REMOVE_REDUNDANT_DEPENDENCY = "Remove redundant dependency" - REMOVE_FORBIDDEN_DEPENDENCY = "Remove forbidden dependency" - FIX_DEPENDENCY_PATH_FORMAT = "Fix dependency path format" - - # Subsystems - CREATE_OR_REMOVE_SUBSYSTEM = "Create or remove subsystem declaration" - REMOVE_INVALID_SUBSYSTEM = "Remove invalid subsystem declaration" - CREATE_SUBSYSTEM_INDEX = "Create subsystem index" - CREATE_DEPENDENCIES_JSON = "Create dependencies.json file" - - # Imports - USE_SUBSYSTEM_INTERFACE = "Use subsystem interface" - USE_UTILS_INTERFACE = "Use utils interface" - USE_SPECIFIC_CHILD = "Use specific child subsystem (not router index)" - REMOVE_CROSS_DOMAIN_IMPORT = "Remove cross-domain import" - MOVE_SHARED_CODE = "Move shared code to appropriate location" - - # Service layer - MOVE_SERVICE_TO_API = "Move service to API layer" - FIX_DOMAIN_SERVICE_IMPORT = "Fix domain service import" - - # Structural - RESOLVE_FILE_FOLDER_CONFLICT = "Resolve file/folder conflict" - FIX_UPWARD_REEXPORT = "Fix upward reexport" - FIX_REEXPORT_BOUNDARY = "Fix reexport boundary" - - # Fallback - OTHER = "Other" - - -@dataclass -class FileInfo: - """Information about a TypeScript file.""" - path: Path - lines: int - imports: List[str] = field(default_factory=list) - content: str = "" - - -@dataclass -class SubsystemInfo: - """Information about a subsystem (directory with dependencies.json).""" - path: Path - name: str - dependencies: Dict = field(default_factory=dict) - files: List[FileInfo] = field(default_factory=list) - total_lines: int = 0 - parent_path: Optional[Path] = None - subsystem_type: Optional[str] = None # "boundary", "router", "domain", or "utility" - - -@dataclass -class ArchError: - """Represents an architectural error with enhanced metadata.""" - message: str - error_type: ErrorType - severity: Severity = Severity.ERROR - subsystem: Optional[str] = None - file_path: Optional[str] = None - line_number: Optional[int] = None - recommendation: Optional[str] = None - recommendation_type: Optional[RecommendationType] = None - metadata: Optional[Dict] = field(default_factory=dict) - - @classmethod - def create_error( - cls, - message: str, - error_type: ErrorType, - subsystem: Optional[str] = None, - file_path: Optional[str] = None, - line_number: Optional[int] = None, - recommendation: Optional[str] = None, - recommendation_type: Optional[RecommendationType] = None - ) -> "ArchError": - """Create an error with ERROR severity.""" - return cls( - message=message, - error_type=error_type, - severity=Severity.ERROR, - subsystem=subsystem, - file_path=file_path, - line_number=line_number, - recommendation=recommendation, - recommendation_type=recommendation_type - ) - - @classmethod - def create_warning( - cls, - message: str, - error_type: ErrorType, - subsystem: Optional[str] = None, - file_path: Optional[str] = None, - line_number: Optional[int] = None, - recommendation: Optional[str] = None, - recommendation_type: Optional[RecommendationType] = None - ) -> "ArchError": - """Create an error with WARNING severity.""" - return cls( - message=message, - error_type=error_type, - severity=Severity.WARNING, - subsystem=subsystem, - file_path=file_path, - line_number=line_number, - recommendation=recommendation, - recommendation_type=recommendation_type - ) - - def to_dict(self) -> Dict: - """Convert error to dictionary for JSON serialization.""" - result = { - "type": self.error_type.value, - "severity": self.severity.value, - "message": self.message, - "subsystem": self.subsystem, - "file": self.file_path, - "line": self.line_number, - "recommendation": self.recommendation, - "recommendation_type": self.recommendation_type.value if self.recommendation_type else None - } - - # Add metadata if present - if self.metadata: - result.update(self.metadata) - - return result - - -@dataclass -class CheckResults: - """Results of architecture checking.""" - errors: List[ArchError] = field(default_factory=list) - warnings: List[ArchError] = field(default_factory=list) - execution_time: float = 0.0 - target_path: str = "src" - - def add_error(self, error: ArchError) -> None: - """Add an error to the appropriate list based on severity.""" - if error.severity == Severity.ERROR: - self.errors.append(error) - else: - self.warnings.append(error) - - def get_all_issues(self) -> List[ArchError]: - """Get all issues (errors + warnings).""" - return self.errors + self.warnings - - def get_summary_by_type(self) -> Dict[str, int]: - """Get count of issues by error type.""" - summary = {} - for issue in self.get_all_issues(): - error_type = issue.error_type.value - summary[error_type] = summary.get(error_type, 0) + 1 - return summary - - def get_summary_by_subsystem(self) -> Dict[str, int]: - """Get count of issues by subsystem.""" - summary = {} - for issue in self.get_all_issues(): - if issue.subsystem: - subsystem = issue.subsystem - summary[subsystem] = summary.get(subsystem, 0) + 1 - return summary - - def get_summary_by_recommendation(self) -> Dict[str, int]: - """Get count of issues by recommendation type.""" - summary = {} - for issue in self.get_all_issues(): - if issue.recommendation: - rec_type = self._categorize_recommendation(issue.recommendation, issue) - summary[rec_type] = summary.get(rec_type, 0) + 1 - return summary - - def get_top_exact_recommendations(self, limit: int = 3) -> List[tuple[str, int]]: - """Get the most common exact recommendations.""" - exact_summary = {} - missing_recommendations = [] - - for issue in self.get_all_issues(): - if issue.recommendation: - # Count exact recommendation text - exact_summary[issue.recommendation] = exact_summary.get(issue.recommendation, 0) + 1 - else: - # Track issues without recommendations - missing_recommendations.append(issue) - - # Warn about missing recommendations - if missing_recommendations: - print(f"⚠️ Warning: {len(missing_recommendations)} issues missing recommendations:") - for issue in missing_recommendations[:3]: # Show first 3 as examples - print(f" β€’ {issue.error_type.value}: {issue.message[:80]}...") - if len(missing_recommendations) > 3: - print(f" β€’ ... and {len(missing_recommendations) - 3} more") - print() - - # Return top exact recommendations - sorted_recommendations = sorted(exact_summary.items(), key=lambda x: x[1], reverse=True) - return sorted_recommendations[:limit] - - def _categorize_recommendation(self, recommendation: str, error: ArchError) -> str: - """Categorize recommendation into types for summary.""" - # Use explicit type if available - if error.recommendation_type: - return error.recommendation_type.value - - # Fallback to pattern matching for backwards compatibility - # Handle router import warnings - if "Consider importing from specific child subsystem instead" in recommendation: - return "Use specific child subsystem (not router index)" - - # Handle service import violations - if "Move service import" in recommendation and "API/server code" in recommendation: - return "Move service to API layer" - elif "Remove service import" in recommendation and "only domain index.ts and services/*" in recommendation: - return "Fix domain service import" - elif "Remove cross-domain import" in recommendation: - return "Remove cross-domain import" - - # Handle file creation patterns (with new WARNING/ERROR prefixes) - elif "Create missing files" in recommendation or "ERROR: Create missing files" in recommendation: - return "Create missing subsystem files" - elif ("Create" in recommendation and "README.md file" in recommendation) or ("WARNING: Create" in recommendation and "README.md file" in recommendation) or ("ERROR: Create" in recommendation and "README.md file" in recommendation): - return "Create README documentation" - elif "Create or update" in recommendation and "index.ts to reexport" in recommendation: - return "Create subsystem index" - - # Handle import fixes - elif "Change import from" in recommendation and "via index.ts" in recommendation: - return "Use subsystem interface" - elif "Change import from" in recommendation and "use utils index.ts" in recommendation: - return "Use utils interface" - - # Handle dependency management - elif "Add" in recommendation and "dependencies.json" in recommendation: - if "'allowed'" in recommendation: - return "Add to allowed dependencies" - elif "'allowedChildren'" in recommendation: - return "Add to allowedChildren" - elif "Remove" in recommendation and "redundant" in recommendation: - return "Remove redundant dependency" - elif "Remove" in recommendation and "dependencies.json" in recommendation: - return "Remove forbidden dependency" - - # Handle subsystem declaration issues - elif "Create" in recommendation and "dependencies.json to formalize this subsystem" in recommendation: - return "Create or remove subsystem declaration" - elif "Remove" in recommendation and "'subsystems' array" in recommendation and "directory does not exist" in recommendation: - return "Remove invalid subsystem declaration" - - # Handle structural issues - elif "Move file contents" in recommendation: - return "Resolve file/folder conflict" - elif "Either move implementation to this directory or import directly from original location" in recommendation: - return "Fix upward reexport" - elif "reexport" in recommendation.lower(): - return "Fix reexport boundary" - - # Legacy fallbacks (for backwards compatibility) - elif "missing:" in recommendation: - if "dependencies.json" in recommendation: - return "Create dependencies.json" - elif "README.md" in recommendation: - return "Create README.md" - elif "ARCHITECTURE.md" in recommendation: - return "Create ARCHITECTURE.md" - elif "index.ts" in recommendation: - return "Use subsystem interface" - - return "Other" - - def has_errors(self) -> bool: - """Check if there are any errors (not warnings).""" - return len(self.errors) > 0 - - def to_dict(self) -> Dict: - """Convert results to dictionary for JSON serialization.""" - all_issues = self.get_all_issues() - return { - "timestamp": None, # Will be set by reporter - "target_path": self.target_path, - "execution_time": self.execution_time, - "summary": { - "total_errors": len(self.errors), - "total_warnings": len(self.warnings), - "by_type": self.get_summary_by_type(), - "by_subsystem": self.get_summary_by_subsystem(), - "by_recommendation": self.get_summary_by_recommendation() - }, - "errors": [issue.to_dict() for issue in all_issues] - } \ No newline at end of file diff --git a/scripts/checks/architecture/reporter.py b/scripts/checks/architecture/reporter.py deleted file mode 100644 index b59c8fd87..000000000 --- a/scripts/checks/architecture/reporter.py +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env python3 -""" -Architecture check reporting module. - -Handles result reporting, JSON output generation, and console summaries. -""" - -import json -from datetime import datetime -from pathlib import Path -from typing import Dict - -from .models import CheckResults, ErrorType, Severity - - -class ArchitectureReporter: - """Handles reporting of architecture check results.""" - - def __init__(self, output_file: str = "test-results/architecture-check.json"): - self.output_file = Path(output_file) - self.complexity_threshold = 1000 - self.doc_threshold = 500 - - def report_results(self, results: CheckResults, format_type: str = "console", suppressed_warning_count: int = 0) -> bool: - """Report results to both JSON file and console. Returns True if no errors.""" - # Ensure output directory exists - self.output_file.parent.mkdir(exist_ok=True) - - # Write detailed JSON report - self._write_json_report(results) - - # Display results based on format - if format_type == "json": - self._display_json_output(results) - else: - self._display_console_summary(results, suppressed_warning_count=suppressed_warning_count) - - return not results.has_errors() - - def _write_json_report(self, results: CheckResults) -> None: - """Write detailed JSON report to file.""" - report_data = results.to_dict() - report_data["timestamp"] = datetime.now().isoformat() - - with open(self.output_file, 'w') as f: - json.dump(report_data, f, indent=2, default=str) - - def _display_console_summary(self, results: CheckResults, suppressed_warning_count: int = 0) -> None: - """Display summary information on console.""" - # print(f"⏱️ Completed in {results.execution_time:.2f} seconds") - print() - - # Show summary statistics - total_errors = len(results.errors) - total_warnings = len(results.warnings) - - # Check for custom thresholds usage - custom_threshold_count = sum(1 for issue in results.get_all_issues() - if issue.metadata and 'custom_threshold' in issue.metadata) - - if custom_threshold_count > 0: - print(f"πŸ”§ Using custom thresholds from .architecture-exceptions for {custom_threshold_count} issue(s)") - print() - - if total_errors > 0 or total_warnings > 0: - print("πŸ“Š Summary:") - print("=" * 72) - print(f"β€’ Total errors: {total_errors}") - print(f"β€’ Total warnings: {total_warnings}") - print() - - # Breakdown by error type - type_summary = results.get_summary_by_type() - if type_summary: - print("πŸ” By error type:") - for error_type, count in sorted(type_summary.items()): - print(f" β€’ {error_type}: {count}") - print() - - # Breakdown by subsystem - subsystem_summary = results.get_summary_by_subsystem() - if subsystem_summary: - print("πŸ“ By subsystem:") - # Show top 10 subsystems with most issues - sorted_subsystems = sorted(subsystem_summary.items(), - key=lambda x: x[1], reverse=True) - for subsystem, count in sorted_subsystems: - print(f" β€’ {subsystem}: {count}") - print() - - # Top exact recommendations (with missing recommendation check) - print("🎯 Top actionable recommendations:") - top_exact = results.get_top_exact_recommendations(limit=10) - if top_exact: - for recommendation, count in top_exact: - # Show full recommendation without truncation - print(f" β€’ ({count}Γ—) {recommendation}") - print() - - # Breakdown by recommendation category - recommendation_summary = results.get_summary_by_recommendation() - if recommendation_summary: - print("πŸ“Š By recommendation type:") - sorted_recommendations = sorted(recommendation_summary.items(), - key=lambda x: x[1], reverse=True)[:8] - for recommendation, count in sorted_recommendations: - print(f" β€’ {recommendation}: {count}") - print() - - # Reference to detailed log - print("πŸ“‹ Detailed results:") - print("-" * 72) - print(f"Full report: {self.output_file}") - print() - else: - print("βœ… Architecture check passed!") - if suppressed_warning_count > 0: - print(f"ℹ️ {suppressed_warning_count} warning(s) suppressed - run with --include-warnings to see them") - print(f"Detailed report: {self.output_file}") - - def get_grep_suggestions(self, error_type: str = None, subsystem: str = None) -> list[str]: - """Get grep command suggestions for filtering the JSON output.""" - suggestions = [] - - if error_type: - suggestions.append(f"jq '.errors[] | select(.type == \"{error_type}\")' {self.output_file}") - - if subsystem: - suggestions.append(f"jq '.errors[] | select(.subsystem | contains(\"{subsystem}\"))' {self.output_file}") - - # General useful filters - suggestions.extend([ - f"jq '.errors[] | select(.severity == \"error\")' {self.output_file}", - f"jq '.errors[] | select(.severity == \"warning\")' {self.output_file}", - f"jq '.summary' {self.output_file}", - f"jq -r '.errors[] | \"\\(.file):\\(.line) \\(.type): \\(.message)\"' {self.output_file}" - ]) - - return suggestions - - def print_error_breakdown(self, results: CheckResults) -> None: - """Print detailed breakdown of errors for debugging.""" - if not results.errors and not results.warnings: - return - - print("\nπŸ” Error Breakdown:") - print("-" * 72) - - # Group by error type - by_type: Dict[ErrorType, list] = {} - for issue in results.get_all_issues(): - if issue.error_type not in by_type: - by_type[issue.error_type] = [] - by_type[issue.error_type].append(issue) - - for error_type, issues in by_type.items(): - print(f"\n{error_type.value.upper()}: {len(issues)} issues") - print("-" * 40) - - # Group by subsystem within each error type - by_subsystem: Dict[str, list] = {} - for issue in issues: - subsystem = issue.subsystem or "unknown" - if subsystem not in by_subsystem: - by_subsystem[subsystem] = [] - by_subsystem[subsystem].append(issue) - - for subsystem, subsystem_issues in by_subsystem.items(): - severity_counts = {} - for issue in subsystem_issues: - sev = issue.severity.value - severity_counts[sev] = severity_counts.get(sev, 0) + 1 - - severity_str = ", ".join(f"{sev}: {count}" for sev, count in severity_counts.items()) - print(f" {subsystem}: {len(subsystem_issues)} ({severity_str})") - - print("-" * 72) - - def generate_ai_friendly_summary(self, results: CheckResults) -> str: - """Generate a summary specifically designed for AI agents.""" - if not results.errors and not results.warnings: - return f"βœ… Architecture check passed! Report: {self.output_file}" - - summary_parts = [ - f"🚨 Architecture issues found: {len(results.errors)} errors, {len(results.warnings)} warnings", - f"πŸ“„ Full report: {self.output_file}", - "", - "🎯 Quick filters for AI agents:" - ] - - # Add useful jq commands for common use cases - summary_parts.extend([ - f" # Get all errors: jq '.errors[] | select(.severity == \"error\")' {self.output_file}", - f" # Get summary: jq '.summary' {self.output_file}", - f" # Get by type: jq '.errors[] | select(.type == \"TYPE\")' {self.output_file}", - f" # Get by subsystem: jq '.errors[] | select(.subsystem | contains(\"PATH\"))' {self.output_file}", - "" - ]) - - # Show top error types and subsystems - type_summary = results.get_summary_by_type() - if type_summary: - top_types = sorted(type_summary.items(), key=lambda x: x[1], reverse=True)[:3] - summary_parts.append("πŸ”₯ Top error types:") - for error_type, count in top_types: - summary_parts.append(f" β€’ {error_type}: {count}") - summary_parts.append("") - - subsystem_summary = results.get_summary_by_subsystem() - if subsystem_summary: - top_subsystems = sorted(subsystem_summary.items(), key=lambda x: x[1], reverse=True)[:3] - summary_parts.append("πŸ“ Top problematic subsystems:") - for subsystem, count in top_subsystems: - summary_parts.append(f" β€’ {subsystem}: {count}") - - return "\n".join(summary_parts) - - def _display_json_output(self, results: CheckResults) -> None: - """Display results in JSON format.""" - report_data = results.to_dict() - report_data["timestamp"] = datetime.now().isoformat() - print(json.dumps(report_data, indent=2, default=str)) \ No newline at end of file diff --git a/scripts/checks/architecture/rules/__init__.py b/scripts/checks/architecture/rules/__init__.py deleted file mode 100644 index ef3d36f48..000000000 --- a/scripts/checks/architecture/rules/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Architecture checking rules.""" - -from .complexity_rules import ComplexityRuleChecker -from .subsystem_rules import SubsystemRuleChecker -from .import_rules import ImportRuleChecker -from .domain_rules import DomainRuleChecker -from .app_page_rules import AppPageRuleChecker - -__all__ = [ - "ComplexityRuleChecker", - "SubsystemRuleChecker", - "ImportRuleChecker", - "DomainRuleChecker", - "AppPageRuleChecker" -] \ No newline at end of file diff --git a/scripts/checks/architecture/rules/app_page_rules.py b/scripts/checks/architecture/rules/app_page_rules.py deleted file mode 100644 index 7f3455ae7..000000000 --- a/scripts/checks/architecture/rules/app_page_rules.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env python3 -""" -App and Page isolation rules. - -Handles Next.js app directory specific rules: -- App isolation: no imports from ~/app outside of app -- Page isolation: no imports from ~/app/[page] outside that page -- Page.tsx detection: folders with page.tsx must be subsystems -""" - -import re -from pathlib import Path -from typing import List - -from ..models import ArchError, ErrorType, RecommendationType, SubsystemInfo -from ..utils.path_utils import PathHelper - - -class AppPageRuleChecker: - """Checker for app and page isolation rules.""" - - def __init__(self, path_helper: PathHelper, file_cache): - self.path_helper = path_helper - self.file_cache = file_cache - - def check_page_tsx_subsystems(self) -> List[ArchError]: - """Check that direct subfolders of src/app with page.tsx are subsystems.""" - errors = [] - - app_dir = self.path_helper.target_path / "app" - if not app_dir.exists(): - return errors - - # Find all direct subfolders of src/app - for subfolder in app_dir.iterdir(): - if not subfolder.is_dir(): - continue - - # Skip if starts with underscore (private folders) or dot - if subfolder.name.startswith('_') or subfolder.name.startswith('.'): - continue - - # Check if this folder or any of its subfolders has page.tsx - has_page_tsx = self._has_page_tsx_recursive(subfolder) - - if has_page_tsx: - # Check if it has dependencies.json (is a subsystem) - deps_file = subfolder / "dependencies.json" - if not deps_file.exists(): - errors.append(ArchError.create_error( - message=f"❌ App subfolder with page.tsx must be a subsystem: {subfolder.relative_to(self.path_helper.target_path)}", - error_type=ErrorType.SUBSYSTEM_STRUCTURE, - subsystem=str(subfolder), - recommendation=f"Create {subfolder}/dependencies.json with appropriate type ('app' for root app, 'page' for pages)", - recommendation_type=RecommendationType.CREATE_DEPENDENCIES_JSON - )) - - return errors - - def check_app_isolation(self) -> List[ArchError]: - """Check that nothing outside ~/app imports from ~/app.""" - errors = [] - - app_dir = self.path_helper.target_path / "app" - if not app_dir.exists(): - return errors - - # Check all TypeScript files, not just those in subsystems - # This catches violations in non-subsystem directories like test-utils - from ..utils.file_utils import find_typescript_files - - all_files = find_typescript_files(self.path_helper.target_path) - - for ts_file in all_files: - # Skip if file is inside app directory - try: - ts_file.relative_to(app_dir) - continue # File is inside app, skip it - except ValueError: - pass # File is outside app, check it - - # Check this file for app imports - file_info = self.file_cache.get_file_info(ts_file) - for import_path in file_info.imports: - if import_path.startswith("~/app"): - # Try to find which subsystem/directory this file belongs to - subsystem_name = "unknown" - relative_path = ts_file - try: - relative_path = ts_file.relative_to(self.path_helper.target_path) - # Get the top-level directory (e.g., "lib", "server", "test-utils") - if len(relative_path.parts) > 0: - subsystem_name = relative_path.parts[0] - except ValueError: - pass - - errors.append(ArchError.create_error( - message=(f"❌ App isolation violation in {subsystem_name}:\n" - f" πŸ”Έ {relative_path}\n" - f" import from '{import_path}'\n" - f" β†’ App code is isolated - move shared code to ~/lib or ~/server"), - error_type=ErrorType.IMPORT_BOUNDARY, - subsystem=str(relative_path.parent if relative_path != ts_file else ts_file.parent), - file_path=str(ts_file), - recommendation=f"Move shared code from {import_path} to ~/lib or remove this import", - recommendation_type=RecommendationType.MOVE_SHARED_CODE - )) - - return errors - - def check_page_isolation(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check that pages don't import from other pages.""" - errors = [] - - # Find all page-type subsystems - page_subsystems = {} - for subsystem in subsystems: - if subsystem.subsystem_type == "page": - # Normalize path: strip trailing slashes, use forward slashes - subsystem_path = f"~/{subsystem.path.relative_to(Path('src'))}".rstrip('/') - page_subsystems[subsystem_path] = subsystem - - # Check each page subsystem - for page_path, page_subsystem in page_subsystems.items(): - for file_info in page_subsystem.files: - for import_path in file_info.imports: - # Normalize import path - normalized_import = import_path.rstrip('/') - - # Check if importing from another page - for other_page_path, other_page in page_subsystems.items(): - if other_page_path == page_path: - continue # Skip self - - # Check for path boundary: exact match or starts with path + separator - if (normalized_import == other_page_path or - normalized_import.startswith(other_page_path + "/")): - errors.append(ArchError.create_error( - message=(f"❌ Page isolation violation in {page_subsystem.name}:\n" - f" πŸ”Έ {file_info.path.relative_to(page_subsystem.path)}\n" - f" import from '{import_path}'\n" - f" β†’ Pages should not import from other pages - move shared code to ~/lib"), - error_type=ErrorType.IMPORT_BOUNDARY, - subsystem=str(page_subsystem.path), - file_path=str(file_info.path), - recommendation=f"Move shared code from {import_path} to ~/lib or ~/app/components", - recommendation_type=RecommendationType.MOVE_SHARED_CODE - )) - - return errors - - def _has_page_tsx_recursive(self, directory: Path, max_depth: int = 3) -> bool: - """Check if directory or any subdirectory has page.tsx (up to max_depth).""" - def search(path: Path, current_depth: int = 0) -> bool: - if current_depth > max_depth: - return False - - # Check current directory - if (path / "page.tsx").exists(): - return True - - # Check subdirectories - try: - for subdir in path.iterdir(): - if subdir.is_dir() and not subdir.name.startswith('.'): - if search(subdir, current_depth + 1): - return True - except PermissionError: - pass - - return False - - return search(directory) diff --git a/scripts/checks/architecture/rules/complexity_rules.py b/scripts/checks/architecture/rules/complexity_rules.py deleted file mode 100644 index a03b2afc9..000000000 --- a/scripts/checks/architecture/rules/complexity_rules.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python3 -""" -Complexity-based architecture rules. - -Handles checking for complexity-based documentation requirements. -""" - -from pathlib import Path -from typing import List - -from ..models import ArchError, ErrorType, RecommendationType, SubsystemInfo -from ..utils.file_utils import count_typescript_lines -from ..utils.path_utils import PathHelper - - -class ComplexityRuleChecker: - """Checker for complexity-based documentation requirements.""" - - def __init__(self, path_helper: PathHelper, file_cache=None, exception_handler=None, - complexity_threshold: int = 1000, - doc_threshold: int = 500): - self.path_helper = path_helper - self.file_cache = file_cache - self.exception_handler = exception_handler - self.complexity_threshold = complexity_threshold - self.doc_threshold = doc_threshold - - def check_complexity_requirements(self) -> List[ArchError]: - """Check directories for complexity-based documentation requirements.""" - errors = [] - # print("Scanning directories for complexity requirements...") - - directories_to_check = self.path_helper.get_directories_to_check() - - for directory in directories_to_check: - if not directory.is_dir(): - continue - - # Skip directories that should not be traversed at all - if self.path_helper.is_traversal_exception(directory): - continue - - # Skip if parent is already a subsystem AND this directory is declared as a subsystem - if self._is_declared_child_subsystem(directory, self.file_cache): - continue - - lines = count_typescript_lines(directory) - - # Only apply architecture requirements if directory is NOT a rule exception - if not self.path_helper.is_rule_exception(directory): - # Check for custom thresholds from exception files - custom_thresholds = self._get_custom_thresholds(directory) - complexity_threshold = custom_thresholds.get("complexity", self.complexity_threshold) - doc_threshold = custom_thresholds.get("doc", self.doc_threshold) - exception_info = custom_thresholds.get("exception_info") - - if lines > complexity_threshold: - # Complex folder needs full subsystem structure - missing = self._get_missing_subsystem_files(directory) - - if missing: - threshold_msg = f" (custom threshold {complexity_threshold})" if exception_info else "" - recommendation = f"ERROR: Create {directory}/README.md file (follow guidelines in scripts/checks/architecture/README-STRUCTURE.md)" if missing == ["README.md"] else f"ERROR: Create missing files in {directory}: {', '.join(missing)} (for README.md follow guidelines in scripts/checks/architecture/README-STRUCTURE.md)" - - rec_type = RecommendationType.CREATE_README if missing == ["README.md"] else RecommendationType.CREATE_SUBSYSTEM_FILES - error = ArchError.create_error( - message=f"❌ {directory} ({lines} lines){threshold_msg} missing: {' '.join(missing)}", - error_type=ErrorType.COMPLEXITY, - subsystem=str(directory), - recommendation=recommendation, - recommendation_type=rec_type - ) - - # Add exception info for reporting - if exception_info: - error.metadata = { - "custom_threshold": complexity_threshold, - "default_threshold": self.complexity_threshold, - "exception_source": exception_info.get("exception_source"), - "justification": exception_info.get("justification") - } - - errors.append(error) - - elif lines > doc_threshold: - # Medium complexity needs README - if not (directory / "README.md").exists(): - threshold_msg = f" (custom threshold {doc_threshold})" if exception_info else "" - - warning = ArchError.create_warning( - message=f"⚠️ {directory} ({lines} lines){threshold_msg} - missing README.md", - error_type=ErrorType.COMPLEXITY, - subsystem=str(directory), - recommendation=f"WARNING: Create {directory}/README.md file (follow guidelines in scripts/checks/architecture/README-STRUCTURE.md)", - recommendation_type=RecommendationType.CREATE_README - ) - - # Add exception info for reporting - if exception_info: - warning.metadata = { - "custom_threshold": doc_threshold, - "default_threshold": self.doc_threshold, - "exception_source": exception_info.get("exception_source"), - "justification": exception_info.get("justification") - } - - errors.append(warning) - - return errors - - def check_subsystem_completeness(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check that subsystems have all required files.""" - errors = [] - # print("Checking subsystems for completeness...") - - for subsystem in subsystems: - # Check for custom thresholds from exception files - custom_thresholds = self._get_custom_thresholds(subsystem.path) - complexity_threshold = custom_thresholds.get("complexity", self.complexity_threshold) - exception_info = custom_thresholds.get("exception_info") - - # Only require documentation for complex subsystems (over threshold) - if subsystem.total_lines <= complexity_threshold: - continue - - missing = self._get_missing_subsystem_files(subsystem.path) - - if missing: - threshold_msg = f" (custom threshold {complexity_threshold})" if exception_info else "" - recommendation = f"ERROR: Create {subsystem.path}/README.md file (follow guidelines in scripts/checks/architecture/README-STRUCTURE.md)" if missing == ["README.md"] else f"ERROR: Create missing files in {subsystem.path}: {', '.join(missing)} (for README.md follow guidelines in scripts/checks/architecture/README-STRUCTURE.md)" - - rec_type = RecommendationType.CREATE_README if missing == ["README.md"] else RecommendationType.CREATE_SUBSYSTEM_FILES - error = ArchError.create_error( - message=(f"❌ Subsystem {subsystem.path} ({subsystem.total_lines} lines){threshold_msg} " - f"missing: {' '.join(missing)}"), - error_type=ErrorType.SUBSYSTEM_STRUCTURE, - subsystem=str(subsystem.path), - recommendation=recommendation, - recommendation_type=rec_type - ) - - # Add exception info for reporting - if exception_info: - error.metadata = { - "custom_threshold": complexity_threshold, - "default_threshold": self.complexity_threshold, - "exception_source": exception_info.get("exception_source"), - "justification": exception_info.get("justification") - } - - errors.append(error) - - return errors - - def _is_declared_child_subsystem(self, directory: Path, file_cache=None) -> bool: - """Check if directory is a declared child subsystem of its parent.""" - parent = directory.parent - if parent == directory or not (parent / "dependencies.json").exists(): - return False - - # Use provided file cache or create temporary one - if file_cache: - parent_deps = file_cache.load_dependencies_json(parent / "dependencies.json") - else: - import json - try: - with open(parent / "dependencies.json") as f: - parent_deps = json.load(f) - except (json.JSONDecodeError, OSError): - parent_deps = {} - - subsystems_array = parent_deps.get("subsystems", []) - relative_path = f"./{directory.name}" - - # Only skip if this directory is properly declared as a subsystem - return relative_path in subsystems_array - - def _get_missing_subsystem_files(self, directory: Path) -> List[str]: - """Get list of missing required files for a subsystem.""" - missing = [] - - if not (directory / "dependencies.json").exists(): - missing.append("dependencies.json") - if not (directory / "README.md").exists(): - missing.append("README.md") - # ARCHITECTURE.md no longer required - consolidated into README.md - - return missing - - def _get_custom_thresholds(self, directory: Path) -> dict: - """Get custom thresholds for a directory from exception files.""" - if not self.exception_handler: - return {} - - exception = self.exception_handler.get_custom_threshold(directory) - if not exception: - return {} - - # For now, use the custom threshold for both complexity and doc thresholds - # Could be extended to support separate thresholds - custom_threshold = exception.threshold - - # Scale the doc threshold proportionally - doc_ratio = self.doc_threshold / self.complexity_threshold - custom_doc_threshold = int(custom_threshold * doc_ratio) - - return { - "complexity": custom_threshold, - "doc": custom_doc_threshold, - "exception_info": { - "exception_source": str(exception.source_file.relative_to(self.exception_handler.project_root)), - "justification": exception.justification - } - } \ No newline at end of file diff --git a/scripts/checks/architecture/rules/domain_rules.py b/scripts/checks/architecture/rules/domain_rules.py deleted file mode 100644 index 16385477c..000000000 --- a/scripts/checks/architecture/rules/domain_rules.py +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env python3 -""" -Domain-specific architecture rules. - -Handles checking domain structure and import restrictions. -""" - -from pathlib import Path -from typing import List - -from ..models import ArchError, ErrorType, RecommendationType -from ..utils.file_utils import find_typescript_files -from ..utils.path_utils import PathHelper - - -class DomainRuleChecker: - """Checker for domain-specific architecture rules.""" - - def __init__(self, path_helper: PathHelper, file_cache): - self.path_helper = path_helper - self.file_cache = file_cache - - def check_domain_structure(self) -> List[ArchError]: - """Check domain-specific structure requirements.""" - errors = [] - # print("Checking domain structure...") - - domains_path = self.path_helper.target_path / "lib" / "domains" - if not domains_path.exists(): - return errors - - for domain_dir in domains_path.iterdir(): - if not domain_dir.is_dir(): - continue - - # Check services structure - errors.extend(self._check_services_structure(domain_dir)) - - # Check infrastructure structure - errors.extend(self._check_infrastructure_structure(domain_dir)) - - # Check utils structure - errors.extend(self._check_utils_structure(domain_dir)) - - return errors - - def check_domain_import_restrictions(self) -> List[ArchError]: - """Check domain service import restrictions with refined rules.""" - errors = [] - # print("Checking domain import restrictions...") - - domains_path = self.path_helper.target_path / "lib" / "domains" - if not domains_path.exists(): - return errors - - # Find all service files - service_files = [] - for service_file in domains_path.rglob("services/*.ts"): - if service_file.name != "index.ts": - service_files.append(service_file) - - # Check each service file for improper imports - for service_file in service_files: - errors.extend(self._check_refined_service_import_violations(service_file)) - - # Check cross-domain imports (no domain should import other domain services/non-utils) - errors.extend(self._check_cross_domain_violations()) - - return errors - - def _check_services_structure(self, domain_dir: Path) -> List[ArchError]: - """Check services directory structure within a domain.""" - errors = [] - services_dir = domain_dir / "services" - - if services_dir.exists(): - # Services must have dependencies.json - if not (services_dir / "dependencies.json").exists(): - errors.append(ArchError.create_error( - message=f"❌ {services_dir} needs dependencies.json", - error_type=ErrorType.DOMAIN_STRUCTURE, - subsystem=str(services_dir), - recommendation=f"Create {services_dir}/dependencies.json file", - recommendation_type=RecommendationType.CREATE_DEPENDENCIES_JSON - )) - - # Services must be exposed in services/index.ts - if not (services_dir / "index.ts").exists(): - errors.append(ArchError.create_error( - message=f"❌ {services_dir} missing index.ts to expose services", - error_type=ErrorType.DOMAIN_STRUCTURE, - subsystem=str(services_dir), - recommendation=f"Create {services_dir}/index.ts file to reexport service modules", - recommendation_type=RecommendationType.CREATE_SUBSYSTEM_INDEX - )) - - return errors - - def _check_infrastructure_structure(self, domain_dir: Path) -> List[ArchError]: - """Check infrastructure directory structure within a domain.""" - errors = [] - - infra_dirs = list(domain_dir.rglob("infrastructure/*")) - for infra_dir in infra_dirs: - if infra_dir.is_dir() and not (infra_dir / "dependencies.json").exists(): - errors.append(ArchError.create_error( - message=f"❌ Infrastructure {infra_dir} needs dependencies.json", - error_type=ErrorType.DOMAIN_STRUCTURE, - subsystem=str(infra_dir), - recommendation=f"Create {infra_dir}/dependencies.json file", - recommendation_type=RecommendationType.CREATE_DEPENDENCIES_JSON - )) - - return errors - - def _check_utils_structure(self, domain_dir: Path) -> List[ArchError]: - """Check utils directory structure within a domain.""" - errors = [] - utils_dir = domain_dir / "utils" - - if utils_dir.exists() and not (utils_dir / "index.ts").exists(): - errors.append(ArchError.create_error( - message=f"❌ {utils_dir} missing index.ts to expose utilities", - error_type=ErrorType.DOMAIN_STRUCTURE, - subsystem=str(utils_dir), - recommendation=f"Create {utils_dir}/index.ts file to reexport utility modules", - recommendation_type=RecommendationType.CREATE_SUBSYSTEM_INDEX - )) - - return errors - - def _check_refined_service_import_violations(self, service_file: Path) -> List[ArchError]: - """Check service imports against refined domain rules.""" - errors = [] - - # Extract domain name and import path for this service file - domain_name = service_file.parts[-3] # e.g., 'iam' from 'lib/domains/iam/services/...' - service_import_path = f"~/{service_file.relative_to(Path('src'))}" - service_import_path = service_import_path.replace(".ts", "") - - typescript_files = find_typescript_files(self.path_helper.target_path) - - # Find files that import this service - for ts_file in typescript_files: - # Skip the service file itself - if ts_file == service_file: - continue - - # Skip API/server files (always allowed) - file_str = str(ts_file) - if "/api/" in file_str or "/server/" in file_str: - continue - - content = self.file_cache.get_file_info(ts_file).content - if not content: - continue - - # Check if this file imports this service - import_patterns = [ - f"from '{service_import_path}'", - f"from \"{service_import_path}\"", - # Also check if importing from the services index that reexports this service - f"from '~/lib/domains/{domain_name}/services'", - f"from \"~/lib/domains/{domain_name}/services\"" - ] - - service_imported = any(pattern in content for pattern in import_patterns) - - if service_imported: - # Apply refined rules based on importing file location - file_path = ts_file.relative_to(self.path_helper.target_path) - file_path_str = str(file_path) - - # Rule 1: {domain}/index.ts can import same domain services - ALLOWED - if file_path_str == f"lib/domains/{domain_name}/index.ts": - continue - - # Rule 2: {domain}/services/* can import same domain services - ALLOWED - if f"lib/domains/{domain_name}/services" in file_path_str: - continue - - # Rule 3 & 4: Everything else in the domain CANNOT import services - ERROR - if f"lib/domains/{domain_name}/" in file_path_str: - service_name = service_file.stem - recommendation = f"Remove service import from {file_path} - only domain index.ts and services/* can import domain services" - errors.append(ArchError.create_error( - message=(f"❌ Service {service_name} imported by restricted file:\n" - f" πŸ”Έ {file_path}\n" - f" β†’ Only domain index.ts and services/* can import domain services"), - error_type=ErrorType.DOMAIN_IMPORT, - subsystem=str(service_file.parent), - file_path=str(file_path), - recommendation=recommendation, - recommendation_type=RecommendationType.FIX_DOMAIN_SERVICE_IMPORT - )) - else: - # Outside domain structure - should go through API - service_name = service_file.stem - recommendation = f"Move service import from {file_path} to API/server code, or use domain public interface" - errors.append(ArchError.create_error( - message=(f"❌ Service {service_name} imported by non-domain file:\n" - f" πŸ”Έ {file_path}\n" - f" β†’ Services should only be used through API/server layer"), - error_type=ErrorType.DOMAIN_IMPORT, - subsystem=str(service_file.parent), - file_path=str(file_path), - recommendation=recommendation, - recommendation_type=RecommendationType.MOVE_SERVICE_TO_API - )) - - return errors - - def _check_cross_domain_violations(self) -> List[ArchError]: - """Check that domains don't import from other domains (except utils).""" - errors = [] - - domains_path = self.path_helper.target_path / "lib" / "domains" - if not domains_path.exists(): - return errors - - # Get all domain directories - domain_dirs = [d for d in domains_path.iterdir() if d.is_dir()] - - for domain_dir in domain_dirs: - domain_name = domain_dir.name - - # Find all TypeScript files in this domain - for ts_file in domain_dir.rglob("*.ts"): - content = self.file_cache.get_file_info(ts_file).content - if not content: - continue - - # Check for imports from other domains - for other_domain_dir in domain_dirs: - if other_domain_dir == domain_dir: - continue # Skip same domain - - other_domain_name = other_domain_dir.name - - # Look for imports from other domains (but allow utils) - forbidden_patterns = [ - f"from '~/lib/domains/{other_domain_name}/services", - f"from \"~/lib/domains/{other_domain_name}/services", - f"from '~/lib/domains/{other_domain_name}/infrastructure", - f"from \"~/lib/domains/{other_domain_name}/infrastructure", - f"from '~/lib/domains/{other_domain_name}/_", - f"from \"~/lib/domains/{other_domain_name}/_", - f"from '~/lib/domains/{other_domain_name}/index", - f"from \"~/lib/domains/{other_domain_name}/index", - ] - - for pattern in forbidden_patterns: - if pattern in content: - file_path = ts_file.relative_to(self.path_helper.target_path) - recommendation = f"Remove cross-domain import from {file_path} - domains should only import other domain utils, not services/infrastructure" - errors.append(ArchError.create_error( - message=(f"❌ Cross-domain import violation:\n" - f" πŸ”Έ {file_path}\n" - f" β†’ Domain '{domain_name}' importing from domain '{other_domain_name}'\n" - f" β†’ Use API orchestration instead of direct domain-to-domain calls"), - error_type=ErrorType.DOMAIN_IMPORT, - subsystem=str(ts_file.parent), - file_path=str(file_path), - recommendation=recommendation, - recommendation_type=RecommendationType.REMOVE_CROSS_DOMAIN_IMPORT - )) - break # Only report first violation per file - - return errors \ No newline at end of file diff --git a/scripts/checks/architecture/rules/import_rules.py b/scripts/checks/architecture/rules/import_rules.py deleted file mode 100644 index 2d192a138..000000000 --- a/scripts/checks/architecture/rules/import_rules.py +++ /dev/null @@ -1,676 +0,0 @@ -#!/usr/bin/env python3 -""" -Import boundary and reexport rules. - -Handles checking import boundaries and reexport restrictions. -""" - -import re -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -from typing import List, Set - -from ..models import ArchError, ErrorType, RecommendationType, SubsystemInfo -from ..utils.file_utils import find_typescript_files -from ..utils.import_utils import ( - is_child_of_subsystem, - resolve_inheritance_chain, - is_import_allowed_by_set -) -from ..utils.path_utils import PathHelper - - -class ImportRuleChecker: - """Checker for import boundaries and reexport rules.""" - - def __init__(self, path_helper: PathHelper, file_cache): - self.path_helper = path_helper - self.file_cache = file_cache - - def check_import_boundaries(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check that external imports go through subsystem index files.""" - errors = [] - # print("Checking import boundaries...") - - for subsystem in subsystems: - violations = self._find_import_boundary_violations(subsystem) - - if violations: - # Provide context based on subsystem type - subsystem_type_desc = f" (type: {subsystem.subsystem_type})" if subsystem.subsystem_type else "" - errors.append(ArchError.create_error( - message=f"❌ External imports bypass {subsystem.name}/index{subsystem_type_desc}:", - error_type=ErrorType.IMPORT_BOUNDARY, - subsystem=str(subsystem.path), - recommendation=f"Create or update {subsystem.path}/index.ts to reexport internal modules", - recommendation_type=RecommendationType.CREATE_SUBSYSTEM_INDEX - )) - for v in violations: - # Extract the import path from the violation - import_match = re.search(r"from\s+['\"]([^'\"]*)['\"]", v['import']) - if import_match: - import_path = import_match.group(1) - # Suggest changing the import to use the index - subsystem_abs_path = f"~/{subsystem.path.relative_to(Path('src'))}" - recommendation = f"Change import from '{import_path}' to '{subsystem_abs_path}' (via index.ts)" - else: - recommendation = f"Import through {subsystem.path}/index.ts instead of direct file access" - - errors.append(ArchError.create_error( - message=f" πŸ”Έ {v['file']}:{v['line']}\n {v['import']}", - error_type=ErrorType.IMPORT_BOUNDARY, - subsystem=str(subsystem.path), - file_path=str(v['file']), - line_number=v['line'], - recommendation=recommendation, - recommendation_type=RecommendationType.USE_SUBSYSTEM_INTERFACE - )) - - return errors - - def check_reexport_boundaries(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check that index.ts files only reexport from child subsystems or internal files.""" - errors = [] - # print("Checking reexport boundaries...") - - for subsystem in subsystems: - violations = self._find_reexport_violations(subsystem) - - if violations: - errors.append(ArchError.create_error( - message=f"❌ Invalid reexports in {subsystem.name}/index.ts:", - error_type=ErrorType.REEXPORT_BOUNDARY, - subsystem=str(subsystem.path), - recommendation=f"Fix reexports in {subsystem.path}/index.ts to only expose internal modules", - recommendation_type=RecommendationType.FIX_REEXPORT_BOUNDARY - )) - for v in violations: - # Create specific recommendation based on violation type - if v['reason'] == 'reexport from external subsystem violates encapsulation': - recommendation = f"Remove reexport '{v['import']}' from index.ts - external dependencies should be imported directly" - else: - recommendation = f"Fix reexport pattern '{v['import']}' in index.ts to follow subsystem rules" - - errors.append(ArchError.create_error( - message=(f" πŸ”Έ Line {v['line']}: {v['full_statement']}\n" - f" β†’ {v['reason']}\n" - f" β†’ Reexports should only expose child subsystems or internal files\n" - f" β†’ External dependencies should be imported directly where needed"), - error_type=ErrorType.REEXPORT_BOUNDARY, - subsystem=str(subsystem.path), - file_path=f"{subsystem.path}/index.ts", - line_number=v['line'], - recommendation=recommendation, - recommendation_type=RecommendationType.FIX_REEXPORT_BOUNDARY - )) - - return errors - - def check_outbound_dependencies_parallel(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check outbound dependencies against allowlist with parallel processing.""" - errors = [] - # print("Checking outbound dependencies...") - - def check_single_subsystem(subsystem: SubsystemInfo) -> List[ArchError]: - subsystem_errors = [] - - # Get all allowed dependencies (local + inherited) - allowed_deps = set(subsystem.dependencies.get("allowed", [])) - allowed_children = set(subsystem.dependencies.get("allowedChildren", [])) - inherited = set(resolve_inheritance_chain(subsystem, self.file_cache)) - - all_allowed = allowed_deps | allowed_children | inherited - - # Add domain _objects if in domain - if self.path_helper.is_domain_path(subsystem.path): - all_allowed.add("_objects") - - # Domain utils are implicitly allowed - handled in is_import_allowed_by_set - - # Check each file's imports - for file_info in subsystem.files: - for import_path in file_info.imports: - # Skip internal imports - if not import_path.startswith("~/") and not import_path.startswith("../"): - continue - - # Convert relative to absolute (simplified for now) - if import_path.startswith("../"): - continue - - # Check if import is allowed (exact match or hierarchical) - is_allowed = is_import_allowed_by_set(import_path, all_allowed, subsystem.path) - - if not is_allowed: - recommendation = f"Add '{import_path}' to {subsystem.path}/dependencies.json 'allowed' array" - subsystem_errors.append(ArchError.create_error( - message=(f"❌ Undeclared outbound dependency in {subsystem.name}:\n" - f" πŸ”Έ {file_info.path.relative_to(subsystem.path)}\n" - f" import from '{import_path}'\n" - f" β†’ {recommendation}"), - error_type=ErrorType.IMPORT_BOUNDARY, - subsystem=str(subsystem.path), - file_path=str(file_info.path), - recommendation=recommendation, - recommendation_type=RecommendationType.ADD_ALLOWED_DEPENDENCY - )) - - return subsystem_errors - - # Process subsystems in parallel - with ThreadPoolExecutor(max_workers=4) as executor: - future_to_subsystem = { - executor.submit(check_single_subsystem, subsystem): subsystem - for subsystem in subsystems - } - - for future in as_completed(future_to_subsystem): - subsystem_errors = future.result() - errors.extend(subsystem_errors) - - return errors - - def check_router_import_patterns(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check for imports from router subsystems - warn to use specific child instead.""" - errors = [] - # print("Checking router import patterns...") - - # Build a map of router subsystems by their import path - router_subsystems = {} - for subsystem in subsystems: - if subsystem.subsystem_type == "router": - subsystem_abs_path = f"~/{subsystem.path.relative_to(Path('src'))}" - router_subsystems[subsystem_abs_path] = subsystem - - # Check all subsystems for imports from routers - for subsystem in subsystems: - for file_info in subsystem.files: - for import_path in file_info.imports: - # Check if importing from a router subsystem's index - for router_path, router_subsystem in router_subsystems.items(): - # Match exact router path (importing from index) but not child paths - if import_path == router_path: - # Get list of child subsystems for suggestion - child_subsystems = router_subsystem.dependencies.get("subsystems", []) - children_list = ", ".join([child.lstrip("./") for child in child_subsystems]) - - recommendation = f"Consider importing from specific child subsystem instead: {router_path}/[{children_list}]" - - errors.append(ArchError.create_warning( - message=(f"⚠️ Import from router subsystem in {subsystem.name}:\n" - f" πŸ”Έ {file_info.path.relative_to(subsystem.path)}\n" - f" import from '{import_path}'\n" - f" β†’ Router subsystems are aggregators - prefer importing from specific children for explicit dependency tracking\n" - f" β†’ Available children: {children_list}"), - error_type=ErrorType.IMPORT_BOUNDARY, - subsystem=str(subsystem.path), - file_path=str(file_info.path), - recommendation=recommendation, - recommendation_type=RecommendationType.USE_SPECIFIC_CHILD - )) - - return errors - - def check_domain_utils_import_patterns(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check that domain utils imports go through index.ts, not specific files.""" - errors = [] - # print("Checking domain utils import patterns...") - - for subsystem in subsystems: - for file_info in subsystem.files: - for import_path in file_info.imports: - # Check if it's a domain utils import to a specific file (not index) - import re - specific_utils_pattern = r"~/lib/domains/[^/]+/utils/[^/]+$" - if re.match(specific_utils_pattern, import_path) and not import_path.endswith("/index"): - # Skip if this is a utils/index.ts file importing from its own utils files - # This allows utils/index.ts to aggregate exports from its own files - if file_info.path.name == "index.ts" and file_info.path.parent.name == "utils": - # Check if the import is from the same domain - domain_match = re.match(r"~/lib/domains/([^/]+)/utils/.*", import_path) - if domain_match: - import_domain = domain_match.group(1) - # Get the domain of the current file - file_path_parts = file_info.path.parts - if "domains" in file_path_parts: - domains_index = file_path_parts.index("domains") - if domains_index + 1 < len(file_path_parts): - current_domain = file_path_parts[domains_index + 1] - # If importing from same domain, allow it - if import_domain == current_domain: - continue - - # Extract domain and suggest proper import - domain_match = re.match(r"~/lib/domains/([^/]+)/utils/.*", import_path) - if domain_match: - domain_name = domain_match.group(1) - proper_import = f"~/lib/domains/{domain_name}/utils" - - error = ArchError.create_error( - message=(f"❌ Direct utils file import in {subsystem.name}:\n" - f" πŸ”Έ {file_info.path.relative_to(subsystem.path)}\n" - f" import from '{import_path}'\n" - f" β†’ Use '{proper_import}' instead (import through utils index.ts)"), - error_type=ErrorType.IMPORT_BOUNDARY, - subsystem=str(subsystem.path), - file_path=str(file_info.path), - recommendation=f"Change import from '{import_path}' to '{proper_import}' (use utils index.ts)", - recommendation_type=RecommendationType.USE_UTILS_INTERFACE - ) - errors.append(error) - - return errors - - def check_standalone_index_reexports(self, index_files: List[Path]) -> List[ArchError]: - """Check upward reexports in all index.ts files, not just those in formal subsystems.""" - errors = [] - - for index_file in index_files: - # Skip if this index file is already part of a formal subsystem - if self._is_index_in_formal_subsystem(index_file): - continue - - # Create a minimal subsystem-like info for this index file - pseudo_subsystem = self._create_pseudo_subsystem(index_file) - - # Check for upward reexport violations - violations = self._find_standalone_index_violations(pseudo_subsystem) - - if violations: - errors.append(ArchError.create_error( - message=f"❌ Invalid upward reexports in {index_file.relative_to(Path('src'))}:", - error_type=ErrorType.REEXPORT_BOUNDARY, - subsystem=str(index_file.parent), - recommendation=f"Fix upward reexports in {index_file.relative_to(Path('src'))} - index files should not reexport from parent directories", - recommendation_type=RecommendationType.FIX_REEXPORT_BOUNDARY - )) - - for v in violations: - errors.append(ArchError.create_error( - message=(f" πŸ”Έ Line {v['line']}: {v['full_statement']}\n" - f" β†’ {v['reason']}"), - error_type=ErrorType.REEXPORT_BOUNDARY, - subsystem=str(index_file.parent), - file_path=str(index_file), - line_number=v['line'], - recommendation="Either move implementation to this directory or import directly from original location", - recommendation_type=RecommendationType.FIX_UPWARD_REEXPORT - )) - - return errors - - def _find_import_boundary_violations(self, subsystem: SubsystemInfo) -> List[dict]: - """Find violations where external files import directly into subsystem.""" - violations = [] - - # Router subsystems allow direct child imports (they're just aggregators) - if subsystem.subsystem_type == "router": - return violations - - # We want to find EXTERNAL files that import directly into this subsystem - typescript_files = find_typescript_files(self.path_helper.target_path) - - for ts_file in typescript_files: - # Skip index.ts files - they're allowed to import from their children - if ts_file.name == "index.ts": - continue - - file_str = str(ts_file) - - # Skip if file IS within this subsystem (internal files, not external importers) - if str(subsystem.path) in file_str: - continue - - # Skip if file is in a child subsystem (children can import parent freely) - if is_child_of_subsystem(ts_file, subsystem, {}): # TODO: pass proper subsystem_cache - continue - - # Now check if this external file imports into the subsystem - content = self.file_cache.get_file_info(ts_file).content - if not content: - continue - - # Find imports that bypass index.ts - # Use full subsystem path for precise matching - subsystem_abs_path = f"~/{subsystem.path.relative_to(Path('src'))}" - import_pattern = rf'from\s+["\']({re.escape(subsystem_abs_path)}/[^"\']*)["\']' - matches = re.finditer(import_pattern, content, re.MULTILINE) - - for match in matches: - import_path = match.group(1) - # Skip if importing from index or root - sub_path = import_path[len(subsystem_abs_path) + 1:] - if not sub_path or sub_path == "index": - continue - - # Check if importing file has permission through its own inheritance chain - if self._file_has_import_permission(ts_file, import_path): - continue - - line_num = content[:match.start()].count('\n') + 1 - violations.append({ - 'file': ts_file, - 'line': line_num, - 'import': match.group(0) - }) - - return violations - - def _find_reexport_violations(self, subsystem: SubsystemInfo) -> List[dict]: - """Find reexport violations in subsystem index.ts.""" - index_file = subsystem.path / "index.ts" - if not index_file.exists(): - return [] - - content = self.file_cache.get_file_info(index_file).content - if not content: - return [] - - # Find all reexport statements (export { ... } from '...' and export * from '...') - reexport_pattern = r'export\s+\{[^}]*\}\s+from\s+["\']([^"\']+)["\']' - reexport_type_pattern = r'export\s+type\s+\{[^}]*\}\s+from\s+["\']([^"\']+)["\']' - reexport_star_pattern = r'export\s+\*\s+from\s+["\']([^"\']+)["\']' - - violations = [] - - for pattern in [reexport_pattern, reexport_type_pattern, reexport_star_pattern]: - matches = re.finditer(pattern, content, re.MULTILINE) - - for match in matches: - import_path = match.group(1) - line_num = content[:match.start()].count('\n') + 1 - - violation = self._check_reexport_violation( - subsystem, import_path, match.group(0), line_num) - if violation: - violations.append(violation) - - return violations - - def _check_reexport_violation(self, subsystem: SubsystemInfo, import_path: str, - full_statement: str, line_num: int) -> dict: - """Check if a single reexport violates rules.""" - - # NEW RULE: index.ts files cannot reexport from parent/higher directories - if self._is_upward_reexport(subsystem, import_path): - return { - 'line': line_num, - 'import': import_path, - 'full_statement': full_statement, - 'reason': 'index.ts files cannot reexport from parent directories - either move implementation here or import directly from original location' - } - - # Special rule: Domain index.ts files should NOT reexport from utils - # Utils should be imported directly, not through domain index - if self._is_domain_index(subsystem): - # Check for relative utils imports - if import_path == './utils' or import_path.startswith('./utils/'): - return { - 'line': line_num, - 'import': import_path, - 'full_statement': full_statement, - 'reason': 'domain index should not reexport utils - import directly from utils instead' - } - # Check for absolute utils imports within same domain - subsystem_abs_path = f"~/{subsystem.path.relative_to(Path('src'))}" - utils_path = f"{subsystem_abs_path}/utils" - if import_path == utils_path or import_path.startswith(f"{utils_path}/"): - return { - 'line': line_num, - 'import': import_path, - 'full_statement': full_statement, - 'reason': 'domain index should not reexport utils - import directly from utils instead' - } - - # EXCEPTION: Domain utils can reexport from sibling subsystems within same domain - # This allows utils to create a client-safe API without server dependencies - if self._is_domain_utils(subsystem): - # Check if import is from the same domain - subsystem_rel_path = subsystem.path.relative_to(Path('src')) - path_parts = subsystem_rel_path.parts - if len(path_parts) >= 4 and path_parts[0] == 'lib' and path_parts[1] == 'domains': - domain_name = path_parts[2] - domain_prefix = f"~/lib/domains/{domain_name}" - - # Allow reexports from same domain for domain/utils - if import_path.startswith(domain_prefix): - return None # Allowed for domain/utils - # Also allow relative imports from siblings - if import_path.startswith('../'): - # Check if it resolves to same domain - return None # Allowed for domain/utils - - # STRICT RULE: Only allow reexports from child subsystems or internal files - if import_path.startswith('./'): - # This is a child reference - check if it's a declared child subsystem or internal file - child_name = import_path[2:] # Remove './' - child_subsystems = subsystem.dependencies.get("subsystems", []) - - if f"./{child_name}" in child_subsystems: - return None # Valid child subsystem reexport - else: - # Check if it's a file within the current subsystem - if self._is_internal_file_reexport(subsystem, child_name): - return None # Valid internal file reexport - - elif import_path.startswith('../'): - # STRICT: No reexports from siblings or parents - return { - 'line': line_num, - 'import': import_path, - 'full_statement': full_statement, - 'reason': 'reexport from external subsystem violates encapsulation' - } - - elif import_path.startswith('~/'): - # Check if this is an internal absolute path within the same subsystem - subsystem_abs_path = f"~/{subsystem.path.relative_to(Path('src'))}" - - if import_path.startswith(f"{subsystem_abs_path}/"): - # This is an internal absolute path reexport - allowed - return None - else: - # This is an external absolute path reexport - not allowed - return { - 'line': line_num, - 'import': import_path, - 'full_statement': full_statement, - 'reason': 'reexport from external subsystem violates encapsulation' - } - - else: - # Check for external library imports (node_modules, etc.) - these are allowed - if not import_path.startswith('.') and not import_path.startswith('~'): - return None # External library reexport is allowed - - # Any other pattern is invalid - return { - 'line': line_num, - 'import': import_path, - 'full_statement': full_statement, - 'reason': 'invalid reexport pattern' - } - - def _is_internal_file_reexport(self, subsystem: SubsystemInfo, child_name: str) -> bool: - """Check if reexport is for an internal file within the subsystem.""" - potential_file = subsystem.path / f"{child_name}.ts" - potential_tsx_file = subsystem.path / f"{child_name}.tsx" - # Also check for directories with index files - potential_dir_index = subsystem.path / child_name / "index.ts" - potential_dir_index_tsx = subsystem.path / child_name / "index.tsx" - - return (potential_file.exists() or potential_tsx_file.exists() or - potential_dir_index.exists() or potential_dir_index_tsx.exists()) - - def _file_has_import_permission(self, file_path: Path, import_path: str) -> bool: - """Check if a file has permission to import from the given path through inheritance.""" - # Allow certain types of imports that don't violate encapsulation - - if not import_path.startswith("~/lib/domains/"): - return False - - # Extract domain from import path (e.g., "~/lib/domains/mapping/utils" -> "mapping") - import_parts = import_path.split('/') - if len(import_parts) < 4: - return False - import_domain = import_parts[3] - - # Check if importing file is within the same domain directory - file_str = str(file_path) - - # Always allow same-domain imports for files within lib/domains/DOMAIN - if f"/lib/domains/{import_domain}/" in file_str: - return True - - # Allow direct imports from domain utils - these are pure, side-effect-free - # utilities that can be safely imported by frontend/external code - if len(import_parts) >= 5 and import_parts[4] == "utils": - return True - - # All other imports (services, infrastructure, etc.) must go through the domain's index.ts - return False - - def _is_upward_reexport(self, subsystem: SubsystemInfo, import_path: str) -> bool: - """Check if this reexport goes to a higher-level directory (parent or ancestor sibling).""" - # EXCEPTION: Domain utils can reexport from their parent domain - # Pattern: src/lib/domains/DOMAIN/utils can reexport from ~/lib/domains/DOMAIN/* - subsystem_rel_path = subsystem.path.relative_to(Path('src')) - path_parts = subsystem_rel_path.parts - - # Check if this is a domain utils directory - if (len(path_parts) >= 4 and - path_parts[0] == 'lib' and - path_parts[1] == 'domains' and - path_parts[3] == 'utils'): - # This is a domain utils directory - domain_name = path_parts[2] - domain_prefix = f"~/lib/domains/{domain_name}" - - # If importing from the same domain (but not a child), allow it - if import_path.startswith(domain_prefix): - subsystem_abs_path = f"~/{subsystem_rel_path}" - # Make sure it's not a child import (those are already allowed) - if not import_path.startswith(f"{subsystem_abs_path}/"): - # This is a same-domain import for utils - allowed - return False - - # Handle relative upward paths like '../types' or '../../../lib' - if import_path.startswith('../'): - return True - - # Handle absolute paths that point to higher-level directories - if import_path.startswith('~/'): - # Get the subsystem's path relative to src - subsystem_rel_path = subsystem.path.relative_to(Path('src')) - # Convert to absolute import pattern - subsystem_abs_path = f"~/{subsystem_rel_path}" - - # If the import path is identical to subsystem path, it's not upward (self-import) - if import_path == subsystem_abs_path: - return False - - # If the import starts with subsystem path + '/', it's a child (downward) - allowed - if import_path.startswith(f"{subsystem_abs_path}/"): - return False - - # Otherwise, check if it's at the same level or higher level - import_parts = import_path.split('/') - subsystem_parts = subsystem_abs_path.split('/') - - # Find common prefix length - common_length = 0 - for i in range(min(len(import_parts), len(subsystem_parts))): - if import_parts[i] == subsystem_parts[i]: - common_length += 1 - else: - break - - # If import has fewer or equal parts than subsystem, and shares a common prefix, - # it's pointing to a higher level directory - if len(import_parts) <= len(subsystem_parts) and common_length > 0: - # Ensure we don't flag completely unrelated paths - if common_length >= 2: # At least '~' and one more level in common - return True - - return False - - def _is_domain_index(self, subsystem: SubsystemInfo) -> bool: - """Check if this subsystem represents a domain's main index.ts file.""" - # Domain paths look like: src/lib/domains/DOMAIN_NAME - path_parts = subsystem.path.parts - return (len(path_parts) >= 4 and - path_parts[-3] == 'lib' and - path_parts[-2] == 'domains' and - (subsystem.path / 'index.ts').exists()) - - def _is_domain_utils(self, subsystem: SubsystemInfo) -> bool: - """Check if this subsystem is a domain/utils directory. - - Domain utils are special - they create a client-safe API by reexporting - types from sibling subsystems without pulling in server dependencies. - Pattern: src/lib/domains/DOMAIN_NAME/utils - """ - path_parts = subsystem.path.parts - return (len(path_parts) >= 5 and - path_parts[0] == 'src' and - path_parts[1] == 'lib' and - path_parts[2] == 'domains' and - path_parts[4] == 'utils') - - def _is_index_in_formal_subsystem(self, index_file: Path) -> bool: - """Check if this index file belongs to a formal subsystem (has dependencies.json).""" - deps_file = index_file.parent / 'dependencies.json' - return deps_file.exists() - - def _create_pseudo_subsystem(self, index_file: Path) -> SubsystemInfo: - """Create a minimal SubsystemInfo for standalone index files.""" - from ..models import FileInfo - - # Get file info for the index file - file_info = self.file_cache.get_file_info(index_file) - - return SubsystemInfo( - path=index_file.parent, - name=index_file.parent.name, - dependencies={}, # Empty dependencies - files=[file_info], - total_lines=file_info.lines, - parent_path=index_file.parent.parent - ) - - def _find_standalone_index_violations(self, pseudo_subsystem: SubsystemInfo) -> List[dict]: - """Find reexport violations in a standalone index file.""" - index_file = pseudo_subsystem.path / "index.ts" - if not index_file.exists(): - index_file = pseudo_subsystem.path / "index.tsx" - if not index_file.exists(): - return [] - - content = self.file_cache.get_file_info(index_file).content - if not content: - return [] - - # Find all reexport statements using same patterns as formal subsystems - reexport_pattern = r'export\s+\{[^}]*\}\s+from\s+["\']([^"\']+)["\']' - reexport_type_pattern = r'export\s+type\s+\{[^}]*\}\s+from\s+["\']([^"\']+)["\']' - reexport_star_pattern = r'export\s+\*\s+from\s+["\']([^"\']+)["\']' - - violations = [] - - for pattern in [reexport_pattern, reexport_type_pattern, reexport_star_pattern]: - matches = re.finditer(pattern, content, re.MULTILINE) - - for match in matches: - import_path = match.group(1) - line_num = content[:match.start()].count('\n') + 1 - - # Only check for upward reexports (our specific rule) - if self._is_upward_reexport(pseudo_subsystem, import_path): - violations.append({ - 'line': line_num, - 'import': import_path, - 'full_statement': match.group(0), - 'reason': 'index.ts files cannot reexport from parent directories - either move implementation here or import directly from original location' - }) - - return violations \ No newline at end of file diff --git a/scripts/checks/architecture/rules/subsystem_rules.py b/scripts/checks/architecture/rules/subsystem_rules.py deleted file mode 100644 index 0826a7a94..000000000 --- a/scripts/checks/architecture/rules/subsystem_rules.py +++ /dev/null @@ -1,437 +0,0 @@ -#!/usr/bin/env python3 -""" -Subsystem structure and declaration rules. - -Handles checking subsystem declarations, file/folder conflicts, and dependency format. -""" - -import re -from pathlib import Path -from typing import List, Set - -from ..models import ArchError, ErrorType, RecommendationType, SubsystemInfo -from ..utils.file_utils import find_typescript_files -from ..utils.path_utils import PathHelper -from ..utils.import_utils import find_redundant_ancestor_declarations - - -class SubsystemRuleChecker: - """Checker for subsystem structure and declarations.""" - - def __init__(self, path_helper: PathHelper, file_cache): - self.path_helper = path_helper - self.file_cache = file_cache - - def check_subsystem_declarations(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check that subsystems are declared in parent dependencies.json.""" - errors = [] - # print("Checking subsystem declarations...") - - for subsystem in subsystems: - parent_dir = subsystem.parent_path - - # Skip if parent is target path itself - if (parent_dir == self.path_helper.target_path or - parent_dir == Path(".")): - continue - - parent_deps_file = parent_dir / "dependencies.json" - if parent_deps_file.exists(): - parent_deps = self.file_cache.load_dependencies_json(parent_deps_file) - subsystems_array = parent_deps.get("subsystems", []) - - relative_path = f"./{subsystem.name}" - if relative_path not in subsystems_array: - error = ArchError.create_error( - message=(f"❌ Subsystem {subsystem.path} not declared in {parent_deps_file}\n" - f" β†’ Add \"{relative_path}\" to the \"subsystems\" array"), - error_type=ErrorType.SUBSYSTEM_STRUCTURE, - subsystem=str(subsystem.path), - recommendation=f"Add \"{relative_path}\" to the \"subsystems\" array in {parent_deps_file}", - recommendation_type=RecommendationType.ADD_ALLOWED_CHILDREN - ) - errors.append(error) - - return errors - - def check_declared_subsystems_exist(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check that declared subsystems actually have dependencies.json files.""" - errors = [] - # print("Checking declared subsystems exist...") - - for subsystem in subsystems: - deps = subsystem.dependencies - declared_subsystems = deps.get("subsystems", []) - - for declared_subsystem in declared_subsystems: - # Convert relative path to absolute - if declared_subsystem.startswith("./"): - subsystem_name = declared_subsystem[2:] # Remove "./" - subsystem_path = subsystem.path / subsystem_name - deps_file = subsystem_path / "dependencies.json" - - if not deps_file.exists(): - # Check if directory exists at all - if subsystem_path.exists(): - recommendation = ( - f"Create {deps_file.relative_to(Path('src'))} to formalize this subsystem, " - f"or remove '{declared_subsystem}' from {subsystem.path}/dependencies.json 'subsystems' array if it's not a subsystem" - ) - else: - recommendation = ( - f"Remove '{declared_subsystem}' from {subsystem.path}/dependencies.json 'subsystems' array " - f"(directory does not exist)" - ) - - error = ArchError.create_error( - message=(f"❌ Declared subsystem missing dependencies.json:\n" - f" πŸ”Έ {subsystem.name} declares '{declared_subsystem}' as a subsystem\n" - f" πŸ”Έ But {deps_file.relative_to(Path('src'))} does not exist\n" - f" β†’ Either create the dependencies.json file\n" - f" β†’ Or remove '{declared_subsystem}' from subsystems array"), - error_type=ErrorType.SUBSYSTEM_STRUCTURE, - subsystem=str(subsystem.path), - recommendation=recommendation, - recommendation_type=RecommendationType.CREATE_OR_REMOVE_SUBSYSTEM if subsystem_path.exists() else RecommendationType.REMOVE_INVALID_SUBSYSTEM - ) - errors.append(error) - - return errors - - def check_dependencies_json_format(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check that dependencies.json files use absolute paths.""" - errors = [] - # print("Checking dependencies.json path format...") - - for subsystem in subsystems: - deps_file = subsystem.path / "dependencies.json" - deps = subsystem.dependencies - - # Check allowed array for relative paths - allowed = deps.get("allowed", []) - for dep in allowed: - if self._is_invalid_relative_path(dep): - error = ArchError.create_error( - message=(f"❌ Relative path in {deps_file}: '{dep}'\n" - f" β†’ Use absolute paths with ~/ prefix instead"), - error_type=ErrorType.DEPENDENCY_FORMAT, - subsystem=str(subsystem.path), - recommendation=f"Change relative path '{dep}' to absolute path with ~/ prefix in {deps_file}", - recommendation_type=RecommendationType.FIX_DEPENDENCY_PATH_FORMAT - ) - errors.append(error) - - # Check allowedChildren array for relative paths - allowed_children = deps.get("allowedChildren", []) - for dep in allowed_children: - if self._is_invalid_relative_path(dep): - error = ArchError.create_error( - message=(f"❌ Relative path in {deps_file}: '{dep}'\n" - f" β†’ Use absolute paths with ~/ prefix instead (except for subsystems)"), - error_type=ErrorType.DEPENDENCY_FORMAT, - subsystem=str(subsystem.path), - recommendation=f"Change relative path '{dep}' to absolute path with ~/ prefix in {deps_file}", - recommendation_type=RecommendationType.FIX_DEPENDENCY_PATH_FORMAT - ) - errors.append(error) - - return errors - - def check_hierarchical_redundancy(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check for hierarchical redundancy within the same dependencies.json.""" - errors = [] - - for subsystem in subsystems: - deps = subsystem.dependencies - - # Check allowed array for hierarchical redundancy - allowed = deps.get("allowed", []) - if allowed: - errors.extend(self._check_hierarchical_redundancy_in_list( - subsystem, allowed, "allowed")) - - # Check allowedChildren array for hierarchical redundancy - allowed_children = deps.get("allowedChildren", []) - if allowed_children: - errors.extend(self._check_hierarchical_redundancy_in_list( - subsystem, allowed_children, "allowedChildren")) - - return errors - - def check_redundant_dependencies(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check for redundant dependency declarations.""" - errors = [] - # print("Checking for redundant dependency declarations...") - - for subsystem in subsystems: - parent_dir = subsystem.parent_path - if not parent_dir or parent_dir == self.path_helper.target_path: - continue - - parent_deps_file = parent_dir / "dependencies.json" - if not parent_deps_file.exists(): - continue - - parent_deps = self.file_cache.load_dependencies_json(parent_deps_file) - parent_allowed_children = parent_deps.get("allowedChildren", []) - - if not parent_allowed_children: - continue - - # Check child's allowed array for redundancy - child_allowed = subsystem.dependencies.get("allowed", []) - for dep in child_allowed: - if dep in parent_allowed_children: - error = ArchError.create_error( - message=(f"❌ Redundant dependency in {subsystem.name}:\n" - f" πŸ”Έ '{dep}' is already provided by parent allowedChildren\n" - f" β†’ Remove from {subsystem.path}/dependencies.json 'allowed' array\n" - f" β†’ Parent allowedChildren automatically cascades to children"), - error_type=ErrorType.REDUNDANCY, - subsystem=str(subsystem.path), - recommendation=f"Remove '{dep}' from {subsystem.path}/dependencies.json 'allowed' array (redundant with parent)", - recommendation_type=RecommendationType.REMOVE_REDUNDANT_DEPENDENCY - ) - errors.append(error) - - # Check child's allowedChildren array for redundancy - child_allowed_children = subsystem.dependencies.get("allowedChildren", []) - for dep in child_allowed_children: - if dep in parent_allowed_children: - error = ArchError.create_error( - message=(f"❌ Redundant allowedChildren in {subsystem.name}:\n" - f" πŸ”Έ '{dep}' is already provided by parent allowedChildren\n" - f" β†’ Remove from {subsystem.path}/dependencies.json 'allowedChildren' array"), - error_type=ErrorType.REDUNDANCY, - subsystem=str(subsystem.path), - recommendation=f"Remove '{dep}' from {subsystem.path}/dependencies.json 'allowedChildren' array (redundant with parent)", - recommendation_type=RecommendationType.REMOVE_REDUNDANT_DEPENDENCY - ) - errors.append(error) - - return errors - - def check_ancestor_redundancy(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check for explicitly declared ancestors that should be auto-inherited.""" - errors = [] - - for subsystem in subsystems: - redundant_ancestors = find_redundant_ancestor_declarations(subsystem, self.file_cache) - - for ancestor_path in redundant_ancestors: - error = ArchError.create_error( - message=(f"❌ Redundant ancestor declaration in {subsystem.name}:\n" - f" πŸ”Έ '{ancestor_path}' is automatically inherited from parent subsystem\n" - f" β†’ Remove '{ancestor_path}' from {subsystem.path}/dependencies.json 'allowed' array\n" - f" β†’ Child subsystems automatically inherit access to ancestor subsystems"), - error_type=ErrorType.REDUNDANCY, - subsystem=str(subsystem.path), - recommendation=f"Remove '{ancestor_path}' from {subsystem.path}/dependencies.json 'allowed' array (automatically inherited)", - recommendation_type=RecommendationType.REMOVE_REDUNDANT_DEPENDENCY - ) - errors.append(error) - - return errors - - def check_domain_utils_redundancy(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check for explicitly declared domain utils that should be implicitly allowed.""" - errors = [] - - for subsystem in subsystems: - allowed_deps = subsystem.dependencies.get("allowed", []) - - for dep in allowed_deps: - # Check if it's a domain utils import that's explicitly declared - import re - utils_pattern = r"~/lib/domains/[^/]+/utils(?:/.*)?$" - if re.match(utils_pattern, dep): - error = ArchError.create_error( - message=(f"❌ Redundant domain utils declaration in {subsystem.name}:\n" - f" πŸ”Έ '{dep}' is implicitly allowed for all subsystems\n" - f" β†’ Remove '{dep}' from {subsystem.path}/dependencies.json 'allowed' array\n" - f" β†’ Domain utils are automatically accessible without explicit permission"), - error_type=ErrorType.REDUNDANCY, - subsystem=str(subsystem.path), - recommendation=f"Remove '{dep}' from {subsystem.path}/dependencies.json 'allowed' array (domain utils are implicitly allowed)", - recommendation_type=RecommendationType.REMOVE_REDUNDANT_DEPENDENCY - ) - errors.append(error) - - return errors - - def check_nonexistent_dependencies(self, subsystems: List[SubsystemInfo]) -> List[ArchError]: - """Check for dependencies pointing to non-existent folders.""" - errors = [] - - for subsystem in subsystems: - deps = subsystem.dependencies - - # Check allowed array for non-existent paths - allowed = deps.get("allowed", []) - for dep in allowed: - if self._is_filesystem_dependency(dep): - resolved_path = self._resolve_dependency_path(dep) - if resolved_path and not self._path_exists(resolved_path): - error = ArchError.create_error( - message=(f"❌ Non-existent dependency in {subsystem.name}:\n" - f" πŸ”Έ '{dep}' points to non-existent path: {resolved_path}\n" - f" β†’ Remove '{dep}' from {subsystem.path}/dependencies.json 'allowed' array\n" - f" β†’ Or create the missing directory/file"), - error_type=ErrorType.NONEXISTENT_DEPENDENCY, - subsystem=str(subsystem.path), - recommendation=f"Remove '{dep}' from {subsystem.path}/dependencies.json 'allowed' array (path does not exist)", - recommendation_type=RecommendationType.REMOVE_FORBIDDEN_DEPENDENCY - ) - errors.append(error) - - # Check allowedChildren array for non-existent paths - allowed_children = deps.get("allowedChildren", []) - for dep in allowed_children: - if self._is_filesystem_dependency(dep): - resolved_path = self._resolve_dependency_path(dep) - if resolved_path and not self._path_exists(resolved_path): - error = ArchError.create_error( - message=(f"❌ Non-existent allowedChildren in {subsystem.name}:\n" - f" πŸ”Έ '{dep}' points to non-existent path: {resolved_path}\n" - f" β†’ Remove '{dep}' from {subsystem.path}/dependencies.json 'allowedChildren' array\n" - f" β†’ Or create the missing directory/file"), - error_type=ErrorType.NONEXISTENT_DEPENDENCY, - subsystem=str(subsystem.path), - recommendation=f"Remove '{dep}' from {subsystem.path}/dependencies.json 'allowedChildren' array (path does not exist)", - recommendation_type=RecommendationType.REMOVE_FORBIDDEN_DEPENDENCY - ) - errors.append(error) - - return errors - - def check_file_folder_conflicts(self) -> List[ArchError]: - """Check for file/folder naming conflicts.""" - errors = [] - # print("Checking for file/folder naming conflicts...") - - typescript_files = find_typescript_files(self.path_helper.target_path) - - for ts_file in typescript_files: - stem = ts_file.stem - if stem == "index": - continue # Skip index files - - # Check if there's a folder with same name - potential_folder = ts_file.parent / stem - if potential_folder.is_dir(): - error = ArchError.create_error( - message=(f"❌ File/folder naming conflict:\n" - f" πŸ”Έ File: {ts_file.relative_to(self.path_helper.target_path)}\n" - f" πŸ”Έ Folder: {potential_folder.relative_to(self.path_helper.target_path)}/\n" - f" β†’ Move file contents to {potential_folder.relative_to(self.path_helper.target_path)}/index.ts"), - error_type=ErrorType.FILE_CONFLICT, - file_path=str(ts_file.relative_to(self.path_helper.target_path)), - recommendation=f"Move {ts_file.relative_to(self.path_helper.target_path)} contents to {potential_folder.relative_to(self.path_helper.target_path)}/index.ts", - recommendation_type=RecommendationType.RESOLVE_FILE_FOLDER_CONFLICT - ) - errors.append(error) - - return errors - - def _is_invalid_relative_path(self, dep: str) -> bool: - """Check if dependency path is an invalid relative path.""" - return dep.startswith("../") or (dep.startswith("./") and "subsystem" not in dep) - - def _check_hierarchical_redundancy_in_list(self, subsystem: SubsystemInfo, - dep_list: list, list_name: str) -> List[ArchError]: - """Check for hierarchical redundancy within a single dependency list.""" - errors = [] - - for i, dep in enumerate(dep_list): - for j, other_dep in enumerate(dep_list): - if i != j and dep != other_dep: - # Check if dep is made redundant by other_dep (other_dep is broader) - if dep.startswith(f"{other_dep}/"): - # BUT only flag as redundant if the child path is NOT a subsystem - potential_subsystem_path = self._get_potential_subsystem_path( - other_dep, dep) - - # If child path is NOT a subsystem (no dependencies.json), it's truly redundant - if (potential_subsystem_path and - not (potential_subsystem_path / "dependencies.json").exists()): - error = ArchError.create_error( - message=(f"❌ Hierarchical redundancy in {subsystem.name}:\n" - f" πŸ”Έ '{dep}' is redundant because '{other_dep}' already allows access\n" - f" β†’ Remove '{dep}' from {subsystem.path}/dependencies.json '{list_name}' array\n" - f" β†’ '{other_dep}' already provides hierarchical access"), - error_type=ErrorType.REDUNDANCY, - subsystem=str(subsystem.path), - recommendation=f"Remove '{dep}' from {subsystem.path}/dependencies.json '{list_name}' array (redundant with '{other_dep}')", - recommendation_type=RecommendationType.REMOVE_REDUNDANT_DEPENDENCY - ) - errors.append(error) - # If child path IS a subsystem, it's NOT redundant - subsystems need explicit access - - return errors - - def _get_potential_subsystem_path(self, other_dep: str, dep: str) -> Path: - """Get potential subsystem path for redundancy checking.""" - if other_dep.startswith("~/"): - base_path = Path("src") / other_dep[2:] - child_suffix = dep[len(other_dep) + 1:] # +1 to skip the "/" - return base_path / child_suffix - else: - return Path(other_dep) / dep[len(other_dep) + 1:] - - def _is_filesystem_dependency(self, dep: str) -> bool: - """Check if dependency is a filesystem path (not an npm package).""" - # Filesystem dependencies start with ~/ or are relative paths - # npm packages don't start with these patterns - return dep.startswith("~/") or dep.startswith("./") or dep.startswith("../") - - def _resolve_dependency_path(self, dep: str) -> Path: - """Resolve dependency path to actual filesystem path.""" - if dep.startswith("~/"): - # ~/ means src/ in our context - return Path("src") / dep[2:] - elif dep.startswith("./"): - # Relative to current directory (should be rare) - return Path(dep) - elif dep.startswith("../"): - # Relative parent (should be rare) - return Path(dep) - else: - # Shouldn't reach here for filesystem dependencies - return None - - def _path_exists(self, path: Path) -> bool: - """Check if path exists as directory or as file with common extensions.""" - if path.exists(): - return True - - # If directory doesn't exist, check for files with common TypeScript/JavaScript extensions - possible_files = [] - - # Only add extensions if the path doesn't already have a recognized extension - has_extension = any(str(path).endswith(ext) for ext in ['.ts', '.tsx', '.js', '.jsx', '.service']) - - if not has_extension: - possible_files.extend([ - path.with_suffix('.ts'), - path.with_suffix('.tsx'), - path.with_suffix('.js'), - path.with_suffix('.jsx') - ]) - else: - # For paths that already have extensions like .service, try adding .ts - possible_files.extend([ - Path(str(path) + '.ts'), - Path(str(path) + '.tsx'), - Path(str(path) + '.js'), - Path(str(path) + '.jsx') - ]) - - # Always check for index files in the directory - possible_files.extend([ - path / 'index.ts', - path / 'index.tsx', - path / 'index.js', - path / 'index.jsx' - ]) - - return any(file_path.exists() for file_path in possible_files) \ No newline at end of file diff --git a/scripts/checks/architecture/tests/__init__.py b/scripts/checks/architecture/tests/__init__.py deleted file mode 100644 index 35e1a269e..000000000 --- a/scripts/checks/architecture/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for architecture boundary checker.""" \ No newline at end of file diff --git a/scripts/checks/architecture/tests/test_architecture_checker.py b/scripts/checks/architecture/tests/test_architecture_checker.py deleted file mode 100644 index b7738363c..000000000 --- a/scripts/checks/architecture/tests/test_architecture_checker.py +++ /dev/null @@ -1,462 +0,0 @@ -""" -Tests for architecture boundary checking functionality. - -Tests subsystem boundaries, domain rules, import patterns, -and complexity rules enforcement. -""" - -import pytest -from pathlib import Path - -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..')) - -from utils.test_helpers import ( - create_test_project, - run_checker, - assert_no_false_positives, - assert_checker_finds_issues -) -from architecture.checker import ArchitectureChecker -from architecture.models import ErrorType - - -class TestArchitectureChecker: - """Test suite for architecture boundary checking.""" - - def test_valid_project_structure(self): - """Test that a well-structured project passes all checks.""" - files = { - "src/app/page.tsx": """ -import { Button } from '~/components/ui/button'; -import { getUsers } from '~/lib/domains/auth/services'; - -export default function HomePage() { - return <Button>Home</Button>; -} - """.strip(), - "src/components/ui/button.tsx": """ -export function Button({ children }: { children: React.ReactNode }) { - return <button>{children}</button>; -} - """.strip(), - "src/lib/domains/auth/services.ts": """ -export function getUsers() { - return []; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Should not have any errors for a well-structured project - assert len(results.errors) == 0, f"Found unexpected errors: {[e.message for e in results.errors]}" - - def test_subsystem_boundary_violations(self): - """Test detection of subsystem boundary violations.""" - files = { - "src/app/admin/page.tsx": """ -// VIOLATION: app/admin importing from app/user directly -import { UserComponent } from '../../user/components/UserComponent'; - -export default function AdminPage() { - return <UserComponent />; -} - """.strip(), - "src/app/user/components/UserComponent.tsx": """ -export function UserComponent() { - return <div>User</div>; -} - """.strip(), - "src/app/shared/components/Layout.tsx": """ -export function Layout({ children }: { children: React.ReactNode }) { - return <div>{children}</div>; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Should find boundary violation - boundary_errors = [e for e in results.errors if e.error_type == ErrorType.SUBSYSTEM_BOUNDARY] - assert len(boundary_errors) > 0, "Should detect subsystem boundary violation" - - def test_domain_isolation_rules(self): - """Test that domain boundaries are enforced.""" - files = { - "src/lib/domains/auth/services.ts": """ -// VIOLATION: auth domain importing directly from mapping domain -import { getMapItem } from '../mapping/services/item-crud'; - -export function getUserWithMap() { - return getMapItem(); -} - """.strip(), - "src/lib/domains/mapping/services/item-crud.ts": """ -export function getMapItem() { - return { id: 1 }; -} - """.strip(), - "src/lib/domains/shared/types.ts": """ -export interface BaseEntity { - id: number; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Should find domain boundary violation - domain_errors = [e for e in results.errors if e.error_type == ErrorType.DOMAIN_BOUNDARY] - assert len(domain_errors) > 0, "Should detect domain boundary violation" - - def test_import_pattern_enforcement(self): - """Test enforcement of absolute vs relative import rules.""" - files = { - "src/app/components/Header.tsx": """ -// VIOLATION: Should use absolute imports with ~/ -import { Button } from '../../../components/ui/button'; -import { utils } from '../../lib/utils'; - -export function Header() { - return <Button>Header</Button>; -} - """.strip(), - "src/components/ui/button.tsx": """ -export function Button({ children }: { children: React.ReactNode }) { - return <button>{children}</button>; -} - """.strip(), - "src/lib/utils.ts": """ -export const utils = {}; - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Should find import pattern violations - import_errors = [e for e in results.errors if e.error_type == ErrorType.IMPORT_PATTERN] - assert len(import_errors) > 0, "Should detect relative import violations" - - def test_rule_of_6_violations(self): - """Test detection of Rule of 6 complexity violations.""" - files = { - "src/utils/complex.ts": """ -// VIOLATION: Too many functions in one file -export function func1() {} -export function func2() {} -export function func3() {} -export function func4() {} -export function func5() {} -export function func6() {} -export function func7() {} // 7th function violates Rule of 6 -export function func8() {} - """.strip(), - "src/utils/large-function.ts": f""" -export function tooManyArgs( - arg1: string, - arg2: number, - arg3: boolean, - arg4: object, - arg5: any, - arg6: unknown, - arg7: never // 7th argument violates Rule of 6 -) {{ - // Very long function body - {chr(10).join([' console.log("line");'] * 60)} // 60+ lines violates Rule of 6 -}} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Should find complexity violations - complexity_errors = [e for e in results.errors if e.error_type == ErrorType.COMPLEXITY] - assert len(complexity_errors) > 0, "Should detect Rule of 6 violations" - - def test_valid_shared_imports(self): - """Test that imports from shared modules are allowed.""" - files = { - "src/app/admin/page.tsx": """ -import { Layout } from '~/app/shared/components/Layout'; -import { formatDate } from '~/lib/shared/utils'; - -export default function AdminPage() { - return <Layout>Admin {formatDate(new Date())}</Layout>; -} - """.strip(), - "src/app/user/page.tsx": """ -import { Layout } from '~/app/shared/components/Layout'; -import { formatDate } from '~/lib/shared/utils'; - -export default function UserPage() { - return <Layout>User {formatDate(new Date())}</Layout>; -} - """.strip(), - "src/app/shared/components/Layout.tsx": """ -export function Layout({ children }: { children: React.ReactNode }) { - return <div className="layout">{children}</div>; -} - """.strip(), - "src/lib/shared/utils.ts": """ -export function formatDate(date: Date): string { - return date.toISOString().split('T')[0]; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Should allow shared imports - assert len(results.errors) == 0, f"Shared imports should be allowed: {[e.message for e in results.errors]}" - - def test_external_library_imports_allowed(self): - """Test that imports from external libraries are always allowed.""" - files = { - "src/app/page.tsx": """ -import React from 'react'; -import { NextPage } from 'next'; -import { Button } from '@radix-ui/react-button'; -import { cn } from 'clsx'; -import { format } from 'date-fns'; - -export const HomePage: NextPage = () => { - return ( - <div> - <Button className={cn('button')}> - {format(new Date(), 'yyyy-MM-dd')} - </Button> - </div> - ); -}; - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # External imports should always be allowed - assert len(results.errors) == 0, f"External imports should be allowed: {[e.message for e in results.errors]}" - - def test_nested_subsystem_structure(self): - """Test handling of deeply nested subsystem structures.""" - files = { - "src/app/admin/users/components/UserList.tsx": """ -import { UserCard } from './UserCard'; -import { useUsers } from '../hooks/useUsers'; - -export function UserList() { - const users = useUsers(); - return ( - <div> - {users.map(user => <UserCard key={user.id} user={user} />)} - </div> - ); -} - """.strip(), - "src/app/admin/users/components/UserCard.tsx": """ -export function UserCard({ user }: { user: any }) { - return <div>{user.name}</div>; -} - """.strip(), - "src/app/admin/users/hooks/useUsers.ts": """ -export function useUsers() { - return []; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Nested subsystem imports should be allowed - assert len(results.errors) == 0, f"Nested subsystem structure should be valid: {[e.message for e in results.errors]}" - - def test_barrel_file_patterns(self): - """Test handling of barrel file export patterns.""" - files = { - "src/components/ui/index.ts": """ -export { Button } from './button'; -export { Input } from './input'; -export { Select } from './select'; -export type { ButtonProps } from './button'; - """.strip(), - "src/components/ui/button.tsx": """ -export interface ButtonProps { - children: React.ReactNode; -} - -export function Button({ children }: ButtonProps) { - return <button>{children}</button>; -} - """.strip(), - "src/app/page.tsx": """ -import { Button, Input } from '~/components/ui'; - -export default function Page() { - return ( - <div> - <Button>Click me</Button> - <Input /> - </div> - ); -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Barrel file patterns should be allowed - assert len(results.errors) == 0, f"Barrel file patterns should be valid: {[e.message for e in results.errors]}" - - -class TestArchitectureIntegration: - """Integration tests for architecture checker with real scenarios.""" - - def test_hexframe_like_structure(self): - """Test with a structure similar to the actual Hexframe codebase.""" - files = { - "src/app/map/Chat/Timeline/Widgets/LoginWidget/login-widget.tsx": """ -import { useState } from 'react'; -import { User } from 'lucide-react'; -import { FormFields } from '~/app/map/Chat/Timeline/Widgets/LoginWidget/FormFields'; -import { BaseWidget, WidgetHeader } from '~/app/map/Chat/Timeline/Widgets/_shared'; - -export function LoginWidget() { - const [isCollapsed, setIsCollapsed] = useState(false); - return ( - <BaseWidget> - <WidgetHeader icon={<User />} title="Login" /> - </BaseWidget> - ); -} - """.strip(), - "src/app/map/Chat/Timeline/Widgets/_shared/BaseWidget.tsx": """ -export function BaseWidget({ children }: { children: React.ReactNode }) { - return <div className="widget">{children}</div>; -} - """.strip(), - "src/app/map/Chat/Timeline/Widgets/_shared/WidgetHeader.tsx": """ -export function WidgetHeader({ icon, title }: { icon: React.ReactNode; title: string }) { - return <div className="header">{icon} {title}</div>; -} - """.strip(), - "src/app/map/Chat/Timeline/Widgets/_shared/index.ts": """ -export { BaseWidget } from './BaseWidget'; -export { WidgetHeader } from './WidgetHeader'; - """.strip(), - "src/lib/domains/mapping/services/item-crud.ts": """ -export function createMapItem() { - return { id: 1 }; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Hexframe-like structure should be valid - assert len(results.errors) == 0, f"Hexframe structure should be valid: {[e.message for e in results.errors]}" - - def test_mixed_violations_in_large_project(self): - """Test detection of multiple violation types in a larger project.""" - files = { - # Valid files - "src/app/dashboard/page.tsx": """ -import { Layout } from '~/app/shared/components/Layout'; -export default function Dashboard() { return <Layout>Dashboard</Layout>; } - """.strip(), - - # Subsystem boundary violation - "src/app/admin/page.tsx": """ -import { UserProfile } from '../../user/components/Profile'; // VIOLATION -export default function Admin() { return <UserProfile />; } - """.strip(), - - # Domain boundary violation - "src/lib/domains/auth/services.ts": """ -import { getMapData } from '../mapping/internal-service'; // VIOLATION -export function authWithMap() { return getMapData(); } - """.strip(), - - # Import pattern violation - "src/components/Header.tsx": """ -import { utils } from '../lib/utils'; // VIOLATION: should use ~/ -export function Header() { return <div>{utils.format()}</div>; } - """.strip(), - - # Rule of 6 violation - "src/utils/helpers.ts": f""" -export function func1() {{}} -export function func2() {{}} -export function func3() {{}} -export function func4() {{}} -export function func5() {{}} -export function func6() {{}} -export function func7() {{}} // VIOLATION: 7th function - """.strip(), - - # Supporting files - "src/app/shared/components/Layout.tsx": "export function Layout({ children }: any) { return <div>{children}</div>; }", - "src/app/user/components/Profile.tsx": "export function UserProfile() { return <div>Profile</div>; }", - "src/lib/domains/mapping/internal-service.ts": "export function getMapData() { return {}; }", - "src/lib/utils.ts": "export const utils = { format: () => 'formatted' };" - } - - with create_test_project(files) as project_path: - results = run_checker('architecture', project_path / 'src') - - # Should find multiple types of violations - error_types = {error.error_type for error in results.errors} - - expected_types = { - ErrorType.SUBSYSTEM_BOUNDARY, - ErrorType.DOMAIN_BOUNDARY, - ErrorType.IMPORT_PATTERN, - ErrorType.COMPLEXITY - } - - found_types = expected_types & error_types - assert len(found_types) >= 2, f"Should find multiple violation types, found: {error_types}" - - def test_performance_on_large_codebase(self): - """Test architecture checker performance on a large codebase.""" - # Generate a large project structure - files = {} - - # Create many subsystems - for subsystem in ['admin', 'user', 'dashboard', 'reports', 'settings']: - for i in range(10): # 10 files per subsystem - files[f"src/app/{subsystem}/components/Component{i}.tsx"] = f""" -import {{ utils }} from '~/lib/shared/utils'; -export function Component{i}() {{ return <div>{{utils.format()}}</div>; }} - """.strip() - - # Create domain files - for domain in ['auth', 'mapping', 'analytics', 'notifications']: - for i in range(5): # 5 files per domain - files[f"src/lib/domains/{domain}/services/service{i}.ts"] = f""" -export function service{i}Function() {{ return 'service{i}'; }} - """.strip() - - # Add shared utilities - files["src/lib/shared/utils.ts"] = "export const utils = { format: () => 'formatted' };" - - with create_test_project(files) as project_path: - try: - results = run_checker('architecture', project_path / 'src') - - # Should complete without timeout - assert isinstance(results.errors, list) - assert isinstance(results.warnings, list) - - except Exception as e: - pytest.fail(f"Architecture checker failed on large codebase: {e}") \ No newline at end of file diff --git a/scripts/checks/architecture/utils/__init__.py b/scripts/checks/architecture/utils/__init__.py deleted file mode 100644 index 6e283eaa7..000000000 --- a/scripts/checks/architecture/utils/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Utility modules for architecture checking.""" - -from .file_utils import FileCache, count_typescript_lines, get_file_content -from .import_utils import extract_imports, resolve_inheritance_chain -from .path_utils import PathHelper -from .exception_handler import ExceptionHandler, ArchitectureException - -__all__ = [ - "FileCache", - "count_typescript_lines", - "get_file_content", - "extract_imports", - "resolve_inheritance_chain", - "PathHelper", - "ExceptionHandler", - "ArchitectureException" -] \ No newline at end of file diff --git a/scripts/checks/architecture/utils/exception_handler.py b/scripts/checks/architecture/utils/exception_handler.py deleted file mode 100644 index eb9fb951b..000000000 --- a/scripts/checks/architecture/utils/exception_handler.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -""" -Exception handling for architecture checker. - -Handles parsing and validation of .architecture-exceptions files. -""" - -import re -from pathlib import Path -from typing import Dict, Tuple, Optional - - -class ArchitectureException: - """Represents a single architecture exception.""" - - def __init__(self, path: str, threshold: int, justification: str, source_file: Path): - self.path = path - self.threshold = threshold - self.justification = justification - self.source_file = source_file - - def __repr__(self): - return f"ArchitectureException({self.path}, {self.threshold}, '{self.justification[:50]}...')" - - -class ExceptionHandler: - """Handles loading and applying architecture exceptions.""" - - def __init__(self, project_root: Path): - self.project_root = project_root - self._exception_cache: Dict[Path, Dict[str, ArchitectureException]] = {} - - def get_custom_threshold(self, target_path: Path) -> Optional[ArchitectureException]: - """ - Get custom threshold for a path by checking exception files. - - Walks up the directory tree looking for .architecture-exceptions files. - Returns the most specific (closest) exception that matches the path. - """ - current_path = target_path.resolve() - - # Make sure target path is relative to project root - try: - target_relative = str(current_path.relative_to(self.project_root)) - except ValueError: - # Path is not under project root, return None - return None - - # Walk up the directory tree (including the project root) - while current_path.parent != current_path: - exception_file = current_path / ".architecture-exceptions" - - if exception_file.exists(): - exceptions = self._load_exception_file(exception_file) - - # Check for exact match first - if target_relative in exceptions: - return exceptions[target_relative] - - # Check for pattern matches (most specific first) - for exception_path, exception in sorted(exceptions.items(), - key=lambda x: len(x[0]), - reverse=True): - if self._path_matches_pattern(target_relative, exception_path): - return exception - - # Stop after checking project root - if current_path == self.project_root: - break - - current_path = current_path.parent - - return None - - def _load_exception_file(self, exception_file: Path) -> Dict[str, ArchitectureException]: - """Load and parse exception file with validation.""" - if exception_file in self._exception_cache: - return self._exception_cache[exception_file] - - exceptions = {} - - try: - with open(exception_file, 'r', encoding='utf-8') as f: - for line_num, line in enumerate(f, 1): - line = line.strip() - - # Skip empty lines and comments - if not line or line.startswith('#'): - continue - - try: - path, threshold, justification = self._parse_exception_line(line, line_num) - - # Validate path exists (relative to project root) - full_path = self.project_root / path - if not full_path.exists(): - print(f"Warning: Exception path {path} does not exist (in {exception_file}:{line_num})") - - # Validate justification exists - if not justification or len(justification.strip()) < 10: - print(f"Warning: Insufficient justification for {path} exception (in {exception_file}:{line_num})") - - exception = ArchitectureException( - path=path, - threshold=threshold, - justification=justification, - source_file=exception_file - ) - - exceptions[path] = exception - - except ValueError as e: - print(f"Error parsing {exception_file}:{line_num}: {e}") - continue - - except (OSError, UnicodeDecodeError) as e: - print(f"Error reading exception file {exception_file}: {e}") - return {} - - self._exception_cache[exception_file] = exceptions - return exceptions - - def _parse_exception_line(self, line: str, line_num: int) -> Tuple[str, int, str]: - """Parse a single exception line into components.""" - # Expected format: path: threshold # justification - if ':' not in line: - raise ValueError(f"Missing ':' separator") - - if '#' not in line: - raise ValueError(f"Missing '#' separator for justification") - - # Split on first ':' and first '#' after that - path_part, rest = line.split(':', 1) - - if '#' not in rest: - raise ValueError(f"Missing justification after threshold") - - threshold_part, justification_part = rest.split('#', 1) - - # Clean up parts - path = path_part.strip() - threshold_str = threshold_part.strip() - justification = justification_part.strip() - - # Validate path format - if not path: - raise ValueError(f"Empty path") - - # Ensure path is relative - if path.startswith('/'): - raise ValueError(f"Path must be relative: {path}") - - # Validate threshold - try: - threshold = int(threshold_str) - if threshold <= 0: - raise ValueError(f"Threshold must be positive: {threshold}") - except ValueError: - raise ValueError(f"Invalid threshold value: {threshold_str}") - - # Validate justification - if not justification: - raise ValueError(f"Empty justification") - - return path, threshold, justification - - def _path_matches_pattern(self, target_path: str, pattern: str) -> bool: - """ - Check if target path matches exception pattern. - - Currently supports exact matching. Could be extended for glob patterns. - """ - # For now, just do exact matching - # Could be extended to support glob patterns like src/legacy-* - return target_path == pattern - - def get_exception_info_for_reporting(self, target_path: Path) -> Optional[Dict]: - """Get exception information for reporting purposes.""" - exception = self.get_custom_threshold(target_path) - - if not exception: - return None - - return { - "custom_threshold": exception.threshold, - "exception_source": str(exception.source_file.relative_to(self.project_root)), - "justification": exception.justification - } \ No newline at end of file diff --git a/scripts/checks/architecture/utils/file_utils.py b/scripts/checks/architecture/utils/file_utils.py deleted file mode 100644 index 2b09a3053..000000000 --- a/scripts/checks/architecture/utils/file_utils.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -""" -File utility functions for architecture checking. - -Handles file reading, caching, and line counting operations. -""" - -import json -from pathlib import Path -from typing import Dict, Set - -from ..models import FileInfo - - -class FileCache: - """Caches file information for performance.""" - - def __init__(self): - self.file_cache: Dict[Path, FileInfo] = {} - self.dependency_cache: Dict[Path, Dict] = {} - - def get_file_info(self, file_path: Path) -> FileInfo: - """Get cached file info or load and cache it.""" - if file_path in self.file_cache: - return self.file_cache[file_path] - - content = get_file_content(file_path) - from .import_utils import extract_imports - - file_info = FileInfo( - path=file_path, - lines=content.count('\n') + 1 if content else 0, - content=content, - imports=extract_imports(content) - ) - self.file_cache[file_path] = file_info - return file_info - - def load_dependencies_json(self, deps_file: Path) -> Dict: - """Load dependencies.json with caching.""" - if deps_file in self.dependency_cache: - return self.dependency_cache[deps_file] - - try: - with open(deps_file) as f: - deps = json.load(f) - self.dependency_cache[deps_file] = deps - return deps - except (json.JSONDecodeError, OSError): - return {} - - -def get_file_content(file_path: Path) -> str: - """Get file content with error handling.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - return f.read() - except (UnicodeDecodeError, OSError): - return "" - - -def get_file_lines(file_path: Path) -> int: - """Get line count for a file.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - return sum(1 for _ in f) - except (UnicodeDecodeError, OSError): - return 0 - - -def is_test_file(file_path: Path) -> bool: - """Check if file is a test file.""" - name = file_path.name - return ".test." in name or ".spec." in name or "/__tests__/" in str(file_path) - - -def count_typescript_lines(directory: Path) -> int: - """Count TypeScript lines in directory, respecting subsystem boundaries and excluding documentation files.""" - if not directory.exists(): - return 0 - - total = 0 - - # Count direct files in this directory - for file in directory.glob("*.ts"): - if not is_test_file(file) and not is_documentation_file(file): - total += get_file_lines(file) - - for file in directory.glob("*.tsx"): - if not is_test_file(file) and not is_documentation_file(file): - total += get_file_lines(file) - - # Count subdirectories if they're not subsystems - for subdir in directory.iterdir(): - if subdir.is_dir(): - deps_file = subdir / "dependencies.json" - if not deps_file.exists(): - # Not a subsystem, count recursively - total += count_typescript_lines(subdir) - - return total - - -def is_documentation_file(file_path: Path) -> bool: - """Check if file is a documentation/metadata file that shouldn't count toward complexity.""" - name = file_path.name.lower() - return name in ["readme.md", "architecture.md", "dependencies.json"] - - -def find_typescript_files(directory: Path) -> list[Path]: - """Find all TypeScript files in directory tree.""" - files = [] - - for pattern in ["*.ts", "*.tsx"]: - for ts_file in directory.rglob(pattern): - if not is_test_file(ts_file): - files.append(ts_file) - - return files \ No newline at end of file diff --git a/scripts/checks/architecture/utils/import_utils.py b/scripts/checks/architecture/utils/import_utils.py deleted file mode 100644 index 8d8cd9b94..000000000 --- a/scripts/checks/architecture/utils/import_utils.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -""" -Import utility functions for architecture checking. - -Handles import parsing and dependency resolution. -""" - -import re -from pathlib import Path -from typing import List, Set -import sys -import os - -from ..models import SubsystemInfo - -# Import shared TypeScript parser -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) -from shared.typescript_parser import TypeScriptParser - -# Create a shared parser instance -_parser = TypeScriptParser() - - -def extract_imports(content: str) -> List[str]: - """Extract import paths from TypeScript content using shared parser.""" - return _parser.extract_import_paths(content) - - -def resolve_inheritance_chain(subsystem: SubsystemInfo, file_cache) -> List[str]: - """Resolve full inheritance chain including ancestor subsystems and allowedChildren.""" - inherited = [] - current_dir = subsystem.path.parent - - # Walk up the directory tree to find all parents with dependencies.json - # Stop at src/ directory level - src_dir = Path("src") - while (current_dir and current_dir != src_dir and - current_dir != Path(".") and current_dir != Path("/")): - deps_file = current_dir / "dependencies.json" - - if deps_file.exists(): - # Add the ancestor subsystem itself (automatic inheritance) - ancestor_abs_path = f"~/{current_dir.relative_to(Path('src'))}" - inherited.append(ancestor_abs_path) - - # Also inherit allowedChildren from ancestors - deps = file_cache.load_dependencies_json(deps_file) - allowed_children = deps.get("allowedChildren", []) - inherited.extend(allowed_children) - - parent = current_dir.parent - if parent == current_dir: # Reached root - break - current_dir = parent - - return inherited - - -def get_ancestor_subsystems(subsystem: SubsystemInfo, file_cache) -> List[str]: - """Get list of ancestor subsystem paths (without allowedChildren).""" - ancestors = [] - current_dir = subsystem.path.parent - - # Walk up the directory tree to find all parents with dependencies.json - src_dir = Path("src") - while (current_dir and current_dir != src_dir and - current_dir != Path(".") and current_dir != Path("/")): - deps_file = current_dir / "dependencies.json" - - if deps_file.exists(): - ancestor_abs_path = f"~/{current_dir.relative_to(Path('src'))}" - ancestors.append(ancestor_abs_path) - - parent = current_dir.parent - if parent == current_dir: # Reached root - break - current_dir = parent - - return ancestors - - -def find_redundant_ancestor_declarations(subsystem: SubsystemInfo, file_cache) -> List[str]: - """Find explicitly declared ancestors that are redundant (auto-inherited).""" - ancestor_paths = get_ancestor_subsystems(subsystem, file_cache) - allowed_deps = subsystem.dependencies.get("allowed", []) - - redundant = [] - for allowed_dep in allowed_deps: - if allowed_dep in ancestor_paths: - redundant.append(allowed_dep) - - return redundant - - -def is_child_of_subsystem(file_path: Path, parent_subsystem: SubsystemInfo, - subsystem_cache: dict) -> bool: - """Check if a file is in a child subsystem of the given parent.""" - for child_subsystem in subsystem_cache.values(): - if child_subsystem.parent_path == parent_subsystem.path: - if str(child_subsystem.path) in str(file_path): - return True - return False - - -def is_same_domain_hierarchical_import(import_path: str, subsystem_path: Path) -> bool: - """Check if import is a hierarchical import within the same domain.""" - if not import_path.startswith("~/lib/domains/"): - return False - - # Extract domain from import path: ~/lib/domains/DOMAIN/... - import_parts = import_path.split('/') - if len(import_parts) < 4: - return False - import_domain = import_parts[3] # domains/DOMAIN - - # Extract domain from subsystem path - subsystem_str = str(subsystem_path) - if "/lib/domains/" not in subsystem_str: - return False - - subsystem_parts = subsystem_str.split('/lib/domains/')[-1].split('/') - subsystem_domain = subsystem_parts[0] - - # Same domain = hierarchical import allowed - return import_domain == subsystem_domain - - -def import_goes_into_subsystem(import_path: str) -> bool: - """Check if an import path goes into a declared subsystem (bypassing its interface).""" - if not import_path.startswith("~/"): - return False - - # Convert to file system path - fs_path = Path("src") / import_path[2:] - - # Walk up the path to find if we're importing INTO a subsystem - current = fs_path - while current and current != Path("src") and current != Path("."): - if (current / "dependencies.json").exists(): - # This directory is a subsystem - subsystem_abs_path = f"~/{current.relative_to(Path('src'))}" - - # If we're importing deeper than the subsystem root, it's going INTO the subsystem - if (import_path.startswith(f"{subsystem_abs_path}/") and - import_path != subsystem_abs_path): - return True - break - current = current.parent - - return False - - -def is_import_allowed_by_set(import_path: str, allowed_set: Set[str], - subsystem_path: Path) -> bool: - """Check if import is allowed by a set of allowed dependencies with proper hierarchical logic.""" - # Convert subsystem_path to absolute path format for internal import checking - # Handle special case where subsystem is src itself - if subsystem_path == Path('src'): - subsystem_abs_path = "~" - else: - subsystem_abs_path = f"~/{subsystem_path.relative_to(Path('src'))}" - - # Allow internal imports within the same subsystem - if (import_path.startswith(f"{subsystem_abs_path}/") or - import_path == subsystem_abs_path): - return True - - # Always allow domain utils imports (implicitly allowed) - if "/lib/domains/" in import_path and "/utils" in import_path: - # Check if it's a domain utils import: ~/lib/domains/{domain}/utils or ~/lib/domains/{domain}/utils/* - import re - utils_pattern = r"~/lib/domains/[^/]+/utils(?:/.*)?$" - if re.match(utils_pattern, import_path): - return True - - # Check direct matches and hierarchical matches - for allowed_dep in allowed_set: - if not allowed_dep: - continue - - # Direct match - if import_path == allowed_dep: - return True - - # Hierarchical match: if ~/lib/utils is allowed, allow ~/lib/utils/something - # Also handle patterns ending with / (like ~/components/ui/) - allowed_dep_normalized = allowed_dep.rstrip('/') - - if (import_path.startswith(f"{allowed_dep_normalized}/") or - (allowed_dep.endswith('/') and import_path.startswith(allowed_dep))): - # Extract the child path - prefix = allowed_dep if allowed_dep.endswith('/') else f"{allowed_dep_normalized}/" - child_path = import_path[len(prefix):] - - if not child_path: # Empty child path means exact match - return True - - # Convert ~/path to src/path for file system checking - if allowed_dep_normalized.startswith("~/"): - potential_subsystem_path = Path("src") / allowed_dep_normalized[2:] / child_path - else: - potential_subsystem_path = Path(allowed_dep_normalized) / child_path - - # CRITICAL: If trying to import INTO a declared subsystem, must use subsystem interface - # Even with broad permissions, subsystem boundaries are protected - # BUT: Allow imports within the same domain hierarchy AND within current allowed hierarchy - if import_goes_into_subsystem(import_path): - # Allow if it's within the current subsystem's hierarchy - if import_path.startswith(f"{subsystem_abs_path}/"): - # This is within our current subsystem, allow it - pass - # Allow if both the import target and current subsystem are within the same allowed hierarchy - elif import_path.startswith(f"{allowed_dep_normalized}/") and subsystem_abs_path.startswith(f"{allowed_dep_normalized}/"): - # Both are within the same allowed parent hierarchy, allow it - pass - # Check if this is a cross-domain import (not allowed) - # or same-domain hierarchical import (allowed) - elif not is_same_domain_hierarchical_import(import_path, subsystem_path): - continue # Blocked: must use subsystem interface - - # If child is a subsystem (has dependencies.json), require explicit permission - # BUT only for grandchildren, not direct children - if (potential_subsystem_path / "dependencies.json").exists(): - # Check if this is a direct child or a deeper nesting - slash_count = child_path.count('/') - if slash_count > 0: # This is a grandchild or deeper, block it - continue # Child is subsystem, needs explicit permission - - # Otherwise, hierarchy allows it - return True - - return False \ No newline at end of file diff --git a/scripts/checks/architecture/utils/path_utils.py b/scripts/checks/architecture/utils/path_utils.py deleted file mode 100644 index eda5bdcad..000000000 --- a/scripts/checks/architecture/utils/path_utils.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -""" -Path utility functions for architecture checking. - -Handles path manipulation and exception checking. -""" - -from pathlib import Path -from typing import Set - - -class PathHelper: - """Helper class for path-related operations.""" - - def __init__(self, target_path: str = "src"): - self.target_path = Path(target_path) - self.rule_exceptions: Set[str] = set() - self.traversal_exceptions: Set[str] = set() - self._load_exceptions() - - def _load_exceptions(self) -> None: - """Load architecture exceptions from .architecture-ignore file.""" - ignore_file = Path(".architecture-ignore") - if ignore_file.exists(): - with open(ignore_file) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - # Most exceptions are rule exceptions (exempt from architecture rules) - self.rule_exceptions.add(line) - # Only add specific patterns to traversal exceptions - if ("node_modules" in line or "__tests__" in line or - "__fixtures__" in line or "__mocks__" in line): - self.traversal_exceptions.add(line) - else: - self.rule_exceptions.add("src/components") - - def is_traversal_exception(self, path: Path) -> bool: - """Check if path should be skipped entirely during traversal.""" - path_str = str(path) - return any(path_str.startswith(exc) for exc in self.traversal_exceptions) - - def is_rule_exception(self, path: Path) -> bool: - """Check if path is exempt from architecture rules.""" - path_str = str(path) - - for exc in self.rule_exceptions: - # Exact match for simple paths - if path_str == exc: - return True - # Pattern matching for /** patterns - elif exc.endswith('/**') and path_str.startswith(exc[:-3] + '/'): - return True - # Pattern matching for /** in the middle or other glob patterns - elif '**' in exc: - # This is a pattern, we could implement more sophisticated matching if needed - # For now, just do prefix matching for ** patterns - if exc.endswith('**') and path_str.startswith(exc[:-2]): - return True - - return False - - def is_domain_path(self, path: Path) -> bool: - """Check if path is in a domain.""" - return str(path).startswith("src/lib/domains/") - - def get_directories_to_check(self) -> list[Path]: - """Get all directories that should be checked for complexity requirements.""" - directories_to_check = [self.target_path] - - # Then add all subdirectories, but skip traversal exceptions - for d in self.target_path.rglob("*"): - if d.is_dir() and not self.is_traversal_exception(d): - directories_to_check.append(d) - - return directories_to_check - - def find_dependencies_files(self) -> list[Path]: - """Find all dependencies.json files in target path.""" - deps_files = [] - for deps_file in self.target_path.rglob("dependencies.json"): - if "node_modules" not in str(deps_file): - deps_files.append(deps_file) - return deps_files \ No newline at end of file diff --git a/scripts/checks/deadcode/README.md b/scripts/checks/deadcode/README.md deleted file mode 100644 index 252e89724..000000000 --- a/scripts/checks/deadcode/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Dead Code Checker - -## Why Remove Dead Code? - -Dead code increases bundle size, maintenance burden, and cognitive load. It can hide bugs, mislead developers about actual dependencies, and slow down refactoring efforts. - -## How Dead Code is Identified - -This script uses regex-based AST parsing to detect: - -1. **Unused Exports** - Exported symbols never imported elsewhere -2. **Unused Local Symbols** - Functions/variables defined but never used -3. **Dead Files** - Files where all exports are unused -4. **Dead Folders** - Directories where all files are dead -5. **Transitive Dead Code** - Code only used by other dead code - -The checker analyzes all files in `src/` to build a complete dependency graph, then reports issues only for the target path. - -## Usage - -```bash -pnpm check:dead-code [path] # Check specific path -python3 scripts/checks/deadcode/main.py --help -``` - -Output: Console summary + `test-results/dead-code-check.json` - -## AI-Friendly Commands - -```bash -# Get all issues -jq '.issues[]' test-results/dead-code-check.json - -# Get by type -jq '.issues[] | select(.type == "unused_export")' test-results/dead-code-check.json - -# Get file locations -jq -r '.issues[] | "\(.file_path):\(.line_number) \(.message)"' test-results/dead-code-check.json - -# Get summary stats -jq '.summary' test-results/dead-code-check.json -``` - -## Important: False Positives - -**Always review before removing code.** Dead code detection can have false positives: -- Dynamic imports or reflection patterns -- Framework conventions (Next.js pages) -- Build-time or test-only usage -- Type definitions used only for constraints - -**Best Practice:** Make dead code removal a dedicated commit to isolate changes and enable easy reversal if issues arise. - -## Configuration - -Create `.deadcode-ignore` to exclude patterns: -``` -src/env.mjs -**/*.test.* -**/*.stories.* -``` \ No newline at end of file diff --git a/scripts/checks/deadcode/__init__.py b/scripts/checks/deadcode/__init__.py deleted file mode 100644 index e35155a39..000000000 --- a/scripts/checks/deadcode/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Dead code checking module. - -Provides tools for detecting unused exports, imports, and symbols in TypeScript/JavaScript codebases. -""" - -from .checker import DeadCodeChecker -from .reporter import DeadCodeReporter -from .models import CheckResults, DeadCodeIssue, DeadCodeType, Severity - -__all__ = [ - "DeadCodeChecker", - "DeadCodeReporter", - "CheckResults", - "DeadCodeIssue", - "DeadCodeType", - "Severity" -] \ No newline at end of file diff --git a/scripts/checks/deadcode/checker.py b/scripts/checks/deadcode/checker.py deleted file mode 100644 index 421a33977..000000000 --- a/scripts/checks/deadcode/checker.py +++ /dev/null @@ -1,1417 +0,0 @@ -#!/usr/bin/env python3 -""" -Dead code checking logic. - -Detects unused exports, imports, functions, and variables in TypeScript/JavaScript codebases. -""" - -import os -import re -import time -from pathlib import Path -from typing import Dict, List, Set, Optional, Tuple -from concurrent.futures import ThreadPoolExecutor, as_completed -from collections import defaultdict, deque - -from .models import ( - CheckResults, DeadCodeIssue, DeadCodeType, Severity, - FileAnalysis -) - -# Import shared TypeScript parser -import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from shared.typescript_parser import TypeScriptParser, Import, Export, Symbol - - -class DependencyGraph: - """Tracks dependencies between symbols for transitive dead code detection.""" - - def __init__(self): - # symbol_key -> set of symbol_keys it depends on - self.dependencies: Dict[str, Set[str]] = defaultdict(set) - # symbol_key -> set of symbol_keys that depend on it - self.dependents: Dict[str, Set[str]] = defaultdict(set) - # Track all symbols - self.all_symbols: Set[str] = set() - # Track which symbols are exports - self.exported_symbols: Set[str] = set() - # Track dead symbols - self.dead_symbols: Set[str] = set() - # Track transitively dead symbols - self.transitively_dead: Set[str] = set() - - def add_dependency(self, from_symbol: str, to_symbol: str) -> None: - """Add a dependency relationship.""" - self.dependencies[from_symbol].add(to_symbol) - self.dependents[to_symbol].add(from_symbol) - self.all_symbols.add(from_symbol) - self.all_symbols.add(to_symbol) - - def mark_as_export(self, symbol: str) -> None: - """Mark a symbol as an export.""" - self.exported_symbols.add(symbol) - self.all_symbols.add(symbol) - - def mark_as_dead(self, symbol: str) -> None: - """Mark a symbol as dead code.""" - self.dead_symbols.add(symbol) - - def find_transitive_dead_code(self) -> None: - """Find all transitively dead code.""" - # Multiple passes to find transitively dead code - changed = True - passes = 0 - - while changed and passes < 10: # Limit passes to avoid infinite loops - changed = False - passes += 1 - - # Check each symbol that isn't already marked as dead - for symbol in list(self.all_symbols): - if symbol in self.dead_symbols or symbol in self.transitively_dead: - continue - - # Check if this symbol is only used by dead code - dependents = self.dependents.get(symbol, set()) - - # If it has no dependents and is an export, it might be directly dead (already handled) - if not dependents: - continue - - # Check if ALL symbols that depend on this are dead - all_dependents_dead = True - for dependent in dependents: - if dependent not in self.dead_symbols and dependent not in self.transitively_dead: - # Special case: file-level dependencies - check if the file has any live exports - if dependent.endswith(':__file__'): - # Extract file path from dependent - file_path = dependent[:-9] # Remove ':__file__' - # Check if this file has any non-dead exports - has_live_exports = False - for other_symbol in self.exported_symbols: - if other_symbol.startswith(file_path + ':'): - if other_symbol not in self.dead_symbols and other_symbol not in self.transitively_dead: - has_live_exports = True - break - if has_live_exports: - all_dependents_dead = False - break - else: - all_dependents_dead = False - break - - if all_dependents_dead and dependents: - # This symbol is only used by dead code - self.transitively_dead.add(symbol) - changed = True - - def count_dead_chain(self, symbol: str) -> int: - """Count total symbols in a dead code chain.""" - visited = set() - queue = deque([symbol]) - count = 0 - - while queue: - current = queue.popleft() - if current in visited: - continue - visited.add(current) - count += 1 - - # Add all symbols that only this symbol uses - for dep in self.dependencies.get(current, set()): - if dep in self.dead_symbols or dep in self.transitively_dead: - queue.append(dep) - - return count - - -class DeadCodeChecker: - """Detects dead code in TypeScript/JavaScript codebases.""" - - def __init__(self, target_path: str = "src"): - self.target_path = Path(target_path) - self.src_path = Path("src") # Always analyze full src - self.file_cache: Dict[Path, FileAnalysis] = {} - self.export_map: Dict[str, List[Export]] = defaultdict(list) - self.import_map: Dict[str, List[Import]] = defaultdict(list) - self.exceptions: Set[str] = set() - self.dependency_graph = DependencyGraph() - self.parser = TypeScriptParser() - - self._load_exceptions() - - def _load_exceptions(self) -> None: - """Load dead code exceptions from .deadcode-ignore file.""" - ignore_file = Path(".deadcode-ignore") - if ignore_file.exists(): - with open(ignore_file) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - self.exceptions.add(line) - - def _is_exception(self, file_path: Path) -> bool: - """Check if file matches any exception pattern.""" - file_str = str(file_path) - return any( - self._matches_pattern(file_str, pattern) - for pattern in self.exceptions - ) - - def _matches_pattern(self, file_str: str, pattern: str) -> bool: - """Check if file matches a glob-like pattern.""" - if "**" in pattern: - # Convert ** pattern to regex - regex_pattern = pattern.replace("**", ".*").replace("*", "[^/]*") - return bool(re.search(regex_pattern, file_str)) - elif "*" in pattern: - regex_pattern = pattern.replace("*", "[^/]*") - return bool(re.search(regex_pattern, file_str)) - else: - return pattern in file_str - - def _is_test_file(self, file_path: Path) -> bool: - """Check if file is a test file.""" - file_str = str(file_path) - return any(pattern in file_str for pattern in [ - ".test.", ".spec.", "__tests__/", ".stories." - ]) - - def _is_barrel_file(self, file_path: Path) -> bool: - """Check if file is a barrel/index file.""" - return file_path.name in ['index.ts', 'index.tsx', 'index.js', 'index.jsx'] - - def _find_all_src_files(self) -> List[Path]: - """Find all TypeScript files in src directory.""" - files = [] - for pattern in ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]: - files.extend(self.src_path.glob(pattern)) - - # Filter out node_modules - return [ - f for f in files - if "node_modules" not in str(f) - ] - - def _find_all_project_files(self) -> List[Path]: - """Find all relevant files in project for usage analysis.""" - files = [] - - # Include all src files - files.extend(self._find_all_src_files()) - - # Include build scripts directory - scripts_path = Path("scripts") - if scripts_path.exists(): - for pattern in ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]: - script_files = scripts_path.glob(pattern) - files.extend([ - f for f in script_files - if "node_modules" not in str(f) - ]) - - # Include configuration files that might import from src - root_path = Path(".") - config_patterns = [ - "*.config.ts", "*.config.js", "*.config.mjs", - "next.config.js", "vite.config.ts", "vitest.config.ts" - ] - for pattern in config_patterns: - config_files = root_path.glob(pattern) - files.extend(config_files) - - return files - - def _analyze_package_json_usage(self) -> Set[str]: - """Extract file usage from package.json scripts.""" - used_files = set() - package_json = Path("package.json") - - if package_json.exists(): - try: - import json - with open(package_json) as f: - data = json.load(f) - - # Check scripts section for TypeScript file references - scripts = data.get("scripts", {}) - for script_content in scripts.values(): - # Find tsx/ts file references in scripts - ts_file_patterns = [ - r'tsx?\s+([^\s]+\.tsx?)', # tsx src/file.ts - r'["\']([^"\']*\.tsx?)["\']', # "src/file.ts" - r'([a-zA-Z0-9/_.-]+\.tsx?)' # direct file references - ] - - for pattern in ts_file_patterns: - matches = re.findall(pattern, script_content) - for file_path in matches: - # Convert to Path and normalize - normalized_path = str(Path(file_path)) - if normalized_path.startswith('src/'): - used_files.add(normalized_path) - - except (json.JSONDecodeError, OSError, ImportError): - pass - - return used_files - - def _find_target_files(self) -> List[Path]: - """Find files in the target path to check for dead code.""" - files = [] - for pattern in ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]: - files.extend(self.target_path.glob(pattern)) - - # Filter out node_modules and test files - return [ - f for f in files - if "node_modules" not in str(f) and not self._is_test_file(f) - ] - - def _extract_exports(self, content: str, file_path: Path) -> List[Export]: - """Extract export statements from file content.""" - exports = [] - - # First handle multi-line exports using regex on full content - # Multi-line named exports: export { ... } - multi_export_pattern = r'export\s*\{\s*((?:[^{}]|{[^}]*})*?)\s*\}(?:\s*from\s*["\']([^"\']+)["\'])?' - for match in re.finditer(multi_export_pattern, content, re.MULTILINE | re.DOTALL): - exports_str = match.group(1) - from_path = match.group(2) - is_reexport = from_path is not None - - # Find line number of the export statement - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - # Parse individual exports - for export_name in exports_str.split(','): - export_name = export_name.strip() - if not export_name: - continue - - # Handle 'as' aliases: foo as bar - if ' as ' in export_name: - export_name = export_name.split(' as ')[-1].strip() - - exports.append(Export( - name=export_name, - file_path=file_path, - line_number=line_number, - export_type='named', - is_reexport=is_reexport, - from_path=from_path - )) - - # Multi-line type exports: export type { ... } - multi_type_pattern = r'export\s+type\s*\{\s*((?:[^{}]|{[^}]*})*?)\s*\}(?:\s*from\s*["\']([^"\']+)["\'])?' - for match in re.finditer(multi_type_pattern, content, re.MULTILINE | re.DOTALL): - exports_str = match.group(1) - from_path = match.group(2) - is_reexport = from_path is not None - - # Find line number - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - for export_name in exports_str.split(','): - export_name = export_name.strip() - if not export_name: - continue - - if ' as ' in export_name: - export_name = export_name.split(' as ')[-1].strip() - - exports.append(Export( - name=export_name, - file_path=file_path, - line_number=line_number, - export_type='type', - is_reexport=is_reexport, - from_path=from_path - )) - - # Now process line by line for other export patterns - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('//') or line.startswith('/*'): - continue - - # Skip lines that are part of multi-line exports (already processed) - # But don't skip direct exports like "export function Toaster() {" - if 'export' in line and ('{' in line or '}' in line): - # Check if this is a direct export pattern - is_direct_export = bool(re.match(r'export\s+(const|function|class|interface|type)\s+\w+', line)) - is_single_line_export = line.startswith('export') and line.endswith('}') - - # Skip only if it's truly part of a multi-line export block - if not (is_direct_export or is_single_line_export): - continue - - # Single-line named exports: export { foo, bar } on one line - single_named_match = re.match(r'^export\s*\{\s*([^}]+)\s*\}(?:\s*from\s*["\']([^"\']+)["\'])?$', line) - if single_named_match: - exports_str = single_named_match.group(1) - from_path = single_named_match.group(2) - is_reexport = from_path is not None - - # Parse individual exports - for export_name in exports_str.split(','): - export_name = export_name.strip() - if not export_name: - continue - - # Handle 'as' aliases: foo as bar - if ' as ' in export_name: - export_name = export_name.split(' as ')[-1].strip() - - exports.append(Export( - name=export_name, - file_path=file_path, - line_number=i, - export_type='named', - is_reexport=is_reexport, - from_path=from_path - )) - continue - - # Default export - if re.match(r'export\s+default\b', line): - # Try to extract name from default export - name_match = re.search(r'export\s+default\s+(function\s+)?(\w+)', line) - name = name_match.group(2) if name_match else 'default' - - exports.append(Export( - name=name, - file_path=file_path, - line_number=i, - export_type='default', - from_path=None - )) - continue - - # Direct exports: export const/function/class/interface/type - direct_export_match = re.match(r'export\s+(const|function|class|interface|type)\s+(\w+)', line) - if direct_export_match: - export_type = direct_export_match.group(1) - name = direct_export_match.group(2) - - exports.append(Export( - name=name, - file_path=file_path, - line_number=i, - export_type=export_type, - from_path=None - )) - continue - - # Single-line type exports: export type { ... } on one line - single_type_match = re.match(r'^export\s+type\s*\{\s*([^}]+)\s*\}(?:\s*from\s*["\']([^"\']+)["\'])?$', line) - if single_type_match: - exports_str = single_type_match.group(1) - from_path = single_type_match.group(2) - is_reexport = from_path is not None - - for export_name in exports_str.split(','): - export_name = export_name.strip() - if not export_name: - continue - - if ' as ' in export_name: - export_name = export_name.split(' as ')[-1].strip() - - exports.append(Export( - name=export_name, - file_path=file_path, - line_number=i, - export_type='type', - is_reexport=is_reexport, - from_path=from_path - )) - - return exports - - def _extract_imports(self, content: str, file_path: Path) -> List[Import]: - """Extract import statements from file content.""" - imports = [] - - # First handle multi-line imports using regex on full content - # Multi-line named imports: import { ... } - multi_import_pattern = r'import\s*\{\s*((?:[^{}]|{[^}]*})*?)\s*\}\s*from\s*["\']([^"\']+)["\']' - for match in re.finditer(multi_import_pattern, content, re.MULTILINE | re.DOTALL): - imports_str = match.group(1) - from_path = match.group(2) - - # Find line number of the import statement - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - # Parse individual imports - for import_name in imports_str.split(','): - import_name = import_name.strip() - if not import_name: - continue - - # Handle 'as' aliases: foo as bar - original_name = import_name - if ' as ' in import_name: - original_name = import_name.split(' as ')[0].strip() - import_name = import_name.split(' as ')[-1].strip() - - imports.append(Import( - name=import_name, - from_path=from_path, - file_path=file_path, - line_number=line_number, - import_type='named', - original_name=original_name if ' as ' in import_name else None - )) - - # Multi-line type imports: import type { ... } - multi_type_pattern = r'import\s+type\s*\{\s*((?:[^{}]|{[^}]*})*?)\s*\}\s*from\s*["\']([^"\']+)["\']' - for match in re.finditer(multi_type_pattern, content, re.MULTILINE | re.DOTALL): - imports_str = match.group(1) - from_path = match.group(2) - - # Find line number - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - for import_name in imports_str.split(','): - import_name = import_name.strip() - if not import_name: - continue - - original_name = import_name - if ' as ' in import_name: - original_name = import_name.split(' as ')[0].strip() - import_name = import_name.split(' as ')[-1].strip() - - imports.append(Import( - name=import_name, - from_path=from_path, - file_path=file_path, - line_number=line_number, - import_type='type', - original_name=original_name if ' as ' in import_name else None - )) - - # Now process line by line for other import patterns - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('//') or line.startswith('/*'): - continue - - # Skip lines that are part of multi-line imports (already processed) - # But don't skip direct imports like "import foo from 'bar'" - if 'import' in line and ('{' in line or '}' in line): - # Check if this is a single-line import pattern - is_single_line_import = line.startswith('import') and line.endswith("';") - - # Skip only if it's truly part of a multi-line import block - if not is_single_line_import: - continue - - # Default import: import foo from 'bar' - default_match = re.match(r'import\s+(\w+)\s+from\s+["\']([^"\']+)["\']', line) - if default_match and '{' not in line: - name = default_match.group(1) - from_path = default_match.group(2) - - imports.append(Import( - name=name, - from_path=from_path, - file_path=file_path, - line_number=i, - import_type='default' - )) - continue - - # Single-line named imports: import { foo, bar } from 'baz' on one line - single_named_match = re.match(r'^import\s*\{\s*([^}]+)\s*\}\s*from\s*["\']([^"\']+)["\']$', line) - if single_named_match: - imports_str = single_named_match.group(1) - from_path = single_named_match.group(2) - - for import_name in imports_str.split(','): - import_name = import_name.strip() - if not import_name: - continue - - # Handle 'as' aliases: foo as bar - original_name = import_name - if ' as ' in import_name: - original_name = import_name.split(' as ')[0].strip() - import_name = import_name.split(' as ')[-1].strip() - - imports.append(Import( - name=import_name, - from_path=from_path, - file_path=file_path, - line_number=i, - import_type='named', - original_name=original_name if ' as ' in import_name else None - )) - continue - - # Single-line type imports: import type { ... } from '...' on one line - single_type_match = re.match(r'^import\s+type\s*\{\s*([^}]+)\s*\}\s*from\s*["\']([^"\']+)["\']$', line) - if single_type_match: - imports_str = single_type_match.group(1) - from_path = single_type_match.group(2) - - for import_name in imports_str.split(','): - import_name = import_name.strip() - if not import_name: - continue - - original_name = import_name - if ' as ' in import_name: - original_name = import_name.split(' as ')[0].strip() - import_name = import_name.split(' as ')[-1].strip() - - imports.append(Import( - name=import_name, - from_path=from_path, - file_path=file_path, - line_number=i, - import_type='type', - original_name=original_name if ' as ' in import_name else None - )) - continue - - # Namespace import: import * as foo from 'bar' - namespace_match = re.match(r'import\s*\*\s*as\s+(\w+)\s+from\s+["\']([^"\']+)["\']', line) - if namespace_match: - name = namespace_match.group(1) - from_path = namespace_match.group(2) - - imports.append(Import( - name=name, - from_path=from_path, - file_path=file_path, - line_number=i, - import_type='namespace' - )) - - return imports - - def _extract_symbols(self, content: str, file_path: Path) -> List[Symbol]: - """Extract local symbols (functions, variables, etc.) from file content.""" - symbols = [] - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('//') or line.startswith('/*'): - continue - - # Function declarations - func_match = re.match(r'(?:export\s+)?(?:async\s+)?function\s+(\w+)', line) - if func_match: - name = func_match.group(1) - is_exported = 'export' in line - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='function', - is_exported=is_exported - )) - continue - - # Arrow function assignments - arrow_match = re.match(r'(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(.*\)\s*=>', line) - if arrow_match: - name = arrow_match.group(1) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='function', - is_exported=is_exported - )) - continue - - # Const/let/var declarations - var_match = re.match(r'(?:export\s+)?(const|let|var)\s+(\w+)', line) - if var_match: - var_type = var_match.group(1) - name = var_match.group(2) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type=var_type, - is_exported=is_exported - )) - continue - - # Class declarations - class_match = re.match(r'(?:export\s+)?class\s+(\w+)', line) - if class_match: - name = class_match.group(1) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='class', - is_exported=is_exported - )) - continue - - # Interface declarations - interface_match = re.match(r'(?:export\s+)?interface\s+(\w+)', line) - if interface_match: - name = interface_match.group(1) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='interface', - is_exported=is_exported - )) - continue - - # Type declarations - type_match = re.match(r'(?:export\s+)?type\s+(\w+)', line) - if type_match: - name = type_match.group(1) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='type', - is_exported=is_exported - )) - - return symbols - - def _find_symbol_usage(self, content: str) -> Set[str]: - """Find all symbol usage in file content.""" - used_symbols = set() - - # Find all identifiers (simplified approach) - # This is a basic implementation - in practice, you'd want AST parsing - identifiers = re.findall(r'\b[a-zA-Z_$][a-zA-Z0-9_$]*\b', content) - - # Filter out keywords and common tokens - keywords = { - 'import', 'export', 'from', 'const', 'let', 'var', 'function', 'class', - 'interface', 'type', 'if', 'else', 'for', 'while', 'return', 'true', - 'false', 'null', 'undefined', 'string', 'number', 'boolean', 'object', - 'async', 'await', 'new', 'this', 'super', 'extends', 'implements' - } - - for identifier in identifiers: - if identifier not in keywords: - used_symbols.add(identifier) - - return used_symbols - - def _analyze_file(self, file_path: Path) -> FileAnalysis: - """Analyze a single TypeScript file using shared parser.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - exports = self.parser.extract_exports(content, file_path) - imports = self.parser.extract_imports(content, file_path) - symbols = self.parser.extract_symbols(content, file_path) - used_symbols = self.parser.find_symbol_usage(content) - interface_implementations = self.parser.extract_interface_implementations(content, file_path) - - analysis = FileAnalysis( - path=file_path, - exports=exports, - imports=imports, - symbols=symbols, - used_symbols=used_symbols, - interface_implementations=interface_implementations, - content=content, - lines=len(content.split('\n')) - ) - - return analysis - - except (UnicodeDecodeError, OSError) as e: - return FileAnalysis(path=file_path) - - def _resolve_import_path(self, import_path: str, from_file: Path) -> Optional[Path]: - """Resolve relative import path to file path consistent with cache storage.""" - if import_path.startswith('.'): - # Relative import - base_dir = from_file.parent - resolved_path = (base_dir / import_path).resolve() - - # Try different extensions - for ext in ['.ts', '.tsx', '.js', '.jsx']: - candidate = resolved_path.parent / f"{resolved_path.name}{ext}" - if candidate.exists(): - # Convert back to relative path consistent with cache storage - try: - return candidate.relative_to(Path.cwd()) - except ValueError: - return candidate - - # Try index files - for ext in ['index.ts', 'index.tsx', 'index.js', 'index.jsx']: - candidate = resolved_path / ext - if candidate.exists(): - # Convert back to relative path consistent with cache storage - try: - return candidate.relative_to(Path.cwd()) - except ValueError: - return candidate - - elif import_path.startswith('~/'): - # Absolute import from src - relative_path = import_path[2:] - - # Try direct file - for ext in ['.ts', '.tsx', '.js', '.jsx', '']: - candidate = self.src_path / f"{relative_path}{ext}" if ext else self.src_path / relative_path - if candidate.exists() and candidate.is_file(): - return candidate - - # Try index files - base_path = self.src_path / relative_path - if base_path.is_dir(): - for ext in ['index.ts', 'index.tsx', 'index.js', 'index.jsx']: - candidate = base_path / ext - if candidate.exists(): - return candidate - - return None - - def _build_dependency_graph(self) -> None: - """Build the dependency graph from analyzed files.""" - # First, register all exports - for file_analysis in self.file_cache.values(): - for export in file_analysis.exports: - symbol_key = f"{export.file_path}:{export.name}" - self.dependency_graph.mark_as_export(symbol_key) - - # Also track internal symbols - for symbol in file_analysis.symbols: - if not symbol.is_exported: - symbol_key = f"{symbol.file_path}:{symbol.name}" - self.dependency_graph.all_symbols.add(symbol_key) - - # Build import dependencies - for file_analysis in self.file_cache.values(): - for imp in file_analysis.imports: - resolved_path = self._resolve_import_path(imp.from_path, imp.file_path) - if resolved_path and resolved_path in self.file_cache: - # Handle different import types - if imp.import_type == 'namespace': - # For namespace imports (import * as foo), mark all exports as potentially used - resolved_analysis = self.file_cache[resolved_path] - for export in resolved_analysis.exports: - from_symbol = f"{imp.file_path}:__file__" - to_symbol = f"{resolved_path}:{export.name}" - self.dependency_graph.add_dependency(from_symbol, to_symbol) - else: - # Find the actual export name (handle aliasing) - export_name = imp.original_name if hasattr(imp, 'original_name') and imp.original_name else imp.name - - # Create dependency relationship - from_symbol = f"{imp.file_path}:__file__" # File-level dependency - to_symbol = f"{resolved_path}:{export_name}" - self.dependency_graph.add_dependency(from_symbol, to_symbol) - - # Build internal dependencies within files - for file_analysis in self.file_cache.values(): - # Track which symbols use which other symbols within the file - for symbol in file_analysis.symbols: - symbol_key = f"{symbol.file_path}:{symbol.name}" - - # This is simplified - ideally we'd parse actual usage - for other_symbol in file_analysis.symbols: - if other_symbol.name != symbol.name and other_symbol.name in file_analysis.used_symbols: - other_key = f"{other_symbol.file_path}:{other_symbol.name}" - self.dependency_graph.add_dependency(symbol_key, other_key) - - def _build_reexport_chains(self) -> Dict[str, Set[str]]: - """Build mapping from original exports to all their re-export locations. - - Returns a dict mapping original symbol keys to sets of re-export symbol keys. - """ - from collections import defaultdict - - reexport_chains = defaultdict(set) - - for file_analysis in self.file_cache.values(): - for export in file_analysis.exports: - if export.is_reexport and export.from_path: - # Resolve the source file - source_path = self._resolve_import_path(export.from_path, export.file_path) - if source_path and source_path in self.file_cache: - - if export.export_type == 'wildcard': - # Handle wildcard exports: export * from './file' - # Mark ALL exports from the source file as re-exported - source_analysis = self.file_cache[source_path] - for source_export in source_analysis.exports: - if source_export.is_reexport: - # Handle transitive re-exports: if A re-exports from B, and C re-exports * from A, - # then C should also re-export from B - if source_export.from_path: - # Resolve the original source - original_source_path = self._resolve_import_path(source_export.from_path, source_path) - if original_source_path and original_source_path in self.file_cache: - original_key = f"{original_source_path}:{source_export.name}" - reexport_key = f"{export.file_path}:{source_export.name}" - reexport_chains[original_key].add(reexport_key) - else: - # Direct export from this file - original_key = f"{source_path}:{source_export.name}" - reexport_key = f"{export.file_path}:{source_export.name}" - reexport_chains[original_key].add(reexport_key) - else: - # Handle named re-exports: export { foo } from './file' or export { foo as bar } - # For aliased exports, we need to map from the original name to the alias - source_name = export.original_name if export.original_name else export.name - original_key = f"{source_path}:{source_name}" - reexport_key = f"{export.file_path}:{export.name}" - reexport_chains[original_key].add(reexport_key) - - return reexport_chains - - def _normalize_path(self, file_path: Path, project_root: Path) -> str: - """Normalize file path to be relative to project root.""" - try: - # Ensure both paths are resolved (absolute) - resolved_file = file_path.resolve() - resolved_root = project_root.resolve() - - # Try to make relative - relative_path = resolved_file.relative_to(resolved_root) - return str(relative_path) - except ValueError: - # If relative conversion fails, use the string representation - return str(file_path) - - def _trace_reexport_usage(self, file_path: Path, symbol_name: str, imported_symbols: set, visited: set = None, project_root: Path = None) -> None: - """Recursively trace re-export chains to mark original sources as used.""" - if visited is None: - visited = set() - - # Prevent infinite recursion - key = f"{file_path}:{symbol_name}" - if key in visited: - return - visited.add(key) - - - if file_path not in self.file_cache: - return - - file_analysis = self.file_cache[file_path] - for export in file_analysis.exports: - if export.name == symbol_name: - if export.is_reexport and export.from_path: - # This is a re-export, trace to the original source - source_path = self._resolve_import_path(export.from_path, file_path) - if source_path: - # Mark original source as used - normalize path - source_name = export.original_name if export.original_name else export.name - if project_root is None: - project_root = Path(".").resolve() - normalized_source_path = self._normalize_path(source_path, project_root) - source_key = f"{normalized_source_path}:{source_name}" - imported_symbols.add(source_key) - - # Continue tracing recursively - self._trace_reexport_usage(source_path, source_name, imported_symbols, visited, project_root) - break - - def _mark_dead_symbols(self) -> None: - """Mark symbols as dead without reporting them yet, considering re-export chains.""" - - # Build export map from all analyzed files (excluding re-exports for dead detection) - all_exports = {} - project_root = Path(".").resolve() - for file_analysis in self.file_cache.values(): - for export in file_analysis.exports: - # Normalize path to be relative to project root - normalized_path = self._normalize_path(export.file_path, project_root) - key = f"{normalized_path}:{export.name}" - all_exports[key] = export - - # Build re-export chains - reexport_chains = self._build_reexport_chains() - - # Check package.json script usage for additional file-level usage - package_json_used_files = self._analyze_package_json_usage() - - # Build import usage map from ALL files (including outside target) - imported_symbols = set() - for file_analysis in self.file_cache.values(): - for imp in file_analysis.imports: - resolved_path = self._resolve_import_path(imp.from_path, imp.file_path) - if resolved_path: - if imp.import_type == 'namespace': - # For namespace imports, mark all exports from that file as used - if resolved_path in self.file_cache: - resolved_analysis = self.file_cache[resolved_path] - for export in resolved_analysis.exports: - if export.is_reexport and export.from_path: - # Add the original source - source_path = self._resolve_import_path(export.from_path, resolved_path) - if source_path: - # Use the original name from the source, not the aliased name - source_name = export.original_name if export.original_name else export.name - normalized_source_path = self._normalize_path(source_path, project_root) - source_key = f"{normalized_source_path}:{source_name}" - imported_symbols.add(source_key) - else: - # Regular export - normalize path - normalized_path = self._normalize_path(resolved_path, project_root) - key = f"{normalized_path}:{export.name}" - imported_symbols.add(key) - elif imp.import_type == 'dynamic': - # For dynamic imports (import('path')), mark all exports from that file as used - # Similar to namespace imports, since dynamic imports can access all exports - if resolved_path in self.file_cache: - resolved_analysis = self.file_cache[resolved_path] - for export in resolved_analysis.exports: - if export.is_reexport and export.from_path: - # Add the original source - source_path = self._resolve_import_path(export.from_path, resolved_path) - if source_path: - # Use the original name from the source, not the aliased name - source_name = export.original_name if export.original_name else export.name - normalized_source_path = self._normalize_path(source_path, project_root) - source_key = f"{normalized_source_path}:{source_name}" - imported_symbols.add(source_key) - else: - # Regular export - normalize path - normalized_path = self._normalize_path(resolved_path, project_root) - key = f"{normalized_path}:{export.name}" - imported_symbols.add(key) - else: - # Handle aliasing - export_name = imp.original_name if hasattr(imp, 'original_name') and imp.original_name else imp.name - - # Normalize path to be relative to project root for consistency - normalized_path = self._normalize_path(resolved_path, project_root) - key = f"{normalized_path}:{export_name}" - imported_symbols.add(key) - - # Check if this is a re-export and add the source (recursively trace) - self._trace_reexport_usage(resolved_path, export_name, imported_symbols, project_root=project_root) - - # Handle default imports - if imp.import_type == 'default': - normalized_path = self._normalize_path(resolved_path, project_root) - default_key = f"{normalized_path}:default" - imported_symbols.add(default_key) - - - # Mark exports as dead, considering re-export chains - for symbol_key, export in all_exports.items(): - if export.is_reexport: - continue # Skip re-exports, we'll check originals - - # Check if this symbol is used directly - is_used = symbol_key in imported_symbols - - # If not used directly, check if any of its re-exports are used - if not is_used: - reexport_keys = reexport_chains.get(symbol_key, set()) - for reexport_key in reexport_keys: - if reexport_key in imported_symbols: - is_used = True - break - - # Special handling for interfaces: check if they have implementations - if not is_used and export.export_type in ['interface', 'type']: - # Check if this interface has implementations across all files - has_implementations = False - for file_analysis in self.file_cache.values(): - if export.name in file_analysis.interface_implementations: - has_implementations = True - break - - # If interface has implementations, mark it as used - if has_implementations: - is_used = True - - # Check if file is used by package.json scripts - if not is_used: - try: - relative_file_path = str(export.file_path.relative_to(Path("."))) - is_package_used = relative_file_path in package_json_used_files - if is_package_used: - is_used = True - except ValueError: - pass # Path is not relative to current directory - - # Special handling for barrel exports: consider them externally visible - if not is_used and self._is_barrel_file(export.file_path): - # Barrel exports are considered as potentially externally used - # Only flag them as dead if they're definitely internal-only - # For now, we'll be more lenient with barrel exports - is_used = True - - # Mark as dead if not used anywhere - if not is_used: - self.dependency_graph.mark_as_dead(symbol_key) - - # Mark unused local symbols as dead - for file_analysis in self.file_cache.values(): - for symbol in file_analysis.symbols: - if symbol.name not in file_analysis.used_symbols: - if not symbol.name.startswith('_') and symbol.symbol_type not in ['interface', 'type']: - symbol_key = f"{symbol.file_path}:{symbol.name}" - self.dependency_graph.mark_as_dead(symbol_key) - - def _check_unused_exports(self, results: CheckResults, dead_files: set) -> None: - """Report unused exports that aren't in dead files.""" - for file_analysis in self.file_cache.values(): - # Only report on files in target path - if not str(file_analysis.path).startswith(str(self.target_path)): - continue - - # Skip if file is already reported as dead - if file_analysis.path in dead_files: - continue - - # Skip files that match exceptions for reporting - if self._is_exception(file_analysis.path): - continue - - for export in file_analysis.exports: - symbol_key = f"{file_analysis.path}:{export.name}" - - # Only report if marked as dead and not used internally - if symbol_key in self.dependency_graph.dead_symbols: - # Skip certain patterns that are commonly intentionally unused - if export.name in ['default'] and 'page' in str(export.file_path): - continue # Next.js page components - - # Check if this export is used internally - is_used_internally = export.name in file_analysis.used_symbols - - # Only report as error if not used anywhere (internally or externally) - if not is_used_internally: - try: - relative_path = str(export.file_path.relative_to(self.src_path)) - except ValueError: - relative_path = str(export.file_path) - - issue = DeadCodeIssue.create_error( - message=f"Unused export '{export.name}'", - issue_type=DeadCodeType.UNUSED_EXPORT, - file_path=relative_path, - line_number=export.line_number, - symbol_name=export.name, - recommendation=f"Remove unused export" - ) - results.add_issue(issue) - - def _check_unused_imports(self, results: CheckResults, dead_files: set) -> None: - """Check for unused imports within files.""" - # Don't check unused imports - they're not critical errors - # Unused imports are typically handled by linters/formatters - pass - - def _check_unused_local_symbols(self, results: CheckResults, dead_files: set) -> None: - """Check for unused local symbols within files.""" - for file_analysis in self.file_cache.values(): - # Only check files in target path - if not str(file_analysis.path).startswith(str(self.target_path)): - continue - - # Skip if file is already reported as dead - if file_analysis.path in dead_files: - continue - - # Skip files that match exceptions - if self._is_exception(file_analysis.path): - continue - - for symbol in file_analysis.symbols: - # Check if symbol is not used anywhere in the file - if symbol.name not in file_analysis.used_symbols: - # Skip certain patterns - if symbol.name.startswith('_'): # Intentionally unused (convention) - continue - if symbol.symbol_type in ['interface', 'type']: # Types are often defined but not used locally - continue - - # Mark as dead - symbol_key = f"{symbol.file_path}:{symbol.name}" - self.dependency_graph.mark_as_dead(symbol_key) - - try: - relative_path = str(symbol.file_path.relative_to(self.src_path)) - except ValueError: - relative_path = str(symbol.file_path) - - # Only report non-exported unused symbols as errors - if not symbol.is_exported: - issue = DeadCodeIssue.create_error( - message=f"Unused {symbol.symbol_type} '{symbol.name}'", - issue_type=DeadCodeType.UNUSED_SYMBOL, - file_path=relative_path, - line_number=symbol.line_number, - symbol_name=symbol.name, - recommendation=f"Remove unused {symbol.symbol_type} '{symbol.name}'" - ) - results.add_issue(issue) - - def _check_transitive_dead_code(self, results: CheckResults, dead_files: set) -> None: - """Check for code that's only used by other dead code.""" - self.dependency_graph.find_transitive_dead_code() - - # Report transitively dead symbols - for symbol_key in self.dependency_graph.transitively_dead: - parts = symbol_key.split(':', 1) - if len(parts) != 2: - continue - - file_path = Path(parts[0]) - symbol_name = parts[1] - - # Only report on files in target path - if not str(file_path).startswith(str(self.target_path)): - continue - - # Skip if file is already reported as dead - if file_path in dead_files: - continue - - # Skip files that match exceptions - if self._is_exception(file_path): - continue - - # Find the actual symbol details - if file_path in self.file_cache: - file_analysis = self.file_cache[file_path] - - # Find in exports - for export in file_analysis.exports: - if export.name == symbol_name: - # Skip reporting re-exports as transitively dead - if export.is_reexport: - continue - - chain_count = self.dependency_graph.count_dead_chain(symbol_key) - - try: - relative_path = str(file_path.relative_to(self.src_path)) - except ValueError: - relative_path = str(file_path) - - issue = DeadCodeIssue.create_error( - message=f"Transitively unused export '{symbol_name}' ({chain_count} symbols in chain)", - issue_type=DeadCodeType.UNUSED_EXPORT, - file_path=relative_path, - line_number=export.line_number, - symbol_name=symbol_name, - recommendation=f"Remove unused export" - ) - results.add_issue(issue) - break - - def _detect_dead_files(self, results: CheckResults) -> List[Path]: - """Detect files that contain only dead code.""" - dead_files = [] - - for file_path, file_analysis in self.file_cache.items(): - # Only check files in target path - if not str(file_path).startswith(str(self.target_path)): - continue - - # Skip test files and exceptions - if self._is_test_file(file_path) or self._is_exception(file_path): - continue - - # Check if all exports are dead - if not file_analysis.exports: - continue # No exports, not a dead file candidate - - all_exports_dead = True - for export in file_analysis.exports: - symbol_key = f"{file_path}:{export.name}" - if symbol_key not in self.dependency_graph.dead_symbols and \ - symbol_key not in self.dependency_graph.transitively_dead: - all_exports_dead = False - break - - if all_exports_dead: - dead_files.append(file_path) - - # Count total symbols in file - total_symbols = len(file_analysis.exports) + len([s for s in file_analysis.symbols if not s.is_exported]) - - try: - relative_path = str(file_path.relative_to(self.src_path)) - except ValueError: - relative_path = str(file_path) - - issue = DeadCodeIssue.create_error( - message=f"Dead file - all exports unused ({total_symbols} total symbols)", - issue_type=DeadCodeType.UNUSED_EXPORT, - file_path=relative_path, - line_number=1, - symbol_name="__file__", - recommendation=f"Remove unused file" - ) - results.add_issue(issue) - - return dead_files - - def _detect_dead_folders(self, results: CheckResults, dead_files: List[Path]) -> Set[Path]: - """Detect folders where all or most files are dead code.""" - from collections import defaultdict - - # Group dead files by their parent directory - dead_files_by_folder = defaultdict(list) - all_files_by_folder = defaultdict(list) - - # Track dead files by folder - for file_path in dead_files: - if file_path in self.file_cache: - parent_dir = file_path.parent - dead_files_by_folder[parent_dir].append(file_path) - - # Track all target files by folder for comparison - target_files = self._find_target_files() - for file_path in target_files: - if not self._is_test_file(file_path): - parent_dir = file_path.parent - all_files_by_folder[parent_dir].append(file_path) - - dead_folders = set() - - # Check each folder to see if it should be reported as dead - for folder_path, folder_dead_files in dead_files_by_folder.items(): - folder_all_files = all_files_by_folder[folder_path] - - # Skip if folder has too few files - if len(folder_all_files) < 2: - continue - - dead_count = len(folder_dead_files) - total_count = len(folder_all_files) - dead_ratio = dead_count / total_count - - # Only report folder if ALL files are dead (100%) - should_report_folder = (dead_ratio >= 1.0) - - if should_report_folder: - dead_folders.add(folder_path) - - try: - relative_folder_path = str(folder_path.relative_to(self.src_path)) - except ValueError: - relative_folder_path = str(folder_path) - - # Remove individual file issues for this folder - issues_to_remove = [] - all_issues = results.get_all_issues() - for issue in all_issues: - if issue.file_path and issue.symbol_name == "__file__": - try: - issue_file_path = self.src_path / issue.file_path - if issue_file_path.parent == folder_path: - issues_to_remove.append(issue) - except: - pass - - # Remove from both errors and warnings lists - for issue in issues_to_remove: - if issue in results.errors: - results.errors.remove(issue) - if issue in results.warnings: - results.warnings.remove(issue) - - # Create folder-level issue - message = f"Dead folder - all {total_count} files unused" - recommendation = f"Remove unused folder" - - issue = DeadCodeIssue.create_error( - message=message, - issue_type=DeadCodeType.UNUSED_EXPORT, - file_path=relative_folder_path + "/", - line_number=1, - symbol_name="__folder__", - recommendation=recommendation - ) - results.add_issue(issue) - - return dead_folders - - def run_all_checks(self) -> CheckResults: - """Run all dead code checks and return results.""" - start_time = time.time() - results = CheckResults(target_path=str(self.target_path)) - - # Find ALL relevant files for usage analysis (including scripts, config) - all_project_files = self._find_all_project_files() - - # Find target files to check for dead code - target_files = self._find_target_files() - results.files_analyzed = len(target_files) - - print(f"Analyzing {len(all_project_files)} files across project, checking {len(target_files)} in {self.target_path}") - - # Analyze ALL files in parallel (for comprehensive usage detection) - with ThreadPoolExecutor(max_workers=4) as executor: - future_to_file = { - executor.submit(self._analyze_file, file_path): file_path - for file_path in all_project_files - } - - for future in as_completed(future_to_file): - file_path = future_to_file[future] - analysis = future.result() - self.file_cache[file_path] = analysis - - # Build dependency graph - self._build_dependency_graph() - - # Run dead code checks - mark dead symbols first - self._mark_dead_symbols() - - # Then detect dead files - dead_files = self._detect_dead_files(results) - dead_files_set = set(dead_files) - - # Then detect dead folders (this may remove some individual file issues) - dead_folders = self._detect_dead_folders(results, dead_files) - - # Then check other issues, but skip symbols in dead files - self._check_unused_exports(results, dead_files_set) - self._check_unused_imports(results, dead_files_set) - self._check_unused_local_symbols(results, dead_files_set) - self._check_transitive_dead_code(results, dead_files_set) - - results.execution_time = time.time() - start_time - return results \ No newline at end of file diff --git a/scripts/checks/deadcode/main.py b/scripts/checks/deadcode/main.py deleted file mode 100644 index 60c93f5e3..000000000 --- a/scripts/checks/deadcode/main.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -""" -Main entry point for dead code checking. - -Usage: - python3 scripts/checks/deadcode/main.py [path] - pnpm check:dead-code [path] -""" - -import sys - -from .checker import DeadCodeChecker -from .reporter import DeadCodeReporter - - -def main(): - """Main entry point for dead code checking.""" - # Handle help flag - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: - from pathlib import Path - readme_path = Path(__file__).parent / "README.md" - if readme_path.exists(): - with open(readme_path, 'r') as f: - print(f.read()) - else: - print(__doc__) - sys.exit(0) - - target_path = sys.argv[1] if len(sys.argv) > 1 else "src" - - print(f"πŸ•΅οΈ Checking for dead code in {target_path}...") - - # Run checks - checker = DeadCodeChecker(target_path) - results = checker.run_all_checks() - - print(f"⏱️ Completed in {results.execution_time:.2f} seconds") - - # Report results - reporter = DeadCodeReporter() - success = reporter.report_results(results) - - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/checks/deadcode/models.py b/scripts/checks/deadcode/models.py deleted file mode 100644 index c77527efd..000000000 --- a/scripts/checks/deadcode/models.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -""" -Data models for dead code checking. - -Contains all data structures used throughout the dead code checking system. -""" - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional, Set -from enum import Enum - - -class DeadCodeType(Enum): - """Types of dead code violations.""" - UNUSED_EXPORT = "unused_export" - UNUSED_IMPORT = "unused_import" - UNUSED_SYMBOL = "unused_symbol" - - -class Severity(Enum): - """Error severity levels.""" - ERROR = "error" - WARNING = "warning" - - -@dataclass -class Export: - """Represents an exported symbol.""" - name: str - file_path: Path - line_number: int - export_type: str # 'default', 'named', 'type', 'interface' - is_reexport: bool = False - from_path: Optional[str] = None # Source path for re-exports - - -@dataclass -class Import: - """Represents an imported symbol.""" - name: str - from_path: str - file_path: Path - line_number: int - import_type: str # 'default', 'named', 'type', 'namespace' - original_name: Optional[str] = None # For aliased imports (import { X as Y }) - - -@dataclass -class Symbol: - """Represents a local symbol (function, variable, etc.).""" - name: str - file_path: Path - line_number: int - symbol_type: str # 'function', 'variable', 'const', 'class', 'interface', 'type' - is_exported: bool = False - - -@dataclass -class FileAnalysis: - """Analysis results for a single file.""" - path: Path - exports: List[Export] = field(default_factory=list) - imports: List[Import] = field(default_factory=list) - symbols: List[Symbol] = field(default_factory=list) - used_symbols: Set[str] = field(default_factory=set) - interface_implementations: Dict[str, List[str]] = field(default_factory=dict) # interface -> list of implementing classes - content: str = "" - lines: int = 0 - - -@dataclass -class DeadCodeIssue: - """Represents a dead code violation with enhanced metadata.""" - message: str - issue_type: DeadCodeType - severity: Severity = Severity.WARNING - file_path: Optional[str] = None - line_number: Optional[int] = None - recommendation: Optional[str] = None - symbol_name: Optional[str] = None - - @classmethod - def create_error( - cls, - message: str, - issue_type: DeadCodeType, - file_path: Optional[str] = None, - line_number: Optional[int] = None, - recommendation: Optional[str] = None, - symbol_name: Optional[str] = None - ) -> "DeadCodeIssue": - """Create an issue with ERROR severity.""" - return cls( - message=message, - issue_type=issue_type, - severity=Severity.ERROR, - file_path=file_path, - line_number=line_number, - recommendation=recommendation, - symbol_name=symbol_name - ) - - @classmethod - def create_warning( - cls, - message: str, - issue_type: DeadCodeType, - file_path: Optional[str] = None, - line_number: Optional[int] = None, - recommendation: Optional[str] = None, - symbol_name: Optional[str] = None - ) -> "DeadCodeIssue": - """Create an issue with WARNING severity.""" - return cls( - message=message, - issue_type=issue_type, - severity=Severity.WARNING, - file_path=file_path, - line_number=line_number, - recommendation=recommendation, - symbol_name=symbol_name - ) - - def to_dict(self) -> Dict: - """Convert issue to dictionary for JSON serialization.""" - return { - "type": self.issue_type.value, - "severity": self.severity.value, - "message": self.message, - "file": self.file_path, - "line": self.line_number, - "recommendation": self.recommendation, - "symbol_name": self.symbol_name - } - - -@dataclass -class CheckResults: - """Results of dead code checking.""" - errors: List[DeadCodeIssue] = field(default_factory=list) - warnings: List[DeadCodeIssue] = field(default_factory=list) - execution_time: float = 0.0 - target_path: str = "src" - files_analyzed: int = 0 - - def add_issue(self, issue: DeadCodeIssue) -> None: - """Add an issue to the appropriate list based on severity.""" - if issue.severity == Severity.ERROR: - self.errors.append(issue) - else: - self.warnings.append(issue) - - def get_all_issues(self) -> List[DeadCodeIssue]: - """Get all issues (errors + warnings).""" - return self.errors + self.warnings - - def get_issues_by_category(self) -> Dict[str, List[DeadCodeIssue]]: - """Get issues categorized by folders, files, and symbols.""" - categories = { - "dead_folders": [], - "dead_files": [], - "dead_symbols": [] - } - - for issue in self.get_all_issues(): - if issue.symbol_name == "__folder__": - categories["dead_folders"].append(issue) - elif issue.symbol_name == "__file__": - categories["dead_files"].append(issue) - else: - categories["dead_symbols"].append(issue) - - return categories - - def get_summary_by_type(self) -> Dict[str, int]: - """Get count of issues by dead code type (folders, files, symbols).""" - summary = {} - for issue in self.get_all_issues(): - if issue.symbol_name == "__folder__": - category = "dead_folders" - elif issue.symbol_name == "__file__": - category = "dead_files" - else: - category = "dead_symbols" - summary[category] = summary.get(category, 0) + 1 - return summary - - def get_summary_by_file(self) -> Dict[str, int]: - """Get count of issues by file.""" - summary = {} - for issue in self.get_all_issues(): - if issue.file_path: - summary[issue.file_path] = summary.get(issue.file_path, 0) + 1 - return summary - - def get_summary_by_recommendation(self) -> Dict[str, int]: - """Get count of issues by recommendation type.""" - summary = {} - for issue in self.get_all_issues(): - if issue.recommendation: - rec_type = self._categorize_recommendation(issue.recommendation) - summary[rec_type] = summary.get(rec_type, 0) + 1 - return summary - - def get_top_exact_recommendations(self, limit: int = 3) -> List[tuple[str, int]]: - """Get the most common exact recommendations.""" - exact_summary = {} - missing_recommendations = [] - - for issue in self.get_all_issues(): - if issue.recommendation: - exact_summary[issue.recommendation] = exact_summary.get(issue.recommendation, 0) + 1 - else: - missing_recommendations.append(issue) - - # Return top exact recommendations - sorted_recommendations = sorted(exact_summary.items(), key=lambda x: x[1], reverse=True) - return sorted_recommendations[:limit] - - def _categorize_recommendation(self, recommendation: str) -> str: - """Categorize recommendation into types for summary.""" - if "Remove unused export" in recommendation: - return "Remove unused export" - elif "Remove unused import" in recommendation: - return "Remove unused import" - elif "Remove unused" in recommendation and "symbol" in recommendation: - return "Remove unused symbol" - elif "Consider refactoring" in recommendation: - return "Refactoring suggestion" - elif "Move to utility" in recommendation: - return "Extract to utility" - - return "Other" - - def has_errors(self) -> bool: - """Check if there are any errors (not warnings).""" - return len(self.errors) > 0 - - def to_dict(self) -> Dict: - """Convert results to dictionary for JSON serialization.""" - all_issues = self.get_all_issues() - return { - "timestamp": None, # Will be set by reporter - "target_path": self.target_path, - "execution_time": self.execution_time, - "files_analyzed": self.files_analyzed, - "summary": { - "total_errors": len(self.errors), - "by_type": self.get_summary_by_type(), - "by_file": self.get_summary_by_file() - }, - "issues": [issue.to_dict() for issue in all_issues] - } \ No newline at end of file diff --git a/scripts/checks/deadcode/reporter.py b/scripts/checks/deadcode/reporter.py deleted file mode 100644 index 55bb2a175..000000000 --- a/scripts/checks/deadcode/reporter.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -""" -Dead code check reporting module. - -Handles result reporting, JSON output generation, and console summaries. -""" - -import json -import re -from datetime import datetime -from pathlib import Path -from typing import Dict, List - -from .models import CheckResults, DeadCodeIssue, DeadCodeType, Severity - - -class DeadCodeReporter: - """Handles reporting of dead code check results.""" - - def __init__(self, output_file: str = "test-results/dead-code-check.json"): - self.output_file = Path(output_file) - - def report_results(self, results: CheckResults) -> bool: - """Report results to both JSON file and console. Returns True if no errors.""" - # Ensure output directory exists - self.output_file.parent.mkdir(exist_ok=True) - - # Write detailed JSON report - self._write_json_report(results) - - # Display console summary - self._display_console_summary(results) - - return not results.has_errors() - - def _write_json_report(self, results: CheckResults) -> None: - """Write detailed JSON report to file.""" - report_data = results.to_dict() - report_data["timestamp"] = datetime.now().isoformat() - - with open(self.output_file, 'w') as f: - json.dump(report_data, f, indent=2, default=str) - - def _extract_symbol_count(self, message: str) -> int: - """Extract symbol count from issue message.""" - # Look for patterns like "({N} total symbols)", "({N} symbols in chain)" - match = re.search(r'\((\d+).*?symbols?\)', message) - return int(match.group(1)) if match else 1 - - def _sort_issues_by_priority(self, issues: List[DeadCodeIssue]) -> List[DeadCodeIssue]: - """Sort issues by symbol count (descending) for priority display.""" - def get_sort_key(issue): - symbol_count = self._extract_symbol_count(issue.message) - return (symbol_count, issue.file_path or "", issue.symbol_name or "") - - return sorted(issues, key=get_sort_key, reverse=True) - - def _display_console_summary(self, results: CheckResults) -> None: - """Display simplified summary information on console.""" - print() - - total_errors = len(results.errors) - - if total_errors > 0: - print("πŸ“Š Dead Code Analysis Summary:") - print("=" * 72) - print(f"β€’ Total errors: {total_errors}") - print(f"β€’ Files analyzed: {results.files_analyzed}") - print() - - # Get issues by category - categories = results.get_issues_by_category() - type_summary = results.get_summary_by_type() - - # Show breakdown by type - if type_summary: - print("πŸ” By issue type:") - type_order = ["dead_folders", "dead_files", "dead_symbols"] - emojis = {"dead_folders": "πŸ“", "dead_files": "πŸ“„", "dead_symbols": "πŸ’€"} - labels = {"dead_folders": "Dead Folders", "dead_files": "Dead Files", "dead_symbols": "Dead Symbols"} - - for issue_type in type_order: - if issue_type in type_summary: - count = type_summary[issue_type] - emoji = emojis[issue_type] - label = labels[issue_type] - print(f" {emoji} {label}: {count}") - print() - - # Display sorted results - self._display_category_section("πŸ“ Dead Folders (by symbol count):", - categories["dead_folders"], limit=10) - - self._display_category_section("πŸ“„ Dead Files (by symbol count):", - categories["dead_files"], limit=10) - - self._display_category_section("πŸ’€ Dead Symbols (by dependency chain):", - categories["dead_symbols"], limit=10) - - # Reference to detailed log - print("πŸ“‹ Full Report:") - print("-" * 72) - print(f"{self.output_file}") - else: - print("βœ… Dead code check passed!") - print(f"πŸ“ Analyzed {results.files_analyzed} files") - print(f"πŸ“‹ Report: {self.output_file}") - - def _display_category_section(self, title: str, issues: List[DeadCodeIssue], limit: int = 10) -> None: - """Display a section for a specific category of issues.""" - if not issues: - return - - print(title) - sorted_issues = self._sort_issues_by_priority(issues) - - for issue in sorted_issues[:limit]: - symbol_count = self._extract_symbol_count(issue.message) - - if issue.symbol_name == "__folder__": - print(f" β€’ {issue.file_path}: {issue.message}") - elif issue.symbol_name == "__file__": - print(f" β€’ {issue.file_path}: {symbol_count} symbols") - else: - chain_info = "" - if "chain" in issue.message: - chain_info = f" ({symbol_count} in chain)" - print(f" β€’ {issue.file_path}:{issue.line_number} {issue.symbol_name}{chain_info}") - - if len(issues) > limit: - print(f" ... and {len(issues) - limit} more") - - print() - - def generate_ai_friendly_summary(self, results: CheckResults) -> str: - """Generate a summary specifically designed for AI agents.""" - if not results.errors: - return f"βœ… Dead code check passed! Analyzed {results.files_analyzed} files. Report: {self.output_file}" - - categories = results.get_issues_by_category() - folder_count = len(categories["dead_folders"]) - file_count = len(categories["dead_files"]) - symbol_count = len(categories["dead_symbols"]) - - summary_parts = [ - f"πŸ’€ Dead code found: {folder_count} folders, {file_count} files, {symbol_count} symbols", - f"πŸ“ Analyzed {results.files_analyzed} files", - f"πŸ“„ Report: {self.output_file}" - ] - - return "\n".join(summary_parts) \ No newline at end of file diff --git a/scripts/checks/deadcode/tests/__init__.py b/scripts/checks/deadcode/tests/__init__.py deleted file mode 100644 index ba0bd2637..000000000 --- a/scripts/checks/deadcode/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for dead code detection.""" \ No newline at end of file diff --git a/scripts/checks/deadcode/tests/test_deadcode_checker.py b/scripts/checks/deadcode/tests/test_deadcode_checker.py deleted file mode 100644 index 4024f62c2..000000000 --- a/scripts/checks/deadcode/tests/test_deadcode_checker.py +++ /dev/null @@ -1,519 +0,0 @@ -""" -Tests for dead code detection functionality. - -Tests detection of unused exports, imports, functions, -and transitive dead code across the codebase. -""" - -import pytest -from pathlib import Path - -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..')) - -from utils.test_helpers import ( - create_test_project, - run_checker, - assert_checker_finds_issues -) -from deadcode.models import DeadCodeType - - -class TestDeadCodeChecker: - """Test suite for dead code detection.""" - - def test_unused_exports_detection(self): - """Test detection of unused exported functions and variables.""" - files = { - "src/utils.ts": """ -export function usedFunction() { - return 'used'; -} - -export function unusedFunction() { // DEAD CODE - return 'unused'; -} - -export const usedConstant = 'used'; -export const unusedConstant = 'unused'; // DEAD CODE - """.strip(), - "src/main.ts": """ -import { usedFunction, usedConstant } from './utils'; - -export function main() { - console.log(usedFunction(), usedConstant); -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Should find unused exports - unused_exports = [issue for issue in results.issues if issue.dead_code_type == DeadCodeType.UNUSED_EXPORT] - assert len(unused_exports) >= 2, f"Should find unused exports: {[i.symbol_name for i in results.issues]}" - - # Check specific unused items - unused_names = {issue.symbol_name for issue in unused_exports} - assert 'unusedFunction' in unused_names - assert 'unusedConstant' in unused_names - - def test_unused_imports_detection(self): - """Test detection of unused imports.""" - files = { - "src/utils.ts": """ -export function utilityA() { return 'A'; } -export function utilityB() { return 'B'; } -export function utilityC() { return 'C'; } - """.strip(), - "src/main.ts": """ -import { utilityA, utilityB, utilityC } from './utils'; // utilityC is unused - -export function main() { - console.log(utilityA(), utilityB()); - // utilityC is imported but never used -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Should find unused import - unused_imports = [issue for issue in results.issues if issue.dead_code_type == DeadCodeType.UNUSED_IMPORT] - assert len(unused_imports) >= 1, f"Should find unused imports: {[i.symbol_name for i in results.issues]}" - - def test_transitive_dead_code(self): - """Test detection of transitively dead code (dead code that depends on other dead code).""" - files = { - "src/utils.ts": """ -export function deadFunction() { // DEAD: not used anywhere - return helper(); -} - -function helper() { // TRANSITIVELY DEAD: only used by dead function - return 'help'; -} - -export function aliveFunction() { // ALIVE: used in main - return aliveHelper(); -} - -function aliveHelper() { // ALIVE: used by alive function - return 'alive help'; -} - """.strip(), - "src/main.ts": """ -import { aliveFunction } from './utils'; - -export function main() { - return aliveFunction(); -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Should find both directly dead and transitively dead code - dead_symbols = {issue.symbol_name for issue in results.issues} - assert 'deadFunction' in dead_symbols, "Should find directly dead function" - # Note: helper might be found as transitively dead depending on implementation - - def test_cross_file_references(self): - """Test tracking of references across multiple files.""" - files = { - "src/moduleA.ts": """ -export function functionA() { - return 'A'; -} - -export function unusedA() { // DEAD - return 'unused A'; -} - """.strip(), - "src/moduleB.ts": """ -import { functionA } from './moduleA'; - -export function functionB() { - return functionA() + 'B'; -} - -export function unusedB() { // DEAD - return 'unused B'; -} - """.strip(), - "src/main.ts": """ -import { functionB } from './moduleB'; - -export function main() { - return functionB(); -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Should find unused functions across files - unused_functions = {issue.symbol_name for issue in results.issues if issue.dead_code_type == DeadCodeType.UNUSED_EXPORT} - assert 'unusedA' in unused_functions - assert 'unusedB' in unused_functions - - # Should NOT flag used functions - used_functions = {'functionA', 'functionB', 'main'} - flagged_used = used_functions & unused_functions - assert len(flagged_used) == 0, f"Should not flag used functions: {flagged_used}" - - def test_dynamic_import_handling(self): - """Test handling of dynamic imports and require statements.""" - files = { - "src/dynamicModule.ts": """ -export function dynamicallyImported() { - return 'dynamic'; -} - -export function notDynamicallyImported() { // Might be flagged as dead - return 'not dynamic'; -} - """.strip(), - "src/main.ts": """ -async function loadDynamic() { - // Dynamic import - might not be detected by regex parsing - const module = await import('./dynamicModule'); - return module.dynamicallyImported(); -} - -export function main() { - return loadDynamic(); -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Known limitation: dynamicallyImported may be missed; at minimum, the unused export should be flagged - dead_symbols = {issue.symbol_name for issue in results.issues} - assert 'notDynamicallyImported' in dead_symbols - - def test_barrel_file_exports(self): - """Test handling of barrel file re-exports.""" - files = { - "src/components/Button.tsx": """ -export function Button() { - return <button>Click</button>; -} - """.strip(), - "src/components/Input.tsx": """ -export function Input() { - return <input />; -} - """.strip(), - "src/components/index.ts": """ -export { Button } from './Button'; -export { Input } from './Input'; - """.strip(), - "src/main.tsx": """ -import { Button } from './components'; // Using barrel file - -export function App() { - return <Button />; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Button should not be flagged as dead (used via barrel file) - dead_symbols = {issue.symbol_name for issue in results.issues} - assert 'Button' not in dead_symbols, "Button should not be flagged as dead" - - # Input might be flagged as dead since it's not used - # This tests the barrel file handling - - def test_react_component_patterns(self): - """Test dead code detection in React component patterns.""" - files = { - "src/components/UsedComponent.tsx": """ -import { useState } from 'react'; - -interface Props { - title: string; -} - -export function UsedComponent({ title }: Props) { // USED - const [count, setCount] = useState(0); - - return ( - <div> - <h1>{title}</h1> - <button onClick={() => setCount(count + 1)}> - Count: {count} - </button> - </div> - ); -} - """.strip(), - "src/components/UnusedComponent.tsx": """ -export function UnusedComponent() { // DEAD - return <div>Unused</div>; -} - -export const unusedConstant = 'unused'; // DEAD - """.strip(), - "src/App.tsx": """ -import { UsedComponent } from './components/UsedComponent'; - -export function App() { - return <UsedComponent title="Hello" />; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Should find unused React components - dead_symbols = {issue.symbol_name for issue in results.issues} - assert 'UnusedComponent' in dead_symbols - assert 'unusedConstant' in dead_symbols - - # Should not flag used components - assert 'UsedComponent' not in dead_symbols - assert 'App' not in dead_symbols - - def test_type_only_exports(self): - """Test handling of TypeScript type-only exports.""" - files = { - "src/types.ts": """ -export interface UsedInterface { - id: string; -} - -export interface UnusedInterface { // DEAD - value: number; -} - -export type UsedType = 'a' | 'b'; -export type UnusedType = 'x' | 'y'; // DEAD - """.strip(), - "src/main.ts": """ -import type { UsedInterface, UsedType } from './types'; - -export function processData(data: UsedInterface): UsedType { - return 'a'; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - dead_symbols = {issue.symbol_name for issue in results.issues} - # Ensure type-only usage isn't flagged as dead - assert 'UsedInterface' not in dead_symbols - - def test_no_false_positives_on_clean_code(self): - """Test that clean, well-used code doesn't generate false positives.""" - files = { - "src/utils.ts": """ -export function formatString(input: string): string { - return input.trim().toLowerCase(); -} - -export function calculateSum(numbers: number[]): number { - return numbers.reduce((sum, num) => sum + num, 0); -} - """.strip(), - "src/services.ts": """ -import { formatString, calculateSum } from './utils'; - -export function processData(data: string[], numbers: number[]) { - const formatted = data.map(formatString); - const sum = calculateSum(numbers); - return { formatted, sum }; -} - """.strip(), - "src/main.ts": """ -import { processData } from './services'; - -export function main() { - const result = processData(['Hello', 'World'], [1, 2, 3]); - console.log(result); -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Should not flag any code as dead in this clean example - assert len(results.issues) == 0, f"Found false positives: {[i.symbol_name for i in results.issues]}" - - def test_complex_dependency_chains(self): - """Test handling of complex dependency chains.""" - files = { - "src/chain.ts": """ -export function entryPoint() { // USED by main - return step1(); -} - -function step1() { // USED by entryPoint - return step2(); -} - -function step2() { // USED by step1 - return step3(); -} - -function step3() { // USED by step2 - return 'result'; -} - -export function deadChain() { // DEAD - return deadStep1(); -} - -function deadStep1() { // TRANSITIVELY DEAD - return deadStep2(); -} - -function deadStep2() { // TRANSITIVELY DEAD - return 'dead result'; -} - """.strip(), - "src/main.ts": """ -import { entryPoint } from './chain'; - -export function main() { - return entryPoint(); -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Should find the dead chain - dead_symbols = {issue.symbol_name for issue in results.issues} - assert 'deadChain' in dead_symbols - - # Should not flag the live chain - live_symbols = {'entryPoint', 'step1', 'step2', 'step3'} - flagged_live = live_symbols & dead_symbols - assert len(flagged_live) == 0, f"Should not flag live chain: {flagged_live}" - - -class TestDeadCodeIntegration: - """Integration tests for dead code checker.""" - - def test_large_project_performance(self): - """Test dead code checker performance on a large project.""" - files = {} - - # Generate many files with various usage patterns - for i in range(50): # 50 modules - files[f"src/module{i}.ts"] = f""" -export function used{i}() {{ - return 'used{i}'; -}} - -export function unused{i}() {{ - return 'unused{i}'; -}} - """.strip() - - # Create a main file that uses some functions - used_imports = [] - for i in range(0, 50, 2): # Use every other function - used_imports.append(f"used{i}") - - files["src/main.ts"] = f""" -import {{ {', '.join(used_imports)} }} from './module0'; - -export function main() {{ - return [{', '.join([f'{func}()' for func in used_imports])}]; -}} - """.strip() - - with create_test_project(files) as project_path: - try: - results = run_checker('deadcode', project_path / 'src') - - # Should complete without timeout - assert isinstance(results.issues, list) - - # Should find many unused functions - unused_count = len([issue for issue in results.issues if issue.dead_code_type == DeadCodeType.UNUSED_EXPORT]) - assert unused_count > 20, f"Should find many unused functions, found {unused_count}" - - except Exception as e: - pytest.fail(f"Dead code checker failed on large project: {e}") - - def test_real_world_patterns(self): - """Test with real-world code patterns similar to Hexframe.""" - files = { - "src/app/map/Chat/Timeline/Widgets/LoginWidget/login-widget.tsx": """ -import { useState } from 'react'; -import { useLoginForm } from './useLoginForm'; -import { BaseWidget } from '../_shared/BaseWidget'; - -export function LoginWidget() { - const [isCollapsed, setIsCollapsed] = useState(false); - const { handleSubmit } = useLoginForm(); - - return ( - <BaseWidget> - <form onSubmit={handleSubmit}> - <input type="email" /> - <button type="submit">Login</button> - </form> - </BaseWidget> - ); -} - """.strip(), - "src/app/map/Chat/Timeline/Widgets/LoginWidget/useLoginForm.ts": """ -export function useLoginForm() { - const handleSubmit = () => {}; - const handleCancel = () => {}; // UNUSED - - return { - handleSubmit, - handleCancel - }; -} - """.strip(), - "src/app/map/Chat/Timeline/Widgets/_shared/BaseWidget.tsx": """ -export function BaseWidget({ children }: { children: React.ReactNode }) { - return <div className="widget">{children}</div>; -} - -export function UnusedWidget() { // DEAD - return <div>Unused</div>; -} - """.strip(), - "src/app/page.tsx": """ -import { LoginWidget } from './map/Chat/Timeline/Widgets/LoginWidget/login-widget'; - -export default function Page() { - return <LoginWidget />; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('deadcode', project_path / 'src') - - # Should find unused exports but not flag used components - dead_symbols = {issue.symbol_name for issue in results.issues} - - # Should NOT flag used components - used_symbols = {'LoginWidget', 'BaseWidget', 'useLoginForm'} - flagged_used = used_symbols & dead_symbols - assert len(flagged_used) == 0, f"Should not flag used symbols: {flagged_used}" - - # SHOULD find unused exports - assert 'UnusedWidget' in dead_symbols or len(results.issues) == 0 # Depending on detection accuracy \ No newline at end of file diff --git a/scripts/checks/final_test_demo.py b/scripts/checks/final_test_demo.py index bbaf47c8b..42554a97d 100644 --- a/scripts/checks/final_test_demo.py +++ b/scripts/checks/final_test_demo.py @@ -26,7 +26,6 @@ def demo_test_infrastructure(): "βœ… Comprehensive fixture catalog (basic, edge_cases, real_world, regression)", "βœ… Parser tests (25+ methods covering all functionality)", "βœ… Architecture checker tests (boundary violations, domain rules)", - "βœ… Dead code checker tests (unused exports, transitive dependencies)", "βœ… Rule of 6 tests (complexity violations, directory structure)", "βœ… Regression tests (template literals, comments, malformed code)", "βœ… Performance tests (large codebases, deeply nested structures)" @@ -45,7 +44,7 @@ def demo_parser_bug_detection(): sys.path.insert(0, str(Path(__file__).parent)) try: - from shared.typescript_parser import TypeScriptParser + from architecture.shared.typescript_parser import TypeScriptParser parser = TypeScriptParser() @@ -180,17 +179,6 @@ def demo_test_categories(): "Barrel file and re-export handling" ] }, - { - "name": "Dead Code Checker Tests", - "coverage": "8+ patterns", - "scope": [ - "Unused export detection", - "Unused import identification", - "Transitive dead code analysis", - "Cross-file reference tracking", - "React component usage patterns" - ] - }, { "name": "Rule of 6 Tests", "coverage": "12+ violations", @@ -287,8 +275,6 @@ def demo_files_created(): β”‚ └── test_helpers.py # πŸ†• Unified test infrastructure β”œβ”€β”€ architecture/tests/ # πŸ†• β”‚ └── test_architecture_checker.py # πŸ†• Architecture tests -β”œβ”€β”€ deadcode/tests/ # πŸ†• -β”‚ └── test_deadcode_checker.py # πŸ†• Dead code tests β”œβ”€β”€ ruleof6/tests/ # ✨ Enhanced existing β”‚ └── test_comprehensive_ruleof6.py # πŸ†• Comprehensive tests β”œβ”€β”€ run-all-tests.py # πŸ†• Master test runner diff --git a/scripts/checks/ruleof6/README.md b/scripts/checks/ruleof6/README.md deleted file mode 100644 index 2bece97fa..000000000 --- a/scripts/checks/ruleof6/README.md +++ /dev/null @@ -1,482 +0,0 @@ -# Rule of 6 Checker - -A comprehensive code quality tool that enforces the **Rule of 6** architecture principle to maintain cognitive simplicity and clear code organization. - -## Philosophy - -The Rule of 6 is not about arbitrary limitsβ€”it's about **cognitive load management** and creating systems that humans can understand and maintain effectively. - -### Why 6? -- **Human working memory** can effectively handle 5Β±2 items (Miller's Law) -- **6 items** allow for meaningful groupings without overwhelming complexity -- **Forces intentional design** decisions rather than accidental complexity growth - -### What it Promotes βœ… -- Clear hierarchical organization -- Single level of abstraction per construct -- Meaningful groupings and responsibilities -- Readable and maintainable code - -### What it Prevents ❌ -- Accidental complexity accumulation -- Deeply nested directory structures -- God functions and classes -- Parameter explosion - -## Rules Enforced - -| Rule | Threshold | Severity | Description | -|------|-----------|----------|-------------| -| **Domain Folders** | 6 folders | Error | Maximum domain folders per directory (generic excluded) | -| **Domain Files** | 6 files | Error | Maximum domain files per directory (generic excluded) | -| **Directory Items** | 6 items | Error | Maximum files/folders per directory (legacy rule) | -| **Functions per File** | 6 functions | Error | Maximum function definitions per file | -| **Function Lines** | 50 lines | Warning | Recommended maximum lines per function | -| **Function Lines** | 100 lines | Error | Hard limit for function length | -| **Function Arguments** | 3 args | Error | Maximum function parameters | -| **Object Parameters** | 6 keys | Warning | Maximum keys in object parameters | - -**Note**: All thresholds can be customized using `.ruleof6-exceptions` files (see [Exception Handling](#exception-handling) below). - -### Generic Infrastructure (Excluded from Domain Counting) - -The new domain-aware Rule of 6 excludes common infrastructure patterns from the count, allowing you to focus on meaningful domain abstractions: - -#### Generic Folders (Always Allowed): -- `docs/`, `doc/` - Documentation -- `types/` - Type definitions -- `utils/` - Utilities -- `components/` - UI components -- `hooks/` - React hooks -- `__tests__/`, `tests/` - Testing -- `fixtures/` - Test fixtures -- `mocks/` - Mock data/functions -- `stories/` - Storybook files - -#### Generic Files (Always Allowed): -- `dependencies.json` - Subsystem dependencies -- `README.md` - Documentation -- `index.ts`, `index.tsx` - Re-exports -- `page.tsx` - Next.js pages -- `layout.tsx` - Next.js layouts -- `loading.tsx` - Next.js loading components -- `error.tsx` - Next.js error boundaries -- `not-found.tsx` - Next.js 404 pages -- `*.config.js/ts` - Configuration files -- `*.stories.tsx/ts` - Storybook files -- `*.test.tsx/ts` - Test files -- `*.spec.tsx/ts` - Test files - -#### Example Valid Structure: -``` -src/app/map/Canvas/ -β”œβ”€β”€ docs/ # ← Generic (excluded) -β”œβ”€β”€ types/ # ← Generic (excluded) -β”œβ”€β”€ utils/ # ← Generic (excluded) -β”œβ”€β”€ components/ # ← Generic (excluded) -β”œβ”€β”€ hooks/ # ← Generic (excluded) -β”œβ”€β”€ dependencies.json # ← Generic (excluded) -β”œβ”€β”€ README.md # ← Generic (excluded) -β”œβ”€β”€ index.ts # ← Generic (excluded) -β”œβ”€β”€ Tile/ # ← Domain folder (1/6) -β”œβ”€β”€ LifeCycle/ # ← Domain folder (2/6) -β”œβ”€β”€ Interaction/ # ← Domain folder (3/6) -β”œβ”€β”€ canvas.tsx # ← Domain file (1/6) -β”œβ”€β”€ frame.tsx # ← Domain file (2/6) -└── context.tsx # ← Domain file (3/6) -``` - -This structure is **valid** because: -- Domain folders: 3 (≀ 6) βœ… -- Domain files: 3 (≀ 6) βœ… -- Generic infrastructure is excluded from count - -## Usage - -### Command Line - -```bash -# Check src directory (default) -python3 scripts/checks/ruleof6/cli.py - -# Check specific directory -python3 scripts/checks/ruleof6/cli.py src/components - -# Generate AI-friendly summary -python3 scripts/checks/ruleof6/cli.py --ai-summary - -# Quiet mode (minimal output) -python3 scripts/checks/ruleof6/cli.py --quiet -``` - -### Package Scripts - -```bash -# Via package.json (recommended) -pnpm check:ruleof6 - -# Check specific path -pnpm check:ruleof6 src/app -``` - -## Output - -### Console Output -The checker provides concise console output with: -- **Summary statistics** (total errors/warnings) -- **Top 10 violations** ordered by severity and impact -- **Reference to detailed JSON report** - -### JSON Report -Detailed machine-readable report saved to `test-results/rule-of-6-check.json`: - -```json -{ - "timestamp": "2024-01-01T12:00:00", - "target_path": "src", - "execution_time": 1.23, - "summary": { - "total_errors": 5, - "total_warnings": 3, - "by_type": { - "directory_items": 2, - "function_lines": 3 - } - }, - "violations": [ - { - "type": "directory_domain_folders", - "severity": "error", - "message": "Directory 'components' has 8 domain folders (max 6)", - "file": "src/components", - "recommendation": "Group related domain folders into subdirectories...", - "context": { - "domain_folder_count": 8, - "domain_file_count": 3, - "total_items": 15, - "domain_folders": ["Button", "Input", "Form", "..."], - "domain_files": ["theme.ts", "constants.ts", "helpers.ts"], - "excluded_generic_folders": ["docs", "types", "utils", "hooks"], - "excluded_generic_files": ["README.md", "index.ts"], - "domain_items": "Button, Input, Form, Modal, Dropdown, ...", - "generic_items_excluded": "docs, types, utils, hooks, README.md, index.ts" - } - } - ] -} -``` - -## AI-Friendly Analysis - -The JSON report supports detailed analysis through `jq` filtering: - -### Basic Filtering - -```bash -# Get all errors -jq '.violations[] | select(.severity == "error")' test-results/rule-of-6-check.json - -# Get summary statistics -jq '.summary' test-results/rule-of-6-check.json - -# Get violations by type -jq '.violations[] | select(.type == "function_lines")' test-results/rule-of-6-check.json -jq '.violations[] | select(.type == "directory_domain_folders")' test-results/rule-of-6-check.json -jq '.violations[] | select(.type == "directory_domain_files")' test-results/rule-of-6-check.json - -# Get violations by path pattern -jq '.violations[] | select(.file | contains("components"))' test-results/rule-of-6-check.json - -# Count violations by type -jq -r '.violations[].type' test-results/rule-of-6-check.json | sort | uniq -c -``` - -### Exception-Aware Filtering - -```bash -# Get violations using custom thresholds (exceptions) -jq '.violations[] | select(.exception_source != null)' test-results/rule-of-6-check.json - -# Get violations using default thresholds only -jq '.violations[] | select(.exception_source == null)' test-results/rule-of-6-check.json - -# Show exception summary -jq '.rules_applied.exceptions' test-results/rule-of-6-check.json - -# Compare custom vs default thresholds -jq '.violations[] | select(.custom_threshold != null) | {file: .file, function: .context.function_name, custom: .custom_threshold, default: .default_threshold}' test-results/rule-of-6-check.json -``` - -### Advanced Analysis - -```bash -# Get top directories with most items -jq '.violations[] | select(.type == "directory_items") | {path: .file, count: .context.item_count}' test-results/rule-of-6-check.json | sort_by(.count) | reverse - -# Get functions with most lines (including custom threshold info) -jq '.violations[] | select(.type == "function_lines") | {function: .context.function_name, file: .file, lines: .context.line_count, custom_threshold: .custom_threshold}' test-results/rule-of-6-check.json | sort_by(.lines) | reverse - -# Find functions exceeding custom thresholds -jq '.violations[] | select(.type == "function_lines" and .custom_threshold != null) | {function: .context.function_name, file: .file, actual: .context.line_count, limit: .custom_threshold, justification: .exception_source}' test-results/rule-of-6-check.json - -# Analyze domain vs generic item distribution -jq '.violations[] | select(.type == "directory_domain_folders" or .type == "directory_domain_files") | {path: .file, domain_folders: .context.domain_folder_count, domain_files: .context.domain_file_count, generic_folders: (.context.excluded_generic_folders | length), generic_files: (.context.excluded_generic_files | length), total: .context.total_items}' test-results/rule-of-6-check.json - -# Find directories with high generic-to-domain ratios (might indicate over-abstraction) -jq '.violations[] | select(.type == "directory_domain_folders" or .type == "directory_domain_files") | {path: .file, domain_count: (.context.domain_folder_count + .context.domain_file_count), generic_count: ((.context.excluded_generic_folders | length) + (.context.excluded_generic_files | length)), ratio: ((((.context.excluded_generic_folders | length) + (.context.excluded_generic_files | length)) | tonumber) / ((.context.domain_folder_count + .context.domain_file_count) | tonumber))} | select(.ratio > 1)' test-results/rule-of-6-check.json -``` - -## Rules Applied - -The checker enforces the following thresholds: - -| Rule | Threshold | Type | Description | -|------|-----------|------|-------------| -| **domain_folders** | 6 folders | Error | Maximum domain folders per directory (generic excluded) | -| **domain_files** | 6 files | Error | Maximum domain files per directory (generic excluded) | -| **directory_items** | 6 items | Error | Maximum files/folders per directory (legacy rule) | -| **functions_per_file** | 6 functions | Error | Maximum function definitions per file | -| **function_lines_warning** | 50 lines | Warning | Recommended maximum lines per function | -| **function_lines_error** | 100 lines | Error | Hard limit for function length | -| **function_args** | 3 arguments | Error | Maximum function parameters | -| **object_keys** | 6 keys | Warning | Maximum keys in object parameters | - -## Exception Handling - -### Custom Thresholds via `.ruleof6-exceptions` Files - -The Rule of 6 checker supports custom thresholds for cases where meaningful refactoring isn't possible without creating artificial abstractions. This allows you to **document complexity explicitly** rather than hide it behind poor abstractions. - -#### Exception File Format - -Create `.ruleof6-exceptions` files with this format: - -``` -# Function-specific exceptions with custom thresholds -src/math/hex-calculations.ts:calculatePoints: 150 # Mathematical algorithm -src/legacy/parser.ts:parseComplexFormat: 200 # Legacy format parser -src/api/routes.ts:createRoute: 5 # Framework requirement (args) - -# Directory-specific exceptions -src/components/forms: 12 # Cohesive form component library -src/utils/math: 8 # Mathematical utility collection - -# Justification comments are required for good practice -# TODO: Refactor calculatePoints when we upgrade the math library -``` - -#### Exception Types Supported - -**Function complexity exceptions** (line count): -``` -<file-path>:<function-name>: <line-threshold> # justification -``` - -**Directory exceptions** (item count): -``` -<directory-path>: <item-threshold> # justification -``` - -#### File Location Strategy - -- Exception files are discovered by walking up from the target directory to the project root -- More specific (closer) exception files take precedence -- Multiple exception files can be used at different directory levels - -#### Validation Requirements - -The checker **validates all exception rules**: - -- βœ… **Files must exist**: Exception references non-existent files will cause errors -- βœ… **Functions must exist**: Function-specific exceptions are validated against actual code -- βœ… **Directories must exist**: Directory paths are verified -- βœ… **Justification recommended**: Warnings for missing justification comments - -**Example validation error**: -``` -Exception validation failed: -Exception references non-existent function 'calculateHexPoints' in src/math/calculations.ts -``` - -#### Console Output Enhancement - -Violations using custom thresholds are clearly marked: - -``` -πŸ“ Rule of 6: 2 errors, 1 warnings -🎯 Loaded 5 custom thresholds from 2 files - -πŸ“ Too Many Lines in Functions -============================= - 1. ❌ Function 'calculatePoints' has 180 lines (custom limit 150) 🎯 - src/math/calculations.ts:42 - Custom threshold (150) vs default (100) -``` - -#### JSON Report Enhancement - -Custom threshold information is included in JSON reports: - -```json -{ - "violations": [ - { - "type": "function_lines", - "severity": "error", - "message": "Function exceeds custom threshold (180 lines, limit 150)", - "file": "src/math/calculations.ts", - "exception_source": ".ruleof6-exceptions", - "custom_threshold": 150, - "default_threshold": 100, - "actual_count": 180 - } - ], - "rules_applied": { - "exceptions": { - "exception_files_loaded": [".ruleof6-exceptions"], - "directory_exceptions": 2, - "function_exceptions": 3, - "total_exceptions": 5 - } - } -} -``` - -#### Exception Philosophy - -**Use exceptions for**: -- Mathematical algorithms that require sequential logic -- Framework-imposed patterns (e.g., route handlers with many parameters) -- Legacy code with planned refactoring timelines -- Cohesive domain collections (e.g., form components that belong together) - -**Don't use exceptions for**: -- Avoiding refactoring that would improve code quality -- Creating artificial permission to write complex code -- Hiding complexity that could be meaningfully abstracted - -**Principle**: Better to explicitly acknowledge complexity with clear reasoning than create meaningless abstractions that reduce code clarity. - -## Configuration - -### Legacy Exception Patterns (Ignored Files) -Customize completely ignored patterns in `.rule-of-6-ignore`: - -``` -# Test directories (often have many test files) -**/__tests__/** -**/*.test.ts -**/*.spec.ts - -# Database schema (many table definitions) -src/server/db/schema/** - -# Generated code -**/generated/** -``` - -**Note**: `.rule-of-6-ignore` completely skips files, while `.ruleof6-exceptions` allows custom thresholds with validation. - -## The CRITICAL Distinction: Meaningful vs. Meaningless Abstractions - -### ❌ WRONG: Meaningless Abstractions - -Don't create artificial splits just to satisfy the rule: - -```typescript -// BAD: Meaningless wrapper just to reduce line count -function processUserData(user: User) { - validateUser(user); - transformUser(user); - saveUser(user); -} - -// BAD: Artificial directory splitting -src/ - components/ - buttons/ - primary/ - PrimaryButton.tsx // Only file in directory -``` - -### βœ… RIGHT: Meaningful Abstractions - -Create abstractions that represent real domain concepts: - -```typescript -// GOOD: Each function has clear semantic meaning -function authenticateUser(credentials: LoginCredentials): Promise<User> -function authorizeUserAction(user: User, action: Action): boolean -function auditUserActivity(user: User, activity: Activity): void - -// GOOD: Logical grouping by domain concerns -src/ - components/ - auth/ # Authentication-related components - navigation/ # Navigation components - forms/ # Form components -``` - -## Refactoring Strategies - -### Directory Violations -**Problem**: Directory has too many items -**Solutions**: -1. **Group by domain**: Related functionality together -2. **Separate by layer**: UI components, business logic, utilities -3. **Extract by feature**: Feature-specific subdirectories - -### Function Violations -**Problem**: Function too long or too many functions per file -**Solutions**: -1. **Extract by responsibility**: Single responsibility per function -2. **Create domain services**: Business logic extraction -3. **Use composition**: Combine smaller, focused functions - -### Parameter Violations -**Problem**: Too many function arguments -**Solutions**: -1. **Group related parameters**: Create meaningful parameter objects -2. **Use configuration objects**: Options pattern for complex setup -3. **Extract to methods**: Object-oriented approach for stateful operations - -## Integration - -### CI/CD Integration -The checker exits with code 1 when errors are found, making it suitable for build pipelines: - -```yaml -# GitHub Actions example -- name: Check Rule of 6 - run: pnpm check:ruleof6 -``` - -### Pre-commit Hooks -Add to `.husky/pre-commit`: - -```bash -#!/bin/sh -pnpm check:ruleof6 -``` - -## Architecture - -The checker follows a modular architecture: - -``` -scripts/checks/ruleof6/ -β”œβ”€β”€ __init__.py # Package exports -β”œβ”€β”€ cli.py # Command-line interface -β”œβ”€β”€ checker.py # Main orchestration -β”œβ”€β”€ models.py # Data structures -β”œβ”€β”€ scanner.py # File/directory scanning -β”œβ”€β”€ parser.py # TypeScript parsing -β”œβ”€β”€ reporter.py # Result reporting -└── README.md # This file -``` - -## Remember - -> **The Rule of 6 promotes better design, not just smaller numbers.** - -The goal is to create more maintainable, understandable code by forcing intentional design decisions. Don't game the system with meaningless abstractionsβ€”embrace the constraint to find better architectural solutions. \ No newline at end of file diff --git a/scripts/checks/ruleof6/__init__.py b/scripts/checks/ruleof6/__init__.py deleted file mode 100644 index cb8d242e5..000000000 --- a/scripts/checks/ruleof6/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -""" -Rule of 6 checker package. - -Validates adherence to the Rule of 6 architecture principle. -""" - -from .models import ViolationType, Severity, RuleOf6Violation, CheckResults -from .checker import RuleOf6Checker -from .reporter import RuleOf6Reporter - -__all__ = [ - "ViolationType", - "Severity", - "RuleOf6Violation", - "CheckResults", - "RuleOf6Checker", - "RuleOf6Reporter" -] \ No newline at end of file diff --git a/scripts/checks/ruleof6/checker.py b/scripts/checks/ruleof6/checker.py deleted file mode 100644 index 4f8234381..000000000 --- a/scripts/checks/ruleof6/checker.py +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env python3 -""" -Main Rule of 6 checker orchestration. - -Coordinates all rule checking and manages the overall checking process. -""" - -import time -from pathlib import Path -from typing import List -from concurrent.futures import ThreadPoolExecutor, as_completed - -from models import CheckResults, ViolationType, RuleOf6Violation, FileAnalysis, DomainDirectoryInfo -from scanner import LegacyIgnoreManager, DirectoryScanner, FileScanner -from parser import TypeScriptParser -from exceptions import CustomThresholdManager - - -class RuleOf6Checker: - """Main Rule of 6 checker that orchestrates all validation.""" - - def __init__(self, target_path: str = "src"): - self.target_path = Path(target_path) - - # Rule thresholds (defaults) - self.max_directory_items = 6 # Legacy rule (backwards compatibility) - self.max_domain_folders = 6 # New rule: max domain folders per directory - self.max_domain_files = 6 # New rule: max domain files per directory - self.max_functions_per_file = 6 - self.max_function_lines = 50 - self.max_function_args = 6 - self.max_object_keys = 6 - self.max_function_lines_error = 100 # Hard limit for errors - - # Initialize components - self.ignore_manager = LegacyIgnoreManager() - - # Determine project root more reliably - project_root = self._find_project_root(self.target_path) - self.threshold_manager = CustomThresholdManager(project_root) - - self.directory_scanner = DirectoryScanner(self.ignore_manager) - self.file_scanner = FileScanner(self.ignore_manager) - self.parser = TypeScriptParser() - - # Load custom thresholds - try: - self.threshold_manager.load_exceptions(self.target_path) - except ValueError as e: - print(f"Warning: Exception validation failed: {e}") - # Continue with default thresholds - - def run_all_checks(self) -> CheckResults: - """Run all Rule of 6 checks and return results.""" - start_time = time.time() - results = CheckResults(target_path=str(self.target_path)) - - # Run all checks - self._check_domain_directory_rule(results) - self._check_file_function_rules(results) - self._check_object_parameter_rule(results) - - results.execution_time = time.time() - start_time - - # Track which rules were applied - results.rules_applied = { - "directory_items": self.max_directory_items, # Legacy rule - "domain_folders": self.max_domain_folders, # New rules - "domain_files": self.max_domain_files, - "functions_per_file": self.max_functions_per_file, - "function_lines_warning": self.max_function_lines, - "function_lines_error": self.max_function_lines_error, - "function_args": self.max_function_args, - "object_keys": self.max_object_keys - } - - # Add exception summary if any exceptions were loaded - if self.threshold_manager.has_exceptions(): - exception_summary = self.threshold_manager.get_exception_summary() - results.rules_applied["exceptions"] = exception_summary - - return results - - def _find_project_root(self, target_path: Path) -> Path: - """Find project root by looking for common markers.""" - current = target_path.resolve() - - # Look for common project root indicators - root_markers = { - 'package.json', 'pnpm-lock.yaml', 'yarn.lock', '.git', - 'pyproject.toml', 'Cargo.toml', 'composer.json' - } - - max_depth = 10 - depth = 0 - - while depth < max_depth: - # Check if any root markers exist in current directory - if any((current / marker).exists() for marker in root_markers): - return current - - # Stop if we've reached filesystem root - if current == current.parent: - break - - current = current.parent - depth += 1 - - # Fallback: use current working directory - return Path.cwd() - - def _check_directory_rule(self, results: CheckResults) -> None: - """Check that directories have max 6 items (with custom threshold support).""" - # First get all directories (we'll filter with custom thresholds) - all_dirs = [] - - # Check the target directory itself first - if not self.ignore_manager.is_exception(self.target_path): - dir_info = self.directory_scanner.scan_directory(self.target_path) - all_dirs.append(dir_info) - - # Then check all subdirectories - for directory in self.target_path.rglob("*"): - if directory.is_dir() and not self.ignore_manager.is_exception(directory): - dir_info = self.directory_scanner.scan_directory(directory) - all_dirs.append(dir_info) - - # Check each directory with appropriate threshold - for dir_info in all_dirs: - # Check for custom threshold first - custom_rule = self.threshold_manager.get_directory_exception(dir_info.path) - threshold = custom_rule.threshold if custom_rule else self.max_directory_items - - if dir_info.item_count > threshold: - relative_path = str(dir_info.path.relative_to(self.target_path) if dir_info.path != self.target_path else '.') - items_display = dir_info.get_item_list_display() - - # Create message with custom threshold info - if custom_rule: - message = f"Directory '{relative_path}' has {dir_info.item_count} items (custom limit {threshold})" - else: - message = f"Directory '{relative_path}' has {dir_info.item_count} items (max {threshold})" - - violation = RuleOf6Violation.create_error( - message=message, - violation_type=ViolationType.DIRECTORY_ITEMS, - file_path=str(dir_info.path), - recommendation="Group related items into subdirectories with meaningful names. Avoid creating empty subdirectories just to meet the rule.", - context={ - "item_count": dir_info.item_count, - "items": dir_info.items, - "items_display": items_display - }, - exception_source=custom_rule.source_file if custom_rule else None, - custom_threshold=custom_rule.threshold if custom_rule else None, - default_threshold=self.max_directory_items - ) - results.add_violation(violation) - - def _check_domain_directory_rule(self, results: CheckResults) -> None: - """Check that directories have max 6 domain folders and 6 domain files (new rule).""" - # Find all directories with domain violations - domain_violating_dirs = self.directory_scanner.find_domain_violating_directories( - self.target_path, self.max_domain_folders, self.max_domain_files - ) - - for dir_info in domain_violating_dirs: - relative_path = str(dir_info.path.relative_to(self.target_path) if dir_info.path != self.target_path else '.') - - # Check for violations in domain folders - if dir_info.domain_folder_count > self.max_domain_folders: - violation = RuleOf6Violation.create_error( - message=f"Directory '{relative_path}' has {dir_info.domain_folder_count} domain folders (max {self.max_domain_folders})", - violation_type=ViolationType.DIRECTORY_DOMAIN_FOLDERS, - file_path=str(dir_info.path), - recommendation="Group related domain folders into subdirectories with meaningful names. Focus on meaningful abstractions rather than arbitrary limits.", - context={ - "domain_folder_count": dir_info.domain_folder_count, - "domain_file_count": dir_info.domain_file_count, - "total_items": dir_info.total_item_count, - "domain_folders": dir_info.domain_folders, - "domain_files": dir_info.domain_files, - "excluded_generic_folders": dir_info.generic_folders, - "excluded_generic_files": dir_info.generic_files, - "domain_items": dir_info.get_domain_items_display(), - "generic_items_excluded": dir_info.get_generic_items_display() - } - ) - results.add_violation(violation) - - # Check for violations in domain files - if dir_info.domain_file_count > self.max_domain_files: - violation = RuleOf6Violation.create_error( - message=f"Directory '{relative_path}' has {dir_info.domain_file_count} domain files (max {self.max_domain_files})", - violation_type=ViolationType.DIRECTORY_DOMAIN_FILES, - file_path=str(dir_info.path), - recommendation="Split domain files into subdirectories or extract related functionality into separate modules. Focus on meaningful abstractions rather than arbitrary limits.", - context={ - "domain_folder_count": dir_info.domain_folder_count, - "domain_file_count": dir_info.domain_file_count, - "total_items": dir_info.total_item_count, - "domain_folders": dir_info.domain_folders, - "domain_files": dir_info.domain_files, - "excluded_generic_folders": dir_info.generic_folders, - "excluded_generic_files": dir_info.generic_files, - "domain_items": dir_info.get_domain_items_display(), - "generic_items_excluded": dir_info.get_generic_items_display() - } - ) - results.add_violation(violation) - - def _check_file_function_rules(self, results: CheckResults) -> None: - """Check file function count and individual function rules.""" - # Find all TypeScript files - ts_files = self.file_scanner.find_typescript_files(self.target_path) - - # Analyze files in parallel - with ThreadPoolExecutor(max_workers=4) as executor: - # Submit all file scanning tasks - file_futures = { - executor.submit(self.file_scanner.scan_file, file_path): file_path - for file_path in ts_files - } - - # Process scanned files and parse them - parse_futures = {} - for future in as_completed(file_futures): - file_analysis = future.result() - if file_analysis: - # Read content and parse functions - parse_future = executor.submit(self._parse_file_functions, file_analysis) - parse_futures[parse_future] = file_analysis - - # Process parsed results - for future in as_completed(parse_futures): - file_analysis = future.result() - if file_analysis: - self._check_single_file(file_analysis, results) - - def _parse_file_functions(self, file_analysis: FileAnalysis) -> FileAnalysis: - """Parse functions in a single file.""" - try: - with open(file_analysis.path, 'r', encoding='utf-8') as f: - content = f.read() - - return self.parser.parse_file(file_analysis, content) - except (UnicodeDecodeError, OSError): - return file_analysis - - def _check_single_file(self, file_analysis: FileAnalysis, results: CheckResults) -> None: - """Check a single file for Rule of 6 violations.""" - relative_path = str(file_analysis.path.relative_to(self.target_path)) - - # Check function count per file with custom threshold support - custom_rule = self.threshold_manager.get_file_exception(file_analysis.path) - threshold = custom_rule.threshold if custom_rule else self.max_functions_per_file - - # Debug print removed - - if file_analysis.function_count > threshold: - func_names = file_analysis.get_function_names() - - # Create message with custom threshold info - if custom_rule: - message = f"File '{relative_path}' has {file_analysis.function_count} functions (custom limit {threshold})" - else: - message = f"File '{relative_path}' has {file_analysis.function_count} functions (max {threshold})" - - violation = RuleOf6Violation.create_error( - message=message, - violation_type=ViolationType.FILE_FUNCTIONS, - file_path=relative_path, - recommendation="Split into multiple files by grouping related functions. Consider extracting utility functions or creating separate modules for distinct concerns.", - context={ - "function_count": file_analysis.function_count, - "function_names": func_names - }, - exception_source=custom_rule.source_file if custom_rule else None, - custom_threshold=custom_rule.threshold if custom_rule else None, - default_threshold=self.max_functions_per_file - ) - results.add_violation(violation) - - # Check individual function rules - for func in file_analysis.functions: - self._check_function_lines(func, relative_path, results) - self._check_function_arguments(func, relative_path, results) - - def _check_function_lines(self, func, relative_path: str, results: CheckResults) -> None: - """Check function line count with custom threshold support.""" - # Check for custom threshold - custom_rule = self.threshold_manager.get_function_exception(relative_path, func.name) - - if custom_rule: - # Use custom threshold - if func.line_count > custom_rule.threshold: - violation = RuleOf6Violation.create_error( - message=f"Function '{func.name}' has {func.line_count} lines (custom limit {custom_rule.threshold})", - violation_type=ViolationType.FUNCTION_LINES, - file_path=relative_path, - line_number=func.line_start, - recommendation=f"Refactor to stay within custom threshold. Justification: {custom_rule.justification}", - context={ - "function_name": func.name, - "line_count": func.line_count, - "line_range": f"{func.line_start}-{func.line_end}" - }, - exception_source=custom_rule.source_file, - custom_threshold=custom_rule.threshold, - default_threshold=self.max_function_lines_error - ) - results.add_violation(violation) - else: - # Use default thresholds - if func.line_count > self.max_function_lines: - if func.line_count < self.max_function_lines_error: - # Warning for functions between 50-100 lines - violation = RuleOf6Violation.create_warning( - message=f"Function '{func.name}' has {func.line_count} lines (recommended max {self.max_function_lines})", - violation_type=ViolationType.FUNCTION_LINES, - file_path=relative_path, - line_number=func.line_start, - recommendation="Break down into max 6 smaller functions at the same abstraction level. Focus on single responsibility and meaningful function names.", - context={ - "function_name": func.name, - "line_count": func.line_count, - "line_range": f"{func.line_start}-{func.line_end}" - }, - default_threshold=self.max_function_lines - ) - else: - # Error for functions 100+ lines - violation = RuleOf6Violation.create_error( - message=f"Function '{func.name}' has {func.line_count} lines (enforced max {self.max_function_lines_error})", - violation_type=ViolationType.FUNCTION_LINES, - file_path=relative_path, - line_number=func.line_start, - recommendation="Immediately refactor into max 6 function calls at the same abstraction level. Avoid creating meaningless wrapper functions.", - context={ - "function_name": func.name, - "line_count": func.line_count, - "line_range": f"{func.line_start}-{func.line_end}" - }, - default_threshold=self.max_function_lines_error - ) - - results.add_violation(violation) - - def _check_function_arguments(self, func, relative_path: str, results: CheckResults) -> None: - """Check function argument count with custom threshold support.""" - # Check for custom threshold for arguments - custom_rule = self.threshold_manager.get_function_exception(relative_path, func.name) - - # For function arguments, we check if there's a custom rule that affects argument count - # The custom rule threshold is used for line count, but we can extend this logic - # For now, use default argument checking (can be enhanced later if needed) - - if func.arg_count > self.max_function_args: - violation = RuleOf6Violation.create_error( - message=f"Function '{func.name}' has {func.arg_count} arguments (max {self.max_function_args})", - violation_type=ViolationType.FUNCTION_ARGS, - file_path=relative_path, - line_number=func.line_start, - recommendation=f"Use max 3 arguments, or 1 object with max {self.max_object_keys} keys. Group related parameters meaningfully.", - context={ - "function_name": func.name, - "arg_count": func.arg_count - }, - default_threshold=self.max_function_args - ) - results.add_violation(violation) - - def _check_object_parameter_rule(self, results: CheckResults) -> None: - """Check object parameters have max 6 keys.""" - ts_files = self.file_scanner.find_typescript_files(self.target_path) - - for file_path in ts_files: - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - violations = self.parser.find_object_parameter_violations( - content, file_path, self.max_object_keys - ) - - relative_path = str(file_path.relative_to(self.target_path)) - - for line_num, key_count, params_preview in violations: - violation = RuleOf6Violation.create_warning( - message=f"Object parameter has {key_count} keys (max {self.max_object_keys})", - violation_type=ViolationType.OBJECT_KEYS, - file_path=relative_path, - line_number=line_num, - recommendation="Group related keys into nested objects or split into multiple focused parameters with clear semantic meaning.", - context={ - "key_count": key_count, - "params_preview": params_preview - } - ) - results.add_violation(violation) - - except (UnicodeDecodeError, OSError): - continue \ No newline at end of file diff --git a/scripts/checks/ruleof6/cli.py b/scripts/checks/ruleof6/cli.py deleted file mode 100644 index 9168b2d96..000000000 --- a/scripts/checks/ruleof6/cli.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -""" -Command-line interface for Rule of 6 checking. - -Entry point for the Rule of 6 checker with enhanced reporting and AI integration. -""" - -import sys -import argparse -from pathlib import Path - -# Add the parent directory to Python path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -from checker import RuleOf6Checker -from reporter import RuleOf6Reporter - - -def create_parser() -> argparse.ArgumentParser: - """Create argument parser for Rule of 6 checker.""" - parser = argparse.ArgumentParser( - description="Rule of 6 Enforcement - Validates adherence to the Rule of 6 architecture principle", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python3 scripts/checks/ruleof6/cli.py # Check src/ directory - python3 scripts/checks/ruleof6/cli.py src/app # Check specific directory - pnpm check:ruleof6 # Via package.json script - pnpm check:ruleof6 src/components # Check specific path - -Rule of 6 Principles: - β€’ Max 6 domain folders + 6 domain files per directory (generic infrastructure excluded) - β€’ Max 6 functions per file - β€’ Max 50 lines per function (warning), 100+ lines (error) - β€’ Max 3 arguments per function (or 1 object with max 6 keys) - -Excluded from count (generic infrastructure): - β€’ Generic folders: docs/, types/, utils/, components/, hooks/, __tests__/, etc. - β€’ Generic files: README.md, index.ts, page.tsx, *.config.*, *.test.*, etc. - -IMPORTANT: Avoid creating meaningless abstractions to satisfy the rules. -Focus on logical groupings, clear responsibilities, and semantic meaning. - """ - ) - - parser.add_argument( - "path", - nargs="?", - default="src", - help="Path to check for Rule of 6 violations (default: src)" - ) - - parser.add_argument( - "--output", - "-o", - default="test-results/rule-of-6-check.json", - help="Output file for detailed JSON report (default: test-results/rule-of-6-check.json)" - ) - - parser.add_argument( - "--ai-summary", - action="store_true", - help="Generate AI-friendly summary for automated processing" - ) - - parser.add_argument( - "--quiet", - "-q", - action="store_true", - help="Reduce output verbosity" - ) - - parser.add_argument( - "--version", - action="version", - version="Rule of 6 Checker v2.0.0" - ) - - return parser - - -def main(): - """Main entry point for Rule of 6 checking.""" - parser = create_parser() - args = parser.parse_args() - - # Validate target path - target_path = Path(args.path) - if not target_path.exists(): - print(f"❌ Error: Path '{target_path}' does not exist", file=sys.stderr) - sys.exit(1) - - # Initialize checker and reporter - checker = RuleOf6Checker(str(target_path)) - reporter = RuleOf6Reporter(args.output) - - try: - # Run all checks - results = checker.run_all_checks() - - # Report results - if args.ai_summary: - # Generate AI-friendly summary - summary = reporter.generate_ai_friendly_summary(results) - print(summary) - elif not args.quiet: - # Full console report - success = reporter.report_results(results) - else: - # Quiet mode - minimal output - success = reporter.report_results(results) - if results.has_errors(): - print(f"❌ Rule of 6 violations found: {len(results.errors)} errors, {len(results.warnings)} warnings") - print(f"πŸ“„ Details: {reporter.output_file}") - else: - print("βœ… Rule of 6 checks passed!") - - # Exit with appropriate code - sys.exit(0 if not results.has_errors() else 1) - - except KeyboardInterrupt: - print("\n⏹️ Rule of 6 check interrupted", file=sys.stderr) - sys.exit(130) - except Exception as e: - print(f"❌ Unexpected error during Rule of 6 check: {e}", file=sys.stderr) - sys.exit(1) - - -def print_philosophy(): - """Print the Rule of 6 philosophy for educational purposes.""" - philosophy = """ -πŸ“ The Rule of 6 Philosophy - -The Rule of 6 is not about arbitrary limits - it's about cognitive load management -and creating systems that humans can understand and maintain effectively. - -Why 6? -β€’ Human working memory can effectively handle 5Β±2 items -β€’ 6 items allow for meaningful groupings without overwhelming complexity -β€’ Forces intentional design decisions rather than accidental complexity - -What it promotes: -βœ… Clear hierarchical organization -βœ… Single level of abstraction per construct -βœ… Meaningful groupings and responsibilities -βœ… Readable and maintainable code - -What it prevents: -❌ Accidental complexity accumulation -❌ Deeply nested directory structures -❌ God functions and classes -❌ Parameter explosion - -CRITICAL: Meaningful Abstraction vs. Arbitrary Splitting - -❌ BAD: Creating empty wrapper functions just to reduce line count -❌ BAD: Splitting coherent logic across multiple files artificially -❌ BAD: Creating unnecessary intermediate directories - -βœ… GOOD: Identifying natural conceptual boundaries -βœ… GOOD: Grouping related functionality together -βœ… GOOD: Creating abstractions that represent domain concepts - -Remember: The goal is better design, not just smaller numbers. - """ - print(philosophy) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/checks/ruleof6/exceptions.py b/scripts/checks/ruleof6/exceptions.py deleted file mode 100644 index c9a8cc22f..000000000 --- a/scripts/checks/ruleof6/exceptions.py +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env python3 -""" -Exception handling for Rule of 6 checker. - -Supports custom thresholds via .ruleof6-exceptions files for cases where -meaningful refactoring isn't possible without creating artificial abstractions. -""" - -import fnmatch -import re -from pathlib import Path -from typing import Dict, List, Optional, Tuple, Set -from dataclasses import dataclass, field - -from parser import TypeScriptParser - - -@dataclass -class ExceptionRule: - """Represents a single exception rule.""" - file_path: str - function_name: Optional[str] = None # None for directory exceptions - threshold: int = 0 - justification: str = "" - source_file: str = "" - line_number: int = 0 - - @property - def is_directory_exception(self) -> bool: - """Check if this is a directory exception.""" - return self.function_name is None - - @property - def is_function_exception(self) -> bool: - """Check if this is a function exception.""" - return self.function_name is not None - - -class ExceptionFileParser: - """Parses .ruleof6-exceptions files.""" - - def __init__(self, project_root: Path): - self.project_root = project_root - - def parse_exception_file(self, exception_file: Path) -> List[ExceptionRule]: - """Parse a .ruleof6-exceptions file and return list of rules.""" - rules = [] - - if not exception_file.exists(): - return rules - - try: - with open(exception_file, 'r', encoding='utf-8') as f: - content = f.read() - except (OSError, UnicodeDecodeError) as e: - raise ValueError(f"Could not read exception file {exception_file}: {e}") - - for line_num, line in enumerate(content.splitlines(), 1): - rule = self._parse_exception_line(line, str(exception_file), line_num) - if rule: - rules.append(rule) - - return rules - - def _parse_exception_line(self, line: str, source_file: str, line_number: int) -> Optional[ExceptionRule]: - """Parse a single line from an exception file.""" - # Remove leading/trailing whitespace - line = line.strip() - - # Skip empty lines and pure comments - if not line or line.startswith('#'): - return None - - # Extract inline comment (justification) - if '#' in line: - content, justification = line.split('#', 1) - content = content.strip() - justification = justification.strip() - else: - content = line - justification = "" - - # Skip lines without justification (warn but continue) - if not justification: - print(f"Warning: Missing justification in {source_file}:{line_number}: {line}") - - # Parse exception rule - if ':' not in content: - raise ValueError(f"Invalid format in {source_file}:{line_number}: {line}") - - parts = content.split(':', 2) - if len(parts) < 2: - raise ValueError(f"Invalid format in {source_file}:{line_number}: {line}") - - # Check if it's a function or directory exception - if len(parts) == 3: - # Function exception: file:function:threshold - file_path, function_name, threshold_str = parts - function_name = function_name.strip() - else: - # Directory exception: path:threshold - file_path, threshold_str = parts - function_name = None - - file_path = file_path.strip() - threshold_str = threshold_str.strip() - - # Parse threshold - try: - threshold = int(threshold_str) - except ValueError: - raise ValueError(f"Invalid threshold in {source_file}:{line_number}: {threshold_str}") - - # Validate threshold is reasonable - if threshold <= 0 or threshold > 1000: - print(f"Warning: Unusual threshold {threshold} in {source_file}:{line_number}") - - return ExceptionRule( - file_path=file_path, - function_name=function_name, - threshold=threshold, - justification=justification, - source_file=source_file, - line_number=line_number - ) - - -class ExceptionValidator: - """Validates exception rules against actual codebase.""" - - def __init__(self, project_root: Path): - self.project_root = project_root - self.parser = TypeScriptParser() - - def validate_exception_rules(self, rules: List[ExceptionRule]) -> List[str]: - """Validate all exception rules and return list of errors.""" - errors = [] - - for rule in rules: - rule_errors = self._validate_single_rule(rule) - errors.extend(rule_errors) - - return errors - - def _validate_single_rule(self, rule: ExceptionRule) -> List[str]: - """Validate a single exception rule.""" - errors = [] - - # Resolve path relative to project root - if rule.file_path.startswith('/'): - file_path = Path(rule.file_path) - else: - file_path = self.project_root / rule.file_path - - # Check if path exists - if rule.is_directory_exception: - if not file_path.exists() or not file_path.is_dir(): - errors.append(f"Exception references non-existent directory: {rule.file_path}") - else: - if not file_path.exists() or not file_path.is_file(): - errors.append(f"Exception references non-existent file: {rule.file_path}") - return errors # Can't validate function if file doesn't exist - - # Validate function exists in file - function_error = self._validate_function_exists(file_path, rule.function_name, rule) - if function_error: - errors.append(function_error) - - return errors - - def _validate_function_exists(self, file_path: Path, function_name: str, rule: ExceptionRule) -> Optional[str]: - """Check if function exists in the specified file.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Use parser to extract function names - function_names = self.parser.shared_parser.extract_function_names_from_content(content) - - if function_name not in function_names: - return (f"Exception references non-existent function '{function_name}' " - f"in {rule.file_path} (source: {rule.source_file}:{rule.line_number})") - - except Exception as e: - return f"Could not validate function in {rule.file_path}: {e}" - - return None - - -class CustomThresholdManager: - """Manages custom thresholds from .ruleof6-exceptions files.""" - - def __init__(self, project_root: Path): - self.project_root = project_root - self.parser = ExceptionFileParser(project_root) - self.validator = ExceptionValidator(project_root) - self.directory_exceptions: Dict[str, ExceptionRule] = {} - self.function_exceptions: Dict[str, ExceptionRule] = {} - self.loaded_exception_files: List[str] = [] - - def load_exceptions(self, target_path: Path) -> None: - """Load exceptions from .ruleof6-exceptions files.""" - exception_files = self._find_exception_files(target_path) - - all_rules = [] - for exception_file in exception_files: - rules = self.parser.parse_exception_file(exception_file) - all_rules.extend(rules) - self.loaded_exception_files.append(str(exception_file)) - - # Validate all rules - validation_errors = self.validator.validate_exception_rules(all_rules) - if validation_errors: - print(f"Warning: Some exception validations failed: {validation_errors}") - # Temporarily allow continuing with validation errors - # raise ValueError("Exception validation failed:\n" + "\n".join(validation_errors)) - - # Organize rules by type - for rule in all_rules: - if rule.is_directory_exception: - # Normalize directory path for consistent matching - normalized_path = self._normalize_path(rule.file_path) - self.directory_exceptions[normalized_path] = rule - else: - # Function exception key: file_path:function_name - func_key = f"{rule.file_path}:{rule.function_name}" - self.function_exceptions[func_key] = rule - - def _find_exception_files(self, target_path: Path) -> List[Path]: - """Find .ruleof6-exceptions files from target path up to project root.""" - exception_files = [] - - # Start from target path and walk up to project root - current_path = target_path.resolve() - project_root_resolved = self.project_root.resolve() - - # Prevent infinite loop with max depth check - max_depth = 20 - depth = 0 - - while depth < max_depth: - exception_file = current_path / ".ruleof6-exceptions" - if exception_file.exists(): - exception_files.append(exception_file) - - # Stop if we've reached project root or filesystem root - if current_path == project_root_resolved or current_path == current_path.parent: - break - - current_path = current_path.parent - depth += 1 - - return exception_files - - def _normalize_path(self, path_str: str) -> str: - """Normalize path for consistent matching.""" - try: - if path_str.startswith('/'): - path = Path(path_str) - else: - path = self.project_root / path_str - - return str(path.resolve().relative_to(self.project_root)) - except ValueError: - # Path is outside project root, use as-is - return path_str - - def get_directory_exception(self, dir_path: Path) -> Optional[ExceptionRule]: - """Get custom threshold for directory if it exists.""" - normalized = self._normalize_path(str(dir_path)) - return self.directory_exceptions.get(normalized) - - def get_file_exception(self, file_path: Path) -> Optional[ExceptionRule]: - """Get custom threshold for file function count if it exists.""" - normalized = self._normalize_path(str(file_path)) - - # Try to match with src prefix removed - if normalized.startswith('src/'): - normalized_without_src = normalized[4:] # Remove 'src/' prefix - return self.directory_exceptions.get(normalized_without_src) - - return self.directory_exceptions.get(normalized) - - def get_function_exception(self, file_path: str, function_name: str) -> Optional[ExceptionRule]: - """Get custom threshold for function if it exists.""" - # Try exact match first - func_key = f"{file_path}:{function_name}" - if func_key in self.function_exceptions: - return self.function_exceptions[func_key] - - # Try wildcard patterns if needed (future enhancement) - for pattern_key, rule in self.function_exceptions.items(): - if fnmatch.fnmatch(func_key, pattern_key): - return rule - - return None - - def has_exceptions(self) -> bool: - """Check if any exceptions were loaded.""" - return bool(self.directory_exceptions or self.function_exceptions) - - def get_exception_summary(self) -> Dict: - """Get summary of loaded exceptions for reporting.""" - return { - "exception_files_loaded": self.loaded_exception_files, - "directory_exceptions": len(self.directory_exceptions), - "function_exceptions": len(self.function_exceptions), - "total_exceptions": len(self.directory_exceptions) + len(self.function_exceptions) - } \ No newline at end of file diff --git a/scripts/checks/ruleof6/models.py b/scripts/checks/ruleof6/models.py deleted file mode 100644 index c933b2498..000000000 --- a/scripts/checks/ruleof6/models.py +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env python3 -""" -Data models for Rule of 6 checking. - -Contains all data structures used throughout the Rule of 6 checking system. -""" - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional -from enum import Enum - - -class ViolationType(Enum): - """Types of Rule of 6 violations.""" - DIRECTORY_ITEMS = "directory_items" - DIRECTORY_DOMAIN_FOLDERS = "directory_domain_folders" - DIRECTORY_DOMAIN_FILES = "directory_domain_files" - FILE_FUNCTIONS = "file_functions" - FUNCTION_LINES = "function_lines" - FUNCTION_ARGS = "function_args" - OBJECT_KEYS = "object_keys" - - -class Severity(Enum): - """Violation severity levels.""" - ERROR = "error" - WARNING = "warning" - - -@dataclass -class FunctionInfo: - """Information about a function in a TypeScript file.""" - name: str - line_start: int - line_end: int - line_count: int - arg_count: int - file_path: Path - - def __post_init__(self): - """Validate function info after initialization.""" - if self.line_start <= 0: - raise ValueError(f"Invalid line_start: {self.line_start}") - if self.line_end < self.line_start: - raise ValueError(f"Invalid line_end: {self.line_end} < {self.line_start}") - - -@dataclass -class DirectoryInfo: - """Information about a directory.""" - path: Path - item_count: int - items: List[str] = field(default_factory=list) - - def get_item_list_display(self, max_items: int = 8) -> str: - """Get a display string for directory items.""" - if len(self.items) <= max_items: - return ", ".join(self.items) - return ", ".join(self.items[:max_items]) + "..." - - -@dataclass -class DomainDirectoryInfo: - """Information about a directory with domain/generic item separation.""" - path: Path - domain_folder_count: int - domain_file_count: int - generic_folder_count: int - generic_file_count: int - domain_folders: List[str] = field(default_factory=list) - domain_files: List[str] = field(default_factory=list) - generic_folders: List[str] = field(default_factory=list) - generic_files: List[str] = field(default_factory=list) - - @property - def total_item_count(self) -> int: - """Get total count of all items (domain + generic).""" - return (self.domain_folder_count + self.domain_file_count + - self.generic_folder_count + self.generic_file_count) - - @property - def domain_item_count(self) -> int: - """Get total count of domain items only.""" - return self.domain_folder_count + self.domain_file_count - - def get_domain_items_display(self, max_items: int = 8) -> str: - """Get a display string for domain items.""" - domain_items = self.domain_folders + self.domain_files - if len(domain_items) <= max_items: - return ", ".join(domain_items) - return ", ".join(domain_items[:max_items]) + "..." - - def get_generic_items_display(self, max_items: int = 8) -> str: - """Get a display string for generic items.""" - generic_items = self.generic_folders + self.generic_files - if len(generic_items) <= max_items: - return ", ".join(generic_items) - return ", ".join(generic_items[:max_items]) + "..." - - -@dataclass -class FileAnalysis: - """Analysis results for a single TypeScript file.""" - path: Path - line_count: int - function_count: int - functions: List[FunctionInfo] = field(default_factory=list) - - def get_function_names(self, max_names: int = 8) -> List[str]: - """Get function names for display, truncated if needed.""" - names = [f.name for f in self.functions[:max_names]] - if len(self.functions) > max_names: - names.append("...") - return names - - -@dataclass -class RuleOf6Violation: - """Represents a Rule of 6 violation with enhanced metadata.""" - message: str - violation_type: ViolationType - severity: Severity = Severity.ERROR - file_path: Optional[str] = None - line_number: Optional[int] = None - recommendation: Optional[str] = None - context: Optional[Dict] = None - exception_source: Optional[str] = None - custom_threshold: Optional[int] = None - default_threshold: Optional[int] = None - - @classmethod - def create_error( - cls, - message: str, - violation_type: ViolationType, - file_path: Optional[str] = None, - line_number: Optional[int] = None, - recommendation: Optional[str] = None, - context: Optional[Dict] = None, - exception_source: Optional[str] = None, - custom_threshold: Optional[int] = None, - default_threshold: Optional[int] = None - ) -> "RuleOf6Violation": - """Create a violation with ERROR severity.""" - return cls( - message=message, - violation_type=violation_type, - severity=Severity.ERROR, - file_path=file_path, - line_number=line_number, - recommendation=recommendation, - context=context, - exception_source=exception_source, - custom_threshold=custom_threshold, - default_threshold=default_threshold - ) - - @classmethod - def create_warning( - cls, - message: str, - violation_type: ViolationType, - file_path: Optional[str] = None, - line_number: Optional[int] = None, - recommendation: Optional[str] = None, - context: Optional[Dict] = None, - exception_source: Optional[str] = None, - custom_threshold: Optional[int] = None, - default_threshold: Optional[int] = None - ) -> "RuleOf6Violation": - """Create a violation with WARNING severity.""" - return cls( - message=message, - violation_type=violation_type, - severity=Severity.WARNING, - file_path=file_path, - line_number=line_number, - recommendation=recommendation, - context=context, - exception_source=exception_source, - custom_threshold=custom_threshold, - default_threshold=default_threshold - ) - - def to_dict(self) -> Dict: - """Convert violation to dictionary for JSON serialization.""" - result = { - "type": self.violation_type.value, - "severity": self.severity.value, - "message": self.message, - "file": self.file_path, - "line": self.line_number, - "recommendation": self.recommendation, - "context": self.context - } - - # Add exception metadata if available - if self.exception_source is not None: - result["exception_source"] = self.exception_source - if self.custom_threshold is not None: - result["custom_threshold"] = self.custom_threshold - if self.default_threshold is not None: - result["default_threshold"] = self.default_threshold - - return result - - -@dataclass -class CheckResults: - """Results of Rule of 6 checking.""" - errors: List[RuleOf6Violation] = field(default_factory=list) - warnings: List[RuleOf6Violation] = field(default_factory=list) - execution_time: float = 0.0 - target_path: str = "src" - rules_applied: Dict[str, int] = field(default_factory=dict) - - def add_violation(self, violation: RuleOf6Violation) -> None: - """Add a violation to the appropriate list based on severity.""" - if violation.severity == Severity.ERROR: - self.errors.append(violation) - else: - self.warnings.append(violation) - - def get_all_violations(self) -> List[RuleOf6Violation]: - """Get all violations (errors + warnings).""" - return self.errors + self.warnings - - def get_summary_by_type(self) -> Dict[str, int]: - """Get count of violations by type.""" - summary = {} - for violation in self.get_all_violations(): - violation_type = violation.violation_type.value - summary[violation_type] = summary.get(violation_type, 0) + 1 - return summary - - def get_summary_by_path(self) -> Dict[str, int]: - """Get count of violations by file path or directory.""" - summary = {} - for violation in self.get_all_violations(): - if violation.file_path: - path_key = str(Path(violation.file_path).parent) - summary[path_key] = summary.get(path_key, 0) + 1 - return summary - - def get_summary_by_recommendation(self) -> Dict[str, int]: - """Get count of violations by recommendation category.""" - summary = {} - for violation in self.get_all_violations(): - if violation.recommendation: - rec_category = self._categorize_recommendation(violation.recommendation) - summary[rec_category] = summary.get(rec_category, 0) + 1 - return summary - - def get_top_exact_recommendations(self, limit: int = 5) -> List[tuple[str, int]]: - """Get the most common exact recommendations.""" - exact_summary = {} - - for violation in self.get_all_violations(): - if violation.recommendation: - exact_summary[violation.recommendation] = exact_summary.get(violation.recommendation, 0) + 1 - - sorted_recommendations = sorted(exact_summary.items(), key=lambda x: x[1], reverse=True) - return sorted_recommendations[:limit] - - def _categorize_recommendation(self, recommendation: str) -> str: - """Categorize recommendation into types for summary.""" - if "group" in recommendation.lower() and ("subdirector" in recommendation.lower() or "folder" in recommendation.lower()): - return "Group into subdirectories" - elif "split" in recommendation.lower() and ("file" in recommendation.lower() or "function" in recommendation.lower()): - return "Split files or functions" - elif "refactor" in recommendation.lower() and ("function" in recommendation.lower() or "smaller" in recommendation.lower()): - return "Refactor large functions" - elif "argument" in recommendation.lower() or "parameter" in recommendation.lower(): - return "Reduce function arguments" - elif "object" in recommendation.lower() and ("key" in recommendation.lower() or "parameter" in recommendation.lower()): - return "Simplify object parameters" - elif "meaningful" in recommendation.lower() or "abstraction" in recommendation.lower(): - return "Create meaningful abstractions" - else: - return "Other refactoring" - - def has_errors(self) -> bool: - """Check if there are any errors (not warnings).""" - return len(self.errors) > 0 - - def to_dict(self) -> Dict: - """Convert results to dictionary for JSON serialization.""" - all_violations = self.get_all_violations() - return { - "timestamp": None, # Will be set by reporter - "target_path": self.target_path, - "execution_time": self.execution_time, - "rules_applied": self.rules_applied, - "summary": { - "total_errors": len(self.errors), - "total_warnings": len(self.warnings), - "by_type": self.get_summary_by_type(), - "by_path": self.get_summary_by_path(), - "by_recommendation": self.get_summary_by_recommendation() - }, - "violations": [violation.to_dict() for violation in all_violations] - } \ No newline at end of file diff --git a/scripts/checks/ruleof6/parser.py b/scripts/checks/ruleof6/parser.py deleted file mode 100644 index 893fc6021..000000000 --- a/scripts/checks/ruleof6/parser.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -""" -TypeScript/TSX parsing utilities for Rule of 6 checking. - -Handles extraction of functions, arguments, and object parameters from TypeScript code. -""" - -import re -from pathlib import Path -from typing import List, Optional, Tuple -import sys -import os - -from models import FileAnalysis - -# Import shared TypeScript parser -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from shared.typescript_parser import TypeScriptParser as SharedParser, FunctionInfo - - -class TypeScriptParser: - """Parses TypeScript/TSX files to extract function information using shared parser.""" - - def __init__(self): - self.shared_parser = SharedParser() - - def parse_file(self, file_analysis: FileAnalysis, content: str) -> FileAnalysis: - """Parse file content and extract function information using shared parser.""" - functions = self.shared_parser.extract_functions(content, file_analysis.path) - - file_analysis.functions = functions - file_analysis.function_count = len(functions) - - return file_analysis - - def find_object_parameter_violations(self, content: str, file_path: Path, max_keys: int = 6): - """Find object parameter violations using shared parser.""" - return self.shared_parser.find_object_parameter_violations(content, file_path, max_keys) \ No newline at end of file diff --git a/scripts/checks/ruleof6/reporter.py b/scripts/checks/ruleof6/reporter.py deleted file mode 100644 index 76841adac..000000000 --- a/scripts/checks/ruleof6/reporter.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env python3 -""" -Rule of 6 check reporting module. - -Handles result reporting, JSON output generation, and console summaries. -""" - -import json -from datetime import datetime -from pathlib import Path -from typing import Dict - -from models import CheckResults, ViolationType, Severity - - -class RuleOf6Reporter: - """Handles reporting of Rule of 6 check results.""" - - def __init__(self, output_file: str = "test-results/rule-of-6-check.json"): - self.output_file = Path(output_file) - - def report_results(self, results: CheckResults) -> bool: - """Report results to both JSON file and console. Returns True if no errors.""" - # Ensure output directory exists - self.output_file.parent.mkdir(exist_ok=True) - - # Write detailed JSON report - self._write_json_report(results) - - # Display console summary - self._display_console_summary(results) - - return not results.has_errors() - - def _write_json_report(self, results: CheckResults) -> None: - """Write detailed JSON report to file.""" - report_data = results.to_dict() - report_data["timestamp"] = datetime.now().isoformat() - - with open(self.output_file, 'w') as f: - json.dump(report_data, f, indent=2, default=str) - - def _display_console_summary(self, results: CheckResults) -> None: - """Display concise summary information on console.""" - # Show summary statistics - total_errors = len(results.errors) - total_warnings = len(results.warnings) - - # Show exception info if available - if "exceptions" in results.rules_applied: - exception_info = results.rules_applied["exceptions"] - if exception_info["total_exceptions"] > 0: - print(f"🎯 Loaded {exception_info['total_exceptions']} custom thresholds from {len(exception_info['exception_files_loaded'])} files") - - if total_errors > 0 or total_warnings > 0: - print(f"πŸ“ Rule of 6: {total_errors} errors, {total_warnings} warnings") - print() - - # Show top 10 violations by type with smart sorting - self._display_top_violations(results) - - print(f"πŸ“„ Detailed report: {self.output_file}") - - else: - print("βœ… Rule of 6 checks passed!") - print(f"πŸ“„ Detailed report: {self.output_file}") - - def _display_top_violations(self, results: CheckResults) -> None: - """Display top 10 violations for each violation type.""" - # Group violations by type - by_type: Dict[ViolationType, list] = {} - for violation in results.get_all_violations(): - if violation.violation_type not in by_type: - by_type[violation.violation_type] = [] - by_type[violation.violation_type].append(violation) - - # Define type order and display names - type_order = [ - (ViolationType.DIRECTORY_DOMAIN_FOLDERS, "πŸ“ Too Many Folders in Directories"), - (ViolationType.DIRECTORY_DOMAIN_FILES, "πŸ“„ Too Many Files in Directories"), - (ViolationType.DIRECTORY_ITEMS, "πŸ“ Too Many Items in Directories"), - (ViolationType.FUNCTION_LINES, "πŸ“ Too Many Lines in Functions"), - (ViolationType.FILE_FUNCTIONS, "πŸ”§ Too Many Functions in Files"), - (ViolationType.FUNCTION_ARGS, "πŸ”’ Too Many Function Arguments"), - (ViolationType.OBJECT_KEYS, "πŸ—οΈ Too Many Object Parameter Keys"), - ] - - # Display violations by type - for violation_type, section_title in type_order: - if violation_type not in by_type: - continue - - violations = by_type[violation_type] - - # Sort violations based on type - if violation_type == ViolationType.DIRECTORY_DOMAIN_FOLDERS: - sorted_violations = sorted(violations, - key=lambda v: v.context.get('domain_folder_count', 0) if v.context else 0, - reverse=True) - elif violation_type == ViolationType.DIRECTORY_DOMAIN_FILES: - sorted_violations = sorted(violations, - key=lambda v: v.context.get('domain_file_count', 0) if v.context else 0, - reverse=True) - elif violation_type == ViolationType.DIRECTORY_ITEMS: - sorted_violations = sorted(violations, - key=lambda v: v.context.get('item_count', 0) if v.context else 0, - reverse=True) - elif violation_type == ViolationType.FILE_FUNCTIONS: - sorted_violations = sorted(violations, - key=lambda v: v.context.get('function_count', 0) if v.context else 0, - reverse=True) - elif violation_type == ViolationType.FUNCTION_LINES: - sorted_violations = sorted(violations, - key=lambda v: v.context.get('line_count', 0) if v.context else 0, - reverse=True) - else: - sorted_violations = violations - - # Display section header - print(section_title) - print("=" * len(section_title)) - - # Show top 10 violations for this type - top_violations = sorted_violations[:10] - if not top_violations: - print(" (No violations)") - print() - continue - - for i, violation in enumerate(top_violations, 1): - severity_icon = "❌" if violation.severity == Severity.ERROR else "⚠️" - - # Format with count information - count_info = "" - if violation_type == ViolationType.DIRECTORY_DOMAIN_FOLDERS and violation.context: - count = violation.context.get('domain_folder_count', 0) - count_info = f" ({count} folders)" - elif violation_type == ViolationType.DIRECTORY_DOMAIN_FILES and violation.context: - count = violation.context.get('domain_file_count', 0) - count_info = f" ({count} files)" - elif violation_type == ViolationType.DIRECTORY_ITEMS and violation.context: - count = violation.context.get('item_count', 0) - count_info = f" ({count} items)" - elif violation_type == ViolationType.FILE_FUNCTIONS and violation.context: - count = violation.context.get('function_count', 0) - count_info = f" ({count} functions)" - elif violation_type == ViolationType.FUNCTION_LINES and violation.context: - count = violation.context.get('line_count', 0) - count_info = f" ({count} lines)" - elif violation_type == ViolationType.FUNCTION_ARGS and violation.context: - count = violation.context.get('arg_count', 0) - count_info = f" ({count} args)" - elif violation_type == ViolationType.OBJECT_KEYS and violation.context: - count = violation.context.get('key_count', 0) - count_info = f" ({count} keys)" - - # Add exception indicator if this violation uses custom threshold - exception_indicator = "" - if violation.exception_source: - exception_indicator = " 🎯" - - print(f"{i:2}. {severity_icon} {violation.message}{count_info}{exception_indicator}") - if violation.file_path and violation.line_number: - print(f" {violation.file_path}:{violation.line_number}") - elif violation.file_path: - print(f" {violation.file_path}") - - # Show custom threshold info if available - if violation.exception_source: - custom_info = f"Custom threshold ({violation.custom_threshold})" - if violation.default_threshold: - custom_info += f" vs default ({violation.default_threshold})" - print(f" {custom_info}") - - if len(sorted_violations) > 10: - remaining = len(sorted_violations) - 10 - print(f" ... and {remaining} more violations") - - print() - - def _format_violation_type(self, violation_type: str) -> str: - """Format violation type for display.""" - type_names = { - "directory_items": "Directory items", - "file_functions": "Functions per file", - "function_lines": "Function lines", - "function_args": "Function arguments", - "object_keys": "Object parameter keys" - } - return type_names.get(violation_type, violation_type.replace("_", " ").title()) - - def _format_rule_name(self, rule_name: str) -> str: - """Format rule name for display.""" - rule_names = { - "directory_items": "Max items per directory", - "functions_per_file": "Max functions per file", - "function_lines_warning": "Max lines per function (warning)", - "function_lines_error": "Max lines per function (error)", - "function_args": "Max arguments per function", - "object_keys": "Max keys per object parameter" - } - return rule_names.get(rule_name, rule_name.replace("_", " ").title()) - - - def generate_ai_friendly_summary(self, results: CheckResults) -> str: - """Generate a summary specifically designed for AI agents.""" - if not results.errors and not results.warnings: - return f"βœ… Rule of 6 check passed! Report: {self.output_file}" - - summary_parts = [ - f"πŸ“ Rule of 6 violations found: {len(results.errors)} errors, {len(results.warnings)} warnings", - f"πŸ“„ Full report: {self.output_file}", - "", - "🎯 Quick filters for AI agents:" - ] - - # Add useful jq commands - summary_parts.extend([ - f" # Get all errors: jq '.violations[] | select(.severity == \"error\")' {self.output_file}", - f" # Get summary: jq '.summary' {self.output_file}", - f" # Get by type: jq '.violations[] | select(.type == \"TYPE\")' {self.output_file}", - f" # Get by path: jq '.violations[] | select(.file | contains(\"PATH\"))' {self.output_file}", - "" - ]) - - # Show top violation types - type_summary = results.get_summary_by_type() - if type_summary: - top_types = sorted(type_summary.items(), key=lambda x: x[1], reverse=True)[:3] - summary_parts.append("πŸ”₯ Top violation types:") - for violation_type, count in top_types: - type_name = self._format_violation_type(violation_type) - summary_parts.append(f" β€’ {type_name}: {count}") - summary_parts.append("") - - # Show top problematic paths - path_summary = results.get_summary_by_path() - if path_summary: - top_paths = sorted(path_summary.items(), key=lambda x: x[1], reverse=True)[:3] - summary_parts.append("πŸ“ Most problematic paths:") - for path, count in top_paths: - summary_parts.append(f" β€’ {path}: {count}") - summary_parts.append("") - - # Emphasize meaningful refactoring - summary_parts.extend([ - "⚠️ IMPORTANT: Avoid meaningless abstractions when fixing violations.", - "Focus on creating logical groupings and clear responsibilities.", - "The Rule of 6 promotes better design, not artificial complexity." - ]) - - return "\n".join(summary_parts) \ No newline at end of file diff --git a/scripts/checks/ruleof6/scanner.py b/scripts/checks/ruleof6/scanner.py deleted file mode 100644 index 63fbcb854..000000000 --- a/scripts/checks/ruleof6/scanner.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python3 -""" -File and directory scanning utilities for Rule of 6 checking. - -Handles discovery and basic analysis of directories and TypeScript files. -""" - -import re -from pathlib import Path -from typing import List, Set, Optional -from models import DirectoryInfo, FileAnalysis, DomainDirectoryInfo - - -class LegacyIgnoreManager: - """Manages legacy .rule-of-6-ignore patterns (separate from custom thresholds).""" - - def __init__(self, exceptions_file: str = ".rule-of-6-ignore"): - self.exceptions: Set[str] = set() - self._load_exceptions(exceptions_file) - - def _load_exceptions(self, exceptions_file: str) -> None: - """Load Rule of 6 exceptions from ignore file.""" - ignore_file = Path(exceptions_file) - if ignore_file.exists(): - with open(ignore_file) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - self.exceptions.add(line) - else: - # Default exceptions - self.exceptions.update([ - "node_modules/**", - ".next/**", - ".git/**", - "dist/**", - "build/**", - "**/__tests__/**", - "**/*.test.ts", - "**/*.test.tsx", - "**/*.spec.ts", - "**/*.spec.tsx", - "**/*.stories.ts", - "**/*.stories.tsx", - "src/server/db/schema/**", - "**/types.ts", - "**/types/**", - ]) - - def is_exception(self, path: Path) -> bool: - """Check if path matches any exception pattern.""" - path_str = str(path) - return any( - self._matches_pattern(path_str, pattern) - for pattern in self.exceptions - ) - - def _matches_pattern(self, path_str: str, pattern: str) -> bool: - """Check if path matches a glob-like pattern.""" - if "**" in pattern: - # Convert ** pattern to regex - regex_pattern = pattern.replace("**", ".*").replace("*", "[^/]*") - return bool(re.search(regex_pattern, path_str)) - elif "*" in pattern: - regex_pattern = pattern.replace("*", "[^/]*") - return bool(re.search(regex_pattern, path_str)) - else: - return pattern in path_str - - -class DirectoryScanner: - """Scans directories for Rule of 6 violations.""" - - def __init__(self, ignore_manager: LegacyIgnoreManager): - self.ignore_manager = ignore_manager - - # Define generic infrastructure patterns that don't count toward Rule of 6 - self.generic_folder_patterns = { - 'docs', 'doc', 'types', 'utils', 'components', 'hooks', - '__tests__', 'tests', 'fixtures', 'mocks', 'stories' - } - - self.generic_file_patterns = { - 'dependencies.json', 'README.md', 'index.ts', 'index.tsx', - 'page.tsx', 'layout.tsx', 'loading.tsx', 'error.tsx', 'not-found.tsx' - } - - self.generic_file_extensions = { - '.config.js', '.config.ts', '.stories.tsx', '.stories.ts', - '.test.tsx', '.test.ts', '.spec.tsx', '.spec.ts' - } - - def is_generic_folder(self, folder_name: str) -> bool: - """Check if folder is a generic infrastructure folder.""" - return folder_name.lower() in self.generic_folder_patterns - - def is_generic_file(self, file_path: Path) -> bool: - """Check if file is a generic infrastructure file.""" - file_name = file_path.name - - # Check exact name patterns - if file_name in self.generic_file_patterns: - return True - - # Check extension patterns - for ext in self.generic_file_extensions: - if file_name.endswith(ext): - return True - - return False - - def scan_directory(self, directory: Path) -> DirectoryInfo: - """Scan a directory and return its info, counting only .ts/.tsx files and subdirectories.""" - if not directory.is_dir(): - return DirectoryInfo(path=directory, item_count=0) - - items = [] - try: - for item in directory.iterdir(): - # Skip hidden files and common build artifacts - if item.name.startswith('.'): - continue - if item.name in ['node_modules', 'dist', 'build', '__pycache__']: - continue - - # Count directories and TypeScript files only - if item.is_dir(): - items.append(item.name) - elif item.suffix in ['.ts', '.tsx']: - items.append(item.name) - - except PermissionError: - return DirectoryInfo(path=directory, item_count=0) - - return DirectoryInfo( - path=directory, - item_count=len(items), - items=items - ) - - def scan_directory_with_domain_separation(self, directory: Path) -> DomainDirectoryInfo: - """Scan a directory and separate generic infrastructure from domain items.""" - if not directory.is_dir(): - return DomainDirectoryInfo( - path=directory, - domain_folder_count=0, - domain_file_count=0, - generic_folder_count=0, - generic_file_count=0 - ) - - domain_folders = [] - domain_files = [] - generic_folders = [] - generic_files = [] - - try: - for item in directory.iterdir(): - # Skip hidden files and common build artifacts - if item.name.startswith('.'): - continue - if item.name in ['node_modules', 'dist', 'build', '__pycache__']: - continue - - if item.is_dir(): - if self.is_generic_folder(item.name): - generic_folders.append(item.name) - else: - domain_folders.append(item.name) - elif item.suffix in ['.ts', '.tsx']: - if self.is_generic_file(item): - generic_files.append(item.name) - else: - domain_files.append(item.name) - - except PermissionError: - return DomainDirectoryInfo( - path=directory, - domain_folder_count=0, - domain_file_count=0, - generic_folder_count=0, - generic_file_count=0 - ) - - return DomainDirectoryInfo( - path=directory, - domain_folder_count=len(domain_folders), - domain_file_count=len(domain_files), - generic_folder_count=len(generic_folders), - generic_file_count=len(generic_files), - domain_folders=domain_folders, - domain_files=domain_files, - generic_folders=generic_folders, - generic_files=generic_files - ) - - def find_violating_directories(self, target_path: Path, max_items: int = 6) -> List[DirectoryInfo]: - """Find directories that violate the Rule of 6 (legacy method).""" - violating_dirs = [] - - for directory in target_path.rglob("*"): - if not directory.is_dir(): - continue - - if self.ignore_manager.is_exception(directory): - continue - - dir_info = self.scan_directory(directory) - - if dir_info.item_count > max_items: - violating_dirs.append(dir_info) - - return violating_dirs - - def find_domain_violating_directories(self, target_path: Path, max_domain_folders: int = 6, max_domain_files: int = 6) -> List[DomainDirectoryInfo]: - """Find directories that violate the new domain-aware Rule of 6.""" - violating_dirs = [] - - # Check the target directory itself first - if not self.ignore_manager.is_exception(target_path): - dir_info = self.scan_directory_with_domain_separation(target_path) - if (dir_info.domain_folder_count > max_domain_folders or - dir_info.domain_file_count > max_domain_files): - violating_dirs.append(dir_info) - - # Then check all subdirectories - for directory in target_path.rglob("*"): - if not directory.is_dir(): - continue - - if self.ignore_manager.is_exception(directory): - continue - - dir_info = self.scan_directory_with_domain_separation(directory) - - if (dir_info.domain_folder_count > max_domain_folders or - dir_info.domain_file_count > max_domain_files): - violating_dirs.append(dir_info) - - return violating_dirs - - -class FileScanner: - """Scans TypeScript files for basic analysis.""" - - def __init__(self, ignore_manager: LegacyIgnoreManager): - self.ignore_manager = ignore_manager - - def is_test_file(self, file_path: Path) -> bool: - """Check if file is a test file.""" - file_str = str(file_path) - return any(pattern in file_str for pattern in [ - ".test.", ".spec.", "__tests__/", ".stories." - ]) - - def scan_file(self, file_path: Path) -> Optional[FileAnalysis]: - """Scan a TypeScript file and return basic analysis.""" - if self.ignore_manager.is_exception(file_path): - return None - - if self.is_test_file(file_path): - return None - - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - lines = content.split('\n') - - return FileAnalysis( - path=file_path, - line_count=len(lines), - function_count=0, # Will be set by parser - functions=[] # Will be populated by parser - ) - - except (UnicodeDecodeError, OSError): - return None - - def find_typescript_files(self, target_path: Path) -> List[Path]: - """Find all TypeScript files in target path.""" - ts_files = [] - - # Handle both individual files and directories - if target_path.is_file(): - # If target is a single file, check if it's a TypeScript file - if target_path.suffix in ['.ts', '.tsx']: - ts_files.append(target_path) - else: - # If target is a directory, glob for TypeScript files - for pattern in ["**/*.ts", "**/*.tsx"]: - ts_files.extend(target_path.glob(pattern)) - - # Filter out exceptions and test files - filtered_files = [] - for file_path in ts_files: - if not self.ignore_manager.is_exception(file_path) and not self.is_test_file(file_path): - filtered_files.append(file_path) - - return filtered_files \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/README.md b/scripts/checks/ruleof6/tests/README.md deleted file mode 100644 index e05901c1f..000000000 --- a/scripts/checks/ruleof6/tests/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Rule of 6 Checker Test Suite - -This directory contains test cases for the Rule of 6 checker, specifically focusing on TypeScript parsing functionality to prevent regressions. - -## Test Structure - -### `typescript/` -Contains TypeScript test files that cover various parsing scenarios: - -- **`simple-function.tsx`** - Tests basic function line counting (should trigger 50+ line warning) -- **`very-long-function.tsx`** - Tests error detection for functions over 100 lines -- **`string-with-braces.tsx`** - Tests handling of braces within strings (the original bug) -- **`react-component-with-complex-strings.tsx`** - Tests complex React component patterns -- **`multiple-functions.tsx`** - Tests file with exactly 6 functions (at the limit) -- **`too-many-functions.tsx`** - Tests file with 7 functions (should trigger error) - -### `test_typescript_parsing.py` -Test runner that validates the parser correctly detects violations and integrates with the checker. - -## Running Tests - -```bash -cd scripts/checks/ruleof6/tests -python3 test_typescript_parsing.py -``` - -## Key Bugs Prevented - -### Original Parser Bug -The parser previously failed to correctly detect function boundaries due to: -1. **String literal confusion**: Braces inside strings (like `'{{template}}'`) were counted as code braces -2. **Single file handling**: The checker couldn't process individual files, only directories -3. **False positives**: Function calls were detected as function declarations - -### Fixed Issues -- βœ… String-aware brace counting ignores braces in strings, template literals, and comments -- βœ… Individual file processing for targeted checks -- βœ… Improved pattern matching to distinguish declarations from calls -- βœ… Multiline parameter handling for complex function signatures - -## Test Coverage - -| Test Case | Purpose | Expected Result | -|-----------|---------|----------------| -| `simple-function.tsx` | Basic line counting | 1 warning (>50 lines) | -| `very-long-function.tsx` | Error detection | 1 error (>100 lines) | -| `string-with-braces.tsx` | String handling | No violations | -| `react-component-with-complex-strings.tsx` | Complex syntax | No violations* | -| `multiple-functions.tsx` | Function count limit | No violations | -| `too-many-functions.tsx` | Function count violation | 1 error (>6 functions) | - -*Note: The complex React component test currently passes due to parser limitations with very complex syntax, but it serves as a regression test for basic string handling. - -## Adding New Tests - -To add a new test case: - -1. Create a `.tsx` file in the `typescript/` directory -2. Add the test case to `test_typescript_parsing.py` with expected violations -3. Run the test suite to verify behavior - -## Integration with CI - -This test suite ensures that modifications to the TypeScript parser don't break existing functionality. It should be run whenever: -- The parser logic changes -- New syntax patterns are added -- Bug fixes are implemented \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/test_comprehensive_ruleof6.py b/scripts/checks/ruleof6/tests/test_comprehensive_ruleof6.py deleted file mode 100644 index 861b2ccc8..000000000 --- a/scripts/checks/ruleof6/tests/test_comprehensive_ruleof6.py +++ /dev/null @@ -1,571 +0,0 @@ -""" -Comprehensive test suite for Rule of 6 checker. - -Integrates with the shared test infrastructure and provides extensive -coverage of Rule of 6 violations in various scenarios. -""" - -import pytest -from pathlib import Path - -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..')) - -from utils.test_helpers import ( - create_test_project, - run_checker, - assert_checker_finds_issues, - assert_no_false_positives -) -from ruleof6.checker import RuleOf6Checker -from ruleof6.models import ViolationType - - -class TestRuleOf6Violations: - """Test suite for Rule of 6 violation detection.""" - - def test_function_count_violations(self): - """Test detection of too many functions in a file.""" - # File with exactly 6 functions (at the limit) - valid_file_content = """ -export function func1() { return 1; } -export function func2() { return 2; } -export function func3() { return 3; } -export function func4() { return 4; } -export function func5() { return 5; } -export function func6() { return 6; } - """.strip() - - # File with 7 functions (violation) - violation_file_content = """ -export function func1() { return 1; } -export function func2() { return 2; } -export function func3() { return 3; } -export function func4() { return 4; } -export function func5() { return 5; } -export function func6() { return 6; } -export function func7() { return 7; } // VIOLATION: 7th function - """.strip() - - files = { - "src/valid.ts": valid_file_content, - "src/violation.ts": violation_file_content - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should flag the file with 7 functions - violation_files = {v.file_path for v in results.violations if v.violation_type == ViolationType.TOO_MANY_FUNCTIONS} - assert (project_path / 'src' / 'violation.ts') in violation_files - - # Should NOT flag the file with exactly 6 functions - assert (project_path / 'src' / 'valid.ts') not in violation_files - - def test_function_length_violations(self): - """Test detection of functions that are too long.""" - # Function with exactly 50 lines (warning threshold) - warning_function = f""" -export function longFunction() {{ -{chr(10).join([' console.log("line");'] * 48)} // 48 lines + declaration + closing -}} - """.strip() - - # Function with 100+ lines (error threshold) - error_function = f""" -export function veryLongFunction() {{ -{chr(10).join([' console.log("line");'] * 98)} // 98 lines + declaration + closing -}} - """.strip() - - files = { - "src/warning.ts": warning_function, - "src/error.ts": error_function - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should find length violations - length_violations = [v for v in results.violations if v.violation_type == ViolationType.FUNCTION_TOO_LONG] - assert len(length_violations) >= 1, "Should find function length violations" - - def test_function_argument_violations(self): - """Test detection of functions with too many arguments.""" - files = { - "src/valid-args.ts": """ -// Valid: 3 arguments (at the limit) -export function validFunction(a: string, b: number, c: boolean) { - return a + b + c; -} - """.strip(), - "src/violation-args.ts": """ -// VIOLATION: 4 arguments -export function tooManyArgs(a: string, b: number, c: boolean, d: object) { - return { a, b, c, d }; -} - -// VIOLATION: 7 arguments -export function wayTooManyArgs( - a: string, - b: number, - c: boolean, - d: object, - e: any, - f: unknown, - g: never -) { - return 'too many'; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should find argument count violations - arg_violations = [v for v in results.violations if v.violation_type == ViolationType.TOO_MANY_ARGUMENTS] - assert len(arg_violations) >= 2, f"Should find argument violations, found: {len(arg_violations)}" - - def test_directory_structure_violations(self): - """Test detection of directories with too many items.""" - files = {} - - # Create a directory with exactly 6 items (at the limit) - for i in range(6): - files[f"src/valid-dir/file{i}.ts"] = f"export const value{i} = {i};" - - # Create a directory with 7 items (violation) - for i in range(7): - files[f"src/violation-dir/file{i}.ts"] = f"export const value{i} = {i};" - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should flag directory with too many items - dir_violations = [v for v in results.violations if v.violation_type == ViolationType.TOO_MANY_ITEMS_IN_DIRECTORY] - - # Check if violation directory is flagged - violation_found = any('violation-dir' in str(v.file_path) for v in dir_violations) - assert violation_found, "Should find directory with too many items" - - def test_complex_typescript_syntax_handling(self): - """Test Rule of 6 checking with complex TypeScript syntax.""" - files = { - "src/complex.ts": """ -// Generic function with constraints -export function processItems< - T extends Record<string, any>, - K extends keyof T ->( - items: T[], - key: K, - processor: (item: T[K]) => boolean = defaultProcessor -): T[K][] { - return items.map(item => item[key]).filter(processor); -} - -// Class with methods (each method counts as a function) -export class ComplexClass { - private value: string = ''; - - constructor(initialValue: string) { - this.value = initialValue; - } - - public getValue(): string { - return this.value; - } - - public setValue(newValue: string): void { - this.value = newValue; - } - - public async processAsync(): Promise<string> { - return new Promise(resolve => { - setTimeout(() => resolve(this.value), 100); - }); - } - - public static createDefault(): ComplexClass { - return new ComplexClass('default'); - } -} - -// Arrow function with complex signature -export const complexArrow = ( - config: { - enableLogging: boolean; - retryCount: number; - timeout: number; - } -) => { - return config.enableLogging ? 'logged' : 'silent'; -}; - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should handle complex syntax without crashing - assert isinstance(results.violations, list) - - # Check if functions are counted correctly - function_count_violations = [v for v in results.violations if v.violation_type == ViolationType.TOO_MANY_FUNCTIONS] - - # This file has: processItems, constructor, getValue, setValue, processAsync, createDefault, complexArrow = 7 functions - # Should trigger violation - assert len(function_count_violations) >= 0 # Depending on how constructors are counted - - def test_react_component_patterns(self): - """Test Rule of 6 checking with React component patterns.""" - files = { - "src/component.tsx": """ -import React, { useState, useEffect, useCallback } from 'react'; - -interface Props { - title: string; - items: string[]; - onItemClick: (item: string) => void; -} - -export function ComplexComponent({ title, items, onItemClick }: Props) { - const [selectedItem, setSelectedItem] = useState<string | null>(null); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - if (items.length > 0) { - setSelectedItem(items[0]); - } - }, [items]); - - const handleItemClick = useCallback((item: string) => { - setSelectedItem(item); - onItemClick(item); - }, [onItemClick]); - - const handleReset = () => { - setSelectedItem(null); - }; - - const renderItem = (item: string) => ( - <div - key={item} - onClick={() => handleItemClick(item)} - className={selectedItem === item ? 'selected' : ''} - > - {item} - </div> - ); - - if (isLoading) { - return <div>Loading...</div>; - } - - return ( - <div> - <h1>{title}</h1> - <button onClick={handleReset}>Reset</button> - <div> - {items.map(renderItem)} - </div> - </div> - ); -} - -// Helper function -function formatTitle(title: string): string { - return title.toUpperCase(); -} - -// Another component -export function SimpleComponent() { - return <div>Simple</div>; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should handle React patterns correctly - assert isinstance(results.violations, list) - - def test_string_literals_with_braces(self): - """Test that braces in string literals don't confuse function counting.""" - files = { - "src/strings-with-braces.ts": """ -export function functionWithStrings() { - const codeTemplate = \` - function generatedFunction() { - return 'generated'; - } - - function anotherGenerated() { - return 'another'; - } - \`; - - const objectLiteral = "{key: 'value', another: 'value'}"; - const regex = /function\\s+\\w+\\s*\\([^)]*\\)\\s*\\{/g; - - return { codeTemplate, objectLiteral, regex }; -} - -export function secondFunction() { - const cssString = \` - .class { - property: value; - } - - .another-class { - property: value; - } - \`; - - return cssString; -} - -export function thirdFunction() { - // This function contains string patterns that look like functions - const fakeFunction = "function fake() { return 'fake'; }"; - return fakeFunction; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should only count 3 real functions, not the ones in strings - function_violations = [v for v in results.violations if v.violation_type == ViolationType.TOO_MANY_FUNCTIONS] - assert len(function_violations) == 0, "Should not be confused by functions in strings" - - def test_nested_function_detection(self): - """Test detection of nested functions and closures.""" - files = { - "src/nested.ts": """ -export function outerFunction() { - function innerFunction() { - return 'inner'; - } - - const arrowInner = () => { - return 'arrow inner'; - }; - - function anotherInner() { - function deeplyNested() { - return 'deeply nested'; - } - return deeplyNested(); - } - - return { - inner: innerFunction(), - arrow: arrowInner(), - another: anotherInner() - }; -} - -export function secondOuter() { - const closure = (x: number) => { - return (y: number) => x + y; - }; - - return closure; -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should handle nested functions appropriately - # (Implementation may or may not count nested functions) - assert isinstance(results.violations, list) - - def test_edge_cases_and_malformed_code(self): - """Test Rule of 6 checker with edge cases and malformed code.""" - files = { - "src/edge-cases.ts": """ -// Incomplete function (should not crash parser) -export function incomplete( - -// Function with unusual formatting -export function -weirdFormatting -( - param: string -) -: -string -{ - return param; -} - -// Empty function -export function empty() {} - -// Function with only comments -export function onlyComments() { - // This function only has comments - /* - * Multiple line comment - */ -} - -// Function with complex destructuring -export function destructuring({ - prop1, - prop2: renamed, - prop3 = defaultValue, - ...rest -}: ComplexType) { - return { prop1, renamed, rest }; -} - """.strip() - } - - with create_test_project(files) as project_path: - try: - results = run_checker('ruleof6', project_path / 'src') - - # Should not crash on malformed code - assert isinstance(results.violations, list) - - except Exception as e: - pytest.fail(f"Rule of 6 checker crashed on edge cases: {e}") - - -class TestRuleOf6Integration: - """Integration tests for Rule of 6 checker.""" - - def test_clean_codebase_no_violations(self): - """Test that a clean, well-structured codebase produces no violations.""" - files = { - "src/utils/math.ts": """ -export function add(a: number, b: number): number { - return a + b; -} - -export function multiply(a: number, b: number): number { - return a * b; -} - -export function divide(a: number, b: number): number { - if (b === 0) throw new Error('Division by zero'); - return a / b; -} - """.strip(), - "src/utils/string.ts": """ -export function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -export function trim(str: string): string { - return str.trim(); -} - """.strip(), - "src/components/Button.tsx": """ -interface ButtonProps { - children: React.ReactNode; - onClick: () => void; -} - -export function Button({ children, onClick }: ButtonProps) { - return ( - <button onClick={onClick}> - {children} - </button> - ); -} - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Should have no violations in clean code - assert len(results.violations) == 0, f"Found violations in clean code: {[v.message for v in results.violations]}" - - def test_performance_on_large_codebase(self): - """Test Rule of 6 checker performance on a large codebase.""" - files = {} - - # Generate many files with functions - for i in range(20): # 20 files - functions = [] - for j in range(5): # 5 functions per file (within limit) - functions.append(f""" -export function func{i}_{j}() {{ - return 'result from {i}_{j}'; -}} - """.strip()) - - files[f"src/module{i}.ts"] = '\n\n'.join(functions) - - # Add one file with violations - files["src/violations.ts"] = '\n\n'.join([ - f"export function violationFunc{i}() {{ return {i}; }}" - for i in range(8) # 8 functions = violation - ]) - - with create_test_project(files) as project_path: - try: - results = run_checker('ruleof6', project_path / 'src') - - # Should complete without timeout - assert isinstance(results.violations, list) - - # Should find the one file with violations - violation_files = {v.file_path for v in results.violations} - violations_file = project_path / 'src' / 'violations.ts' - assert violations_file in violation_files, "Should find the violations file" - - except Exception as e: - pytest.fail(f"Rule of 6 checker failed on large codebase: {e}") - - def test_hexframe_like_patterns(self): - """Test Rule of 6 checker with patterns similar to Hexframe codebase.""" - files = { - "src/app/map/Chat/Timeline/Widgets/LoginWidget/login-widget.tsx": """ -import { useState } from 'react'; -import { useLoginForm } from './useLoginForm'; - -export function LoginWidget() { - const [isCollapsed, setIsCollapsed] = useState(false); - const { handleSubmit } = useLoginForm(); - - return ( - <form onSubmit={handleSubmit}> - <input type="email" /> - <button>Login</button> - </form> - ); -} - """.strip(), - "src/app/map/Chat/Timeline/Widgets/LoginWidget/useLoginForm.ts": """ -export function useLoginForm() { - const handleSubmit = () => {}; - const handleCancel = () => {}; - - return { handleSubmit, handleCancel }; -} - """.strip(), - "src/lib/domains/mapping/services/item-crud.ts": """ -export function createItem() { return {}; } -export function updateItem() { return {}; } -export function deleteItem() { return {}; } -export function getItem() { return {}; } -export function listItems() { return []; } - """.strip() - } - - with create_test_project(files) as project_path: - results = run_checker('ruleof6', project_path / 'src') - - # Hexframe patterns should be clean - assert len(results.violations) == 0, f"Hexframe patterns should be clean: {[v.message for v in results.violations]}" \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/test_false_positives.py b/scripts/checks/ruleof6/tests/test_false_positives.py deleted file mode 100644 index f06ce8d3c..000000000 --- a/scripts/checks/ruleof6/tests/test_false_positives.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive test suite for false positive scenarios in Rule of 6 checker. - -This test suite specifically targets the known false positive issues: -1. Function calls being detected as function declarations -2. Object properties being counted as function arguments -""" - -import sys -from pathlib import Path - -# Add the parent directory to the path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - -from checker import RuleOf6Checker -from models import ViolationType -from shared.typescript_parser import TypeScriptParser, FunctionInfo - - -class TestFalsePositives: - """Test suite for false positive scenarios.""" - - def setup_method(self): - """Set up test environment.""" - self.parser = TypeScriptParser() - self.test_files_dir = Path(__file__).parent / "typescript" - - def test_function_calls_not_detected_as_declarations(self): - """Test that function calls are not incorrectly detected as function declarations.""" - test_file = self.test_files_dir / "function-calls.tsx" - - with open(test_file, 'r') as f: - content = f.read() - - functions = self.parser.extract_functions(content, test_file) - function_names = [f.name for f in functions] - - # Should NOT detect these as function declarations - false_positive_names = ['dispatch', 'useEffect', 'eventBus', 'someFunction', 'api'] - for name in false_positive_names: - assert name not in function_names, f"'{name}' should NOT be detected as a function declaration" - - # Should detect real function declarations - expected_functions = ['testFunctionCalls', 'realFunction', 'arrowFunction'] - for name in expected_functions: - assert name in function_names, f"'{name}' should be detected as a function declaration" - - def test_object_arguments_counted_correctly(self): - """Test that object arguments are counted as single arguments, not multiple.""" - # Test single object argument - single_obj_code = """ - dispatch({ - type: 'message', - payload: { content: 'test', actor: 'system' }, - id: 'debug-test', - timestamp: new Date(), - actor: 'system', - }); - """ - - # Extract just the arguments part - args_str = """type: 'message', - payload: { content: 'test', actor: 'system' }, - id: 'debug-test', - timestamp: new Date(), - actor: 'system'""" - - arg_count = self.parser._count_arguments(args_str) - # This should be recognized as properties of a single object, not separate arguments - # The current implementation incorrectly counts this as multiple args - # After fix, this should return a low count (ideally handled at a higher level) - - # Test multiple real arguments - real_args = "a: string, b: number, c: boolean" - real_count = self.parser._count_arguments(real_args) - assert real_count == 3, f"Real arguments should count as 3, got {real_count}" - - def test_nested_object_structures(self): - """Test complex nested object structures are handled correctly.""" - complex_args = """method: 'POST', - data: { - user: { id: 123, profile: { name: 'Test', settings: { theme: 'dark' } } }, - metadata: { source: 'web', session: { id: 'session-123' } } - }, - headers: { 'Content-Type': 'application/json' }""" - - # Before fix: this incorrectly counts many arguments due to comma splitting - # After fix: should be handled better - count = self.parser._count_arguments(complex_args) - - # For now, we'll test that it doesn't count excessively - assert count < 15, f"Complex nested object shouldn't count as {count} arguments" - - def test_class_methods_vs_function_calls(self): - """Test that class methods are detected but function calls within them are not.""" - test_file = self.test_files_dir / "class-methods.tsx" - - with open(test_file, 'r') as f: - content = f.read() - - functions = self.parser.extract_functions(content, test_file) - function_names = [f.name for f in functions] - - # Should detect class methods - expected_methods = [ - 'methodOne', 'methodTwo', 'methodThree', 'methodFour', - 'methodFive', 'methodSix', 'methodSeven', 'constructor' - ] - - for method in expected_methods: - # Note: Some methods might not be detected due to current limitations - # This test documents the current behavior and will be updated as fixes are made - pass - - # Should NOT detect function calls within methods - false_positives = ['someOtherMethod', 'dispatch', 'useEffect', 'initialize'] - for fp in false_positives: - assert fp not in function_names, f"'{fp}' should NOT be detected as a function declaration" - - def test_react_hooks_pattern(self): - """Test that React hooks and patterns are handled correctly.""" - test_file = self.test_files_dir / "react-hooks.tsx" - - with open(test_file, 'r') as f: - content = f.read() - - functions = self.parser.extract_functions(content, test_file) - function_names = [f.name for f in functions] - - # Should detect real function declarations - expected_functions = ['useCustomHook', 'TestComponent', 'calculateExpensiveValue', 'fetchData'] - for name in expected_functions: - assert name in function_names, f"'{name}' should be detected as a function declaration" - - # Should NOT detect hook calls as function declarations - hook_calls = ['useEffect', 'useState', 'useCallback', 'useMemo'] - for hook in hook_calls: - assert hook not in function_names, f"'{hook}' should NOT be detected as a function declaration" - - def test_argument_counting_edge_cases(self): - """Test edge cases in argument counting.""" - test_cases = [ - # Empty arguments - ("", 0), - - # Single simple argument - ("a", 1), - - # Multiple simple arguments - ("a, b, c", 3), - - # Arguments with type annotations - ("a: string, b: number", 2), - - # Arguments with default values - ("a = 1, b = 'test'", 2), - - # Mix of types and defaults - ("a: string = 'default', b: number", 2), - ] - - for args_str, expected_count in test_cases: - actual_count = self.parser._count_arguments(args_str) - assert actual_count == expected_count, f"Args '{args_str}' should count as {expected_count}, got {actual_count}" - - def test_real_problematic_patterns(self): - """Test the actual patterns that cause problems in the real codebase.""" - - # Pattern from useEventSubscriptions.ts - dispatch_pattern = """type: 'message', - payload: { - content: `[DEBUG] EventBus: **${event.type}** | Source: ${event.source}`, - actor: 'system', - }, - id: `debug-${event.type}-${Date.now()}`, - timestamp: event.timestamp ?? new Date(), - actor: 'system'""" - - count = self.parser._count_arguments(dispatch_pattern) - # This currently counts as many arguments, but it's actually properties of one object - # After fix, this should be handled better at the function call detection level - - # Pattern from tile-operations.ts - tile_pattern = """tileId: tile.metadata.coordId, - tileData: { - id: tile.metadata.dbId.toString(), - title: tile.data.name, - description: tile.data.description, - content: tile.data.description, - coordId: tile.metadata.coordId, - }, - openInEditMode: true""" - - tile_count = self.parser._count_arguments(tile_pattern) - # Similar issue - these are object properties, not function arguments - - -def run_tests(): - """Run all false positive tests.""" - print("πŸ§ͺ Running False Positive Test Suite") - print("=" * 50) - - test_instance = TestFalsePositives() - test_instance.setup_method() - - test_methods = [ - ('Function Calls vs Declarations', test_instance.test_function_calls_not_detected_as_declarations), - ('Object Arguments Counting', test_instance.test_object_arguments_counted_correctly), - ('Nested Object Structures', test_instance.test_nested_object_structures), - ('Class Methods vs Function Calls', test_instance.test_class_methods_vs_function_calls), - ('React Hooks Pattern', test_instance.test_react_hooks_pattern), - ('Argument Counting Edge Cases', test_instance.test_argument_counting_edge_cases), - ('Real Problematic Patterns', test_instance.test_real_problematic_patterns), - ] - - passed = 0 - total = len(test_methods) - - for test_name, test_method in test_methods: - try: - test_method() - print(f"βœ… PASS: {test_name}") - passed += 1 - except AssertionError as e: - print(f"❌ FAIL: {test_name}") - print(f" Error: {e}") - - print("=" * 50) - print(f"πŸ“Š Test Summary: {passed}/{total} tests passed") - - if passed == total: - print("πŸŽ‰ All tests passed!") - return 0 - else: - print("❌ Some tests failed - this is expected before fixes are implemented!") - return 1 - - -if __name__ == "__main__": - sys.exit(run_tests()) \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/test_typescript_parsing.py b/scripts/checks/ruleof6/tests/test_typescript_parsing.py deleted file mode 100644 index 63a0093b6..000000000 --- a/scripts/checks/ruleof6/tests/test_typescript_parsing.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -""" -Test suite for TypeScript parsing in the Rule of 6 checker. - -This test suite ensures that the TypeScript parser correctly identifies -function boundaries, handles complex syntax, and integrates properly -with the Rule of 6 checker. -""" - -import sys -import os -from pathlib import Path - -# Add the parent directory to the path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from checker import RuleOf6Checker - - -class TestResult: - def __init__(self, test_name: str, expected_errors: int, expected_warnings: int): - self.test_name = test_name - self.expected_errors = expected_errors - self.expected_warnings = expected_warnings - self.actual_errors = 0 - self.actual_warnings = 0 - self.passed = False - self.details = [] - - def check(self, results): - self.actual_errors = len(results.errors) - self.actual_warnings = len(results.warnings) - self.passed = (self.actual_errors == self.expected_errors and - self.actual_warnings == self.expected_warnings) - - # Collect violation details - for violation in results.get_all_violations(): - self.details.append(f" {violation.severity.name}: {violation.message}") - - def report(self): - status = "βœ… PASS" if self.passed else "❌ FAIL" - print(f"{status} {self.test_name}") - print(f" Expected: {self.expected_errors} errors, {self.expected_warnings} warnings") - print(f" Actual: {self.actual_errors} errors, {self.actual_warnings} warnings") - - if self.details: - print(" Violations found:") - for detail in self.details: - print(detail) - - if not self.passed: - print(" ❌ Test failed!") - print() - - -def run_test(test_file: str, expected_errors: int, expected_warnings: int) -> TestResult: - """Run a single test case.""" - test_file_path = Path(__file__).parent / "typescript" / test_file - test_dir = test_file_path.parent - - # Change to the test directory and run checker with relative path - import os - old_cwd = os.getcwd() - try: - os.chdir(str(test_dir)) - checker = RuleOf6Checker(".") - results = checker.run_all_checks() - finally: - os.chdir(old_cwd) - - # Filter results to only include violations from the specific test file - filtered_violations = [] - for violation in results.get_all_violations(): - # Check if this violation is from our test file - if (violation.file_path == test_file or - violation.file_path.endswith(test_file) or - test_file in violation.file_path): - filtered_violations.append(violation) - - # Create filtered results - filtered_errors = [v for v in filtered_violations if v.severity.name == 'ERROR'] - filtered_warnings = [v for v in filtered_violations if v.severity.name == 'WARNING'] - - # Create a mock results object with filtered violations - class FilteredResults: - def __init__(self, errors, warnings, all_violations): - self.errors = errors - self.warnings = warnings - self._all_violations = all_violations - - def get_all_violations(self): - return self._all_violations - - filtered_results = FilteredResults(filtered_errors, filtered_warnings, filtered_violations) - - # Create and check test result - test_result = TestResult(test_file, expected_errors, expected_warnings) - test_result.check(filtered_results) - - return test_result - - -def main(): - print("πŸ§ͺ Running TypeScript Parser Test Suite") - print("=" * 50) - - # Define test cases: (filename, expected_errors, expected_warnings) - test_cases = [ - # Basic function line count tests - ("simple-function.tsx", 0, 1), # ~53 lines should trigger warning - ("very-long-function.tsx", 1, 0), # >100 lines should trigger error - - # String handling tests (the original bug) - ("string-with-braces.tsx", 0, 0), # Should handle braces in strings correctly - - # Complex React component test (like DebugLogsWidget) - # Note: Parser currently detects main function as 49 lines due to complex syntax - ("react-component-with-complex-strings.tsx", 0, 0), # Currently no violations detected - - # Function count tests - ("multiple-functions.tsx", 0, 0), # 6 functions should pass - ("too-many-functions.tsx", 1, 0), # 7 functions should trigger error - ] - - # Run all tests - results = [] - for test_file, expected_errors, expected_warnings in test_cases: - try: - result = run_test(test_file, expected_errors, expected_warnings) - results.append(result) - result.report() - except Exception as e: - print(f"❌ FAIL {test_file}") - print(f" Exception: {e}") - print() - results.append(TestResult(test_file, expected_errors, expected_warnings)) - - # Summary - passed = sum(1 for r in results if r.passed) - total = len(results) - - print("=" * 50) - print(f"πŸ“Š Test Summary: {passed}/{total} tests passed") - - if passed == total: - print("πŸŽ‰ All tests passed!") - return 0 - else: - print("❌ Some tests failed!") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/class-methods.tsx b/scripts/checks/ruleof6/tests/typescript/class-methods.tsx deleted file mode 100644 index a7badd02c..000000000 --- a/scripts/checks/ruleof6/tests/typescript/class-methods.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// Test file for proper class method declarations -// These should be detected as function declarations - -export class TestClass { - // Public method - should be detected - public methodOne() { - return 'method one'; - } - - // Private method - should be detected - private methodTwo() { - return 'method two'; - } - - // Protected method - should be detected - protected methodThree() { - return 'method three'; - } - - // Static method - should be detected - static methodFour() { - return 'method four'; - } - - // Async method - should be detected - async methodFive() { - return 'method five'; - } - - // Method with arguments - should be detected and count arguments correctly - methodSix(a: string, b: number) { - return a + b; - } - - // Method that makes calls - the method should be detected, but not the calls inside - methodSeven() { - // These are function calls, not declarations - this.someOtherMethod(); - dispatch({ type: 'action' }); - useEffect(() => {}); - - return 'method seven'; - } - - // Constructor - should be detected - constructor(private config: any) { - // This is a method call, not a declaration - this.initialize(); - } - - // Getter - should be detected - get value() { - return this._value; - } - - // Setter - should be detected - set value(newValue: any) { - this._value = newValue; - } - - private _value: any; -} - -// Function outside class - should be detected -export function outsideFunction() { - // This creates an instance but doesn't declare methods - const instance = new TestClass({}); - - // This is a method call, not a declaration - instance.methodOne(); - - return instance; -} \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/function-calls.tsx b/scripts/checks/ruleof6/tests/typescript/function-calls.tsx deleted file mode 100644 index 9fabfea13..000000000 --- a/scripts/checks/ruleof6/tests/typescript/function-calls.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// Test file for function calls that should NOT be detected as function declarations -// These are all function CALLS, not function declarations - -import { useEffect } from 'react'; - -// Function calls that currently cause false positives -export function testFunctionCalls() { - // This is a function call with an object argument - should NOT be counted as a function declaration - dispatch({ - type: 'message', - payload: { - content: 'test content', - actor: 'system', - }, - id: 'debug-test', - timestamp: new Date(), - actor: 'system', - }); - - // This is a React hook call - should NOT be counted as a function declaration - useEffect(() => { - console.log('effect running'); - return () => { - console.log('cleanup'); - }; - }, []); - - // EventBus method call - should NOT be counted as a function declaration - eventBus.on('map.*', (event) => { - console.log('event received', event); - }); - - // Another function call with multiple arguments - someFunction( - 'first argument', - { - nested: 'object', - with: 'multiple properties', - and: { - deeply: 'nested values' - } - }, - [1, 2, 3, 4, 5] - ); - - // Method chaining - should NOT be function declarations - api - .get('/endpoint') - .then(response => response.json()) - .catch(error => console.error(error)); -} - -// This IS a real function declaration and should be detected -export function realFunction() { - return 'this should be detected'; -} - -// This IS a real arrow function and should be detected -export const arrowFunction = () => { - return 'this should also be detected'; -}; \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/multiple-functions.tsx b/scripts/checks/ruleof6/tests/typescript/multiple-functions.tsx deleted file mode 100644 index a108ea4d9..000000000 --- a/scripts/checks/ruleof6/tests/typescript/multiple-functions.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// Test case: file with exactly 6 functions (at the limit) -export function Function1() { - return "Function 1"; -} - -export function Function2() { - return "Function 2"; -} - -export function Function3() { - return "Function 3"; -} - -export function Function4() { - return "Function 4"; -} - -export function Function5() { - return "Function 5"; -} - -export function Function6() { - return "Function 6"; -} - -// This file should pass the 6-function limit \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/object-arguments.tsx b/scripts/checks/ruleof6/tests/typescript/object-arguments.tsx deleted file mode 100644 index a3d1ffee9..000000000 --- a/scripts/checks/ruleof6/tests/typescript/object-arguments.tsx +++ /dev/null @@ -1,94 +0,0 @@ -// Test file for object arguments that should be counted correctly -// These test various argument counting scenarios - -// Function with single object argument - should count as 1 argument, not multiple -export function testSingleObjectArg() { - dispatch({ - type: 'tile_selected', - payload: { - tileId: 'test-tile', - tileData: { - id: '123', - title: 'Test Title', - description: 'Test Description', - content: 'Test Content', - coordId: 'coord-123', - }, - openInEditMode: true, - }, - id: 'chat-123', - timestamp: new Date(), - actor: 'system', - }); -} - -// Function with multiple real arguments - should count correctly -export function testMultipleArgs(a: string, b: number, c: boolean) { - return a + b + c; -} - -// Function with mixed arguments - should count as 3 arguments -export function testMixedArgs( - name: string, - config: { - enabled: boolean, - timeout: number, - retries: number, - }, - callback: (result: any) => void -) { - callback({ name, config }); -} - -// Function with array argument - should count as 2 arguments -export function testArrayArg( - items: [string, number, boolean, object, null], - processor: (item: any) => any -) { - return items.map(processor); -} - -// Complex nested object argument - should count as 1 argument -export function testComplexNested() { - api.call({ - method: 'POST', - url: '/api/endpoint', - data: { - user: { - id: 123, - profile: { - name: 'Test User', - settings: { - theme: 'dark', - notifications: { - email: true, - push: false, - }, - }, - }, - }, - metadata: { - source: 'web', - timestamp: Date.now(), - session: { - id: 'session-123', - duration: 3600, - }, - }, - }, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer token', - }, - }); -} - -// Function with destructured object parameter - should count as 1 argument -export function testDestructured({ id, name, config, metadata }: { - id: string; - name: string; - config: object; - metadata: any; -}) { - return { id, name, config, metadata }; -} \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/react-component-with-complex-strings.tsx b/scripts/checks/ruleof6/tests/typescript/react-component-with-complex-strings.tsx deleted file mode 100644 index 967eeddbe..000000000 --- a/scripts/checks/ruleof6/tests/typescript/react-component-with-complex-strings.tsx +++ /dev/null @@ -1,121 +0,0 @@ -// Test case: React component with complex string patterns (like DebugLogsWidget) -export function ComplexReactComponent({ title, content, onClose }: ComplexProps) { - // This function contains the patterns that caused the original bug - - // String with double braces (template placeholders) - const isInteractive = content.includes('{{INTERACTIVE_CONTROLS:'); - - // Template literals with code blocks - const renderCode = () => { - if (content.includes('```')) { - const parts = content.split(/(```[\s\S]*?```)/); - return parts.map((part, index) => { - if (part.startsWith('```') && part.endsWith('```')) { - const code = part.slice(3, -3).trim(); - return ( - <pre key={index} className="bg-neutral-100 dark:bg-neutral-700 p-3 rounded text-sm overflow-x-auto"> - <code>{code}</code> - </pre> - ); - } - return ( - <div key={index} className="text-sm"> - {part.split('\n').map((line, lineIndex) => { - if (line.startsWith('β€’ ')) { - return ( - <div key={lineIndex} className="flex items-start mb-1"> - <span className="text-neutral-500 mt-0.5 mr-1">β€’</span> - <span>{line.slice(2)}</span> - </div> - ); - } - return line ? <div key={lineIndex} className="mb-1">{line}</div> : null; - })} - </div> - ); - }); - } - return <div className="text-sm">{content}</div>; - }; - - // Complex object with nested braces - const config = { - templates: { - success: 'Operation {{type}} completed successfully', - error: 'Error in {{operation}}: {{message}}', - warning: 'Warning: {{details}}' - }, - patterns: [ - /\{\{([^}]+)\}\}/g, - /\${([^}]+)}/g, - /{([^}]+)}/g - ], - handlers: { - process: (input: string) => { - return input.replace(/\{\{([^}]+)\}\}/g, (match, key) => { - return `processed_${key}`; - }); - }, - validate: (data: any) => { - return data && typeof data === 'object'; - } - } - }; - - // String with escaped braces and complex patterns - const complexTemplate = ` - function processTemplate(data) { - const result = data.map(item => ({ - id: item.id, - value: \`\${item.value}_processed\`, - metadata: { - timestamp: new Date().toISOString(), - processed: true - } - })); - return { processed: result.length, data: result }; - } - `; - - // Multi-line JSX with braces - return ( - <div className="complex-component"> - <header> - <h1>{title}</h1> - {onClose && ( - <button onClick={onClose} className="close-btn"> - Γ— - </button> - )} - </header> - - <main> - {isInteractive ? ( - <div className="interactive-content"> - <pre className="template-code">{complexTemplate}</pre> - <div className="config-display"> - {JSON.stringify(config, null, 2)} - </div> - </div> - ) : ( - <div className="static-content"> - {renderCode()} - </div> - )} - </main> - - <footer> - <div className="metadata"> - Processed at: {new Date().toISOString()} - </div> - </footer> - </div> - ); - // This function should be detected as ~100+ lines and trigger an error -} - -interface ComplexProps { - title: string; - content: string; - onClose?: () => void; -} \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/react-hooks.tsx b/scripts/checks/ruleof6/tests/typescript/react-hooks.tsx deleted file mode 100644 index 346e2a36a..000000000 --- a/scripts/checks/ruleof6/tests/typescript/react-hooks.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// Test file for React hooks and common patterns -// These should demonstrate proper vs improper detection - -import { useEffect, useState, useCallback, useMemo } from 'react'; - -// Real function that should be detected -export function useCustomHook() { - const [state, setState] = useState(null); - - // These are hook calls, NOT function declarations - useEffect(() => { - // Complex effect with nested function calls - const cleanup = () => { - console.log('cleaning up'); - }; - - const handler = (event: any) => { - setState(event.data); - }; - - window.addEventListener('message', handler); - - return () => { - cleanup(); - window.removeEventListener('message', handler); - }; - }, []); - - // Another hook call with complex dependencies - useEffect(() => { - if (state) { - dispatch({ - type: 'state_changed', - payload: { - newState: state, - timestamp: Date.now(), - source: 'useCustomHook', - }, - }); - } - }, [state]); - - // useCallback - should NOT be detected as function declaration - const handleClick = useCallback((event: MouseEvent) => { - dispatch({ - type: 'click', - payload: { - target: event.target, - position: { x: event.clientX, y: event.clientY }, - }, - }); - }, []); - - // useMemo - should NOT be detected as function declaration - const memoizedValue = useMemo(() => { - return calculateExpensiveValue({ - input: state, - options: { - precision: 2, - format: 'json', - }, - }); - }, [state]); - - return { state, handleClick, memoizedValue }; -} - -// React component function - should be detected -export function TestComponent() { - // Hook calls - should NOT be detected as function declarations - useEffect(() => { - const subscription = eventBus.on('test.*', (event: any) => { - console.log('received event:', event); - }); - - return () => { - subscription.unsubscribe(); - }; - }, []); - - // Event handler function - should be detected as function declaration - const handleSubmit = (data: any) => { - dispatch({ - type: 'form_submit', - payload: { - formData: data, - submittedAt: new Date(), - source: 'TestComponent', - }, - }); - }; - - return null; // JSX would go here -} - -// Utility functions - should be detected -export const calculateExpensiveValue = (config: any) => { - // Some expensive calculation - return config.input * 2; -}; - -export async function fetchData(url: string) { - // Function calls, not declarations - const response = await fetch(url); - return response.json(); -} \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/simple-function.tsx b/scripts/checks/ruleof6/tests/typescript/simple-function.tsx deleted file mode 100644 index 1ec45295d..000000000 --- a/scripts/checks/ruleof6/tests/typescript/simple-function.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// Simple test case: a function that should trigger the 50+ line warning -export function SimpleLongFunction() { - // Line 3 - const data = []; - - // Line 5 - for (let i = 0; i < 100; i++) { - data.push(i); - } - - // Line 9 - if (data.length > 50) { - console.log('Data is long'); - } - - // Line 13 - const processed = data.map(item => { - return item * 2; - }); - - // Line 17 - const filtered = processed.filter(item => { - return item > 10; - }); - - // Line 21 - const result = filtered.reduce((acc, item) => { - return acc + item; - }, 0); - - // Line 25 - if (result > 1000) { - console.log('Result is large'); - } - - // Line 29 - const finalData = { - original: data, - processed: processed, - filtered: filtered, - result: result - }; - - // Line 37 - console.log('Processing complete'); - console.log('Data length:', data.length); - console.log('Processed length:', processed.length); - console.log('Filtered length:', filtered.length); - console.log('Final result:', result); - - // Line 43 - if (finalData.result > 500) { - console.log('High value result'); - } - - // Line 47 - const summary = `Processed ${data.length} items, result: ${result}`; - - // Line 49 - return { - summary, - data: finalData, - success: true - }; - // This function should be ~53 lines and trigger a warning -} \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/string-with-braces.tsx b/scripts/checks/ruleof6/tests/typescript/string-with-braces.tsx deleted file mode 100644 index c2a69eb21..000000000 --- a/scripts/checks/ruleof6/tests/typescript/string-with-braces.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// Test case: functions with strings containing braces (the original bug) -export function FunctionWithStringBraces() { - // This should not be confused by braces inside strings - const template = 'Hello {{name}}, welcome to {{app}}!'; - const config = { - apiUrl: 'https://api.example.com/{{version}}/{{endpoint}}', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer {{token}}' - } - }; - - // Template literals with braces - const templateLiteral = ` - function render() { - return { - component: 'div', - props: { className: 'container' } - }; - } - `; - - // Escaped braces - const escapedBraces = "\\{\\{ not a template \\}\\}"; - - // Multiple string types - const singleQuote = 'Single quote with {braces}'; - const doubleQuote = "Double quote with {braces}"; - const templateStr = `Template with {braces}`; - - return { - template, - config, - templateLiteral, - escapedBraces, - singleQuote, - doubleQuote, - templateStr - }; -} \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/too-many-functions.tsx b/scripts/checks/ruleof6/tests/typescript/too-many-functions.tsx deleted file mode 100644 index 1c62c71e7..000000000 --- a/scripts/checks/ruleof6/tests/typescript/too-many-functions.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// Test case: file with 7 functions (should trigger error) -export function Function1() { - return "Function 1"; -} - -export function Function2() { - return "Function 2"; -} - -export function Function3() { - return "Function 3"; -} - -export function Function4() { - return "Function 4"; -} - -export function Function5() { - return "Function 5"; -} - -export function Function6() { - return "Function 6"; -} - -export function Function7() { - return "Function 7 - This should trigger a violation"; -} - -// This file should violate the 6-function limit \ No newline at end of file diff --git a/scripts/checks/ruleof6/tests/typescript/very-long-function.tsx b/scripts/checks/ruleof6/tests/typescript/very-long-function.tsx deleted file mode 100644 index 121d127b4..000000000 --- a/scripts/checks/ruleof6/tests/typescript/very-long-function.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// Test case: a function that should trigger the 100+ line error -export function VeryLongFunction() { - // This function will be over 100 lines to trigger an error - const step1 = "Initialize"; - console.log(step1); - - const step2 = "Process data"; - console.log(step2); - - const step3 = "Validate input"; - console.log(step3); - - const step4 = "Transform data"; - console.log(step4); - - const step5 = "Filter results"; - console.log(step5); - - const step6 = "Sort data"; - console.log(step6); - - const step7 = "Group by category"; - console.log(step7); - - const step8 = "Calculate totals"; - console.log(step8); - - const step9 = "Generate report"; - console.log(step9); - - const step10 = "Export data"; - console.log(step10); - - // Add more lines to exceed 100 - for (let i = 0; i < 10; i++) { - console.log(`Iteration ${i}`); - - if (i % 2 === 0) { - console.log('Even number'); - } else { - console.log('Odd number'); - } - - const calculation = i * 2 + 5; - console.log(`Calculation result: ${calculation}`); - - if (calculation > 15) { - console.log('High calculation'); - } - } - - // More processing - const data = Array.from({length: 20}, (_, i) => ({ - id: i, - value: Math.random() * 100, - processed: false - })); - - for (const item of data) { - item.processed = true; - item.value = Math.round(item.value); - - if (item.value > 50) { - console.log(`High value item: ${item.id}`); - } - - if (item.value < 10) { - console.log(`Low value item: ${item.id}`); - } - } - - // Final processing - const summary = { - totalItems: data.length, - processedItems: data.filter(item => item.processed).length, - highValueItems: data.filter(item => item.value > 50).length, - lowValueItems: data.filter(item => item.value < 10).length - }; - - console.log('Summary:', summary); - - // Validation - if (summary.totalItems !== summary.processedItems) { - throw new Error('Not all items were processed'); - } - - // Additional processing steps - const categories = ['A', 'B', 'C']; - const categorizedData = categories.map(category => ({ - category, - items: data.filter(item => item.id % 3 === categories.indexOf(category)) - })); - - for (const categoryData of categorizedData) { - console.log(`Category ${categoryData.category}: ${categoryData.items.length} items`); - - for (const item of categoryData.items) { - console.log(` Item ${item.id}: ${item.value}`); - } - } - - // Final validation and return - const result = { - data, - summary, - categorizedData, - timestamp: new Date().toISOString() - }; - - console.log('Processing completed successfully'); - return result; - // This function should be well over 100 lines and trigger an error -} \ No newline at end of file diff --git a/scripts/checks/run-all-tests.py b/scripts/checks/run-all-tests.py index 8b46b7000..8626ff6f9 100755 --- a/scripts/checks/run-all-tests.py +++ b/scripts/checks/run-all-tests.py @@ -88,7 +88,6 @@ def run_quick_validation(): checkers_to_test = [ ('shared', 'TypeScript parser'), ('architecture', 'Architecture boundaries'), - ('deadcode', 'Dead code detection'), ('ruleof6', 'Rule of 6 complexity') ] @@ -188,7 +187,7 @@ def main(): return # Run comprehensive tests - checkers = ['shared', 'regression', 'architecture', 'deadcode', 'ruleof6'] + checkers = ['shared', 'regression', 'architecture', 'ruleof6'] results = [] total_start = time.time() diff --git a/scripts/checks/run-subsystem-tree.py b/scripts/checks/run-subsystem-tree.py new file mode 100644 index 000000000..637977388 --- /dev/null +++ b/scripts/checks/run-subsystem-tree.py @@ -0,0 +1,16 @@ +"""Thin entry point for subsystem-tree invocation from package.json.""" + +import sys +from pathlib import Path + +# Strip leading '--' that pnpm passes when using `pnpm subsystem-tree -- args` +if len(sys.argv) > 1 and sys.argv[1] == "--": + sys.argv = [sys.argv[0]] + sys.argv[2:] + +# Add this directory to sys.path so `architecture` package is importable +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from architecture.tree.main import main + +if __name__ == "__main__": + main() diff --git a/scripts/checks/run-tests.py b/scripts/checks/run-tests.py index e59f8d047..46c56f3fd 100755 --- a/scripts/checks/run-tests.py +++ b/scripts/checks/run-tests.py @@ -18,7 +18,7 @@ def test_shared_parser(): sys.path.insert(0, str(Path(__file__).parent)) try: - from shared.typescript_parser import TypeScriptParser + from architecture.shared.typescript_parser import TypeScriptParser parser = TypeScriptParser() @@ -98,39 +98,6 @@ def test_architecture_checker(): return False -def test_deadcode_checker(): - """Test the dead code checker.""" - sys.path.insert(0, str(Path(__file__).parent)) - - try: - from deadcode.checker import DeadCodeChecker - - with tempfile.TemporaryDirectory() as temp_dir: - test_path = Path(temp_dir) - - # Create files with dead code - (test_path / "used.ts").write_text(""" -export function usedFunction() { return 'used'; } -export function unusedFunction() { return 'unused'; } - """.strip()) - - (test_path / "main.ts").write_text(""" -import { usedFunction } from './used'; -export function main() { return usedFunction(); } - """.strip()) - - checker = DeadCodeChecker(str(test_path)) - results = checker.run_all_checks() - issues = results.get_all_issues() - - print(f" βœ… Dead code: {len(issues)} issues found") - return True - - except Exception as e: - print(f" ❌ Dead code error: {e}") - return False - - def test_ruleof6_checker(): """Test the Rule of 6 checker.""" original_cwd = os.getcwd() @@ -172,7 +139,7 @@ def test_comprehensive_scenarios(): sys.path.insert(0, str(Path(__file__).parent)) try: - from shared.typescript_parser import TypeScriptParser + from architecture.shared.typescript_parser import TypeScriptParser parser = TypeScriptParser() @@ -226,7 +193,6 @@ def run_all_tests(): tests = [ ("Shared Parser", test_shared_parser), ("Architecture Checker", test_architecture_checker), - ("Dead Code Checker", test_deadcode_checker), ("Rule of 6 Checker", test_ruleof6_checker), ("Complex Scenarios", test_comprehensive_scenarios), ] @@ -261,7 +227,6 @@ def run_all_tests(): print(" β€’ All checkers are working correctly") print(" β€’ Parser handles complex TypeScript syntax") print(" β€’ Architecture boundaries are enforced") - print(" β€’ Dead code detection is functional") print(" β€’ Rule of 6 complexity checking works") print(" β€’ Ready for production use!") return 0 diff --git a/scripts/checks/shared/__init__.py b/scripts/checks/shared/__init__.py deleted file mode 100644 index 798cc0dff..000000000 --- a/scripts/checks/shared/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -""" -Shared utilities for TypeScript code analysis. - -This module provides common functionality used across different code checking tools, -including import/export parsing, function extraction, and symbol analysis. -""" - -from .typescript_parser import TypeScriptParser, Import, Export, Symbol, FunctionInfo - -__all__ = [ - "TypeScriptParser", - "Import", - "Export", - "Symbol", - "FunctionInfo" -] \ No newline at end of file diff --git a/scripts/checks/shared/typescript_parser.py b/scripts/checks/shared/typescript_parser.py deleted file mode 100644 index 9f50666ce..000000000 --- a/scripts/checks/shared/typescript_parser.py +++ /dev/null @@ -1,1148 +0,0 @@ -#!/usr/bin/env python3 -""" -Shared TypeScript/JavaScript parsing utilities. - -This module provides comprehensive parsing of TypeScript and JavaScript files, -extracting imports, exports, functions, and other symbols for use by various -code analysis tools. -""" - -import re -from pathlib import Path -from typing import List, Set, Optional, NamedTuple, Tuple -from dataclasses import dataclass - - -@dataclass -class Import: - """Represents an import statement.""" - name: str - from_path: str - file_path: Path - line_number: int - import_type: str # 'default', 'named', 'namespace', 'type' - original_name: Optional[str] = None # For aliased imports - - -@dataclass -class Export: - """Represents an export statement.""" - name: str - file_path: Path - line_number: int - export_type: str # 'default', 'named', 'const', 'function', 'class', 'interface', 'type' - is_reexport: bool = False - from_path: Optional[str] = None - original_name: Optional[str] = None # For aliased exports like "export { foo as bar }" - - -@dataclass -class Symbol: - """Represents a local symbol (function, variable, etc.).""" - name: str - file_path: Path - line_number: int - symbol_type: str # 'function', 'const', 'let', 'var', 'class', 'interface', 'type' - is_exported: bool = False - - -@dataclass -class FunctionInfo: - """Represents function information for Rule of 6 checking.""" - name: str - line_start: int - line_end: int - line_count: int - arg_count: int - file_path: Path - - -class TypeScriptParser: - """ - Comprehensive TypeScript/JavaScript parser for code analysis. - - Extracts imports, exports, functions, and symbols from TypeScript files - with proper handling of multi-line statements and various syntax patterns. - """ - - def __init__(self): - # Keywords to exclude from function detection - self.excluded_keywords = { - 'if', 'else', 'for', 'while', 'switch', 'case', 'default', 'try', 'catch', - 'finally', 'with', 'return', 'throw', 'break', 'continue', 'do', 'typeof', - 'instanceof', 'in', 'new', 'delete', 'void', 'yield', 'await' - } - - # Function declaration patterns - more precise to avoid false positives - self.function_patterns = [ - # Function declarations: export function name() or function name() - r'^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(', - # Arrow functions: const name = () => or export const name = () => - r'^\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s*)?\(', - # Object method arrow functions: methodName: () => (at start of line or after {) - r'^\s*(\w+)\s*:\s*(?:async\s*)?\(', - # Class methods: public/private/static methodName() - r'^\s*(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(\w+)\s*\(', - ] - - def extract_imports(self, content: str, file_path: Path) -> List[Import]: - """Extract import statements from file content with multi-line support.""" - imports = [] - - # First handle multi-line imports using regex on full content - # Multi-line named imports: import { ... } - multi_import_pattern = r'import\s*\{\s*((?:[^{}]|{[^}]*})*?)\s*\}\s*from\s*["\']([^"\']+)["\']' - for match in re.finditer(multi_import_pattern, content, re.MULTILINE | re.DOTALL): - imports_str = match.group(1) - from_path = match.group(2) - - # Find line number of the import statement - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - # Parse individual imports - for import_name in imports_str.split(','): - import_name = import_name.strip() - if not import_name: - continue - - # Handle inline type imports: type Foo - import_type = 'named' - if import_name.startswith('type '): - import_type = 'type' - import_name = import_name[5:].strip() # Remove 'type ' prefix - - # Handle 'as' aliases: foo as bar - has_alias = ' as ' in import_name - original_name = import_name.split(' as ')[0].strip() if has_alias else None - if has_alias: - import_name = import_name.split(' as ')[-1].strip() - - imports.append(Import( - name=import_name, - from_path=from_path, - file_path=file_path, - line_number=line_number, - import_type=import_type, - original_name=original_name - )) - - # Multi-line type imports: import type { ... } - multi_type_pattern = r'import\s+type\s*\{\s*((?:[^{}]|{[^}]*})*?)\s*\}\s*from\s*["\']([^"\']+)["\']' - for match in re.finditer(multi_type_pattern, content, re.MULTILINE | re.DOTALL): - imports_str = match.group(1) - from_path = match.group(2) - - # Find line number - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - for import_name in imports_str.split(','): - import_name = import_name.strip() - if not import_name: - continue - - has_alias = ' as ' in import_name - original_name = import_name.split(' as ')[0].strip() if has_alias else None - if has_alias: - import_name = import_name.split(' as ')[-1].strip() - - imports.append(Import( - name=import_name, - from_path=from_path, - file_path=file_path, - line_number=line_number, - import_type='type', - original_name=original_name - )) - - # Now process line by line for other import patterns - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('//') or line.startswith('/*'): - continue - - # Skip lines that are part of multi-line imports (already processed) - if 'import' in line and ('{' in line or '}' in line): - # Check if this is a single-line import pattern - is_single_line_import = line.startswith('import') and line.endswith("';") - - # Skip only if it's truly part of a multi-line import block - if not is_single_line_import: - continue - - # Default import: import foo from 'bar' - default_match = re.match(r'import\s+(\w+)\s+from\s+["\']([^"\']+)["\']', line) - if default_match and '{' not in line: - name = default_match.group(1) - from_path = default_match.group(2) - - imports.append(Import( - name=name, - from_path=from_path, - file_path=file_path, - line_number=i, - import_type='default' - )) - continue - - # Single-line named imports: import { foo, bar } from 'baz' on one line - single_named_match = re.match(r'^import\s*\{\s*([^}]+)\s*\}\s*from\s*["\']([^"\']+)["\']$', line) - if single_named_match: - imports_str = single_named_match.group(1) - from_path = single_named_match.group(2) - - for import_name in imports_str.split(','): - import_name = import_name.strip() - if not import_name: - continue - - # Handle inline type imports: type Foo - import_type = 'named' - if import_name.startswith('type '): - import_type = 'type' - import_name = import_name[5:].strip() # Remove 'type ' prefix - - # Handle 'as' aliases: foo as bar - has_alias = ' as ' in import_name - original_name = import_name.split(' as ')[0].strip() if has_alias else None - if has_alias: - import_name = import_name.split(' as ')[-1].strip() - - imports.append(Import( - name=import_name, - from_path=from_path, - file_path=file_path, - line_number=i, - import_type=import_type, - original_name=original_name - )) - continue - - # Single-line type imports: import type { ... } from '...' on one line - single_type_match = re.match(r'^import\s+type\s*\{\s*([^}]+)\s*\}\s*from\s*["\']([^"\']+)["\']$', line) - if single_type_match: - imports_str = single_type_match.group(1) - from_path = single_type_match.group(2) - - for import_name in imports_str.split(','): - import_name = import_name.strip() - if not import_name: - continue - - has_alias = ' as ' in import_name - original_name = import_name.split(' as ')[0].strip() if has_alias else None - if has_alias: - import_name = import_name.split(' as ')[-1].strip() - - imports.append(Import( - name=import_name, - from_path=from_path, - file_path=file_path, - line_number=i, - import_type='type', - original_name=original_name - )) - continue - - # Namespace import: import * as foo from 'bar' - namespace_match = re.match(r'import\s*\*\s*as\s+(\w+)\s+from\s+["\']([^"\']+)["\']', line) - if namespace_match: - name = namespace_match.group(1) - from_path = namespace_match.group(2) - - imports.append(Import( - name=name, - from_path=from_path, - file_path=file_path, - line_number=i, - import_type='namespace' - )) - - # Handle dynamic imports: import('path') and await import('path') - # These are common in modern TypeScript for lazy loading - dynamic_import_pattern = r'(?:await\s+)?import\s*\(\s*["\']([^"\']+)["\']\s*\)' - for match in re.finditer(dynamic_import_pattern, content, re.MULTILINE): - from_path = match.group(1) - - # Find line number - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - # For dynamic imports, we'll mark it as a namespace import since - # the entire module is being imported dynamically - imports.append(Import( - name='*', # Dynamic imports import the whole module - from_path=from_path, - file_path=file_path, - line_number=line_number, - import_type='dynamic' - )) - - return imports - - def extract_exports(self, content: str, file_path: Path) -> List[Export]: - """Extract export statements from file content with multi-line support.""" - exports = [] - - # First handle multi-line exports using regex on full content - # Multi-line named exports: export { ... } - multi_export_pattern = r'export\s*\{\s*((?:[^{}]|{[^}]*})*?)\s*\}(?:\s*from\s*["\']([^"\']+)["\'])?' - for match in re.finditer(multi_export_pattern, content, re.MULTILINE | re.DOTALL): - exports_str = match.group(1) - from_path = match.group(2) - is_reexport = from_path is not None - - # Find line number of the export statement - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - # Parse individual exports - for export_name in exports_str.split(','): - export_name = export_name.strip() - if not export_name: - continue - - # Handle 'as' aliases: foo as bar - original_name = None - if ' as ' in export_name: - original_name = export_name.split(' as ')[0].strip() - export_name = export_name.split(' as ')[-1].strip() - - exports.append(Export( - name=export_name, - file_path=file_path, - line_number=line_number, - export_type='named', - is_reexport=is_reexport, - from_path=from_path, - original_name=original_name - )) - - # Multi-line type exports: export type { ... } - multi_type_pattern = r'export\s+type\s*\{\s*((?:[^{}]|{[^}]*})*?)\s*\}(?:\s*from\s*["\']([^"\']+)["\'])?' - for match in re.finditer(multi_type_pattern, content, re.MULTILINE | re.DOTALL): - exports_str = match.group(1) - from_path = match.group(2) - is_reexport = from_path is not None - - # Find line number - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - for export_name in exports_str.split(','): - export_name = export_name.strip() - if not export_name: - continue - - original_name = None - if ' as ' in export_name: - original_name = export_name.split(' as ')[0].strip() - export_name = export_name.split(' as ')[-1].strip() - - exports.append(Export( - name=export_name, - file_path=file_path, - line_number=line_number, - export_type='type', - is_reexport=is_reexport, - from_path=from_path, - original_name=original_name - )) - - # Wildcard exports: export * from '...' - wildcard_pattern = r'export\s*\*\s*from\s*["\']([^"\']+)["\']' - for match in re.finditer(wildcard_pattern, content, re.MULTILINE): - from_path = match.group(1) - - # Find line number - content_before = content[:match.start()] - line_number = content_before.count('\n') + 1 - - exports.append(Export( - name='*', # Special marker for wildcard exports - file_path=file_path, - line_number=line_number, - export_type='wildcard', - is_reexport=True, - from_path=from_path - )) - - # Now process line by line for other export patterns - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('//') or line.startswith('/*'): - continue - - # Skip lines that are part of multi-line exports (already processed) - # But don't skip direct exports like "export function Toaster() {" - if 'export' in line and ('{' in line or '}' in line): - # Check if this is a direct export pattern - is_direct_export = bool(re.match(r'export\s+(const|function|class|interface|type)\s+\w+', line)) - is_single_line_export = line.startswith('export') and line.endswith('}') - - # Skip only if it's truly part of a multi-line export block - if not (is_direct_export or is_single_line_export): - continue - - # Single-line named exports: export { foo, bar } on one line - single_named_match = re.match(r'^export\s*\{\s*([^}]+)\s*\}(?:\s*from\s*["\']([^"\']+)["\'])?$', line) - if single_named_match: - exports_str = single_named_match.group(1) - from_path = single_named_match.group(2) - is_reexport = from_path is not None - - # Parse individual exports - for export_name in exports_str.split(','): - export_name = export_name.strip() - if not export_name: - continue - - # Handle 'as' aliases: foo as bar - original_name = None - if ' as ' in export_name: - original_name = export_name.split(' as ')[0].strip() - export_name = export_name.split(' as ')[-1].strip() - - exports.append(Export( - name=export_name, - file_path=file_path, - line_number=i, - export_type='named', - is_reexport=is_reexport, - from_path=from_path, - original_name=original_name - )) - continue - - # Default export - if re.match(r'export\s+default\b', line): - # Try to extract name from default export - name_match = re.search(r'export\s+default\s+(function\s+)?(\w+)', line) - name = name_match.group(2) if name_match else 'default' - - exports.append(Export( - name=name, - file_path=file_path, - line_number=i, - export_type='default', - from_path=None - )) - continue - - # Direct exports: export const/function/class/interface/type - direct_export_match = re.match(r'export\s+(const|function|class|interface|type)\s+(\w+)', line) - if direct_export_match: - export_type = direct_export_match.group(1) - name = direct_export_match.group(2) - - exports.append(Export( - name=name, - file_path=file_path, - line_number=i, - export_type=export_type, - from_path=None - )) - continue - - # Single-line type exports: export type { ... } on one line - single_type_match = re.match(r'^export\s+type\s*\{\s*([^}]+)\s*\}(?:\s*from\s*["\']([^"\']+)["\'])?$', line) - if single_type_match: - exports_str = single_type_match.group(1) - from_path = single_type_match.group(2) - is_reexport = from_path is not None - - for export_name in exports_str.split(','): - export_name = export_name.strip() - if not export_name: - continue - - original_name = None - if ' as ' in export_name: - original_name = export_name.split(' as ')[0].strip() - export_name = export_name.split(' as ')[-1].strip() - - exports.append(Export( - name=export_name, - file_path=file_path, - line_number=i, - export_type='type', - is_reexport=is_reexport, - from_path=from_path, - original_name=original_name - )) - - return exports - - def extract_symbols(self, content: str, file_path: Path) -> List[Symbol]: - """Extract local symbols (functions, variables, etc.) from file content.""" - symbols = [] - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('//') or line.startswith('/*'): - continue - - # Function declarations - func_match = re.match(r'(?:export\s+)?(?:async\s+)?function\s+(\w+)', line) - if func_match: - name = func_match.group(1) - is_exported = 'export' in line - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='function', - is_exported=is_exported - )) - continue - - # Arrow function assignments - arrow_match = re.match(r'(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(.*\)\s*=>', line) - if arrow_match: - name = arrow_match.group(1) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='function', - is_exported=is_exported - )) - continue - - # Const/let/var declarations - var_match = re.match(r'(?:export\s+)?(const|let|var)\s+(\w+)', line) - if var_match: - var_type = var_match.group(1) - name = var_match.group(2) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type=var_type, - is_exported=is_exported - )) - continue - - # Class declarations (including implements clauses) - class_match = re.match(r'(?:export\s+)?class\s+(\w+)', line) - if class_match: - name = class_match.group(1) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='class', - is_exported=is_exported - )) - continue - - # Interface declarations - interface_match = re.match(r'(?:export\s+)?interface\s+(\w+)', line) - if interface_match: - name = interface_match.group(1) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='interface', - is_exported=is_exported - )) - continue - - # Type declarations - type_match = re.match(r'(?:export\s+)?type\s+(\w+)', line) - if type_match: - name = type_match.group(1) - is_exported = line.startswith('export') - - symbols.append(Symbol( - name=name, - file_path=file_path, - line_number=i, - symbol_type='type', - is_exported=is_exported - )) - - return symbols - - def extract_interface_implementations(self, content: str, file_path: Path) -> dict[str, list[str]]: - """Extract interface implementations (class implements Interface).""" - implementations = {} - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('//') or line.startswith('/*'): - continue - - # Look for class declarations with implements - class_implements_match = re.match(r'(?:export\s+)?class\s+(\w+).*?\bimplements\s+([^{]+)', line) - if class_implements_match: - class_name = class_implements_match.group(1) - implements_str = class_implements_match.group(2).strip() - - # Parse implemented interfaces (can be comma-separated) - interfaces = [iface.strip() for iface in implements_str.split(',')] - - for interface in interfaces: - # Clean up interface name (remove generic parameters) - interface = re.sub(r'<.*>', '', interface).strip() - if interface: - if interface not in implementations: - implementations[interface] = [] - implementations[interface].append(class_name) - - return implementations - - def extract_functions(self, content: str, file_path: Path) -> List[FunctionInfo]: - """Extract function information for Rule of 6 checking.""" - functions = [] - lines = content.split('\n') - - # Track context to avoid counting interface properties as functions - in_interface_block = False - brace_level = 0 - - i = 0 - while i < len(lines): - line = lines[i].strip() - - # Skip comments and empty lines - if not line or line.startswith('//') or line.startswith('/*'): - i += 1 - continue - - # Track if we're inside an interface block - if re.match(r'(?:export\s+)?interface\s+\w+', line): - in_interface_block = True - brace_level = 0 - - # Track brace levels to know when we exit interface - if in_interface_block: - brace_level += line.count('{') - brace_level -= line.count('}') - if brace_level <= 0: - in_interface_block = False - - # Skip lines inside interface blocks (they're type definitions, not functions) - if in_interface_block: - i += 1 - continue - - function_match = None - matched_pattern_idx = None - for idx, pattern in enumerate(self.function_patterns): - match = re.search(pattern, line) - if match: - func_name = match.group(1) - # Skip if it's a control flow keyword - if func_name.lower() not in self.excluded_keywords: - function_match = match - matched_pattern_idx = idx - break - - if function_match: - func_name = function_match.group(1) - # Extract arguments by finding the complete parameter list from the opening parenthesis - args_str = self._extract_function_parameters(lines, i, function_match.start(1) if hasattr(function_match, 'start') else 0) - - # Skip obvious function calls (not declarations) with improved detection - if not self._is_valid_function_declaration(line, matched_pattern_idx, lines, i, in_interface_block): - i += 1 - continue - - # Count arguments - arg_count = self._count_arguments(args_str) - - # Find function boundaries and count lines - line_start, line_end = self._find_function_boundaries(lines, i, matched_pattern_idx) - line_count = line_end - line_start + 1 - - functions.append(FunctionInfo( - name=func_name, - line_start=line_start, - line_end=line_end, - line_count=line_count, - arg_count=arg_count, - file_path=file_path - )) - - i += 1 - - return functions - - def extract_import_paths(self, content: str) -> List[str]: - """Simple extraction of import paths only (for architecture checker).""" - import_pattern = r'from\s+["\']([^"\']+)["\']' - return re.findall(import_pattern, content) - - def find_symbol_usage(self, content: str) -> Set[str]: - """Find all symbol usage in file content with enhanced detection.""" - used_symbols = set() - - # Find all identifiers (basic approach) - identifiers = re.findall(r'\b[a-zA-Z_$][a-zA-Z0-9_$]*\b', content) - - # JSX component usage: <ComponentName> or <ComponentName /> - jsx_components = re.findall(r'<\s*([A-Z][a-zA-Z0-9_]*)', content) - - # Method/property access: obj.method(), obj.property - method_calls = re.findall(r'\.([a-zA-Z_$][a-zA-Z0-9_$]*)', content) - - # Object method chains: ObjectName.method() - object_methods = re.findall(r'([A-Z][a-zA-Z0-9_$]*)\.', content) - - # Dynamic imports: import("./file") or await import("./file") - dynamic_imports = re.findall(r'import\s*\(\s*["\']([^"\']+)["\']', content) - - # Schema/config object property references: schema.tableName - schema_refs = re.findall(r'schema\.([a-zA-Z_$][a-zA-Z0-9_$]*)', content) - - # Combine all symbol usage - all_symbols = set(identifiers + jsx_components + method_calls + object_methods + schema_refs) - - # Also track dynamic import files for later processing - for import_path in dynamic_imports: - # This will be handled separately in import resolution - pass - - # Filter out keywords and common tokens - keywords = { - 'import', 'export', 'from', 'const', 'let', 'var', 'function', 'class', - 'interface', 'type', 'if', 'else', 'for', 'while', 'return', 'true', - 'false', 'null', 'undefined', 'string', 'number', 'boolean', 'object', - 'async', 'await', 'new', 'this', 'super', 'extends', 'implements', - 'default', 'case', 'switch', 'try', 'catch', 'finally', 'throw' - } - - for identifier in all_symbols: - if identifier not in keywords: - used_symbols.add(identifier) - - return used_symbols - - def _count_arguments(self, args_str: str) -> int: - """Count arguments in function signature, properly handling nested structures.""" - if not args_str.strip(): - return 0 - - # Split arguments while respecting nested structures (braces, brackets, parentheses) - args = self._split_arguments_safely(args_str) - - # Filter out empty args and clean up - real_args = [] - for arg in args: - arg = arg.strip() - if not arg or arg == '...': - continue - - # Remove type annotations and default values - # Handle complex types like { prop: string } - arg = re.sub(r':\s*\{[^}]*\}', '', arg) # Remove object type annotations - arg = re.sub(r':\s*[^=,{}]+', '', arg) # Remove simple type annotations - arg = re.sub(r'=.*$', '', arg) # Remove default values - arg = arg.strip() - - if arg: - real_args.append(arg) - - return len(real_args) - - def _split_arguments_safely(self, args_str: str) -> List[str]: - """Split arguments by comma while respecting nested structures.""" - if not args_str.strip(): - return [] - - args = [] - current_arg = "" - depth = {'braces': 0, 'brackets': 0, 'parens': 0} - in_string = {'single': False, 'double': False, 'template': False} - i = 0 - - while i < len(args_str): - char = args_str[i] - - # Handle escape sequences - if char == '\\' and i + 1 < len(args_str): - current_arg += char + args_str[i + 1] - i += 2 - continue - - # Handle string literals - if char == "'" and not in_string['double'] and not in_string['template']: - in_string['single'] = not in_string['single'] - elif char == '"' and not in_string['single'] and not in_string['template']: - in_string['double'] = not in_string['double'] - elif char == '`' and not in_string['single'] and not in_string['double']: - in_string['template'] = not in_string['template'] - - # If we're inside any string, just add the character - if any(in_string.values()): - current_arg += char - i += 1 - continue - - # Track nesting depth outside strings - if char == '{': - depth['braces'] += 1 - elif char == '}': - depth['braces'] -= 1 - elif char == '[': - depth['brackets'] += 1 - elif char == ']': - depth['brackets'] -= 1 - elif char == '(': - depth['parens'] += 1 - elif char == ')': - depth['parens'] -= 1 - elif char == ',' and all(d == 0 for d in depth.values()): - # Found a top-level comma, split here - if current_arg.strip(): - args.append(current_arg.strip()) - current_arg = "" - i += 1 - continue - - current_arg += char - i += 1 - - # Add the last argument - if current_arg.strip(): - args.append(current_arg.strip()) - - return args - - def _is_valid_function_declaration(self, line: str, pattern_idx: int, lines: List[str], line_idx: int, in_interface: bool) -> bool: - """ - Determine if a matched pattern represents a valid function declaration. - Returns True if it's a valid function declaration, False if it's likely a function call. - """ - line_stripped = line.strip() - - # Pattern 0: function declarations - these are always valid - if pattern_idx == 0: - return True - - # Pattern 1: const name = ... pattern - elif pattern_idx == 1: - # Must have '=' and either '=>' or 'function' keyword - return '=' in line and ('=>' in line or 'function' in line) - - # Pattern 2: object method pattern (methodName: ...) - elif pattern_idx == 2: - # Must have ':' and '=>' to be a method declaration - return ':' in line and '=>' in line - - # Pattern 3: class method pattern - this is the problematic one - elif pattern_idx == 3: - # First, check for obvious function calls that should never be method declarations - if self._is_obvious_function_call(line_stripped): - return False - - # Check if we're actually in a class context - if not self._is_in_class_context(lines, line_idx): - return False - - # Additional checks for class methods - # Should have visibility modifiers or be inside class body - has_visibility = any(keyword in line for keyword in ['public', 'private', 'protected', 'static']) - - # Check if line ends with opening brace (method declaration) vs just parenthesis (method call) - if '{' in line: - return True - - # If no opening brace, it's likely a method call - if line_stripped.endswith(');') or line_stripped.endswith(')'): - return False - - # Look ahead to see if next non-empty line has opening brace - next_line_idx = line_idx + 1 - while next_line_idx < len(lines) and not lines[next_line_idx].strip(): - next_line_idx += 1 - - if next_line_idx < len(lines): - next_line = lines[next_line_idx].strip() - if next_line.startswith('{'): - return True - - return has_visibility - - return False - - def _is_obvious_function_call(self, line_stripped: str) -> bool: - """ - Check if a line is obviously a function call rather than a function declaration. - Returns True for obvious function calls. - """ - # Common patterns that indicate function calls - function_call_patterns = [ - # Lines that end with semicolon and parenthesis (function calls) - r'\w+\([^)]*\);?\s*$', - # Lines with object method calls (dot notation) - r'\w+\.\w+\(', - # Lines that start with 'this.' (method calls) - r'^\s*this\.\w+\(', - # Hook calls (useEffect, useState, etc.) - r'^\s*use\w+\(', - # Common function calls - r'^\s*(?:console|setTimeout|setInterval|addEventListener|dispatch|eventBus)\(', - # Lines with complex call chains - r'\w+\([^)]*\)\s*\.', - ] - - for pattern in function_call_patterns: - if re.search(pattern, line_stripped): - return True - - # Check for lines that have nested calls or complex expressions - # These are unlikely to be method declarations - if (line_stripped.count('(') > 1 or - line_stripped.count('{') > 1 or - '(' in line_stripped and '{' in line_stripped and line_stripped.endswith(');')): - return True - - return False - - def _is_in_class_context(self, lines: List[str], current_line_idx: int) -> bool: - """ - Check if the current line is within a class declaration. - Returns True if inside a class body. - """ - brace_count = 0 - in_class = False - - # Look backwards to find class declaration - for i in range(current_line_idx, -1, -1): - line = lines[i].strip() - - # Skip empty lines and comments - if not line or line.startswith('//') or line.startswith('/*'): - continue - - # Count braces to track nesting - brace_count += line.count('}') - brace_count -= line.count('{') - - # If we find a class declaration and we're inside its braces, return True - if re.match(r'(?:export\s+)?class\s+\w+', line): - # If brace_count is 0 or negative, we're inside this class - return brace_count <= 0 - - # If we've gone too far back (outside of current scope), stop - if brace_count > 0: - break - - return False - - def find_object_parameter_violations(self, content: str, file_path: Path, max_keys: int = 6) -> List[tuple[int, int, str]]: - """Find object parameters that violate the Rule of 6 (more than max_keys keys).""" - violations = [] - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - line_stripped = line.strip() - - # Skip comments and empty lines - if not line_stripped or line_stripped.startswith('//') or line_stripped.startswith('/*'): - continue - - # Look for object parameter patterns - # Pattern: function foo({ key1, key2, key3, ... }: { ... }) - # Pattern: const foo = ({ key1, key2, key3, ... }) => - # Pattern: method({ key1, key2, key3, ... }) - - # Find destructured object parameters - destructure_matches = re.findall(r'\{\s*([^}]+)\s*\}[^=]*(?::\s*\{[^}]*\})?(?:\s*=\s*\{[^}]*\})?', line_stripped) - - for match in destructure_matches: - # Count the keys in the destructured object - keys = [key.strip().split(':')[0].strip() for key in match.split(',') if key.strip()] - # Filter out spread operators and empty keys - actual_keys = [key for key in keys if key and not key.startswith('...')] - - if len(actual_keys) > max_keys: - # Create a preview of the parameters (first few keys) - preview_keys = actual_keys[:3] - params_preview = ', '.join(preview_keys) - if len(actual_keys) > 3: - params_preview += f", ... (+{len(actual_keys) - 3} more)" - - violations.append((i, len(actual_keys), params_preview)) - - return violations - - def extract_function_names_from_content(self, content: str) -> Set[str]: - """Extract function names from content for validation purposes.""" - function_names = set() - - for pattern in self.function_patterns: - matches = re.finditer(pattern, content, re.MULTILINE) - for match in matches: - func_name = match.group(1) - if func_name.lower() not in self.excluded_keywords: - function_names.add(func_name) - - return function_names - - def _extract_function_parameters(self, lines: List[str], start_line_idx: int, name_start_pos: int = 0) -> str: - """Extract function parameters from opening parenthesis to closing parenthesis.""" - # Find the opening parenthesis - start_line = lines[start_line_idx] - paren_pos = start_line.find('(', name_start_pos) - if paren_pos == -1: - return "" - - # Start collecting parameters from the opening parenthesis - paren_count = 0 - params = "" - - for i in range(start_line_idx, len(lines)): - line = lines[i] - - # Start from the opening parenthesis position on the first line - start_pos = paren_pos if i == start_line_idx else 0 - line_part = line[start_pos:] - - for char in line_part: - if char == '(': - paren_count += 1 - elif char == ')': - paren_count -= 1 - if paren_count == 0: - # Found the closing parenthesis, return the collected parameters - return params.strip() - - # Only collect characters inside the parentheses (not the parentheses themselves) - if paren_count > 0 and char != '(': - params += char - - # Add newline if we're continuing to the next line - if paren_count > 0 and i < len(lines) - 1: - params += '\n' - - return params.strip() - - def _count_braces_outside_strings(self, line: str) -> int: - """Count { and } braces while ignoring those inside string literals.""" - brace_count = 0 - in_single_quote = False - in_double_quote = False - in_template_literal = False - i = 0 - - while i < len(line): - char = line[i] - - # Handle escape sequences - if char == '\\' and i + 1 < len(line): - i += 2 # Skip the escaped character - continue - - # Handle string delimiters - if char == "'" and not in_double_quote and not in_template_literal: - in_single_quote = not in_single_quote - elif char == '"' and not in_single_quote and not in_template_literal: - in_double_quote = not in_double_quote - elif char == '`' and not in_single_quote and not in_double_quote: - in_template_literal = not in_template_literal - - # Count braces only if we're not inside any string - elif not in_single_quote and not in_double_quote and not in_template_literal: - if char == '{': - brace_count += 1 - elif char == '}': - brace_count -= 1 - - i += 1 - - return brace_count - - def _find_function_boundaries(self, lines: List[str], start_line_idx: int, pattern_idx: Optional[int] = None) -> Tuple[int, int]: - """Find the start and end line numbers of a function.""" - line_start = start_line_idx + 1 # Convert to 1-indexed - - # Handle arrow functions differently - if pattern_idx == 1: # const foo = () => pattern - # For arrow functions, look for the end of the statement - brace_count = 0 - found_arrow = False - arrow_has_braces = False - - for i in range(start_line_idx, len(lines)): - line = lines[i] - - if '=>' in line: - found_arrow = True - # Check if this arrow function uses braces - if '{' in line: - arrow_has_braces = True - - if found_arrow: - if arrow_has_braces: - # Count braces for multi-line arrow functions - brace_count += line.count('{') - brace_count -= line.count('}') - - if brace_count == 0 and i > start_line_idx: # Make sure we've moved past the start - return line_start, i + 1 - else: - # Single-expression arrow function (no braces) - if (line.rstrip().endswith(';') or - line.rstrip().endswith(',') or - i == len(lines) - 1 or - # Check if next line starts a new statement - (i + 1 < len(lines) and lines[i + 1].strip() and - not lines[i + 1].strip().startswith('.') and - not lines[i + 1].strip().startswith(')'))): - return line_start, i + 1 - - return line_start, line_start - - # For regular functions and methods - brace_count = 0 - found_opening = False - - for i in range(start_line_idx, len(lines)): - line = lines[i] - - # Skip comments and empty lines when looking for braces - if line.strip().startswith('//') or line.strip().startswith('/*') or not line.strip(): - continue - - # Count braces while ignoring those inside strings - line_brace_count = self._count_braces_outside_strings(line) - brace_count += line_brace_count - - # Mark that we found the opening brace - if not found_opening and line_brace_count > 0: - found_opening = True - - # If we've found an opening brace and the count is back to 0, we're done - if found_opening and brace_count == 0: - return line_start, i + 1 # Convert to 1-indexed - - # If we can't find the end, return just the start line - return line_start, line_start \ No newline at end of file diff --git a/scripts/checks/test_simple.py b/scripts/checks/test_simple.py index 021bbc918..cb1057fc4 100755 --- a/scripts/checks/test_simple.py +++ b/scripts/checks/test_simple.py @@ -17,7 +17,7 @@ def test_shared_parser(): print("πŸ” Testing shared TypeScript parser...") try: - from shared.typescript_parser import TypeScriptParser + from architecture.shared.typescript_parser import TypeScriptParser parser = TypeScriptParser() content = """ @@ -79,47 +79,6 @@ def test_architecture_checker(): print(f" ❌ Error: {e}") return False -def test_deadcode_checker(): - """Test the deadcode checker.""" - print("πŸ” Testing deadcode checker...") - - try: - from deadcode.checker import DeadCodeChecker - - # Create a temporary test structure - with tempfile.TemporaryDirectory() as temp_dir: - test_path = Path(temp_dir) - - # Create files with potential dead code - (test_path / "used.ts").write_text(""" -export function usedFunction() { - return 'used'; -} - -export function unusedFunction() { - return 'unused'; -} - """.strip()) - - (test_path / "main.ts").write_text(""" -import { usedFunction } from './used'; - -export function main() { - return usedFunction(); -} - """.strip()) - - checker = DeadCodeChecker(str(test_path)) - results = checker.run_all_checks() - - all_issues = results.get_all_issues() - print(f" βœ… Dead code check completed with {len(all_issues)} issues") - return True - - except Exception as e: - print(f" ❌ Error: {e}") - return False - def test_ruleof6_checker(): """Test the Rule of 6 checker.""" print("πŸ” Testing Rule of 6 checker...") @@ -167,7 +126,6 @@ def test_basic_functionality(): tests = [ ("Shared Parser", test_shared_parser), ("Architecture Checker", test_architecture_checker), - ("Dead Code Checker", test_deadcode_checker), ("Rule of 6 Checker", test_ruleof6_checker), ] @@ -197,7 +155,7 @@ def test_parser_edge_cases(): print("=" * 40) try: - from shared.typescript_parser import TypeScriptParser + from architecture.shared.typescript_parser import TypeScriptParser parser = TypeScriptParser() # Test template literals diff --git a/scripts/checks/test_working.py b/scripts/checks/test_working.py index d0191b06c..807bb7434 100755 --- a/scripts/checks/test_working.py +++ b/scripts/checks/test_working.py @@ -137,13 +137,11 @@ def test_performance_with_large_project(): try: print(f"Testing with {len(files)} files...") - # Test all checkers + # Test checkers arch_results = run_checker('architecture', project_path / 'src') - dead_results = run_checker('deadcode', project_path / 'src') rule_results = run_checker('ruleof6', project_path / 'src') print(f"βœ… Architecture: {len(arch_results.errors)} errors") - print(f"βœ… Dead code: {len(dead_results.get_all_issues())} issues") print(f"βœ… Rule of 6: {len(rule_results.get_all_violations())} violations") print("βœ… All checkers completed successfully on large project") diff --git a/scripts/checks/tests/test_runner.py b/scripts/checks/tests/test_runner.py index 2d0cbda14..3e985c80b 100755 --- a/scripts/checks/tests/test_runner.py +++ b/scripts/checks/tests/test_runner.py @@ -54,9 +54,7 @@ def run_tests( if coverage: cmd.extend([ '--cov=architecture', - '--cov=deadcode', '--cov=ruleof6', - '--cov=shared', '--cov-report=term-missing', '--cov-report=html:htmlcov' ]) @@ -98,7 +96,7 @@ def main(): parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') parser.add_argument('-c', '--coverage', action='store_true', help='Generate coverage report') parser.add_argument('-k', '--pattern', help='Pattern to match test names') - parser.add_argument('--checker', choices=['architecture', 'deadcode', 'ruleof6', 'shared'], + parser.add_argument('--checker', choices=['architecture', 'ruleof6', 'shared'], help='Run tests for specific checker only') args = parser.parse_args() diff --git a/scripts/checks/tests/utils/test_helpers.py b/scripts/checks/tests/utils/test_helpers.py index b6bc4af8c..63c3e9e54 100644 --- a/scripts/checks/tests/utils/test_helpers.py +++ b/scripts/checks/tests/utils/test_helpers.py @@ -17,9 +17,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..')) from architecture.checker import ArchitectureChecker -from deadcode.checker import DeadCodeChecker from ruleof6.checker import RuleOf6Checker -from shared.typescript_parser import TypeScriptParser +from architecture.shared.typescript_parser import TypeScriptParser @contextmanager @@ -102,9 +101,6 @@ def run_checker(checker_type: str, path: Union[str, Path], **kwargs) -> Any: if checker_type == 'architecture': checker = ArchitectureChecker(str(path)) return checker.run_all_checks() - elif checker_type == 'deadcode': - checker = DeadCodeChecker(str(path)) - return checker.check() elif checker_type == 'ruleof6': checker = RuleOf6Checker(str(path)) return checker.check() @@ -136,9 +132,6 @@ def assert_no_false_positives(results: Any, expected_clean: List[str], checker_t if checker_type == 'architecture': # Architecture results have errors list flagged_files = {error.file_path for error in results.errors} - elif checker_type == 'deadcode': - # Dead code results have issues list - flagged_files = {issue.file_path for issue in results.issues} elif checker_type == 'ruleof6': # Rule of 6 results have violations list flagged_files = {violation.file_path for violation in results.violations} @@ -265,8 +258,6 @@ def assert_checker_finds_issues(results: Any, expected_issues: List[str], checke """ if checker_type == 'architecture': found_issues = [error.message for error in results.errors] - elif checker_type == 'deadcode': - found_issues = [f"{issue.symbol_name} in {issue.file_path}" for issue in results.issues] elif checker_type == 'ruleof6': found_issues = [violation.message for violation in results.violations] else: diff --git a/scripts/checks/tests/utils/test_helpers_fixed.py b/scripts/checks/tests/utils/test_helpers_fixed.py index 5aebc92cf..7cff4c342 100644 --- a/scripts/checks/tests/utils/test_helpers_fixed.py +++ b/scripts/checks/tests/utils/test_helpers_fixed.py @@ -17,8 +17,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..')) from architecture.checker import ArchitectureChecker -from deadcode.checker import DeadCodeChecker -from shared.typescript_parser import TypeScriptParser +from architecture.shared.typescript_parser import TypeScriptParser def import_ruleof6_checker(): @@ -83,10 +82,6 @@ def run_checker(checker_type: str, path: Union[str, Path], **kwargs) -> Any: checker = ArchitectureChecker(str(path)) return checker.run_all_checks() - elif checker_type == 'deadcode': - checker = DeadCodeChecker(str(path)) - return checker.run_all_checks() - elif checker_type == 'ruleof6': RuleOf6Checker = import_ruleof6_checker() checker = RuleOf6Checker(str(path)) @@ -121,10 +116,6 @@ def assert_no_false_positives(results: Any, expected_clean: List[str], checker_t if checker_type == 'architecture': # Architecture results have errors list flagged_files = {error.file_path for error in results.errors} - elif checker_type == 'deadcode': - # Dead code results - get all issues - all_issues = results.get_all_issues() - flagged_files = {issue.file_path for issue in all_issues} elif checker_type == 'ruleof6': # Rule of 6 results - get all violations all_violations = results.get_all_violations() @@ -148,9 +139,6 @@ def assert_checker_finds_issues(results: Any, expected_issues: List[str], checke """ if checker_type == 'architecture': found_issues = [error.message for error in results.errors] - elif checker_type == 'deadcode': - all_issues = results.get_all_issues() - found_issues = [f"{issue.symbol_name} in {issue.file_path}" for issue in all_issues] elif checker_type == 'ruleof6': all_violations = results.get_all_violations() found_issues = [violation.message for violation in all_violations] @@ -293,14 +281,6 @@ def test_all_checkers_basic(): except Exception as e: results['architecture'] = f"❌ Error: {e}" - # Test deadcode checker - try: - dead_results = run_checker('deadcode', project_path / 'src') - all_issues = dead_results.get_all_issues() - results['deadcode'] = f"βœ… {len(all_issues)} issues found" - except Exception as e: - results['deadcode'] = f"❌ Error: {e}" - # Test ruleof6 checker try: rule_results = run_checker('ruleof6', project_path / 'src') diff --git a/scripts/cleanup-runs.ts b/scripts/cleanup-runs.ts new file mode 100644 index 000000000..29a254857 --- /dev/null +++ b/scripts/cleanup-runs.ts @@ -0,0 +1,99 @@ +/** + * Cleanup all runs from the database. + * + * Run with: + * pnpm tsx scripts/cleanup-runs.ts + * pnpm tsx scripts/cleanup-runs.ts --dry-run + */ + +import "dotenv/config"; +import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { pgTableCreator, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; + +const createTable = pgTableCreator((name) => `vde_${name}`); + +const runs = createTable( + "runs", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + rootCoords: text("root_coords").notNull(), + status: text("status").notNull(), + blockageReason: text("blockage_reason"), + executionLog: jsonb("execution_log").notNull().default([]), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => ({ + userRunsIdx: index("idx_user_runs").on(table.userId, table.status), + rootCoordsStatusIdx: index("idx_root_coords_status").on(table.rootCoords, table.status), + uniqueOpenRun: uniqueIndex("unique_open_run").on(table.rootCoords).where(sql`status = 'open'`), + }) +); + +const schema = { runs }; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error("❌ DATABASE_URL environment variable is required"); + process.exit(1); +} + +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + + console.log("━".repeat(70)); + console.log("Runs Cleanup"); + console.log(`Dry Run: ${dryRun ? "Yes (no changes will be made)" : "No (will delete all runs)"}`); + console.log("━".repeat(70)); + console.log(); + + const client = postgres(DATABASE_URL); + const db = drizzle(client, { schema }); + + try { + const existingRuns = await db.select({ id: runs.id, status: runs.status, rootCoords: runs.rootCoords }).from(runs); + + console.log(`πŸ“Š Found ${existingRuns.length} runs in database:\n`); + + const statusCounts = { open: 0, blocked: 0, closed: 0 }; + for (const run of existingRuns) { + statusCounts[run.status as keyof typeof statusCounts]++; + console.log(` - ${run.id}: ${run.status} (${run.rootCoords})`); + } + + console.log(); + console.log(` Open: ${statusCounts.open}`); + console.log(` Blocked: ${statusCounts.blocked}`); + console.log(` Closed: ${statusCounts.closed}`); + console.log(); + + if (existingRuns.length === 0) { + console.log("βœ… No runs to delete.\n"); + return; + } + + if (dryRun) { + console.log(`\nβœ… [DRY RUN] Would have deleted ${existingRuns.length} runs`); + } else { + await db.delete(runs); + console.log(`\nβœ… Deleted ${existingRuns.length} runs`); + } + + console.log("\n━".repeat(70)); + + if (dryRun) { + console.log("\nπŸ’‘ This was a dry run. Re-run without --dry-run to apply changes."); + } + } catch (error) { + console.error("❌ Error:", error); + process.exit(1); + } finally { + await client.end(); + } +} + +main(); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index ff7eea43a..b653ebc4a 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -84,6 +84,7 @@ if [[ "$PHASE" == "phase1" ]] || [[ "$PHASE" == "all" ]]; then --exclude "**/map-items-negative-directions.integration.test.ts" \ --exclude "**/composition-negative-directions.test.tsx" \ --exclude "**/frame-interior-negative-directions.test.tsx" \ + --exclude "**/template-name-column.integration.test.ts" \ "${STORYBOOK_EXCLUDE[@]}" 2>&1 | tee test-results/main-suite.log MAIN_EXIT_CODE=${PIPESTATUS[0]} @@ -137,7 +138,8 @@ for file in \ src/server/api/routers/map/__tests__/map-items-copy.integration.test.ts \ src/server/api/routers/map/__tests__/map-items-negative-directions.integration.test.ts \ src/app/map/Canvas/__tests__/composition-negative-directions.test.tsx \ - src/app/map/Canvas/__tests__/frame-interior-negative-directions.test.tsx + src/app/map/Canvas/__tests__/frame-interior-negative-directions.test.tsx \ + src/server/db/schema/__tests__/template-name-column.integration.test.ts do if [ -f "$file" ]; then REACT_TEST_FILES="$REACT_TEST_FILES $file" diff --git a/src/.ruleof6-exceptions b/src/.ruleof6-exceptions index e6872224d..bb471ba25 100644 --- a/src/.ruleof6-exceptions +++ b/src/.ruleof6-exceptions @@ -4,6 +4,8 @@ # Function line count exceptions server/email.ts:sendEmail:150 # Complex email sending with multiple providers, templating, and error handling +repositories/_helpers/sdk-helpers.ts:getClaudeModels:150 # Static model data array - pure data definition, not logic +repositories/claude-agent-sdk.repository.ts:onChunk:160 # Stream processing loop with multiple event types - inherent complexity in SDK integration # File function count exceptions (too many functions errors) test/setup.ts:25 # Test setup file requires many utility functions for comprehensive test environment @@ -17,8 +19,8 @@ server/api/routers/map/_mcp-tools/_item-tools.ts:10 # MCP tool definition collec app/map/Chat/Timeline/Widgets/TileWidget/_internals/_form-utils.ts:20 # Form utility helpers module - collection of related form processing functions app/map/Chat/Timeline/Widgets/TileWidget/_internals/_handlers.ts:20 # Event handler utilities module - collection of related event handlers app/map/Chat/Input/_hooks/autocomplete/useAutocompleteLogic.ts:17 # Autocomplete logic hook - complex state machine with multiple internal helpers for commands and @favorites -app/map/Chat/Timeline/_components/_renderers/widget-renderers.tsx:15 # Widget renderer collection - one render function per widget type for type safety -app/map/Chat/Timeline/_components/_renderers/_markdown-components.tsx:12 # Markdown component collection - one component per markdown element type +app/map/Chat/Timeline/Widgets/Adapters/index.ts:15 # Widget adapter collection - one adapter function per widget type for type safety +app/map/Chat/Timeline/_components/_markdown-components.tsx:12 # Markdown component collection - one component per markdown element type app/map/Chat/Timeline/Widgets/TileWidget/tile-widget.tsx:12 # Complex widget orchestrator with multiple event handlers and state transitions # Function argument exceptions diff --git a/src/app/api/stream/execute-task/route.ts b/src/app/api/stream/execute-task/route.ts index 0dfd5de7e..6a31419d9 100644 --- a/src/app/api/stream/execute-task/route.ts +++ b/src/app/api/stream/execute-task/route.ts @@ -35,7 +35,7 @@ import { asRequesterUserId, type HexecuteContext } from '~/lib/domains/mapping' -import { CoordSystem, Direction, MapItemType } from '~/lib/domains/mapping/utils' +import { CoordSystem, Direction, MapItemType, isBuiltInItemType, type ItemTypeValue } from '~/lib/domains/mapping/utils' import { createAgenticServiceAsync, executeTaskStreaming, @@ -203,6 +203,27 @@ async function _createAgenticService(userId: string, sandboxSessionId: string | // Hexplan Management (Orchestration) // ============================================================================= +/** + * Check if a tile type should have hexplan auto-created. + * + * Only SYSTEM tiles and custom (non-built-in) types should have hexplans. + * USER tiles use "recent-history" at direction-0 instead. + * ORGANIZATIONAL and CONTEXT tiles are not executable. + */ +function _shouldAutoCreateHexplan(itemType: ItemTypeValue | null | undefined): boolean { + if (itemType === null || itemType === undefined) { + return false + } + + // Custom (non-built-in) types are treated like SYSTEM tiles + if (!isBuiltInItemType(itemType)) { + return true + } + + // Only SYSTEM tiles get hexplans among built-in types + return itemType === MapItemType.SYSTEM +} + async function _ensureHexplan( mappingService: MappingService, hexecuteContext: HexecuteContext, @@ -297,8 +318,11 @@ export async function GET(request: NextRequest): Promise<Response> { return } - // 3. Ensure hexplan exists (mapping domain operation) - const hexPlanContent = await _ensureHexplan(mappingService, hexecuteContext, taskCoords, instruction) + // 3. Ensure hexplan exists (mapping domain operation) - only for SYSTEM/custom tiles + // USER tiles don't auto-create hexplans (they use "recent-history" at direction-0) + const hexPlanContent = _shouldAutoCreateHexplan(hexecuteContext.task.itemType) + ? await _ensureHexplan(mappingService, hexecuteContext, taskCoords, instruction) + : hexecuteContext.hexPlan ?? '' // 4. Execute task via pure agentic service const response = await executeTaskStreaming( diff --git a/src/app/map/Cache/Handlers/NavigationHandler/_helpers/_loading/region-loader.ts b/src/app/map/Cache/Handlers/NavigationHandler/_helpers/_loading/region-loader.ts index 8cf446e0c..4d184dcf5 100644 --- a/src/app/map/Cache/Handlers/NavigationHandler/_helpers/_loading/region-loader.ts +++ b/src/app/map/Cache/Handlers/NavigationHandler/_helpers/_loading/region-loader.ts @@ -2,7 +2,7 @@ import type { Dispatch } from "react"; import type { CacheAction } from "~/app/map/Cache/State"; import { cacheActions } from "~/app/map/Cache/State"; import type { ServerService } from "~/app/map/Cache/Services"; -import type { MapItemType } from "~/lib/domains/mapping/utils"; +import type { ItemTypeValue } from "~/lib/domains/mapping/utils"; import type { Visibility } from '~/lib/domains/mapping/utils'; import { loggers } from "~/lib/debug/debug-logger"; @@ -19,7 +19,7 @@ export async function loadRegionForItem( preview: string | undefined; link: string; parentId: string | null; - itemType: MapItemType; + itemType: ItemTypeValue; ownerId: string; originId: string | null; visibility: Visibility; diff --git a/src/app/map/Cache/Handlers/NavigationHandler/_helpers/_validation/tile-converter.ts b/src/app/map/Cache/Handlers/NavigationHandler/_helpers/_validation/tile-converter.ts index 4b2c350fe..7b2c87f9e 100644 --- a/src/app/map/Cache/Handlers/NavigationHandler/_helpers/_validation/tile-converter.ts +++ b/src/app/map/Cache/Handlers/NavigationHandler/_helpers/_validation/tile-converter.ts @@ -1,7 +1,7 @@ import type { TileData } from "~/app/map/types"; import { getColor } from "~/app/map/types"; import { CoordSystem } from "~/lib/domains/mapping/utils"; -import type { MapItemType } from "~/lib/domains/mapping/utils"; +import type { ItemTypeValue } from "~/lib/domains/mapping/utils"; import { Visibility } from '~/lib/domains/mapping/utils'; /** @@ -17,7 +17,7 @@ export function convertToTileData(item: { parentId: string | null; ownerId: string; depth: number; - itemType: MapItemType; + itemType: ItemTypeValue; }): TileData { const coordId = item.coordinates; const itemCoords = CoordSystem.parseId(coordId); diff --git a/src/app/map/Cache/Lifecycle/MutationCoordinator/_mutation-wrappers.ts b/src/app/map/Cache/Lifecycle/MutationCoordinator/_mutation-wrappers.ts index a6790787f..ee1e03c2e 100644 --- a/src/app/map/Cache/Lifecycle/MutationCoordinator/_mutation-wrappers.ts +++ b/src/app/map/Cache/Lifecycle/MutationCoordinator/_mutation-wrappers.ts @@ -1,4 +1,4 @@ -import type { Coord, NonUserMapItemTypeString, VisibilityString } from "~/lib/domains/mapping/utils"; +import type { Coord, VisibilityString } from "~/lib/domains/mapping/utils"; import { Visibility } from "~/lib/domains/mapping/utils"; import type { MapItemUpdateAttributes } from "~/lib/domains/mapping/utils"; import type { MapItemAPIContract } from "~/server/api"; @@ -14,7 +14,7 @@ export function _wrapTRPCMutations(mutations: { content?: string; preview?: string; link?: string; - itemType: NonUserMapItemTypeString; + itemType: string; }) => Promise<MapItemAPIContract> }; updateItemMutation: { mutateAsync: (params: { coords: Coord; @@ -46,7 +46,7 @@ export function _wrapTRPCMutations(mutations: { mutateAsync: async (params: { coords: Coord; parentId?: number | null; - itemType: NonUserMapItemTypeString; + itemType: string; title?: string; content?: string; preview?: string; diff --git a/src/app/map/Cache/Lifecycle/MutationCoordinator/mutation-coordinator.ts b/src/app/map/Cache/Lifecycle/MutationCoordinator/mutation-coordinator.ts index 60652c1fd..da5161970 100644 --- a/src/app/map/Cache/Lifecycle/MutationCoordinator/mutation-coordinator.ts +++ b/src/app/map/Cache/Lifecycle/MutationCoordinator/mutation-coordinator.ts @@ -1,6 +1,6 @@ import { type Dispatch } from "react"; import { CoordSystem, Direction, type Coord } from "~/lib/domains/mapping/utils"; -import type { MapItemUpdateAttributes, MapItemCreateAttributes, NonUserMapItemTypeString } from "~/lib/domains/mapping/utils"; +import type { MapItemUpdateAttributes, MapItemCreateAttributes } from "~/lib/domains/mapping/utils"; import type { MapItemAPIContract } from "~/server/api"; import type { CacheAction } from "~/app/map/Cache/State"; import { cacheActions } from "~/app/map/Cache/State"; @@ -27,7 +27,7 @@ export interface MutationCoordinatorConfig { mutateAsync: (params: { coords: Coord; parentId?: number | null; - itemType: NonUserMapItemTypeString; + itemType: string; title?: string; content?: string; preview?: string; @@ -289,7 +289,7 @@ export class MutationCoordinator { } } - async createItem(coordId: string, data: Omit<MapItemCreateAttributes, 'coords' | 'itemType'> & { parentId?: number; itemType: NonUserMapItemTypeString }): Promise<MutationResult> { + async createItem(coordId: string, data: Omit<MapItemCreateAttributes, 'coords' | 'itemType'> & { parentId?: number; itemType: string }): Promise<MutationResult> { return this.trackOperation(coordId, 'create', async () => { const changeId = this.tracker.generateChangeId(); @@ -1177,7 +1177,7 @@ export class MutationCoordinator { preview?: string; link?: string; visibility?: "public" | "private"; - itemType?: MapItemType; + itemType?: string; } ): { optimisticItem: MapItemAPIContract; previousData: MapItemAPIContract } { const previousData = this._reconstructApiData(existingItem); diff --git a/src/app/map/Cache/Lifecycle/_callbacks/mutation-callbacks.ts b/src/app/map/Cache/Lifecycle/_callbacks/mutation-callbacks.ts index e5e463d2c..77f935a3d 100644 --- a/src/app/map/Cache/Lifecycle/_callbacks/mutation-callbacks.ts +++ b/src/app/map/Cache/Lifecycle/_callbacks/mutation-callbacks.ts @@ -1,6 +1,6 @@ import type { MutationOperations } from "~/app/map/Cache/types/handlers"; -import type { NonUserMapItemType, NonUserMapItemTypeString, VisibilityString } from '~/lib/domains/mapping/utils'; -import { Visibility, MapItemType } from '~/lib/domains/mapping/utils'; +import type { VisibilityString } from '~/lib/domains/mapping/utils'; +import { Visibility } from '~/lib/domains/mapping/utils'; /** * Convert string visibility to Visibility enum @@ -10,18 +10,6 @@ function toVisibilityEnum(visibility: VisibilityString | undefined): Visibility return visibility === "public" ? Visibility.PUBLIC : Visibility.PRIVATE; } -/** - * Convert string itemType to MapItemType enum - */ -function toItemTypeEnum(itemType: NonUserMapItemTypeString | undefined): NonUserMapItemType | undefined { - if (!itemType) return undefined; - switch (itemType) { - case "organizational": return MapItemType.ORGANIZATIONAL; - case "context": return MapItemType.CONTEXT; - case "system": return MapItemType.SYSTEM; - } -} - /** * Create mutation operation callbacks with clean public API naming * Normalizes legacy field names to canonical domain types @@ -35,7 +23,7 @@ export function createMutationCallbacks(mutationOperations: MutationOperations) description?: string; content?: string; url?: string; - itemType: NonUserMapItemTypeString; + itemType: string; }) => { // Normalize legacy field names to canonical domain names await mutationOperations.createItem(coordId, { @@ -55,7 +43,7 @@ export function createMutationCallbacks(mutationOperations: MutationOperations) content?: string; url?: string; visibility?: VisibilityString; - itemType?: NonUserMapItemTypeString; + itemType?: string; }) => { // Normalize legacy field names to canonical domain names await mutationOperations.updateItem(coordId, { @@ -64,7 +52,7 @@ export function createMutationCallbacks(mutationOperations: MutationOperations) preview: data.preview, link: data.url, visibility: toVisibilityEnum(data.visibility), - itemType: toItemTypeEnum(data.itemType), + itemType: data.itemType, }); }; diff --git a/src/app/map/Cache/Services/types.ts b/src/app/map/Cache/Services/types.ts index 26e0cb605..e2d2a3b1c 100644 --- a/src/app/map/Cache/Services/types.ts +++ b/src/app/map/Cache/Services/types.ts @@ -1,5 +1,5 @@ // Service interface types for dependency injection and mocking -import type { MapItemType, MapItemUpdateAttributes, MapItemCreateAttributes } from "~/lib/domains/mapping/utils"; +import type { ItemTypeValue, MapItemUpdateAttributes, MapItemCreateAttributes } from "~/lib/domains/mapping/utils"; import type { Visibility } from '~/lib/domains/mapping/utils'; export interface ServerService { @@ -16,7 +16,7 @@ export interface ServerService { preview: string | undefined; link: string; parentId: string | null; - itemType: MapItemType; + itemType: ItemTypeValue; ownerId: string; originId: string | null; visibility: Visibility; @@ -32,7 +32,7 @@ export interface ServerService { preview: string | undefined; link: string; parentId: string | null; - itemType: MapItemType; + itemType: ItemTypeValue; ownerId: string; originId: string | null; visibility: Visibility; @@ -46,7 +46,7 @@ export interface ServerService { preview: string | undefined; link: string; parentId: string | null; - itemType: MapItemType; + itemType: ItemTypeValue; ownerId: string; originId: string | null; visibility: Visibility; @@ -60,7 +60,7 @@ export interface ServerService { preview: string | undefined; link: string; parentId: string | null; - itemType: MapItemType; + itemType: ItemTypeValue; ownerId: string; originId: string | null; visibility: Visibility; @@ -74,7 +74,7 @@ export interface ServerService { preview: string | undefined; link: string; parentId: string | null; - itemType: MapItemType; + itemType: ItemTypeValue; ownerId: string; originId: string | null; visibility: Visibility; @@ -91,7 +91,7 @@ export interface ServerService { preview: string | undefined; link: string; parentId: string | null; - itemType: MapItemType; + itemType: ItemTypeValue; ownerId: string; originId: string | null; visibility: Visibility; diff --git a/src/app/map/Cache/types/handlers.ts b/src/app/map/Cache/types/handlers.ts index 7e44da202..7c8c2f879 100644 --- a/src/app/map/Cache/types/handlers.ts +++ b/src/app/map/Cache/types/handlers.ts @@ -1,6 +1,6 @@ import type { Dispatch } from "react"; import type { CacheAction, CacheState } from "~/app/map/Cache/State"; -import type { MapItemUpdateAttributes, MapItemCreateAttributes, Visibility, NonUserMapItemTypeString } from "~/lib/domains/mapping/utils"; +import type { MapItemUpdateAttributes, MapItemCreateAttributes, Visibility } from "~/lib/domains/mapping/utils"; // Common handler dependencies export interface HandlerConfig { @@ -92,7 +92,7 @@ export interface NavigationOperations { } export interface MutationOperations { - createItem: (coordId: string, data: Omit<MapItemCreateAttributes, 'coords' | 'itemType'> & { parentId?: number; itemType: NonUserMapItemTypeString }) => Promise<MutationResult>; + createItem: (coordId: string, data: Omit<MapItemCreateAttributes, 'coords' | 'itemType'> & { parentId?: number; itemType: string }) => Promise<MutationResult>; updateItem: (coordId: string, data: MapItemUpdateAttributes) => Promise<MutationResult>; deleteItem: (coordId: string) => Promise<MutationResult>; deleteChildrenByType: (coordId: string, directionType: 'structural' | 'composed' | 'hexPlan') => Promise<MutationResult & { deletedCount: number }>; diff --git a/src/app/map/Cache/types/index.ts b/src/app/map/Cache/types/index.ts index 0f9f67ebf..e24c9de01 100644 --- a/src/app/map/Cache/types/index.ts +++ b/src/app/map/Cache/types/index.ts @@ -30,7 +30,7 @@ import type { import type { ServerService, StorageService, ServiceConfig } from "~/app/map/Cache/Services"; import type { SyncOperations, SyncResult, SyncStatus } from "~/app/map/Cache/Sync/types"; import type { EventBusService } from '~/app/map'; -import type { NonUserMapItemTypeString, VisibilityString } from '~/lib/domains/mapping/utils'; +import type { VisibilityString } from '~/lib/domains/mapping/utils'; // Cache context interface export interface MapCacheContextValue { @@ -94,7 +94,7 @@ export interface MapCacheHook { description?: string; content?: string; url?: string; - itemType: NonUserMapItemTypeString; + itemType: string; }) => Promise<void>; updateItemOptimistic: (coordId: string, data: { title?: string; @@ -103,7 +103,7 @@ export interface MapCacheHook { content?: string; url?: string; visibility?: VisibilityString; - itemType?: NonUserMapItemTypeString; + itemType?: string; }) => Promise<void>; deleteItemOptimistic: (coordId: string) => Promise<void>; deleteChildrenByTypeOptimistic: (coordId: string, directionType: 'structural' | 'composed' | 'hexPlan') => Promise<{ success: boolean; deletedCount: number }>; diff --git a/src/app/map/Canvas/DynamicFrameCore.tsx b/src/app/map/Canvas/DynamicFrameCore.tsx index e0b4f26c4..efabaeac6 100644 --- a/src/app/map/Canvas/DynamicFrameCore.tsx +++ b/src/app/map/Canvas/DynamicFrameCore.tsx @@ -529,8 +529,7 @@ const FrameSlot = (props: { // Check if composition should be shown if (isCenter) { const isCompositionExpanded = props.isCompositionExpanded ?? false; - const isUserTile = item.metadata.coordinates.path.length === 0; - const canShowComposition = isCompositionExpanded && slotScale > 1 && !isUserTile; + const canShowComposition = isCompositionExpanded && slotScale > 1; if (canShowComposition) { // Render composition frame: show direction 0 child (or empty) in center diff --git a/src/app/map/Canvas/Menu/_builders/edit-actions.ts b/src/app/map/Canvas/Menu/_builders/edit-actions.ts index c01bc972e..1c08fc92c 100644 --- a/src/app/map/Canvas/Menu/_builders/edit-actions.ts +++ b/src/app/map/Canvas/Menu/_builders/edit-actions.ts @@ -1,4 +1,4 @@ -import { Edit, Trash2, Move, Copy, Plus, Layers, FolderTree, Clock } from "lucide-react"; +import { Edit, Trash2, Move, Copy, Plus, Layers, FolderTree } from "lucide-react"; import type { MenuItem } from "~/app/map/Canvas/Menu/items-builder"; // Re-export visibility actions for backward compatibility @@ -41,18 +41,16 @@ interface DeleteSubmenuCallbacks { onDelete?: () => void; onDeleteChildren?: () => void; onDeleteComposed?: () => void; - onDeleteHexplan?: () => void; } export function _buildDeleteSubmenu(canEdit: boolean, callbacks: DeleteSubmenuCallbacks): MenuItem[] { - const { onDelete, onDeleteChildren, onDeleteComposed, onDeleteHexplan } = callbacks; + const { onDelete, onDeleteChildren, onDeleteComposed } = callbacks; if (!canEdit) return []; const submenuItems: MenuItem[] = []; if (onDelete) submenuItems.push({ icon: Trash2, label: "Delete Tile", shortcut: "", onClick: onDelete, variant: "destructive" }); if (onDeleteChildren) submenuItems.push({ icon: FolderTree, label: "Delete Children", shortcut: "", onClick: onDeleteChildren, variant: "destructive" }); if (onDeleteComposed) submenuItems.push({ icon: Layers, label: "Delete Composed", shortcut: "", onClick: onDeleteComposed, variant: "destructive" }); - if (onDeleteHexplan) submenuItems.push({ icon: Clock, label: "Delete Hexplan", shortcut: "", onClick: onDeleteHexplan, variant: "destructive" }); if (submenuItems.length === 1 && onDelete) { return [{ icon: Trash2, label: "Delete", shortcut: "", onClick: onDelete, variant: "destructive" }]; diff --git a/src/app/map/Canvas/Menu/_builders/run-actions.ts b/src/app/map/Canvas/Menu/_builders/run-actions.ts new file mode 100644 index 000000000..f03534219 --- /dev/null +++ b/src/app/map/Canvas/Menu/_builders/run-actions.ts @@ -0,0 +1,35 @@ +import { Play } from "lucide-react" +import type { MenuItem } from "~/app/map/Canvas/Menu/items-builder" +import type { TileData } from "~/app/map/types/tile-data" +import { MapItemType, isCustomItemType, type ItemTypeValue } from "~/lib/domains/mapping/utils" + +/** + * Run-related menu item builder for SYSTEM tiles + * + * The Run action allows executing SYSTEM tiles via the agentic.run endpoint. + * Only SYSTEM tiles and tiles with custom types can be run. + */ + +export function _buildRunItem( + tileData: TileData, + canEdit: boolean, + onRun?: () => void +): MenuItem[] { + if (!onRun || !canEdit) return [] + + const itemType = tileData.data.itemType as ItemTypeValue | null + const isSystemTile = itemType === MapItemType.SYSTEM + const isCustomTypeTile = itemType !== null && isCustomItemType(itemType) + + if (!isSystemTile && !isCustomTypeTile) return [] + + return [ + { + icon: Play, + label: "Run", + shortcut: "", + onClick: onRun, + separator: true, + }, + ] +} diff --git a/src/app/map/Canvas/Menu/items-builder.ts b/src/app/map/Canvas/Menu/items-builder.ts index 5f1ec2d80..2ae260ec0 100644 --- a/src/app/map/Canvas/Menu/items-builder.ts +++ b/src/app/map/Canvas/Menu/items-builder.ts @@ -18,6 +18,7 @@ import { _buildVisibilitySubmenu, } from "~/app/map/Canvas/Menu/_builders/edit-actions"; import { _buildFavoriteMenuItem } from "~/app/map/Canvas/Menu/_builders/favorite-actions"; +import { _buildRunItem } from "~/app/map/Canvas/Menu/_builders/run-actions"; export type MenuItem = ContextMenuItemData; @@ -37,7 +38,6 @@ interface MenuItemsConfig { onDelete?: () => void; onDeleteChildren?: () => void; onDeleteComposed?: () => void; - onDeleteHexplan?: () => void; onCreate?: () => void; onCompositionToggle?: (tileData: TileData) => void; onViewHistory?: () => void; @@ -52,6 +52,8 @@ interface MenuItemsConfig { onRemoveFavorite?: () => void; /** Callback when user selects "Edit Shortcut" (opens favorites panel to edit this tile's shortcut) */ onEditShortcut?: () => void; + /** Callback when user selects "Run" for SYSTEM tiles */ + onRun?: () => void; } export function buildMenuItems(config: MenuItemsConfig): MenuItem[] { @@ -70,7 +72,6 @@ export function buildMenuItems(config: MenuItemsConfig): MenuItem[] { onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, onCreate, onCompositionToggle, onViewHistory, @@ -82,6 +83,7 @@ export function buildMenuItems(config: MenuItemsConfig): MenuItem[] { onAddFavorite, onRemoveFavorite, onEditShortcut, + onRun, } = config; if (isEmptyTile) { @@ -101,6 +103,7 @@ export function buildMenuItems(config: MenuItemsConfig): MenuItem[] { onCompositionToggle, ), ..._buildNavigateItem(onNavigate), + ..._buildRunItem(tileData, canEdit, onRun), ..._buildViewHistoryItem(onViewHistory), ..._buildFavoriteMenuItem({ canEdit, @@ -120,7 +123,6 @@ export function buildMenuItems(config: MenuItemsConfig): MenuItem[] { onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, }), ..._buildCopyCoordinatesItem(onCopyCoordinates), ]; diff --git a/src/app/map/Canvas/Tile/Base/BaseFrame.tsx b/src/app/map/Canvas/Tile/Base/BaseFrame.tsx index 3824d3fb1..68bf01753 100644 --- a/src/app/map/Canvas/Tile/Base/BaseFrame.tsx +++ b/src/app/map/Canvas/Tile/Base/BaseFrame.tsx @@ -40,8 +40,7 @@ export const BaseFrame = ({ : false; const hasComposition = _hasCompositionChild(center, mapItems); - const isUserTile = centerItem?.metadata.coordinates.path.length === 0; - const canShowComposition = isExpanded && hasComposition && scale > 1 && !isUserTile; + const canShowComposition = isExpanded && hasComposition && scale > 1; if (!centerItem) { // Find parent item for context @@ -466,44 +465,46 @@ const CompositionFrame = ({ isDarkMode={isDarkMode} /> <div className="flex flex-col"> - {compositionContainer ? ( + {/* Render the center tile itself at the center of composition expansion */} + {/* Previously showed direction-0 (hexplan tile), now hexplans are stored in runs */} + {mapItems[center] ? ( interactive ? ( <DynamicItemTile - item={compositionContainer} + item={mapItems[center]} scale={innerScale} baseHexSize={baseHexSize} allExpandedItemIds={expandedItemIds} - hasChildren={true} + hasChildren={_hasCompositionChild(center, mapItems)} isCenter={false} urlInfo={urlInfo} interactive={interactive} /> ) : ( <BaseItemTile - item={compositionContainer} + item={mapItems[center]} scale={innerScale} isExpanded={false} isDarkMode={isDarkMode} /> ) ) : ( - // Render empty tile for composition container when it doesn't exist + // Render empty tile if center doesn't exist (shouldn't happen) interactive ? ( <DynamicEmptyTile - coordId={compositionCoordId} + coordId={center} scale={innerScale} baseHexSize={baseHexSize} urlInfo={urlInfo} parentItem={{ - id: mapItems[center]?.metadata.dbId ?? '', - name: mapItems[center]?.data.title ?? 'Parent', + id: '', + name: 'Center', }} interactive={interactive} currentUserId={currentUserId} /> ) : ( <BaseEmptyTile - coordId={compositionCoordId} + coordId={center} scale={innerScale} baseHexSize={baseHexSize} isDarkMode={isDarkMode} diff --git a/src/app/map/Canvas/Tile/Base/__tests__/BaseFrame-composition.test.tsx b/src/app/map/Canvas/Tile/Base/__tests__/BaseFrame-composition.test.tsx index ab2b98621..ffb5140ef 100644 --- a/src/app/map/Canvas/Tile/Base/__tests__/BaseFrame-composition.test.tsx +++ b/src/app/map/Canvas/Tile/Base/__tests__/BaseFrame-composition.test.tsx @@ -319,11 +319,10 @@ describe("BaseFrame - Composition Rendering", () => { }); describe("Edge Cases", () => { - it("should handle empty composition container (no children)", () => { + it("should handle composition with center tile at inner frame", () => { const centerCoordId = "1,0:1"; const mapItems: Record<string, TileData> = { [centerCoordId]: createMockItem(centerCoordId, "item-1", "Center Item"), - "1,0:1,0": createMockItem("1,0:1,0", "item-comp", "Empty Composition"), }; const { container } = render( @@ -340,9 +339,9 @@ describe("BaseFrame - Composition Rendering", () => { { wrapper } ); - // Should render composition container even if empty - const compositionContainer = container.querySelector('[data-tile-id="1,0:1,0"]'); - expect(compositionContainer).toBeInTheDocument(); + // Should render the center tile in composition mode (center tile shows at inner frame) + const centerTile = container.querySelector('[data-tile-id="1,0:1"]'); + expect(centerTile).toBeInTheDocument(); }); it("should handle composition when center is not expanded", () => { @@ -371,12 +370,12 @@ describe("BaseFrame - Composition Rendering", () => { expect(frames.length).toBe(0); }); - it("should NOT allow composition expansion for user tiles (empty path)", () => { - // User tiles have empty path (e.g., "1,0:") + it("should allow composition expansion for user tiles (empty path)", () => { + // User tiles have empty path (e.g., "1,0:") and can now show composition const centerCoordId = "1,0:"; const mapItems: Record<string, TileData> = { [centerCoordId]: createMockItem(centerCoordId, "item-1", "User Tile"), - // Composition container - should not be rendered for user tiles + // Composition container - should be rendered for user tiles now "1,0:0": createMockItem("1,0:0", "item-comp", "Composition"), "1,0:0,1": createMockItem("1,0:0,1", "item-comp-1", "Composed Child"), }; @@ -395,9 +394,9 @@ describe("BaseFrame - Composition Rendering", () => { { wrapper } ); - // Should NOT render composition for user tiles + // Should render composition for user tiles now const compositionTiles = container.querySelectorAll('[data-tile-id^="1,0:0"]'); - expect(compositionTiles.length).toBe(0); + expect(compositionTiles.length).toBeGreaterThan(0); }); it("should handle missing compositionExpandedIds prop (undefined)", () => { diff --git a/src/app/map/Canvas/TileActionsContext.tsx b/src/app/map/Canvas/TileActionsContext.tsx index 97cf3cb40..9d32e22cc 100644 --- a/src/app/map/Canvas/TileActionsContext.tsx +++ b/src/app/map/Canvas/TileActionsContext.tsx @@ -20,8 +20,8 @@ export interface TileActionsContextValue { onDeleteClick?: (tileData: TileData) => void; onDeleteChildrenClick?: (tileData: TileData) => void; onDeleteComposedClick?: (tileData: TileData) => void; - onDeleteHexplanClick?: (tileData: TileData) => void; onCompositionToggle?: (tileData: TileData) => void; + onRunClick?: (tileData: TileData) => void; onTileDragStart: (tileData: TileData) => void; onTileDrop: (tileData: TileData) => void; isDragging: boolean; @@ -48,7 +48,6 @@ interface TileActionsProviderProps { onDeleteClick?: (tileData: TileData) => void; onDeleteChildrenClick?: (tileData: TileData) => void; onDeleteComposedClick?: (tileData: TileData) => void; - onDeleteHexplanClick?: (tileData: TileData) => void; onCompositionToggle?: (tileData: TileData) => void; onSetVisibility?: (tileData: TileData, visibility: Visibility) => void; onSetVisibilityWithDescendants?: (tileData: TileData, visibility: Visibility) => void; @@ -59,6 +58,7 @@ interface TileActionsProviderProps { onRemoveFavorite?: (tileData: TileData) => void; isFavorited?: (coordId: string) => boolean; onEditShortcut?: (tileData: TileData) => void; + onRunClick?: (tileData: TileData) => void; } interface ContextMenuState { @@ -96,9 +96,10 @@ function useDragMenuHandlers(closeContextMenu: () => void) { export function TileActionsProvider(props: TileActionsProviderProps) { const { children, onSelectClick, onNavigateClick, onExpandClick, onCreateClick, onEditClick, - onDeleteClick, onDeleteChildrenClick, onDeleteComposedClick, onDeleteHexplanClick, + onDeleteClick, onDeleteChildrenClick, onDeleteComposedClick, onCompositionToggle, onSetVisibility, onSetVisibilityWithDescendants, hasComposition, isCompositionExpanded, canShowComposition, onAddFavorite, onRemoveFavorite, isFavorited, onEditShortcut, + onRunClick, } = props; const [isDragging, setIsDragging] = useState(false); @@ -119,11 +120,13 @@ export function TileActionsProvider(props: TileActionsProviderProps) { const value = useMemo(() => ({ onTileClick, onTileDoubleClick, onTileRightClick, onTileHover, onTileDragStart, onTileDrop, isDragging, onSelectClick, onNavigateClick, onExpandClick, onCreateClick, onEditClick, onDeleteClick, - onDeleteChildrenClick, onDeleteComposedClick, onDeleteHexplanClick, onCompositionToggle, isFavorited, + onDeleteChildrenClick, onDeleteComposedClick, onCompositionToggle, isFavorited, + onRunClick, }), [ onTileClick, onTileDoubleClick, onTileRightClick, onTileHover, onTileDragStart, onTileDrop, isDragging, onSelectClick, onNavigateClick, onExpandClick, onCreateClick, onEditClick, onDeleteClick, - onDeleteChildrenClick, onDeleteComposedClick, onDeleteHexplanClick, onCompositionToggle, isFavorited, + onDeleteChildrenClick, onDeleteComposedClick, onCompositionToggle, isFavorited, + onRunClick, ]); return ( @@ -140,7 +143,6 @@ export function TileActionsProvider(props: TileActionsProviderProps) { onDeleteClick={onDeleteClick} onDeleteChildrenClick={onDeleteChildrenClick} onDeleteComposedClick={onDeleteComposedClick} - onDeleteHexplanClick={onDeleteHexplanClick} onCopyClick={handleCopyToClick} onMoveClick={handleMoveToClick} onCopyCoordinatesSuccess={triggerSuccess} @@ -155,6 +157,7 @@ export function TileActionsProvider(props: TileActionsProviderProps) { onRemoveFavorite={onRemoveFavorite} isFavorited={isFavorited} onEditShortcut={onEditShortcut} + onRunClick={onRunClick} /> {showCopyFeedback && ( <CopyFeedback diff --git a/src/app/map/Canvas/TileContextMenu.tsx b/src/app/map/Canvas/TileContextMenu.tsx index e2187dba2..7774b00b8 100644 --- a/src/app/map/Canvas/TileContextMenu.tsx +++ b/src/app/map/Canvas/TileContextMenu.tsx @@ -16,7 +16,6 @@ interface TileContextMenuProps { onDelete?: () => void; onDeleteChildren?: () => void; onDeleteComposed?: () => void; - onDeleteHexplan?: () => void; onCreate?: () => void; onCompositionToggle?: (tileData: TileData) => void; onViewHistory?: () => void; @@ -31,6 +30,8 @@ interface TileContextMenuProps { onRemoveFavorite?: () => void; /** Callback when user selects "Edit Shortcut" from the context menu (opens favorites panel) */ onEditShortcut?: () => void; + /** Callback when user selects "Run" for SYSTEM tiles */ + onRun?: () => void; visibility?: Visibility; /** Whether this tile is currently in the user's favorites list */ isFavorited?: boolean; @@ -52,7 +53,6 @@ export function TileContextMenu({ onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, onCreate, onCompositionToggle, onViewHistory, @@ -64,6 +64,7 @@ export function TileContextMenu({ onAddFavorite, onRemoveFavorite, onEditShortcut, + onRun, visibility, isFavorited, canEdit, @@ -87,7 +88,6 @@ export function TileContextMenu({ onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, onCreate, onCompositionToggle, onViewHistory, @@ -99,6 +99,7 @@ export function TileContextMenu({ onAddFavorite, onRemoveFavorite, onEditShortcut, + onRun, }); return ( diff --git a/src/app/map/Canvas/__tests__/TileContextMenu-composition.test.tsx b/src/app/map/Canvas/__tests__/TileContextMenu-composition.test.tsx index 5a26aec37..8500d3d1c 100644 --- a/src/app/map/Canvas/__tests__/TileContextMenu-composition.test.tsx +++ b/src/app/map/Canvas/__tests__/TileContextMenu-composition.test.tsx @@ -186,8 +186,8 @@ describe('TileContextMenu - Show Composition', () => { expect(screen.queryByText('Show Composition')).not.toBeInTheDocument(); }); - it('should NOT show "Show Composition" for user tiles (empty path)', () => { - // User tiles have empty path and should never show composition + it('should show "Show Composition" for user tiles (empty path) when canShowComposition is true', () => { + // User tiles have empty path and can now show composition const userTile = createMockTileData({ metadata: { dbId: '100', @@ -210,13 +210,13 @@ describe('TileContextMenu - Show Composition', () => { onClose={vi.fn()} onCompositionToggle={vi.fn()} canEdit={true} - hasComposition={false} + hasComposition={true} isCompositionExpanded={false} - canShowComposition={false} // Should be false for user tiles + canShowComposition={true} // User tiles can now show composition /> ); - expect(screen.queryByText('Show Composition')).not.toBeInTheDocument(); + expect(screen.getByText('Show Composition')).toBeInTheDocument(); }); it('should show "Show Composition" when canShowComposition is true (even without existing composition)', () => { diff --git a/src/app/map/Canvas/_internals/ContextMenuContainer.tsx b/src/app/map/Canvas/_internals/ContextMenuContainer.tsx index a29dae26a..ed860067a 100644 --- a/src/app/map/Canvas/_internals/ContextMenuContainer.tsx +++ b/src/app/map/Canvas/_internals/ContextMenuContainer.tsx @@ -21,7 +21,6 @@ interface ContextMenuContainerProps { onDeleteClick?: (tileData: TileData) => void; onDeleteChildrenClick?: (tileData: TileData) => void; onDeleteComposedClick?: (tileData: TileData) => void; - onDeleteHexplanClick?: (tileData: TileData) => void; onCopyClick?: (tileData: TileData) => void; onMoveClick?: (tileData: TileData) => void; onCopyCoordinatesSuccess?: () => void; @@ -40,6 +39,8 @@ interface ContextMenuContainerProps { isFavorited?: (coordId: string) => boolean; /** Callback when user wants to edit the shortcut for a favorited tile */ onEditShortcut?: (tileData: TileData) => void; + /** Callback when user runs a SYSTEM tile */ + onRunClick?: (tileData: TileData) => void; } export function ContextMenuContainer({ @@ -53,7 +54,6 @@ export function ContextMenuContainer({ onDeleteClick, onDeleteChildrenClick, onDeleteComposedClick, - onDeleteHexplanClick, onCopyClick, onMoveClick, onCopyCoordinatesSuccess, @@ -68,6 +68,7 @@ export function ContextMenuContainer({ onRemoveFavorite, isFavorited, onEditShortcut, + onRunClick, }: ContextMenuContainerProps) { if (!contextMenu) return null; @@ -83,7 +84,6 @@ export function ContextMenuContainer({ onDelete={() => onDeleteClick?.(contextMenu.tileData)} onDeleteChildren={() => onDeleteChildrenClick?.(contextMenu.tileData)} onDeleteComposed={() => onDeleteComposedClick?.(contextMenu.tileData)} - onDeleteHexplan={() => onDeleteHexplanClick?.(contextMenu.tileData)} onCreate={() => onCreateClick?.(contextMenu.tileData)} onCopy={() => onCopyClick?.(contextMenu.tileData)} onMove={() => onMoveClick?.(contextMenu.tileData)} @@ -111,6 +111,7 @@ export function ContextMenuContainer({ onAddFavorite={() => onAddFavorite?.(contextMenu.tileData)} onRemoveFavorite={() => onRemoveFavorite?.(contextMenu.tileData)} onEditShortcut={() => onEditShortcut?.(contextMenu.tileData)} + onRun={() => onRunClick?.(contextMenu.tileData)} /> ); } diff --git a/src/app/map/Chat/Input/_commands/index.ts b/src/app/map/Chat/Input/_commands/index.ts index 3f8148b93..f3e44ae5a 100644 --- a/src/app/map/Chat/Input/_commands/index.ts +++ b/src/app/map/Chat/Input/_commands/index.ts @@ -3,6 +3,7 @@ import { createNavigationCommands } from '~/app/map/Chat/Input/_commands/navigat import { authCommands } from '~/app/map/Chat/Input/_commands/auth-commands'; import { mcpCommands } from '~/app/map/Chat/Input/_commands/mcp-commands'; import { favoritesCommands } from '~/app/map/Chat/Input/_commands/favorites-commands'; +import { runCommands } from '~/app/map/Chat/Input/_commands/run-commands'; export interface Command { description: string; @@ -15,6 +16,7 @@ export const getAllCommands = (center: string | null): Record<string, Command> = ...authCommands, ...mcpCommands, ...favoritesCommands, + ...runCommands, }); -export { debugCommands, createNavigationCommands, authCommands, mcpCommands, favoritesCommands }; \ No newline at end of file +export { debugCommands, createNavigationCommands, authCommands, mcpCommands, favoritesCommands, runCommands }; \ No newline at end of file diff --git a/src/app/map/Chat/Input/_commands/run-commands.ts b/src/app/map/Chat/Input/_commands/run-commands.ts new file mode 100644 index 000000000..b37a80882 --- /dev/null +++ b/src/app/map/Chat/Input/_commands/run-commands.ts @@ -0,0 +1,10 @@ +interface Command { + description: string; + action?: () => string; +} + +export const runCommands: Record<string, Command> = { + '/run': { + description: 'Show runs list or open run for specific tile (e.g., /run userId,0:1,2)', + }, +}; diff --git a/src/app/map/Chat/Input/_hooks/commands/useCommandExecution.ts b/src/app/map/Chat/Input/_hooks/commands/useCommandExecution.ts index b86402ab1..43de675a5 100644 --- a/src/app/map/Chat/Input/_hooks/commands/useCommandExecution.ts +++ b/src/app/map/Chat/Input/_hooks/commands/useCommandExecution.ts @@ -11,6 +11,7 @@ interface CommandHandlers { handleClear: () => void; handleMcpCommand: (commandPath: string) => void; handleFavoritesCommand: (commandPath: string) => void; + handleRunCommand: (commandInput: string) => void; chatState: ChatOperations; } @@ -39,6 +40,13 @@ export function useCommandExecution( }, [extendedCommands]); const executeCommand = useCallback(async (commandPath: string): Promise<string> => { + // Handle /run command specially since it can have arguments + // Format: /run or /run userId,0:1,2 + if (commandPath === '/run' || commandPath.startsWith('/run ')) { + handlers.handleRunCommand(commandPath); + return ''; + } + const command = findCommand(commandPath); if (!command) { diff --git a/src/app/map/Chat/Input/_hooks/commands/useCommandHandlers.ts b/src/app/map/Chat/Input/_hooks/commands/useCommandHandlers.ts index 32b30a7de..fa4b2fa6d 100644 --- a/src/app/map/Chat/Input/_hooks/commands/useCommandHandlers.ts +++ b/src/app/map/Chat/Input/_hooks/commands/useCommandHandlers.ts @@ -117,12 +117,53 @@ export function useCommandHandlers(chatState: { } }, [chatState]); + const handleRunCommand = useCallback((commandInput: string) => { + if (!chatState) return; + + // Parse the command input to extract optional coords + // Format: /run or /run userId,0:1,2 + const trimmedInput = commandInput.trim(); + const coordsMatch = /^\/run\s+(.+)$/.exec(trimmedInput); + + if (coordsMatch?.[1]) { + // Has coords argument - show RunWidget for specific tile + const tileCoords = coordsMatch[1].trim(); + if ('showRunWidget' in chatState) { + const showWidget = chatState.showRunWidget as (data: { tileCoords: string; tileTitle: string }) => void; + // Title will be fetched by the widget itself + showWidget({ tileCoords, tileTitle: 'Loading...' }); + } + } else { + // No coords - show RunsListWidget + if ('showRunsListWidget' in chatState && 'closeWidget' in chatState) { + const showWidget = chatState.showRunsListWidget as () => void; + const closeWidget = chatState.closeWidget as (widgetId: string) => void; + + // Toggle behavior: close if already open + if ('getActiveWidgets' in chatState) { + const getActiveWidgets = chatState.getActiveWidgets as () => Array<{ id: string; type: string }>; + const activeWidgets = getActiveWidgets(); + const existingRunsListWidget = activeWidgets.find(widget => widget.type === 'runs-list'); + + if (existingRunsListWidget) { + closeWidget(existingRunsListWidget.id); + } else { + showWidget(); + } + } else { + showWidget(); + } + } + } + }, [chatState]); + return { handleLogout, handleLogin, handleRegister, handleClear, handleMcpCommand, - handleFavoritesCommand + handleFavoritesCommand, + handleRunCommand }; } \ No newline at end of file diff --git a/src/app/map/Chat/Timeline/Widgets/AIResponseWidget/_components/CompletedStatus.tsx b/src/app/map/Chat/Timeline/Widgets/AIResponseWidget/_components/CompletedStatus.tsx index 20031bd11..eb5a4e190 100644 --- a/src/app/map/Chat/Timeline/Widgets/AIResponseWidget/_components/CompletedStatus.tsx +++ b/src/app/map/Chat/Timeline/Widgets/AIResponseWidget/_components/CompletedStatus.tsx @@ -1,5 +1,5 @@ import { CheckCircle } from 'lucide-react'; -import { MarkdownRenderer } from '~/app/map/Chat/Timeline/_components/MarkdownRenderer'; +import { MarkdownRenderer } from '~/app/map/Chat/Timeline/_components'; import { BaseWidget, WidgetHeader, WidgetContent } from '~/app/map/Chat/Timeline/Widgets/_shared'; interface CompletedStatusProps { diff --git a/src/app/map/Chat/Timeline/Widgets/AIResponseWidget/_components/DirectResponse.tsx b/src/app/map/Chat/Timeline/Widgets/AIResponseWidget/_components/DirectResponse.tsx index a84e1377b..db0855999 100644 --- a/src/app/map/Chat/Timeline/Widgets/AIResponseWidget/_components/DirectResponse.tsx +++ b/src/app/map/Chat/Timeline/Widgets/AIResponseWidget/_components/DirectResponse.tsx @@ -1,4 +1,4 @@ -import { MarkdownRenderer } from '~/app/map/Chat/Timeline/_components/MarkdownRenderer'; +import { MarkdownRenderer } from '~/app/map/Chat/Timeline/_components'; import { BaseWidget, WidgetHeader, WidgetContent } from '~/app/map/Chat/Timeline/Widgets/_shared'; interface DirectResponseProps { diff --git a/src/app/map/Chat/Timeline/Widgets/Adapters/README.md b/src/app/map/Chat/Timeline/Widgets/Adapters/README.md new file mode 100644 index 000000000..2a492aee1 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/README.md @@ -0,0 +1,19 @@ +# Adapters + +## Mental Model +Like a translation layer - takes Widget state objects from Chat state and adapts them into the appropriate Widget UI components for display. + +## Responsibilities +- Adapt Widget state objects into Widget UI components +- Handle different widget types (tile, login, error, loading, etc.) +- Pass through handlers and data to the underlying widgets + +## Non-Responsibilities +- Widget implementations -> See `../README.md` (parent Widgets) +- Widget state management -> See `../../../_state/README.md` + +## Interface +*See `index.ts` for the public API - the ONLY exports other subsystems can use* +*See `dependencies.json` for what this subsystem can import* + +Note: Child subsystems can import from parent freely, but all other subsystems MUST go through index.ts. The CI tool `pnpm check:architecture` enforces this boundary. diff --git a/src/app/map/Chat/Timeline/_components/_renderers/_ai-debug-renderers.tsx b/src/app/map/Chat/Timeline/Widgets/Adapters/_ai-debug-adapters.tsx similarity index 94% rename from src/app/map/Chat/Timeline/_components/_renderers/_ai-debug-renderers.tsx rename to src/app/map/Chat/Timeline/Widgets/Adapters/_ai-debug-adapters.tsx index 20f34626d..8278be937 100644 --- a/src/app/map/Chat/Timeline/_components/_renderers/_ai-debug-renderers.tsx +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/_ai-debug-adapters.tsx @@ -1,7 +1,7 @@ import type { Widget } from '~/app/map/Chat/_state'; import { AIResponseWidget, McpKeysWidget, DebugLogsWidget, LoadingWidget, FavoritesWidget } from '~/app/map/Chat/Timeline/Widgets'; import type { AIResponseWidgetData } from '~/app/map/Chat/types'; -import type { WidgetHandlers } from '~/app/map/Chat/Timeline/_components/_renderers/widget-renderers'; +import type { WidgetHandlers } from '~/app/map/Chat/Timeline/Widgets/Adapters'; export function _renderLoadingWidget(widget: Widget) { const loadingData = widget.data as { message?: string; operation?: string }; diff --git a/src/app/map/Chat/Timeline/_components/_renderers/_auth-error-renderers.tsx b/src/app/map/Chat/Timeline/Widgets/Adapters/_auth-error-adapters.tsx similarity index 93% rename from src/app/map/Chat/Timeline/_components/_renderers/_auth-error-renderers.tsx rename to src/app/map/Chat/Timeline/Widgets/Adapters/_auth-error-adapters.tsx index d655c7852..961dd6d6c 100644 --- a/src/app/map/Chat/Timeline/_components/_renderers/_auth-error-renderers.tsx +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/_auth-error-adapters.tsx @@ -1,7 +1,7 @@ import type { Widget, AuthRequiredPayload, ErrorOccurredPayload } from '~/app/map/Chat/_state'; import { LoginWidget, ErrorWidget } from '~/app/map/Chat/Timeline/Widgets'; -import type { WidgetHandlers } from '~/app/map/Chat/Timeline/_components/_renderers/widget-renderers'; -import { _safeStringify } from '~/app/map/Chat/Timeline/_components/_renderers/_utils'; +import type { WidgetHandlers } from '~/app/map/Chat/Timeline/Widgets/Adapters'; +import { _safeStringify } from '~/app/map/Chat/Timeline/Widgets/Adapters/_utils'; export function _renderLoginWidget(widget: Widget, handlers: WidgetHandlers) { const loginData = widget.data as AuthRequiredPayload; diff --git a/src/app/map/Chat/Timeline/Widgets/Adapters/_run-adapter.tsx b/src/app/map/Chat/Timeline/Widgets/Adapters/_run-adapter.tsx new file mode 100644 index 000000000..ab266f45a --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/_run-adapter.tsx @@ -0,0 +1,19 @@ +import type { Widget } from '~/app/map/Chat/_state'; +import { RunWidget } from '~/app/map/Chat/Timeline/Widgets/RunWidget'; +import type { WidgetHandlers } from '~/app/map/Chat/Timeline/Widgets/Adapters'; + +interface RunWidgetData { + tileCoords: string; + tileTitle: string; +} + +export function _renderRunWidget(widget: Widget, handlers: WidgetHandlers) { + const data = widget.data as RunWidgetData; + return ( + <RunWidget + tileCoords={data.tileCoords} + tileTitle={data.tileTitle} + onClose={handlers.handleCancel} + /> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/Adapters/_runs-list-adapter.tsx b/src/app/map/Chat/Timeline/Widgets/Adapters/_runs-list-adapter.tsx new file mode 100644 index 000000000..7ca493546 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/_runs-list-adapter.tsx @@ -0,0 +1,13 @@ +import type { Widget } from '~/app/map/Chat/_state'; +import { RunsListWidget } from '~/app/map/Chat/Timeline/Widgets/RunsListWidget'; +import type { WidgetHandlers } from '~/app/map/Chat/Timeline/Widgets/Adapters'; + +export function _renderRunsListWidget(widget: Widget, handlers: WidgetHandlers) { + return ( + <RunsListWidget + key={widget.id} + onClose={handlers.handleCancel} + onOpenRun={handlers.showRunWidget} + /> + ); +} diff --git a/src/app/map/Chat/Timeline/_components/_renderers/_tile-renderers.tsx b/src/app/map/Chat/Timeline/Widgets/Adapters/_tile-adapters.tsx similarity index 93% rename from src/app/map/Chat/Timeline/_components/_renderers/_tile-renderers.tsx rename to src/app/map/Chat/Timeline/Widgets/Adapters/_tile-adapters.tsx index 8eeb44f74..596c81303 100644 --- a/src/app/map/Chat/Timeline/_components/_renderers/_tile-renderers.tsx +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/_tile-adapters.tsx @@ -1,7 +1,7 @@ import type { Widget, TileSelectedPayload } from '~/app/map/Chat/_state'; import type { TileData } from '~/app/map/types'; import { TileWidget } from '~/app/map/Chat/Timeline/Widgets'; -import type { WidgetHandlers } from '~/app/map/Chat/Timeline/_components/_renderers/widget-renderers'; +import type { WidgetHandlers } from '~/app/map/Chat/Timeline/Widgets/Adapters'; export function _renderTileWidget( widget: Widget, @@ -14,7 +14,6 @@ export function _renderTileWidget( handleDelete = () => { /* noop */ }, handleDeleteChildren, handleDeleteComposed, - handleDeleteHexplan, handleSetVisibility, handleSetVisibilityWithDescendants, handleTileSave = () => { /* noop */ }, @@ -45,7 +44,6 @@ export function _renderTileWidget( onDelete={handleDelete} onDeleteChildren={handleDeleteChildren} onDeleteComposed={handleDeleteComposed} - onDeleteHexplan={handleDeleteHexplan} onSetVisibility={handleSetVisibility} onSetVisibilityWithDescendants={handleSetVisibilityWithDescendants} onSave={handleTileSave} @@ -92,7 +90,7 @@ export function _renderDeleteChildrenWidget(widget: Widget, handlers: WidgetHand const deleteData = widget.data as { tileId?: string; tileName?: string; - directionType?: 'structural' | 'composed' | 'hexPlan'; + directionType?: 'structural' | 'composed'; }; const { handleCancel = () => { /* noop */ } } = handlers; diff --git a/src/app/map/Chat/Timeline/Widgets/Adapters/_tool-call-adapter.tsx b/src/app/map/Chat/Timeline/Widgets/Adapters/_tool-call-adapter.tsx new file mode 100644 index 000000000..f16fa2e95 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/_tool-call-adapter.tsx @@ -0,0 +1,14 @@ +import type { Widget, ToolCallWidgetData } from '~/app/map/Chat/_state'; +import { ToolCallWidget } from '~/app/map/Chat/Timeline/Widgets'; + +export function _renderToolCallWidget(widget: Widget) { + const data = widget.data as ToolCallWidgetData; + return ( + <ToolCallWidget + toolName={data.toolName} + arguments={data.arguments} + status={data.status} + result={data.result} + /> + ); +} diff --git a/src/app/map/Chat/Timeline/_components/_renderers/_utils.ts b/src/app/map/Chat/Timeline/Widgets/Adapters/_utils.ts similarity index 100% rename from src/app/map/Chat/Timeline/_components/_renderers/_utils.ts rename to src/app/map/Chat/Timeline/Widgets/Adapters/_utils.ts diff --git a/src/app/map/Chat/Timeline/Widgets/Adapters/dependencies.json b/src/app/map/Chat/Timeline/Widgets/Adapters/dependencies.json new file mode 100644 index 000000000..8cc4095e0 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/dependencies.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../../scripts/checks/architecture/dependencies.schema.json", + "allowed": [ + "~/app/map/Chat/_state", + "~/app/map/Chat/types", + "~/app/map/types" + ], + "subsystems": [] +} diff --git a/src/app/map/Chat/Timeline/_components/_renderers/widget-renderers.tsx b/src/app/map/Chat/Timeline/Widgets/Adapters/index.ts similarity index 69% rename from src/app/map/Chat/Timeline/_components/_renderers/widget-renderers.tsx rename to src/app/map/Chat/Timeline/Widgets/Adapters/index.ts index e9fe781c7..23858706f 100644 --- a/src/app/map/Chat/Timeline/_components/_renderers/widget-renderers.tsx +++ b/src/app/map/Chat/Timeline/Widgets/Adapters/index.ts @@ -1,23 +1,32 @@ +/** + * Widget Adapters - Public API + * + * Transforms Widget state objects into Widget UI components. + */ + import type { Widget } from '~/app/map/Chat/_state'; import type { TileData } from '~/app/map/types'; -import { _renderTileWidget, _renderCreationWidget, _renderDeleteWidget, _renderDeleteChildrenWidget } from '~/app/map/Chat/Timeline/_components/_renderers/_tile-renderers'; -import { _renderLoginWidget, _renderErrorWidget } from '~/app/map/Chat/Timeline/_components/_renderers/_auth-error-renderers'; -import { _renderLoadingWidget, _renderAIResponseWidget, _renderMcpKeysWidget, _renderDebugLogsWidget, _renderFavoritesWidget } from '~/app/map/Chat/Timeline/_components/_renderers/_ai-debug-renderers'; -import type { Visibility, NonUserMapItemTypeString } from '~/lib/domains/mapping/utils'; +import type { Visibility } from '~/lib/domains/mapping/utils'; +import { _renderTileWidget, _renderCreationWidget, _renderDeleteWidget, _renderDeleteChildrenWidget } from '~/app/map/Chat/Timeline/Widgets/Adapters/_tile-adapters'; +import { _renderLoginWidget, _renderErrorWidget } from '~/app/map/Chat/Timeline/Widgets/Adapters/_auth-error-adapters'; +import { _renderLoadingWidget, _renderAIResponseWidget, _renderMcpKeysWidget, _renderDebugLogsWidget, _renderFavoritesWidget } from '~/app/map/Chat/Timeline/Widgets/Adapters/_ai-debug-adapters'; +import { _renderToolCallWidget } from '~/app/map/Chat/Timeline/Widgets/Adapters/_tool-call-adapter'; +import { _renderRunWidget } from '~/app/map/Chat/Timeline/Widgets/Adapters/_run-adapter'; +import { _renderRunsListWidget } from '~/app/map/Chat/Timeline/Widgets/Adapters/_runs-list-adapter'; export interface WidgetHandlers { handleEdit?: () => void; handleDelete?: () => void; handleDeleteChildren?: () => void; handleDeleteComposed?: () => void; - handleDeleteHexplan?: () => void; handleSetVisibility?: (visibility: Visibility) => void; handleSetVisibilityWithDescendants?: (visibility: Visibility) => void; - handleTileSave?: (title: string, preview: string, content: string, itemType?: NonUserMapItemTypeString) => void; + handleTileSave?: (title: string, preview: string, content: string, itemType?: string) => void; handleTileClose?: () => void; handleSave?: (name: string, preview: string, content: string) => void; handleCancel?: () => void; onInsertToChat?: (text: string) => void; + showRunWidget?: (coords: string, title: string) => void; } export function renderTileWidget( @@ -66,4 +75,16 @@ export function renderDebugLogsWidget(widget: Widget, handlers: WidgetHandlers) export function renderFavoritesWidget(widget: Widget, handlers: WidgetHandlers) { return _renderFavoritesWidget(widget, handlers); -} \ No newline at end of file +} + +export function renderToolCallWidget(widget: Widget) { + return _renderToolCallWidget(widget); +} + +export function renderRunWidget(widget: Widget, handlers: WidgetHandlers) { + return _renderRunWidget(widget, handlers); +} + +export function renderRunsListWidget(widget: Widget, handlers: WidgetHandlers) { + return _renderRunsListWidget(widget, handlers); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/README.md b/src/app/map/Chat/Timeline/Widgets/RunWidget/README.md new file mode 100644 index 000000000..9987de5fb --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/README.md @@ -0,0 +1,20 @@ +# RunWidget + +## Mental Model +The RunWidget is the "mission control panel" for executing a tile's system. It manages the full lifecycle of a run: starting, monitoring progress step-by-step, handling blockages with human-in-the-loop editing, and showing completion results. + +## Responsibilities +- Rendering the run execution UI (pre-run, running, blocked, complete, error states) +- Managing run lifecycle (start, resume, stop) via the `useRunWidget` hook +- Displaying executed steps with expandable details (prompts, responses, tool calls) +- Providing hexplan editing during blocked states for human intervention + +## Non-Responsibilities +- Run orchestration logic (server-side) -> See `src/lib/domains/agentic/README.md` +- Step list rendering internals -> See `./_subsystems/StepsList/` +- Shared widget primitives (BaseWidget, WidgetHeader) -> See `../_shared/` +- Map navigation -> See `src/app/map/Cache/README.md` + +## Interface +See `index.ts` for the public API - exports `RunWidget` component and `useRunWidget` hook. +See `dependencies.json` for what this subsystem can import. diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/RunWidget.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/RunWidget.tsx new file mode 100644 index 000000000..a50bfbe11 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/RunWidget.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { Play, AlertCircle } from 'lucide-react'; +import { BaseWidget, WidgetHeader, WidgetContent } from '~/app/map/Chat/Timeline/Widgets/_shared'; +import { useRunWidget } from '~/app/map/Chat/Timeline/Widgets/RunWidget/useRunWidget'; +import { PreRunState } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/PreRunState'; +import { RunningState } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/RunningState'; +import { BlockedState } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/BlockedState'; +import { CompletedState } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/CompletedState'; +import { StepsList } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/StepsList'; +import { useMapCacheNavigation } from '~/app/map/Cache'; + +interface RunWidgetProps { + tileCoords: string; + tileTitle: string; + onClose?: () => void; +} + +function _formatElapsedTime(seconds: number): string { + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +export function RunWidget({ tileCoords, tileTitle, onClose }: RunWidgetProps) { + const { navigateToItem } = useMapCacheNavigation(); + + const { + status, + instruction, + currentStep, + currentPrompt, + executedSteps, + blockageReason, + error, + elapsedTime, + currentStepHexplan, + parentHexplan, + setInstruction, + startRun, + resumeRun, + resumeWithInput, + stopRun, + } = useRunWidget({ + tileCoords, + tileTitle, + onClose, + onNavigateToTile: (coords) => void navigateToItem(coords), + }); + + const handleNavigateToTile = (coords: string) => { + void navigateToItem(coords); + }; + + const subtitle = + status === 'running' || status === 'blocked' || status === 'complete' + ? _formatElapsedTime(elapsedTime) + : undefined; + + return ( + <BaseWidget className="w-full"> + <WidgetHeader + icon={<Play className="h-5 w-5 text-primary" />} + title={tileTitle} + subtitle={subtitle} + onClose={onClose} + /> + + <WidgetContent> + {status === 'idle' && ( + <> + {executedSteps.length > 0 && ( + <div className="mb-4"> + <StepsList steps={executedSteps} onNavigateToTile={handleNavigateToTile} /> + </div> + )} + <PreRunState + instruction={instruction} + onInstructionChange={setInstruction} + onStartRun={() => void startRun()} + /> + </> + )} + + {status === 'running' && ( + <> + <RunningState + currentStep={currentStep} + currentPrompt={currentPrompt} + onNavigateToTile={handleNavigateToTile} + onStopRun={stopRun} + /> + {executedSteps.length > 0 && ( + <StepsList + steps={executedSteps} + onNavigateToTile={handleNavigateToTile} + /> + )} + </> + )} + + {status === 'blocked' && ( + <> + <BlockedState + blockageReason={blockageReason} + currentPrompt={currentPrompt} + currentStepHexplan={currentStepHexplan} + parentHexplan={parentHexplan} + currentToolCalls={executedSteps.at(-1)?.toolCalls} + onResumeRun={() => void resumeRun()} + onResumeWithInput={(input) => void resumeWithInput(input)} + onStop={stopRun} + /> + {executedSteps.length > 0 && ( + <StepsList + steps={executedSteps} + onNavigateToTile={handleNavigateToTile} + /> + )} + </> + )} + + {status === 'complete' && ( + <CompletedState + executedSteps={executedSteps} + elapsedTime={elapsedTime} + onNavigateToTile={handleNavigateToTile} + /> + )} + + {status === 'error' && ( + <div className="flex items-start gap-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md"> + <AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" /> + <div className="flex flex-col gap-1"> + <span className="text-sm font-medium text-destructive"> + Error + </span> + {error && ( + <span className="text-sm text-destructive/80"> + {error.message} + </span> + )} + </div> + </div> + )} + </WidgetContent> + </BaseWidget> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/BlockedState.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/BlockedState.tsx new file mode 100644 index 000000000..5a13ae9b4 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/BlockedState.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useState } from 'react'; +import { AlertTriangle, Play, Square, Code2, FileText, Wrench } from 'lucide-react'; +import { CollapsibleSection } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/CollapsibleSection'; +import { HexplanEditor } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/HexplanEditor'; +import type { ToolCallDisplay } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList'; + +interface HexplanData { + runId: string; + coords: string; + content: string; +} + +interface BlockedStateProps { + blockageReason: string | null; + currentPrompt?: string | null; + currentStepHexplan?: HexplanData | null; + parentHexplan?: HexplanData | null; + currentToolCalls?: ToolCallDisplay[]; + onResumeRun: () => void; + onResumeWithInput?: (input: string) => void; + onStop?: () => void; +} + +function _ToolCallsDisplay({ toolCalls }: { toolCalls: ToolCallDisplay[] }) { + return ( + <div className="space-y-2 p-2 bg-purple-50 dark:bg-purple-950/30 border border-purple-200 dark:border-purple-800/50 rounded-md"> + {toolCalls.map((toolCall) => ( + <div key={toolCall.toolCallId} className="text-xs"> + <div className="flex items-center justify-between mb-1"> + <span className="font-medium text-purple-700 dark:text-purple-300"> + {toolCall.toolName} + </span> + {toolCall.durationMs !== undefined && ( + <span className="text-purple-500 dark:text-purple-400"> + {toolCall.durationMs}ms + </span> + )} + </div> + {toolCall.arguments && ( + <details className="mb-1"> + <summary className="cursor-pointer text-purple-600 dark:text-purple-400 hover:underline"> + Arguments + </summary> + <pre className="mt-1 p-1 bg-purple-100 dark:bg-purple-900/50 rounded font-mono text-purple-700 dark:text-purple-300 whitespace-pre-wrap break-words max-h-[80px] overflow-y-auto"> + {toolCall.arguments} + </pre> + </details> + )} + {toolCall.result && ( + <details> + <summary className="cursor-pointer text-green-600 dark:text-green-400 hover:underline"> + Result + </summary> + <pre className="mt-1 p-1 bg-green-100 dark:bg-green-900/50 rounded font-mono text-green-700 dark:text-green-300 whitespace-pre-wrap break-words max-h-[80px] overflow-y-auto"> + {toolCall.result} + </pre> + </details> + )} + {toolCall.error && ( + <div className="text-destructive"> + <span className="font-medium">Error: </span> + <span>{toolCall.error}</span> + </div> + )} + </div> + ))} + </div> + ); +} + +export function BlockedState({ + blockageReason, + currentPrompt, + currentStepHexplan, + parentHexplan, + currentToolCalls, + onResumeRun, + onResumeWithInput, + onStop, +}: BlockedStateProps) { + const [resumeInput, setResumeInput] = useState(''); + + const handleResume = () => { + if (resumeInput.trim() && onResumeWithInput) { + onResumeWithInput(resumeInput); + } else { + onResumeRun(); + } + setResumeInput(''); + }; + + const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + handleResume(); + } + }; + + const hasToolCalls = currentToolCalls && currentToolCalls.length > 0; + + return ( + <div className="flex flex-col gap-2"> + {/* Compact alert */} + <div className="flex items-center gap-2 px-2.5 py-1.5 bg-secondary/10 border border-secondary/30 rounded-md"> + <AlertTriangle className="h-3.5 w-3.5 text-secondary flex-shrink-0" /> + <span className="text-xs font-medium text-secondary truncate"> + Blocked{blockageReason ? `: ${blockageReason}` : ''} + </span> + </div> + + {/* Collapsible sections */} + <div className="flex flex-col gap-1"> + {currentPrompt && ( + <CollapsibleSection + title="Prompt" + icon={<Code2 className="h-3.5 w-3.5" />} + iconColorClass="text-neutral-500" + > + <pre className="max-h-[120px] overflow-y-auto p-2 bg-neutral-100 dark:bg-neutral-900 rounded text-xs font-mono text-neutral-600 dark:text-neutral-400 whitespace-pre-wrap break-words"> + {currentPrompt} + </pre> + </CollapsibleSection> + )} + + {currentStepHexplan && ( + <CollapsibleSection + title="Current Hexplan" + icon={<FileText className="h-3.5 w-3.5" />} + iconColorClass="text-amber-500" + > + <HexplanEditor + label="" + runId={currentStepHexplan.runId} + coords={currentStepHexplan.coords} + content={currentStepHexplan.content} + /> + </CollapsibleSection> + )} + + {parentHexplan && ( + <CollapsibleSection + title="Parent Hexplan" + icon={<FileText className="h-3.5 w-3.5" />} + iconColorClass="text-amber-400" + > + <HexplanEditor + label="" + runId={parentHexplan.runId} + coords={parentHexplan.coords} + content={parentHexplan.content} + /> + </CollapsibleSection> + )} + + {hasToolCalls && ( + <CollapsibleSection + title={`Tool Calls (${currentToolCalls.length})`} + icon={<Wrench className="h-3.5 w-3.5" />} + iconColorClass="text-purple-500" + > + <_ToolCallsDisplay toolCalls={currentToolCalls} /> + </CollapsibleSection> + )} + </div> + + {/* Compact input with inline buttons */} + <div className="flex items-center gap-2 mt-1"> + <input + type="text" + value={resumeInput} + onChange={(event) => setResumeInput(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add context (optional)..." + className="flex-1 px-2.5 py-1.5 text-xs border border-neutral-200 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + <button + type="button" + onClick={handleResume} + className="flex items-center justify-center p-1.5 text-white bg-primary hover:bg-primary/90 rounded-md transition-colors" + title={resumeInput.trim() ? 'Resume with context' : 'Resume'} + > + <Play className="h-4 w-4" /> + </button> + {onStop && ( + <button + type="button" + onClick={onStop} + className="flex items-center justify-center p-1.5 text-neutral-600 dark:text-neutral-400 bg-neutral-200 dark:bg-neutral-700 hover:bg-neutral-300 dark:hover:bg-neutral-600 rounded-md transition-colors" + title="Stop" + > + <Square className="h-4 w-4" /> + </button> + )} + </div> + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/CollapsibleSection.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/CollapsibleSection.tsx new file mode 100644 index 000000000..262c7c2b7 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/CollapsibleSection.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; + +interface CollapsibleSectionProps { + title: string; + icon: ReactNode; + iconColorClass?: string; + defaultExpanded?: boolean; + children: ReactNode; +} + +export function CollapsibleSection({ + title, + icon, + iconColorClass = 'text-neutral-400', + defaultExpanded = false, + children, +}: CollapsibleSectionProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + <div className="flex flex-col gap-1"> + <button + type="button" + onClick={() => setIsExpanded(!isExpanded)} + className={`flex items-center gap-2 p-1.5 rounded-md transition-colors ${ + isExpanded + ? 'bg-neutral-100 dark:bg-neutral-800' + : 'hover:bg-neutral-100 dark:hover:bg-neutral-800' + }`} + title={isExpanded ? `Hide ${title}` : `Show ${title}`} + > + <span className={iconColorClass}>{icon}</span> + <span className="text-xs font-medium text-neutral-600 dark:text-neutral-400"> + {title} + </span> + </button> + {isExpanded && <div className="mt-1">{children}</div>} + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/CompletedState.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/CompletedState.tsx new file mode 100644 index 000000000..68c508110 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/CompletedState.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { CheckCircle2 } from 'lucide-react'; +import type { ExecutedStep } from '~/app/map/Chat/Timeline/Widgets/RunWidget/useRunWidget'; +import { StepsList } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/StepsList'; + +interface CompletedStateProps { + executedSteps: ExecutedStep[]; + elapsedTime: number; + onNavigateToTile?: (coords: string) => void; +} + +function _formatElapsedTime(seconds: number): string { + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +export function CompletedState({ + executedSteps, + elapsedTime, + onNavigateToTile, +}: CompletedStateProps) { + const completedCount = executedSteps.filter( + (step) => step.status === 'completed' + ).length; + + return ( + <div className="flex flex-col gap-3"> + <div className="flex items-center gap-2 p-3 bg-success/10 border border-success/30 rounded-md"> + <CheckCircle2 className="h-4 w-4 text-success" /> + <div className="flex flex-col gap-0.5"> + <span className="text-sm font-medium text-success"> + Run Complete + </span> + <span className="text-xs text-success/80"> + {completedCount} step{completedCount !== 1 ? 's' : ''} completed in{' '} + {_formatElapsedTime(elapsedTime)} + </span> + </div> + </div> + + {executedSteps.length > 0 && ( + <StepsList steps={executedSteps} onNavigateToTile={onNavigateToTile} /> + )} + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/HexplanEditor.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/HexplanEditor.tsx new file mode 100644 index 000000000..6b277f90a --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/HexplanEditor.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ChevronDown, ChevronRight, FileText, Save, Loader2 } from 'lucide-react'; +import { api } from '~/commons/trpc/react'; + +interface HexplanEditorProps { + label: string; + runId: string; + coords: string; + content: string; + onSaved?: (newContent: string) => void; +} + +export function HexplanEditor({ label, runId, coords, content, onSaved }: HexplanEditorProps) { + const [isExpanded, setIsExpanded] = useState(!label); // Auto-expand when no label (used inside CollapsibleSection) + const [editedContent, setEditedContent] = useState(content); + const [hasChanges, setHasChanges] = useState(false); + + const updateRunHexplanMutation = api.agentic.updateRunHexplan.useMutation(); + + // Sync edited content when prop changes + useEffect(() => { + setEditedContent(content); + setHasChanges(false); + }, [content]); + + const handleContentChange = (newContent: string) => { + setEditedContent(newContent); + setHasChanges(newContent !== content); + }; + + const handleSave = async () => { + if (!hasChanges) return; + + try { + await updateRunHexplanMutation.mutateAsync({ + runId, + coords, + content: editedContent, + }); + setHasChanges(false); + onSaved?.(editedContent); + } catch { + // Error handled by mutation state + } + }; + + const editorContent = ( + <div className="flex flex-col gap-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/50 rounded-md"> + <textarea + value={editedContent} + onChange={(event) => handleContentChange(event.target.value)} + placeholder="Hexplan content..." + className="w-full min-h-[100px] max-h-[200px] px-2 py-1.5 text-xs font-mono border border-amber-200 dark:border-amber-800 rounded bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-1 focus:ring-amber-500 resize-y" + rows={4} + /> + <div className="flex items-center justify-between"> + <span className="text-xs text-neutral-400 truncate max-w-[200px]" title={coords}> + {coords} + </span> + <button + type="button" + onClick={handleSave} + disabled={!hasChanges || updateRunHexplanMutation.isPending} + className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-900/50 hover:bg-amber-200 dark:hover:bg-amber-900 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + {updateRunHexplanMutation.isPending ? ( + <Loader2 className="h-3 w-3 animate-spin" /> + ) : ( + <Save className="h-3 w-3" /> + )} + Save + </button> + </div> + {updateRunHexplanMutation.isError && ( + <span className="text-xs text-destructive"> + Failed to save: {updateRunHexplanMutation.error?.message} + </span> + )} + </div> + ); + + // If no label, render editor directly (used inside CollapsibleSection) + if (!label) { + return editorContent; + } + + // Otherwise, render with collapsible header + return ( + <div className="flex flex-col gap-1"> + <button + type="button" + onClick={() => setIsExpanded(!isExpanded)} + className="flex items-center gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300" + > + {isExpanded ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + <FileText className="h-3 w-3 text-amber-500" /> + <span>{label}</span> + {hasChanges && ( + <span className="text-amber-500 text-xs">(unsaved)</span> + )} + </button> + + {isExpanded && editorContent} + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/PreRunState.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/PreRunState.tsx new file mode 100644 index 000000000..3573cf137 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/PreRunState.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { Play } from 'lucide-react'; + +interface PreRunStateProps { + instruction: string; + onInstructionChange: (value: string) => void; + onStartRun: () => void; + isStarting?: boolean; +} + +export function PreRunState({ + instruction, + onInstructionChange, + onStartRun, + isStarting = false, +}: PreRunStateProps) { + return ( + <div className="flex flex-col gap-3"> + <div> + <label + htmlFor="run-instruction" + className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1" + > + Instructions (optional) + </label> + <textarea + id="run-instruction" + value={instruction} + onChange={(e) => onInstructionChange(e.target.value)} + placeholder="Add specific instructions for this run..." + className="w-full h-20 px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none" + disabled={isStarting} + /> + </div> + <button + type="button" + onClick={onStartRun} + disabled={isStarting} + className="flex items-center justify-center gap-2 w-full px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary/90 disabled:bg-primary/50 disabled:cursor-not-allowed rounded-md transition-colors" + > + <Play className="h-4 w-4" /> + {isStarting ? 'Starting...' : 'Start Run'} + </button> + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/PromptDisplay.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/PromptDisplay.tsx new file mode 100644 index 000000000..b56f9529a --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/PromptDisplay.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronDown, ChevronRight, Code } from 'lucide-react'; + +interface PromptDisplayProps { + prompt: string; + defaultExpanded?: boolean; +} + +export function PromptDisplay({ + prompt, + defaultExpanded = false, +}: PromptDisplayProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + if (!prompt) { + return null; + } + + return ( + <div className="flex flex-col gap-1 border border-neutral-200 dark:border-neutral-700 rounded-md overflow-hidden"> + <button + type="button" + onClick={() => setIsExpanded(!isExpanded)} + className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" + > + {isExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + <Code className="h-4 w-4" /> + <span>{isExpanded ? 'Hide Prompt' : 'Show Prompt'}</span> + </button> + + {isExpanded && ( + <div className="px-3 pb-3"> + <pre className="max-h-[200px] overflow-y-auto p-3 bg-neutral-50 dark:bg-neutral-900 rounded-md text-xs font-mono text-neutral-700 dark:text-neutral-300 whitespace-pre-wrap break-words"> + {prompt} + </pre> + </div> + )} + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/RunningState.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/RunningState.tsx new file mode 100644 index 000000000..fadd1d83a --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/RunningState.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Loader2, Square, ExternalLink } from 'lucide-react'; +import { PromptDisplay } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_components/PromptDisplay'; + +interface RunningStateProps { + currentStep: string | null; + currentPrompt?: string | null; + onNavigateToTile?: (coords: string) => void; + onStopRun: () => void; +} + +export function RunningState({ + currentStep, + currentPrompt, + onNavigateToTile, + onStopRun, +}: RunningStateProps) { + return ( + <div className="flex flex-col gap-3"> + <div className="flex items-center gap-2 p-3 bg-primary/10 rounded-md"> + <Loader2 className="h-4 w-4 animate-spin text-primary" /> + <span className="text-sm text-neutral-700 dark:text-neutral-300"> + Running... + </span> + </div> + + {currentStep && ( + <div className="flex items-center gap-2 p-2 bg-neutral-100 dark:bg-neutral-800 rounded-md"> + <span className="text-sm text-neutral-600 dark:text-neutral-400"> + Current step: + </span> + <button + type="button" + onClick={() => onNavigateToTile?.(currentStep)} + className="flex items-center gap-1 text-sm text-primary hover:underline" + > + {currentStep} + <ExternalLink className="h-3 w-3" /> + </button> + </div> + )} + + {currentPrompt && <PromptDisplay prompt={currentPrompt} />} + + <button + type="button" + onClick={onStopRun} + className="flex items-center justify-center gap-2 w-full px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-200 dark:bg-neutral-700 hover:bg-neutral-300 dark:hover:bg-neutral-600 rounded-md transition-colors" + > + <Square className="h-4 w-4" /> + Stop + </button> + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/StepsList.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/StepsList.tsx new file mode 100644 index 000000000..1ce459cdf --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_components/StepsList.tsx @@ -0,0 +1,4 @@ +/** + * Re-export StepsList from subsystem for backward compatibility + */ +export { StepsList } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList'; diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/ExpandedContent.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/ExpandedContent.tsx new file mode 100644 index 000000000..d147e75ab --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/ExpandedContent.tsx @@ -0,0 +1,95 @@ +'use client'; + +import type { ExpandedSection, ExecutedStep, ToolCallDisplay } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/types'; + +interface ExpandedContentProps { + expandedSection: ExpandedSection; + step: ExecutedStep; +} + +function _ToolCallItem({ toolCall }: { toolCall: ToolCallDisplay }) { + return ( + <div className="p-2 bg-purple-50 dark:bg-purple-950/30 border border-purple-200 dark:border-purple-800/50 rounded text-xs"> + <div className="flex items-center justify-between mb-1"> + <span className="font-medium text-purple-700 dark:text-purple-300"> + {toolCall.toolName} + </span> + {toolCall.durationMs !== undefined && ( + <span className="text-purple-500 dark:text-purple-400"> + {toolCall.durationMs}ms + </span> + )} + </div> + {toolCall.arguments && ( + <details className="mb-1"> + <summary className="cursor-pointer text-purple-600 dark:text-purple-400 hover:underline"> + Arguments + </summary> + <pre className="mt-1 p-1 bg-purple-100 dark:bg-purple-900/50 rounded font-mono text-purple-700 dark:text-purple-300 whitespace-pre-wrap break-words max-h-[100px] overflow-y-auto"> + {toolCall.arguments} + </pre> + </details> + )} + {toolCall.result && ( + <details> + <summary className="cursor-pointer text-green-600 dark:text-green-400 hover:underline"> + Result + </summary> + <pre className="mt-1 p-1 bg-green-100 dark:bg-green-900/50 rounded font-mono text-green-700 dark:text-green-300 whitespace-pre-wrap break-words max-h-[100px] overflow-y-auto"> + {toolCall.result} + </pre> + </details> + )} + {toolCall.error && ( + <div className="text-destructive"> + <span className="font-medium">Error: </span> + <span>{toolCall.error}</span> + </div> + )} + </div> + ); +} + +export function ExpandedContent({ expandedSection, step }: ExpandedContentProps) { + if (expandedSection === 'prompt' && step.prompt) { + return ( + <div className="px-2 pb-2"> + <pre className="max-h-[150px] overflow-y-auto p-2 bg-neutral-100 dark:bg-neutral-900 rounded text-xs font-mono text-neutral-600 dark:text-neutral-400 whitespace-pre-wrap break-words"> + {step.prompt} + </pre> + </div> + ); + } + + if (expandedSection === 'response' && step.agentResponse) { + return ( + <div className="px-2 pb-2"> + <pre className="max-h-[150px] overflow-y-auto p-2 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800/50 rounded text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-words"> + {step.agentResponse} + </pre> + </div> + ); + } + + if (expandedSection === 'hexplan' && step.hexplanContent) { + return ( + <div className="px-2 pb-2"> + <pre className="max-h-[150px] overflow-y-auto p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/50 rounded text-xs font-mono text-amber-700 dark:text-amber-300 whitespace-pre-wrap break-words"> + {step.hexplanContent} + </pre> + </div> + ); + } + + if (expandedSection === 'toolCalls' && step.toolCalls && step.toolCalls.length > 0) { + return ( + <div className="px-2 pb-2 space-y-2"> + {step.toolCalls.map((toolCall) => ( + <_ToolCallItem key={toolCall.toolCallId} toolCall={toolCall} /> + ))} + </div> + ); + } + + return null; +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/StatusIcon.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/StatusIcon.tsx new file mode 100644 index 000000000..488797d0e --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/StatusIcon.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { CheckCircle2, AlertTriangle, XCircle } from 'lucide-react'; +import type { ExecutedStep } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/types'; + +interface StatusIconProps { + status: ExecutedStep['status']; +} + +export function StatusIcon({ status }: StatusIconProps) { + switch (status) { + case 'completed': + return <CheckCircle2 className="h-3.5 w-3.5 text-success" />; + case 'blocked': + return <AlertTriangle className="h-3.5 w-3.5 text-secondary" />; + case 'error': + return <XCircle className="h-3.5 w-3.5 text-destructive" />; + } +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/StepItem.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/StepItem.tsx new file mode 100644 index 000000000..6823fcf7f --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/StepItem.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState } from 'react'; +import { ExternalLink, Code2, MessageSquare, FileText, Wrench } from 'lucide-react'; +import type { ExecutedStep, ExpandedSection } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/types'; +import { StatusIcon } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/StatusIcon'; +import { ExpandedContent } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/ExpandedContent'; + +interface StepItemProps { + step: ExecutedStep; + onNavigateToTile?: (coords: string) => void; +} + +export function StepItem({ step, onNavigateToTile }: StepItemProps) { + const [expandedSection, setExpandedSection] = useState<ExpandedSection>('none'); + + const toggleSection = (section: ExpandedSection) => { + setExpandedSection(expandedSection === section ? 'none' : section); + }; + + const hasPrompt = Boolean(step.prompt); + const hasResponse = Boolean(step.agentResponse); + const hasHexplan = Boolean(step.hexplanContent); + const hasToolCalls = Boolean(step.toolCalls && step.toolCalls.length > 0); + + return ( + <li className="flex flex-col bg-neutral-50 dark:bg-neutral-800/50 rounded-md text-sm"> + <div className="flex items-center gap-2 p-2"> + <StatusIcon status={step.status} /> + <button + type="button" + onClick={() => onNavigateToTile?.(step.coords)} + className="flex items-center gap-1 text-primary hover:underline truncate" + > + <span className="truncate">{step.title}</span> + <ExternalLink className="h-3 w-3 flex-shrink-0" /> + </button> + <div className="ml-auto flex items-center gap-1"> + {hasPrompt && ( + <button + type="button" + onClick={() => toggleSection('prompt')} + className={`p-1 rounded transition-colors ${ + expandedSection === 'prompt' + ? 'text-neutral-700 dark:text-neutral-200 bg-neutral-200 dark:bg-neutral-700' + : 'text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300' + }`} + title="Show prompt" + > + <Code2 className="h-3 w-3" /> + </button> + )} + {hasResponse && ( + <button + type="button" + onClick={() => toggleSection('response')} + className={`p-1 rounded transition-colors ${ + expandedSection === 'response' + ? 'text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/50' + : 'text-neutral-400 hover:text-blue-500 dark:hover:text-blue-400' + }`} + title="Show agent response" + > + <MessageSquare className="h-3 w-3" /> + </button> + )} + {hasHexplan && ( + <button + type="button" + onClick={() => toggleSection('hexplan')} + className={`p-1 rounded transition-colors ${ + expandedSection === 'hexplan' + ? 'text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/50' + : 'text-neutral-400 hover:text-amber-500 dark:hover:text-amber-400' + }`} + title="Show hexplan" + > + <FileText className="h-3 w-3" /> + </button> + )} + {hasToolCalls && ( + <button + type="button" + onClick={() => toggleSection('toolCalls')} + className={`p-1 rounded transition-colors ${ + expandedSection === 'toolCalls' + ? 'text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/50' + : 'text-neutral-400 hover:text-purple-500 dark:hover:text-purple-400' + }`} + title="Show tool calls" + > + <Wrench className="h-3 w-3" /> + </button> + )} + </div> + </div> + <ExpandedContent expandedSection={expandedSection} step={step} /> + </li> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/index.tsx b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/index.tsx new file mode 100644 index 000000000..7816eaf0f --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/index.tsx @@ -0,0 +1,35 @@ +'use client'; + +import type { ExecutedStep } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/types'; +import { StepItem } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/_components/StepItem'; + +interface StepsListProps { + steps: ExecutedStep[]; + onNavigateToTile?: (coords: string) => void; +} + +export function StepsList({ steps, onNavigateToTile }: StepsListProps) { + if (steps.length === 0) { + return null; + } + + return ( + <div className="flex flex-col gap-1"> + <span className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wide"> + Executed Steps + </span> + <ul className="flex flex-col gap-1 max-h-[250px] overflow-y-auto"> + {steps.map((step, index) => ( + <StepItem + key={`${step.coords}-${index}`} + step={step} + onNavigateToTile={onNavigateToTile} + /> + ))} + </ul> + </div> + ); +} + +// Re-export types for convenience +export type { ExecutedStep, ToolCallDisplay, ExpandedSection } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/types'; diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/types.ts b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/types.ts new file mode 100644 index 000000000..b09cc5cf4 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList/types.ts @@ -0,0 +1,21 @@ +export interface ToolCallDisplay { + toolCallId: string; + toolName: string; + arguments?: string; + result?: string; + error?: string; + durationMs?: number; +} + +export interface ExecutedStep { + coords: string; + title: string; + status: 'completed' | 'blocked' | 'error'; + timestamp: Date; + prompt?: string; + agentResponse?: string; + hexplanContent?: string; + toolCalls?: ToolCallDisplay[]; +} + +export type ExpandedSection = 'none' | 'prompt' | 'response' | 'hexplan' | 'toolCalls'; diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/dependencies.json b/src/app/map/Chat/Timeline/Widgets/RunWidget/dependencies.json new file mode 100644 index 000000000..0023ea4d7 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/dependencies.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../../../scripts/checks/architecture/dependencies.schema.json", + "allowed": [ + "~/app/map/Chat/Timeline/Widgets/_shared", + "~/app/map/Cache", + "~/app/map/_hooks/use-run", + "~/commons/trpc/react" + ], + "subsystems": [] +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/index.ts b/src/app/map/Chat/Timeline/Widgets/RunWidget/index.ts new file mode 100644 index 000000000..5dbd68d02 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/index.ts @@ -0,0 +1,2 @@ +export { RunWidget } from '~/app/map/Chat/Timeline/Widgets/RunWidget/RunWidget'; +export { useRunWidget, type ExecutedStep, type RunWidgetStatus } from '~/app/map/Chat/Timeline/Widgets/RunWidget/useRunWidget'; diff --git a/src/app/map/Chat/Timeline/Widgets/RunWidget/useRunWidget.ts b/src/app/map/Chat/Timeline/Widgets/RunWidget/useRunWidget.ts new file mode 100644 index 000000000..90cd31e9d --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunWidget/useRunWidget.ts @@ -0,0 +1,286 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useRun } from '~/app/map/_hooks/use-run'; +import { api } from '~/commons/trpc/react'; +import type { ExecutedStep, ToolCallDisplay } from '~/app/map/Chat/Timeline/Widgets/RunWidget/_subsystems/StepsList'; + +// Re-export types for backward compatibility +export type { ExecutedStep, ToolCallDisplay }; + +export type RunWidgetStatus = 'idle' | 'running' | 'blocked' | 'complete' | 'error'; + +interface UseRunWidgetOptions { + tileCoords: string; + tileTitle: string; + onClose?: () => void; + onNavigateToTile?: (coords: string) => void; +} + +interface HexplanData { + runId: string; + coords: string; + content: string; +} + +interface UseRunWidgetReturn { + status: RunWidgetStatus; + instruction: string; + currentStep: string | null; + currentPrompt: string | null; + executedSteps: ExecutedStep[]; + blockageReason: string | null; + error: Error | null; + startedAt: Date | null; + elapsedTime: number; + currentStepHexplan: HexplanData | null; + parentHexplan: HexplanData | null; + setInstruction: (value: string) => void; + startRun: () => Promise<void>; + resumeRun: () => Promise<void>; + resumeWithInput: (input: string) => Promise<void>; + stopRun: () => void; +} + +export function useRunWidget(options: UseRunWidgetOptions): UseRunWidgetReturn { + const { tileCoords } = options; + + const [status, setStatus] = useState<RunWidgetStatus>('idle'); + const [instruction, setInstruction] = useState(''); + const [executedSteps, setExecutedSteps] = useState<ExecutedStep[]>([]); + const [startedAt, setStartedAt] = useState<Date | null>(null); + const [elapsedTime, setElapsedTime] = useState(0); + const [currentPrompt, setCurrentPrompt] = useState<string | null>(null); + const [isInitialized, setIsInitialized] = useState(false); + const [currentStepHexplan, setCurrentStepHexplan] = useState<HexplanData | null>(null); + const [parentHexplan, setParentHexplan] = useState<HexplanData | null>(null); + + const instructionRef = useRef(instruction); + const shouldContinueRef = useRef(false); + const lastPromptRef = useRef<string | null>(null); + const lastResponseRef = useRef<string | null>(null); + const lastHexplanRef = useRef<string | null>(null); + + // Fetch existing run state on mount + const { data: runState, refetch: refetchRunState } = api.agentic.getRunState.useQuery( + { coords: tileCoords }, + { refetchOnWindowFocus: false } + ); + + // Initialize widget state from existing run + useEffect(() => { + if (runState && !isInitialized) { + // Map executionLog to executedSteps + const steps = runState.executionLog.map(entry => ({ + coords: entry.stepCoords, + title: entry.stepTitle ?? entry.stepCoords, + status: entry.status, + timestamp: new Date(entry.startedAt), + prompt: entry.hexecutePrompt, + agentResponse: entry.agentResponse, + hexplanContent: entry.hexplanContent, + toolCalls: entry.toolCalls, + })); + setExecutedSteps(steps); + + // Set widget status from run status + if (runState.status === 'blocked') { + setStatus('blocked'); + // Pre-load the next step's prompt for immediate display + if (runState.nextStep) { + setCurrentPrompt(runState.nextStep.prompt); + lastPromptRef.current = runState.nextStep.prompt; + } + // Load hexplan data for blocked state editing + if (runState.currentStepHexplan) { + setCurrentStepHexplan({ + runId: runState.runId, + coords: runState.currentStepHexplan.coords, + content: runState.currentStepHexplan.content, + }); + } + if (runState.parentHexplan) { + setParentHexplan({ + runId: runState.runId, + coords: runState.parentHexplan.coords, + content: runState.parentHexplan.content, + }); + } + } else if (runState.status === 'closed') { + setStatus('complete'); + } else if (runState.status === 'open' && steps.length > 0) { + // Has steps but still open - was likely interrupted + setStatus('idle'); + } + + setIsInitialized(true); + } + }, [runState, isInitialized]); + + // Keep instruction ref in sync + useEffect(() => { + instructionRef.current = instruction; + }, [instruction]); + + // Elapsed time tracking + useEffect(() => { + if (status !== 'running' || !startedAt) { + return; + } + + const intervalId = setInterval(() => { + setElapsedTime(Math.floor((Date.now() - startedAt.getTime()) / 1000)); + }, 1000); + + return () => clearInterval(intervalId); + }, [status, startedAt]); + + const { + run, + currentStep, + blockageReason, + error, + } = useRun({ + onStepStart: (stepCoords, stepTitle, prompt) => { + setCurrentPrompt(prompt); + lastPromptRef.current = prompt; + }, + onStepComplete: (stepCoords, stepTitle, response, hexplanContent) => { + lastResponseRef.current = response ?? null; + lastHexplanRef.current = hexplanContent ?? null; + setExecutedSteps((previous) => [ + ...previous, + { + coords: stepCoords, + title: stepTitle, + status: 'completed', + timestamp: new Date(), + prompt: lastPromptRef.current ?? undefined, + agentResponse: response, + hexplanContent: hexplanContent, + }, + ]); + }, + onRunComplete: () => { + setStatus('complete'); + shouldContinueRef.current = false; + // Refetch to get final state + void refetchRunState(); + }, + onBlocked: (_reason, stepCoords, stepTitle, prompt, response, hexplanContent, stepRunId) => { + setStatus('blocked'); + shouldContinueRef.current = false; + setCurrentPrompt(prompt); + lastPromptRef.current = prompt; + lastResponseRef.current = response ?? null; + lastHexplanRef.current = hexplanContent ?? null; + // Update hexplan state for editing (runId comes from callback or fallback to runState) + const resolvedRunId = stepRunId ?? runState?.runId ?? ''; + if (hexplanContent && resolvedRunId) { + setCurrentStepHexplan({ + runId: resolvedRunId, + coords: stepCoords, // Use the step coords directly (hexplan is stored per coords in run_hexplans) + content: hexplanContent + }); + } + setExecutedSteps((previous) => [ + ...previous, + { + coords: stepCoords, + title: stepTitle, + status: 'blocked', + timestamp: new Date(), + prompt: prompt || undefined, + agentResponse: response, + hexplanContent: hexplanContent, + }, + ]); + // Refetch to get parent hexplan and updated state + void refetchRunState(); + }, + onError: (_errorInstance) => { + setStatus('error'); + shouldContinueRef.current = false; + }, + }); + + const executeLoop = useCallback(async () => { + while (shouldContinueRef.current) { + await run(tileCoords, instructionRef.current || undefined); + + // Check if we should continue after the run completes + // The status is updated via callbacks, so check the ref + if (!shouldContinueRef.current) { + break; + } + + // If run returned to idle, we can continue with next step + // If blocked, complete, or error, the loop will exit via shouldContinueRef + } + }, [run, tileCoords]); + + const startRun = useCallback(async () => { + setStatus('running'); + setStartedAt(new Date()); + setElapsedTime(0); + setExecutedSteps([]); + shouldContinueRef.current = true; + + // Show pre-computed prompt immediately (if available from getRunState) + if (runState?.nextStep) { + setCurrentPrompt(runState.nextStep.prompt); + lastPromptRef.current = runState.nextStep.prompt; + } + + await executeLoop(); + }, [executeLoop, runState]); + + const resumeRun = useCallback(async () => { + setStatus('running'); + shouldContinueRef.current = true; + + // Show pre-computed prompt immediately (if available from getRunState) + if (runState?.nextStep) { + setCurrentPrompt(runState.nextStep.prompt); + lastPromptRef.current = runState.nextStep.prompt; + } + + await executeLoop(); + }, [executeLoop, runState]); + + const resumeWithInput = useCallback(async (input: string) => { + // Store the user input as the instruction for the next run + // This will be passed to the run mutation - backend will format as User Feedback + if (input.trim()) { + setInstruction(input.trim()); + instructionRef.current = input.trim(); + } + + // Resume execution + await resumeRun(); + }, [resumeRun]); + + const stopRun = useCallback(() => { + shouldContinueRef.current = false; + setStatus('idle'); + }, []); + + return { + status, + instruction, + currentStep, + currentPrompt, + executedSteps, + blockageReason, + error, + startedAt, + elapsedTime, + currentStepHexplan, + parentHexplan, + setInstruction, + startRun, + resumeRun, + resumeWithInput, + stopRun, + }; +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_components/RunItem.tsx b/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_components/RunItem.tsx new file mode 100644 index 000000000..5a91a317f --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_components/RunItem.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { formatDistanceToNow } from 'date-fns'; +import type { RunListItem } from '~/app/map/Chat/Timeline/Widgets/RunsListWidget/_hooks/useRunsListState'; + +interface RunItemProps { + run: RunListItem; + onClick: (coords: string) => void; + onClose: (runId: string) => void; + onReopen: (runId: string) => void; +} + +function _getStatusIndicator(status: string) { + switch (status) { + case 'open': + return { color: 'bg-green-500', label: 'Running' }; + case 'blocked': + return { color: 'bg-yellow-500', label: 'Blocked' }; + case 'closed': + return { color: 'bg-neutral-400 dark:bg-neutral-600', label: 'Closed' }; + default: + return { color: 'bg-neutral-400', label: 'Unknown' }; + } +} + +export function RunItem({ run, onClick, onClose, onReopen }: RunItemProps) { + const statusIndicator = _getStatusIndicator(run.status); + const timeAgo = formatDistanceToNow(new Date(run.updatedAt), { addSuffix: true }); + const isClosed = run.status === 'closed'; + + function handleActionClick(event: React.MouseEvent) { + event.stopPropagation(); + if (isClosed) { + onReopen(run.id); + } else { + onClose(run.id); + } + } + + return ( + <button + type="button" + onClick={() => onClick(run.rootCoords)} + className="w-full text-left p-3 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors" + > + <div className="flex items-start gap-2"> + <div + className={`mt-1.5 h-2.5 w-2.5 rounded-full flex-shrink-0 ${statusIndicator.color}`} + title={statusIndicator.label} + /> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-medium text-sm text-neutral-900 dark:text-neutral-100 truncate"> + {run.title} + </span> + {run.totalSteps > 0 && ( + <span className="text-xs text-neutral-500 dark:text-neutral-400 flex-shrink-0"> + {run.stepsCompleted}/{run.totalSteps} steps + </span> + )} + </div> + <div className="flex items-center gap-2 mt-0.5"> + <span className="text-xs text-neutral-500 dark:text-neutral-400"> + {timeAgo} + </span> + {run.status === 'blocked' && run.blockageReason && ( + <span className="text-xs text-yellow-600 dark:text-yellow-400 truncate"> + {run.blockageReason} + </span> + )} + </div> + </div> + <button + type="button" + onClick={handleActionClick} + className="flex-shrink-0 px-2 py-1 text-xs rounded border border-neutral-300 dark:border-neutral-600 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors" + > + {isClosed ? 'Reopen' : 'Close'} + </button> + </div> + </button> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_components/StatusFilter.tsx b/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_components/StatusFilter.tsx new file mode 100644 index 000000000..deb835799 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_components/StatusFilter.tsx @@ -0,0 +1,35 @@ +'use client'; + +import type { StatusFilter as StatusFilterType } from '~/app/map/Chat/Timeline/Widgets/RunsListWidget/_hooks/useRunsListState'; + +interface StatusFilterProps { + value: StatusFilterType; + onChange: (value: StatusFilterType) => void; +} + +export function StatusFilter({ value, onChange }: StatusFilterProps) { + const filters: Array<{ value: StatusFilterType; label: string }> = [ + { value: 'active', label: 'Active' }, + { value: 'closed', label: 'Closed' }, + { value: 'all', label: 'All' }, + ]; + + return ( + <div className="flex gap-1 p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg"> + {filters.map((filter) => ( + <button + key={filter.value} + type="button" + onClick={() => onChange(filter.value)} + className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${ + value === filter.value + ? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 shadow-sm' + : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100' + }`} + > + {filter.label} + </button> + ))} + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_hooks/useRunsListState.ts b/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_hooks/useRunsListState.ts new file mode 100644 index 000000000..b4f0de11a --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunsListWidget/_hooks/useRunsListState.ts @@ -0,0 +1,78 @@ +'use client'; + +import { useState, useCallback, useMemo } from 'react'; +import { api } from '~/commons/trpc/react'; +import type { RunStatus } from '~/lib/domains/agentic'; + +export type StatusFilter = 'active' | 'closed' | 'all'; + +export interface RunListItem { + id: string; + rootCoords: string; + title: string; + status: RunStatus; + blockageReason: string | null; + stepsCompleted: number; + totalSteps: number; + createdAt: Date; + updatedAt: Date; +} + +export function useRunsListState() { + const [statusFilter, setStatusFilter] = useState<StatusFilter>('active'); + + const statusFilterToApi = useMemo((): RunStatus[] => { + switch (statusFilter) { + case 'active': + return ['open', 'blocked']; + case 'closed': + return ['closed']; + case 'all': + return ['open', 'blocked', 'closed']; + } + }, [statusFilter]); + + const runsQuery = api.agentic.listRuns.useQuery( + { statusFilter: statusFilterToApi, limit: 50 }, + { refetchInterval: 5000 } + ); + + const closeRunMutation = api.agentic.closeRun.useMutation({ + onSuccess: () => { + void runsQuery.refetch(); + }, + }); + + const reopenRunMutation = api.agentic.reopenRun.useMutation({ + onSuccess: () => { + void runsQuery.refetch(); + }, + }); + + const runs = useMemo(() => runsQuery.data?.runs ?? [], [runsQuery.data]); + const isLoading = runsQuery.isLoading; + const error = runsQuery.error?.message; + + const handleRefresh = useCallback(() => { + void runsQuery.refetch(); + }, [runsQuery]); + + const handleCloseRun = useCallback((runId: string) => { + closeRunMutation.mutate({ runId }); + }, [closeRunMutation]); + + const handleReopenRun = useCallback((runId: string) => { + reopenRunMutation.mutate({ runId }); + }, [reopenRunMutation]); + + return { + runs, + isLoading, + error, + statusFilter, + setStatusFilter, + handleRefresh, + handleCloseRun, + handleReopenRun, + }; +} diff --git a/src/app/map/Chat/Timeline/Widgets/RunsListWidget/index.tsx b/src/app/map/Chat/Timeline/Widgets/RunsListWidget/index.tsx new file mode 100644 index 000000000..da7dc16e3 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/RunsListWidget/index.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { List, RefreshCw, AlertCircle } from 'lucide-react'; +import { BaseWidget, WidgetHeader, WidgetContent } from '~/app/map/Chat/Timeline/Widgets/_shared'; +import { useRunsListState } from '~/app/map/Chat/Timeline/Widgets/RunsListWidget/_hooks/useRunsListState'; +import { StatusFilter } from '~/app/map/Chat/Timeline/Widgets/RunsListWidget/_components/StatusFilter'; +import { RunItem } from '~/app/map/Chat/Timeline/Widgets/RunsListWidget/_components/RunItem'; + +interface RunsListWidgetProps { + onClose?: () => void; + onOpenRun?: (coords: string, title: string) => void; +} + +export function RunsListWidget({ onClose, onOpenRun }: RunsListWidgetProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + const { + runs, + isLoading, + error, + statusFilter, + setStatusFilter, + handleRefresh, + handleCloseRun, + handleReopenRun, + } = useRunsListState(); + + const handleRunClick = useCallback((coords: string) => { + // Title will be fetched by the RunWidget + onOpenRun?.(coords, 'Loading...'); + }, [onOpenRun]); + + return ( + <BaseWidget className="w-full"> + <WidgetHeader + icon={<List className="h-5 w-5 text-primary" />} + title="Runs" + onClose={onClose} + collapsible={true} + isCollapsed={isCollapsed} + onToggleCollapse={() => setIsCollapsed(!isCollapsed)} + /> + + <WidgetContent isCollapsed={isCollapsed}> + <div className="flex flex-col gap-3"> + {/* Filter and refresh controls */} + <div className="flex items-center justify-between gap-2"> + <StatusFilter value={statusFilter} onChange={setStatusFilter} /> + <button + type="button" + onClick={handleRefresh} + disabled={isLoading} + className="p-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors disabled:opacity-50" + aria-label="Refresh runs" + > + <RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} /> + </button> + </div> + + {/* Error state */} + {error && ( + <div className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive rounded-md text-sm"> + <AlertCircle className="h-4 w-4 flex-shrink-0" /> + <span>{error}</span> + </div> + )} + + {/* Loading state */} + {isLoading && ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="h-5 w-5 animate-spin text-neutral-400" /> + </div> + )} + + {/* Empty state */} + {!isLoading && runs.length === 0 && ( + <div className="text-center py-8 text-neutral-500 dark:text-neutral-400"> + <List className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">No runs found</p> + <p className="text-xs mt-1"> + {statusFilter === 'active' + ? 'Start a run by executing a SYSTEM tile' + : statusFilter === 'closed' + ? 'No closed runs yet' + : 'No runs in the system'} + </p> + </div> + )} + + {/* Runs list */} + {!isLoading && runs.length > 0 && ( + <div className="flex flex-col gap-2 max-h-[400px] overflow-y-auto"> + {runs.map((run) => ( + <RunItem + key={run.id} + run={run} + onClick={handleRunClick} + onClose={handleCloseRun} + onReopen={handleReopenRun} + /> + ))} + </div> + )} + </div> + </WidgetContent> + </BaseWidget> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/ActionMenu.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/ActionMenu.tsx index bba816571..9f1022453 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/ActionMenu.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/ActionMenu.tsx @@ -11,7 +11,6 @@ interface ActionMenuProps { onDelete?: () => void; onDeleteChildren?: () => void; onDeleteComposed?: () => void; - onDeleteHexplan?: () => void; onClose?: () => void; onCopyCoordinates?: () => void; onHistory?: () => void; @@ -25,7 +24,6 @@ export function ActionMenu({ onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, onClose, onCopyCoordinates, onHistory, @@ -37,7 +35,7 @@ export function ActionMenu({ const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); const menuButtonRef = useRef<HTMLButtonElement>(null); - const hasActions = onEdit ?? onDelete ?? onDeleteChildren ?? onDeleteComposed ?? onDeleteHexplan ?? onClose ?? onCopyCoordinates ?? onHistory ?? onSetVisibility ?? onSetVisibilityWithDescendants; + const hasActions = onEdit ?? onDelete ?? onDeleteChildren ?? onDeleteComposed ?? onClose ?? onCopyCoordinates ?? onHistory ?? onSetVisibility ?? onSetVisibilityWithDescendants; const _calculateMenuPosition = () => { if (!menuButtonRef.current) return null; @@ -101,7 +99,6 @@ export function ActionMenu({ onDelete={onDelete} onDeleteChildren={onDeleteChildren} onDeleteComposed={onDeleteComposed} - onDeleteHexplan={onDeleteHexplan} onClose={onClose} onCopyCoordinates={onCopyCoordinates} onHistory={onHistory} diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/TileForm.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/TileForm.tsx index 878048b1f..692a4f5fe 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/TileForm.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/TileForm.tsx @@ -13,18 +13,16 @@ import { _PreviewField } from '~/app/map/Chat/Timeline/Widgets/TileWidget/_inter import { _ContentField } from '~/app/map/Chat/Timeline/Widgets/TileWidget/_internals/form/_ContentField'; import { _TypeSelectorField } from '~/app/map/Chat/Timeline/Widgets/TileWidget/_internals/form/_TypeSelectorField'; -type EditableItemType = 'organizational' | 'context' | 'system'; - interface TileFormProps { mode: 'create' | 'edit'; title: string; preview: string; content: string; - itemType?: EditableItemType; + itemType?: string; isUserTile?: boolean; onPreviewChange: (value: string) => void; onContentChange: (value: string) => void; - onItemTypeChange?: (value: EditableItemType) => void; + onItemTypeChange?: (value: string) => void; onSave: () => void; onCancel: () => void; } diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/TileHeader.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/TileHeader.tsx index 0ebaac9e4..9d28fa56e 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/TileHeader.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/TileHeader.tsx @@ -24,7 +24,6 @@ interface TileHeaderProps { onDelete?: () => void; onDeleteChildren?: () => void; onDeleteComposed?: () => void; - onDeleteHexplan?: () => void; onSetVisibility?: (visibility: Visibility) => void; onSetVisibilityWithDescendants?: (visibility: Visibility) => void; onClose?: () => void; @@ -52,7 +51,6 @@ export function TileHeader({ onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, onSetVisibility, onSetVisibilityWithDescendants, onClose, @@ -117,7 +115,6 @@ export function TileHeader({ onDelete={onDelete} onDeleteChildren={onDeleteChildren} onDeleteComposed={onDeleteComposed} - onDeleteHexplan={onDeleteHexplan} onSetVisibility={onSetVisibility} onSetVisibilityWithDescendants={onSetVisibilityWithDescendants} onClose={onClose} diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/__tests__/type-selector.test.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/__tests__/type-selector.test.tsx index a2865c048..0e4fb210a 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/__tests__/type-selector.test.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/__tests__/type-selector.test.tsx @@ -1,12 +1,37 @@ import "~/test/setup"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -// These imports will fail until implementation exists -// This is expected in TDD - tests are written first +// Mock the tRPC API for all TypeSelectorField tests +vi.mock("~/commons/trpc/react", () => ({ + api: { + agentic: { + getEffectiveAllowlist: { + useQuery: vi.fn(() => ({ + data: { allowedTypes: ['organizational', 'context', 'system'] }, + isLoading: false, + })), + }, + generatePreview: { + useMutation: vi.fn(() => ({ + mutate: vi.fn(), + })), + }, + getJobStatus: { + useQuery: vi.fn(() => ({ + refetch: vi.fn(), + })), + }, + }, + }, +})); describe("TypeSelectorField Component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe("rendering", () => { it("should render a dropdown with label 'Type'", async () => { const { _TypeSelectorField } = await import( @@ -142,23 +167,7 @@ describe("TypeSelectorField Component", () => { }); describe("TileForm with TypeSelector", () => { - // Mock tRPC before importing - vi.mock("~/commons/trpc/react", () => ({ - api: { - agentic: { - generatePreview: { - useMutation: vi.fn(() => ({ - mutate: vi.fn(), - })), - }, - getJobStatus: { - useQuery: vi.fn(() => ({ - refetch: vi.fn(), - })), - }, - }, - }, - })); + // Uses the mock already defined at the top of the file it("should include TypeSelectorField in the form", async () => { const { TileForm } = await import( @@ -298,7 +307,7 @@ describe("useTileState hook - itemType state", () => { expect(result.current.editing.itemType).toBe("system"); }); - it("should default itemType to 'context' when prop is user type", async () => { + it("should preserve itemType when prop is user type", async () => { const { renderHook } = await import("@testing-library/react"); const { useTileState } = await import( "~/app/map/Chat/Timeline/Widgets/TileWidget/useTileState" @@ -314,7 +323,7 @@ describe("useTileState hook - itemType state", () => { }) ); - // USER type is not editable, so it defaults to 'context' - expect(result.current.editing.itemType).toBe("context"); + // USER type is preserved in state (UI hides the selector via isUserTile prop) + expect(result.current.editing.itemType).toBe("user"); }); }); diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/_handlers.ts b/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/_handlers.ts index 8984d5bba..f42e6f3c5 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/_handlers.ts +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/_handlers.ts @@ -13,16 +13,14 @@ export function _handleEdit(editState: EditState) { editState.setIsExpanded(true); } -type EditableItemType = 'organizational' | 'context' | 'system'; - export function _handleSave( editTitle: string, editPreview: string, editContent: string, - editItemType: EditableItemType | undefined, + editItemType: string | undefined, currentMode: 'view' | 'edit' | 'create' | 'delete' | 'delete_children' | 'history', setIsEditing: (value: boolean) => void, - onSave?: (title: string, preview: string, content: string, itemType?: EditableItemType) => void + onSave?: (title: string, preview: string, content: string, itemType?: string) => void ) { onSave?.(editTitle, editPreview, editContent, editItemType); if (currentMode !== 'create') { diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/form/_TypeSelectorField.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/form/_TypeSelectorField.tsx index 7c890c06d..27868ee89 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/form/_TypeSelectorField.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/form/_TypeSelectorField.tsx @@ -1,24 +1,47 @@ 'use client'; -type ItemTypeValue = 'organizational' | 'context' | 'system'; +import { api } from '~/commons/trpc/react'; + +// Built-in types that have special labels +const BUILT_IN_LABELS: Record<string, string> = { + organizational: 'Organizational', + context: 'Context', + system: 'System', +}; + +// Types that should be excluded from the dropdown (reserved) +const EXCLUDED_TYPES = ['user']; interface TypeSelectorFieldProps { - value: ItemTypeValue; - onChange: (value: ItemTypeValue) => void; + value: string; + onChange: (value: string) => void; disabled?: boolean; } -const TYPE_OPTIONS: { value: ItemTypeValue; label: string }[] = [ - { value: 'organizational', label: 'Organizational' }, - { value: 'context', label: 'Context' }, - { value: 'system', label: 'System' }, -]; +function _formatTypeLabel(type: string): string { + // Use built-in label if available + if (BUILT_IN_LABELS[type]) { + return BUILT_IN_LABELS[type]; + } + // Capitalize first letter for custom types + return type.charAt(0).toUpperCase() + type.slice(1); +} export function _TypeSelectorField({ value, onChange, disabled = false, }: TypeSelectorFieldProps) { + const { data, isLoading } = api.agentic.getEffectiveAllowlist.useQuery(); + + // Filter out excluded types and create options + const allowedTypes = (data?.allowedTypes ?? ['organizational', 'context', 'system']) + .filter(type => !EXCLUDED_TYPES.includes(type)); + + // Ensure current value is in the list (for display purposes) + const hasCurrentValue = allowedTypes.includes(value); + const displayTypes = hasCurrentValue ? allowedTypes : [value, ...allowedTypes]; + return ( <div> <label className="text-xs text-muted-foreground mb-1 block"> @@ -27,17 +50,17 @@ export function _TypeSelectorField({ <select value={value} onChange={(e) => { - const newValue = e.target.value as ItemTypeValue; + const newValue = e.target.value; if (newValue !== value) { onChange(newValue); } }} - disabled={disabled} + disabled={disabled || isLoading} className="w-full p-2 text-sm bg-neutral-100 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed" > - {TYPE_OPTIONS.map((option) => ( - <option key={option.value} value={option.value}> - {option.label} + {displayTypes.map((type) => ( + <option key={type} value={type}> + {_formatTypeLabel(type)} </option> ))} </select> diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/header/_HeaderActions.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/header/_HeaderActions.tsx index 8b3155997..c08609704 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/header/_HeaderActions.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/header/_HeaderActions.tsx @@ -15,7 +15,6 @@ interface HeaderActionsProps { onDelete?: () => void; onDeleteChildren?: () => void; onDeleteComposed?: () => void; - onDeleteHexplan?: () => void; onSetVisibility?: (visibility: Visibility) => void; onSetVisibilityWithDescendants?: (visibility: Visibility) => void; onClose?: () => void; @@ -33,7 +32,6 @@ export function _HeaderActions({ onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, onSetVisibility, onSetVisibilityWithDescendants, onClose, @@ -73,7 +71,6 @@ export function _HeaderActions({ onDelete={onDelete} onDeleteChildren={onDeleteChildren} onDeleteComposed={onDeleteComposed} - onDeleteHexplan={onDeleteHexplan} onSetVisibility={onSetVisibility} onSetVisibilityWithDescendants={onSetVisibilityWithDescendants} onClose={onClose} diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/menu/_MenuDropdown.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/menu/_MenuDropdown.tsx index c1ea4d4e1..74b2026b8 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/menu/_MenuDropdown.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/_internals/menu/_MenuDropdown.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Edit, Trash2, X, Copy, History, FolderTree, Layers, Clock, Lock, Unlock, Eye } from 'lucide-react'; +import { Edit, Trash2, X, Copy, History, FolderTree, Layers, Lock, Unlock, Eye } from 'lucide-react'; import { ContextMenu, type ContextMenuItemData } from '~/components/ui/context-menu'; import { Visibility } from '~/lib/domains/mapping/utils'; @@ -10,7 +10,6 @@ interface MenuDropdownProps { onDelete?: () => void; onDeleteChildren?: () => void; onDeleteComposed?: () => void; - onDeleteHexplan?: () => void; onClose?: () => void; onCopyCoordinates?: () => void; onHistory?: () => void; @@ -26,7 +25,6 @@ export function _MenuDropdown({ onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, onClose, onCopyCoordinates, onHistory, @@ -65,15 +63,6 @@ export function _MenuDropdown({ }); } - if (onDeleteHexplan) { - deleteSubmenuItems.push({ - icon: Clock, - label: 'Delete Hexplan', - onClick: onDeleteHexplan, - variant: 'destructive', - }); - } - // Determine if we should show submenu or single delete item const hasMultipleDeleteActions = deleteSubmenuItems.length > 1; diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/tile-widget.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/tile-widget.tsx index 2fc411e17..8cc0ca24a 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/tile-widget.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/tile-widget.tsx @@ -23,9 +23,6 @@ import { CoordSystem } from '~/lib/domains/mapping/utils'; import { getColor } from '~/app/map/types'; import type { Visibility } from '~/lib/domains/mapping/utils'; -type EditableItemType = 'organizational' | 'context' | 'system'; -type ItemType = EditableItemType | 'user' | null; - interface TileWidgetProps { mode?: 'view' | 'edit' | 'create' | 'delete' | 'delete_children' | 'history'; tileId?: string; @@ -33,22 +30,21 @@ interface TileWidgetProps { title?: string; preview?: string; content?: string; - itemType?: ItemType; + itemType?: string | null; forceExpanded?: boolean; openInEditMode?: boolean; tileColor?: string; parentName?: string; parentCoordId?: string; - directionType?: 'structural' | 'composed' | 'hexPlan'; + directionType?: 'structural' | 'composed'; visibility?: Visibility; onEdit?: () => void; onDelete?: () => void; onDeleteChildren?: () => void; onDeleteComposed?: () => void; - onDeleteHexplan?: () => void; onSetVisibility?: (visibility: Visibility) => void; onSetVisibilityWithDescendants?: (visibility: Visibility) => void; - onSave?: (title: string, preview: string, content: string, itemType?: EditableItemType) => void; + onSave?: (title: string, preview: string, content: string, itemType?: string) => void; onClose?: () => void; } @@ -88,7 +84,6 @@ export function TileWidget({ onDelete: _onDelete, onDeleteChildren, onDeleteComposed, - onDeleteHexplan, onSetVisibility, onSetVisibilityWithDescendants, onSave, @@ -180,7 +175,6 @@ export function TileWidget({ onDelete={currentMode !== 'create' ? () => setCurrentMode('delete') : undefined} onDeleteChildren={currentMode !== 'create' ? onDeleteChildren : undefined} onDeleteComposed={currentMode !== 'create' ? onDeleteComposed : undefined} - onDeleteHexplan={currentMode !== 'create' ? onDeleteHexplan : undefined} onSetVisibility={currentMode !== 'create' ? onSetVisibility : undefined} onSetVisibilityWithDescendants={currentMode !== 'create' ? onSetVisibilityWithDescendants : undefined} onClose={onClose} diff --git a/src/app/map/Chat/Timeline/Widgets/TileWidget/useTileState.tsx b/src/app/map/Chat/Timeline/Widgets/TileWidget/useTileState.tsx index f35d607d8..dc2255f8e 100644 --- a/src/app/map/Chat/Timeline/Widgets/TileWidget/useTileState.tsx +++ b/src/app/map/Chat/Timeline/Widgets/TileWidget/useTileState.tsx @@ -2,13 +2,11 @@ import { useState, useEffect } from 'react'; -type ItemTypeValue = 'organizational' | 'context' | 'system' | 'user' | null; - interface UseTileStateProps { title: string; preview?: string; content: string; - itemType?: ItemTypeValue; + itemType?: string | null; forceExpanded?: boolean; openInEditMode?: boolean; tileId: string; @@ -30,10 +28,8 @@ export function useTileState({ const [editPreview, setEditPreview] = useState(preview); const [editContent, setEditContent] = useState(content); // Default to 'context' if itemType is null/undefined - const [editItemType, setEditItemType] = useState<'organizational' | 'context' | 'system'>( - (itemType === 'organizational' || itemType === 'context' || itemType === 'system') - ? itemType - : 'context' + const [editItemType, setEditItemType] = useState<string>( + itemType ?? 'context' ); // Update expansion state when forceExpanded changes @@ -65,11 +61,7 @@ export function useTileState({ setEditTitle(title); setEditPreview(preview); setEditContent(content); - setEditItemType( - (itemType === 'organizational' || itemType === 'context' || itemType === 'system') - ? itemType - : 'context' - ); + setEditItemType(itemType ?? 'context'); }, [title, preview, content, itemType]); return { diff --git a/src/app/map/Chat/Timeline/Widgets/ToolCallWidget/ToolCallWidget.tsx b/src/app/map/Chat/Timeline/Widgets/ToolCallWidget/ToolCallWidget.tsx new file mode 100644 index 000000000..ea96da840 --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/ToolCallWidget/ToolCallWidget.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, Check, X, Wrench, ChevronDown, ChevronRight } from 'lucide-react'; +import { cn } from '~/lib/utils'; + +interface ToolCallWidgetProps { + toolName: string; + arguments: Record<string, unknown>; + status: 'running' | 'completed' | 'failed'; + result?: string; +} + +function formatToolName(toolName: string): string { + // Remove common prefixes for cleaner display + // e.g., "mcp__hexframe__addItem" -> "addItem" + const parts = toolName.split('__'); + return parts[parts.length - 1] ?? toolName; +} + +function StatusIcon({ status }: { status: 'running' | 'completed' | 'failed' }) { + switch (status) { + case 'running': + return <Loader2 className="h-3.5 w-3.5 animate-spin text-link" />; + case 'completed': + return <Check className="h-3.5 w-3.5 text-success" />; + case 'failed': + return <X className="h-3.5 w-3.5 text-destructive" />; + } +} + +function CollapsibleSection({ + title, + content, + defaultOpen = false +}: { + title: string; + content: string; + defaultOpen?: boolean; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + if (!content) return null; + + return ( + <div className="mt-1"> + <button + onClick={() => setIsOpen(!isOpen)} + className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" + > + {isOpen ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + <span>{title}</span> + </button> + {isOpen && ( + <pre className="mt-1 text-xs bg-muted/30 rounded p-2 overflow-x-auto max-h-48 overflow-y-auto"> + {content} + </pre> + )} + </div> + ); +} + +export function ToolCallWidget({ + toolName, + arguments: toolArguments, + status, + result +}: ToolCallWidgetProps) { + const displayName = formatToolName(toolName); + const argumentsJson = JSON.stringify(toolArguments, null, 2); + + return ( + <div + className={cn( + 'w-full px-3 py-2 rounded-lg border', + status === 'running' && 'bg-link/5 border-link/20', + status === 'completed' && 'bg-success/5 border-success/20', + status === 'failed' && 'bg-destructive/5 border-destructive/20' + )} + > + <div className="flex items-center gap-2"> + <Wrench className="h-3.5 w-3.5 text-muted-foreground" /> + <span className="text-sm font-medium">{displayName}</span> + <StatusIcon status={status} /> + <span className="text-xs text-muted-foreground ml-auto"> + {toolName !== displayName && toolName} + </span> + </div> + + <CollapsibleSection title="Arguments" content={argumentsJson} /> + + {result && ( + <CollapsibleSection + title={status === 'failed' ? 'Error' : 'Result'} + content={result} + defaultOpen={status === 'failed'} + /> + )} + </div> + ); +} diff --git a/src/app/map/Chat/Timeline/Widgets/ToolCallWidget/index.ts b/src/app/map/Chat/Timeline/Widgets/ToolCallWidget/index.ts new file mode 100644 index 000000000..627006b8b --- /dev/null +++ b/src/app/map/Chat/Timeline/Widgets/ToolCallWidget/index.ts @@ -0,0 +1 @@ +export { ToolCallWidget } from '~/app/map/Chat/Timeline/Widgets/ToolCallWidget/ToolCallWidget'; diff --git a/src/app/map/Chat/Timeline/Widgets/dependencies.json b/src/app/map/Chat/Timeline/Widgets/dependencies.json index ff680240c..943c1dd2d 100644 --- a/src/app/map/Chat/Timeline/Widgets/dependencies.json +++ b/src/app/map/Chat/Timeline/Widgets/dependencies.json @@ -11,8 +11,10 @@ "~/lib/utils/mcp-config" ], "subsystems": [ + "./Adapters", "./AIResponseWidget", "./LoginWidget", + "./RunWidget", "./TileWidget" ] } diff --git a/src/app/map/Chat/Timeline/Widgets/index.ts b/src/app/map/Chat/Timeline/Widgets/index.ts index 860531fe1..98cfd3e0a 100644 --- a/src/app/map/Chat/Timeline/Widgets/index.ts +++ b/src/app/map/Chat/Timeline/Widgets/index.ts @@ -14,6 +14,9 @@ export { AIResponseWidget } from '~/app/map/Chat/Timeline/Widgets/AIResponseWidg export { McpKeysWidget } from '~/app/map/Chat/Timeline/Widgets/McpKeysWidget/McpKeysWidget'; export { DebugLogsWidget } from '~/app/map/Chat/Timeline/Widgets/DebugLogsWidget'; export { FavoritesWidget } from '~/app/map/Chat/Timeline/Widgets/FavoritesWidget'; +export { ToolCallWidget } from '~/app/map/Chat/Timeline/Widgets/ToolCallWidget'; +export { RunWidget } from '~/app/map/Chat/Timeline/Widgets/RunWidget'; +export { RunsListWidget } from '~/app/map/Chat/Timeline/Widgets/RunsListWidget'; // Shared Widget Components (for internal widget implementations) export { BaseWidget, WidgetHeader, WidgetContent } from '~/app/map/Chat/Timeline/Widgets/_shared'; @@ -27,12 +30,31 @@ export * from '~/app/map/Chat/Timeline/Widgets/LoginWidget'; // AI Response Widget Components (re-exported from their subsystem) export * from '~/app/map/Chat/Timeline/Widgets/AIResponseWidget'; +// Widget Adapters (transform Widget state β†’ Widget components) +export { + type WidgetHandlers, + renderTileWidget, + renderLoginWidget, + renderErrorWidget, + renderCreationWidget, + renderLoadingWidget, + renderDeleteWidget, + renderDeleteChildrenWidget, + renderAIResponseWidget, + renderMcpKeysWidget, + renderDebugLogsWidget, + renderFavoritesWidget, + renderToolCallWidget, + renderRunWidget, + renderRunsListWidget, +} from '~/app/map/Chat/Timeline/Widgets/Adapters'; + /** * Chat Widgets Subsystem Public Interface - * + * * Provides interactive UI components for complex user operations * within the chat interface. Widgets handle multi-step interactions, * form input, and structured user flows. - * + * * All components are exported above for direct usage. */ \ No newline at end of file diff --git a/src/app/map/Chat/Timeline/_components/CollapsiblePrompt.tsx b/src/app/map/Chat/Timeline/_components/CollapsiblePrompt.tsx index 564ea0564..84151ff66 100644 --- a/src/app/map/Chat/Timeline/_components/CollapsiblePrompt.tsx +++ b/src/app/map/Chat/Timeline/_components/CollapsiblePrompt.tsx @@ -1,23 +1,45 @@ /** * Collapsible prompt display component * Shows the hexecute prompt in a collapsed section that users can expand + * Styled to match the ToolCallWidget appearance */ -import { MarkdownRenderer } from '~/app/map/Chat/Timeline/_components/MarkdownRenderer' +'use client'; + +import { useState } from 'react'; +import { Wrench, ChevronDown, ChevronRight, Sparkles } from 'lucide-react'; +import { MarkdownRenderer } from '~/app/map/Chat/Timeline/_components/MarkdownRenderer'; interface CollapsiblePromptProps { prompt: string } export function CollapsiblePrompt({ prompt }: CollapsiblePromptProps) { + const [isOpen, setIsOpen] = useState(false); + return ( - <details className="mb-2 text-xs"> - <summary className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"> - Show the prompt - </summary> - <div className="mt-2 p-3 bg-muted/50 rounded-md overflow-x-auto max-h-96 overflow-y-auto"> - <MarkdownRenderer content={prompt} isSystemMessage={false} /> - </div> - </details> + <div className="w-full mb-3 px-3 py-2 rounded-lg border bg-primary/5 border-primary/20"> + <button + onClick={() => setIsOpen(!isOpen)} + className="flex items-center gap-2 w-full text-left" + > + <Wrench className="h-3.5 w-3.5 text-muted-foreground" /> + <span className="text-sm font-medium">hexecute</span> + <Sparkles className="h-3.5 w-3.5 text-primary" /> + <span className="text-xs text-muted-foreground ml-auto flex items-center gap-1"> + {isOpen ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + <span>Backend Prompt</span> + </span> + </button> + {isOpen && ( + <div className="mt-2 p-3 bg-muted/30 rounded-md overflow-x-auto max-h-96 overflow-y-auto"> + <MarkdownRenderer content={prompt} isSystemMessage={false} /> + </div> + )} + </div> ) } diff --git a/src/app/map/Chat/Timeline/_components/MarkdownRenderer.tsx b/src/app/map/Chat/Timeline/_components/MarkdownRenderer.tsx index f58fa9f7f..f17389a04 100644 --- a/src/app/map/Chat/Timeline/_components/MarkdownRenderer.tsx +++ b/src/app/map/Chat/Timeline/_components/MarkdownRenderer.tsx @@ -1,6 +1,6 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { _createMarkdownComponents } from '~/app/map/Chat/Timeline/_components/_renderers/_markdown-components'; +import { _createMarkdownComponents } from '~/app/map/Chat/Timeline/_components/_markdown-components'; interface MarkdownRendererProps { content: string; diff --git a/src/app/map/Chat/Timeline/_components/MessageActorRenderer.tsx b/src/app/map/Chat/Timeline/_components/MessageActorRenderer.tsx index 7f70e5ed9..8af9f2d15 100644 --- a/src/app/map/Chat/Timeline/_components/MessageActorRenderer.tsx +++ b/src/app/map/Chat/Timeline/_components/MessageActorRenderer.tsx @@ -9,6 +9,7 @@ import { CollapsiblePrompt } from '~/app/map/Chat/Timeline/_components/Collapsib import { authClient } from '~/lib/auth'; import { useEventBus } from '~/app/map/Services/EventBus'; import { ThinkingIndicator } from '~/app/map/Chat/Timeline/_components/ThinkingIndicator'; +import { ToolCallWidget } from '~/app/map/Chat/Timeline/Widgets'; interface StreamingMessage extends Message { isStreaming?: boolean; @@ -142,6 +143,20 @@ export function MessageActorRenderer({ message }: MessageActorRendererProps) { /> )} {renderCopyButtons()} + {/* Render tool calls embedded in the message */} + {message.toolCalls && message.toolCalls.length > 0 && ( + <div className="mt-3 space-y-2"> + {message.toolCalls.map(toolCall => ( + <ToolCallWidget + key={toolCall.toolCallId} + toolName={toolCall.toolName} + arguments={toolCall.arguments} + status={toolCall.status} + result={toolCall.result} + /> + ))} + </div> + )} </div> </div> </div> diff --git a/src/app/map/Chat/Timeline/_components/_renderers/_markdown-components.tsx b/src/app/map/Chat/Timeline/_components/_markdown-components.tsx similarity index 100% rename from src/app/map/Chat/Timeline/_components/_renderers/_markdown-components.tsx rename to src/app/map/Chat/Timeline/_components/_markdown-components.tsx diff --git a/src/app/map/Chat/Timeline/_components/index.ts b/src/app/map/Chat/Timeline/_components/index.ts new file mode 100644 index 000000000..764a0d9db --- /dev/null +++ b/src/app/map/Chat/Timeline/_components/index.ts @@ -0,0 +1,13 @@ +/** + * Chat Timeline _components - Public API + * + * Provides reusable UI components for rendering chat timeline elements. + */ + +// Core components +export { DaySeparator } from '~/app/map/Chat/Timeline/_components/DaySeparator'; +export { MessageActorRenderer } from '~/app/map/Chat/Timeline/_components/MessageActorRenderer'; +export { MarkdownRenderer } from '~/app/map/Chat/Timeline/_components/MarkdownRenderer'; + +// Hooks +export { useAuthStateCoordinator } from '~/app/map/Chat/Timeline/_components/_hooks/useAuthStateCoordinator'; diff --git a/src/app/map/Chat/Timeline/_core/UnifiedTimeline.tsx b/src/app/map/Chat/Timeline/_core/UnifiedTimeline.tsx index 0fc56dc64..14a76b500 100644 --- a/src/app/map/Chat/Timeline/_core/UnifiedTimeline.tsx +++ b/src/app/map/Chat/Timeline/_core/UnifiedTimeline.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef } from 'react'; import type { Message, Widget } from '~/app/map/Chat/_state'; -import { DaySeparator } from '~/app/map/Chat/Timeline/_components/DaySeparator'; -import { MessageActorRenderer } from '~/app/map/Chat/Timeline/_components/MessageActorRenderer'; +import { DaySeparator, MessageActorRenderer } from '~/app/map/Chat/Timeline/_components'; import { WidgetManager } from '~/app/map/Chat/Timeline/_core/WidgetManager'; import { loggers } from '~/lib/debug/debug-logger'; diff --git a/src/app/map/Chat/Timeline/_core/_widget-handler-factory.ts b/src/app/map/Chat/Timeline/_core/_widget-handler-factory.ts index 91e4281cf..49dcbfd00 100644 --- a/src/app/map/Chat/Timeline/_core/_widget-handler-factory.ts +++ b/src/app/map/Chat/Timeline/_core/_widget-handler-factory.ts @@ -1,5 +1,5 @@ import type { Widget, useChatOperations } from '~/app/map/Chat/_state'; -import type { WidgetHandlers } from '~/app/map/Chat/Timeline/_components/_renderers/widget-renderers'; +import type { WidgetHandlers } from '~/app/map/Chat/Timeline/Widgets'; import { createCreationHandlers } from '~/app/map/Chat/Timeline/_utils/creation-handlers'; import { createTileHandlers } from '~/app/map/Chat/Timeline/_utils/tile-handlers'; import { insertTextIntoChatInput } from '~/app/map/Chat/Timeline/_utils/focus-helpers'; @@ -56,6 +56,21 @@ export function _createWidgetHandlers(widget: Widget, deps: HandlerDependencies) insertTextIntoChatInput(text); } }; + case 'run': + return _createSimpleCloseHandler(widget.id, deps.chatState, deps.focusChatInput); + case 'runs-list': + return { + handleCancel: () => { + deps.chatState.closeWidget(widget.id); + deps.focusChatInput(); + }, + showRunWidget: (coords: string, title: string) => { + // Close the runs-list widget first + deps.chatState.closeWidget(widget.id); + // Open the run widget for the selected run + deps.chatState.showRunWidget({ tileCoords: coords, tileTitle: title }); + } + }; default: return {}; } diff --git a/src/app/map/Chat/Timeline/_core/_widget-renderer-factory.tsx b/src/app/map/Chat/Timeline/_core/_widget-renderer-factory.tsx index e846becc2..87ef6d1eb 100644 --- a/src/app/map/Chat/Timeline/_core/_widget-renderer-factory.tsx +++ b/src/app/map/Chat/Timeline/_core/_widget-renderer-factory.tsx @@ -1,8 +1,8 @@ import type { Widget } from '~/app/map/Chat/_state'; import type { TileData } from '~/app/map/types'; import type { ReactNode } from 'react'; -import type { WidgetHandlers } from '~/app/map/Chat/Timeline/_components/_renderers/widget-renderers'; import { + type WidgetHandlers, renderTileWidget, renderLoginWidget, renderErrorWidget, @@ -13,8 +13,11 @@ import { renderAIResponseWidget, renderMcpKeysWidget, renderDebugLogsWidget, - renderFavoritesWidget -} from '~/app/map/Chat/Timeline/_components/_renderers/widget-renderers'; + renderFavoritesWidget, + renderToolCallWidget, + renderRunWidget, + renderRunsListWidget, +} from '~/app/map/Chat/Timeline/Widgets'; export function _renderWidget( widget: Widget, @@ -44,6 +47,12 @@ export function _renderWidget( return renderDebugLogsWidget(widget, handlers); case 'favorites': return renderFavoritesWidget(widget, handlers); + case 'tool-call': + return renderToolCallWidget(widget); + case 'run': + return renderRunWidget(widget, handlers); + case 'runs-list': + return renderRunsListWidget(widget, handlers); default: return null; } diff --git a/src/app/map/Chat/Timeline/_core/timeline.tsx b/src/app/map/Chat/Timeline/_core/timeline.tsx index 77bd242ba..2f2635fbc 100644 --- a/src/app/map/Chat/Timeline/_core/timeline.tsx +++ b/src/app/map/Chat/Timeline/_core/timeline.tsx @@ -2,7 +2,7 @@ import type { Widget, Message } from '~/app/map/Chat/_state'; import { useChatSettings } from '~/app/map/Chat/_settings/useChatSettings'; -import { useAuthStateCoordinator } from '~/app/map/Chat/Timeline/_components/_hooks/useAuthStateCoordinator'; +import { useAuthStateCoordinator } from '~/app/map/Chat/Timeline/_components'; import { UnifiedTimeline } from '~/app/map/Chat/Timeline/_core/UnifiedTimeline'; import { useEffect } from 'react'; import { loggers } from '~/lib/debug/debug-logger'; diff --git a/src/app/map/Chat/Timeline/_utils/creation-handlers.ts b/src/app/map/Chat/Timeline/_utils/creation-handlers.ts index 4d2996709..9176f73f4 100644 --- a/src/app/map/Chat/Timeline/_utils/creation-handlers.ts +++ b/src/app/map/Chat/Timeline/_utils/creation-handlers.ts @@ -1,7 +1,6 @@ import type { Widget } from '~/app/map/Chat/_state'; import { focusChatInput } from '~/app/map/Chat/Timeline/_utils/focus-helpers'; import type { EventBusService } from '~/app/map/types'; -import type { NonUserMapItemTypeString } from '~/lib/domains/mapping/utils'; interface CreationHandlerDeps { createItemOptimistic: (coordId: string, data: { @@ -10,7 +9,7 @@ interface CreationHandlerDeps { name?: string; preview?: string; description?: string; - itemType: NonUserMapItemTypeString; + itemType: string; }) => Promise<void>; eventBus: EventBusService | null; chatState: { @@ -24,7 +23,7 @@ export function createCreationHandlers( ) { const { createItemOptimistic, eventBus, chatState } = deps; - const handleSave = async (name: string, preview: string, content: string, itemType: NonUserMapItemTypeString = "context") => { + const handleSave = async (name: string, preview: string, content: string, itemType = "context") => { const creationData = widget.data as { coordId?: string; parentName?: string; parentCoordId?: string; parentId?: string }; try { diff --git a/src/app/map/Chat/Timeline/_utils/tile-handlers.ts b/src/app/map/Chat/Timeline/_utils/tile-handlers.ts index 2bf07c500..58df2079a 100644 --- a/src/app/map/Chat/Timeline/_utils/tile-handlers.ts +++ b/src/app/map/Chat/Timeline/_utils/tile-handlers.ts @@ -1,7 +1,7 @@ import type { Widget, TileSelectedPayload } from '~/app/map/Chat/_state'; import { focusChatInput } from '~/app/map/Chat/Timeline/_utils/focus-helpers'; import type { EventBusService } from '~/app/map/types'; -import type { NonUserMapItemTypeString, VisibilityString } from '~/lib/domains/mapping/utils'; +import type { VisibilityString } from '~/lib/domains/mapping/utils'; import { Visibility } from '~/lib/domains/mapping/utils'; interface TileHandlerDeps { @@ -11,7 +11,7 @@ interface TileHandlerDeps { preview?: string; description?: string; visibility?: VisibilityString; - itemType?: NonUserMapItemTypeString; + itemType?: string; }) => Promise<void>; updateVisibilityWithDescendantsOptimistic: (coordId: string, visibility: VisibilityString) => Promise<unknown>; eventBus: EventBusService | null; @@ -74,27 +74,12 @@ export function createTileHandlers( }); }; - const handleDeleteHexplan = () => { - const previewData = widget.data as TileSelectedPayload; - - eventBus?.emit({ - type: 'map.delete_children_requested', - payload: { - tileId: previewData.tileData.coordId, - tileName: previewData.tileData.title, - directionType: 'hexPlan', - }, - source: 'chat_cache', - timestamp: new Date(), - }); - }; - const handleTileClose = () => { chatState.closeWidget(widget.id); focusChatInput(); }; - const handleTileSave = async (title: string, preview: string, content: string, itemType?: NonUserMapItemTypeString) => { + const handleTileSave = async (title: string, preview: string, content: string, itemType?: string) => { const tileData = widget.data as TileSelectedPayload; try { // updateItemOptimistic already emits map.tile_updated via MutationCoordinator @@ -166,7 +151,6 @@ export function createTileHandlers( handleDelete, handleDeleteChildren, handleDeleteComposed, - handleDeleteHexplan, handleSetVisibility, handleSetVisibilityWithDescendants, handleTileSave, diff --git a/src/app/map/Chat/_hooks/_streaming-chat-callbacks.ts b/src/app/map/Chat/_hooks/_streaming-chat-callbacks.ts index c524adf64..74161b7f6 100644 --- a/src/app/map/Chat/_hooks/_streaming-chat-callbacks.ts +++ b/src/app/map/Chat/_hooks/_streaming-chat-callbacks.ts @@ -92,26 +92,26 @@ export function createStreamingChatCallbacks( const success = !error const resultContent = result ?? error ?? '' + // Parse the final arguments from the end event (args are streamed via deltas) + let parsedArguments: Record<string, unknown> = {} + if (argsString) { + try { + parsedArguments = JSON.parse(argsString) as Record<string, unknown> + } catch { + // Keep empty object if parsing fails + } + } + // End tool call in message operations chatState.endToolCall(getStreamId(), toolCallId, resultContent, success) - // Update tool call widget - chatState.updateToolCallWidget(toolCallId, resultContent, success) + // Update tool call widget with final arguments + chatState.updateToolCallWidget(toolCallId, resultContent, success, parsedArguments) // Check if this was a hexframe mutation tool and notify for cache invalidation const mutationType = toolName ? HEXFRAME_MUTATION_TOOLS[toolName] : undefined if (mutationType && toolName && success && onHexframeMutation) { - // Parse the arguments from the end event - let parsedArguments: Record<string, unknown> = {} - if (argsString) { - try { - parsedArguments = JSON.parse(argsString) as Record<string, unknown> - } catch { - // Keep empty object if parsing fails - } - } - const mutation: HexframeMutationInfo = { type: mutationType, toolName, diff --git a/src/app/map/Chat/_state/__tests__/streaming-events.test.ts b/src/app/map/Chat/_state/__tests__/streaming-events.test.ts index abf0f6c07..5411ff4bb 100644 --- a/src/app/map/Chat/_state/__tests__/streaming-events.test.ts +++ b/src/app/map/Chat/_state/__tests__/streaming-events.test.ts @@ -413,7 +413,9 @@ describe('Message Selector Streaming Event Handling', () => { // ============================================================================= describe('Widget Selector Tool Call Handling', () => { describe('deriveActiveWidgets with tool-call widgets', () => { - it('should create tool-call widget from widget_created event', () => { + // Tool-call widgets are now filtered out from the widget list + // because they are embedded inside messages instead of shown as separate timeline items + it('should filter out tool-call widgets (now embedded in messages)', () => { const events: ChatEvent[] = [ { id: 'tool-call-1', @@ -439,17 +441,11 @@ describe('Widget Selector Tool Call Handling', () => { const widgets = deriveActiveWidgets(events) - expect(widgets).toHaveLength(1) - expect(widgets[0]).toMatchObject({ - id: 'tool-call-call_abc123', - type: 'tool-call', - data: expect.objectContaining({ - status: 'running' - }) as unknown - }) + // Tool-call widgets are filtered out - they're now embedded in messages + expect(widgets).toHaveLength(0) }) - it('should handle widget_updated event to update widget status', () => { + it('should still track widget_updated events for tool-call widgets internally', () => { const baseTime = new Date() const events: ChatEvent[] = [ { @@ -488,14 +484,28 @@ describe('Widget Selector Tool Call Handling', () => { const widgets = deriveActiveWidgets(events) - expect(widgets).toHaveLength(1) - expect((widgets[0]!.data as { status: string }).status).toBe('completed') - expect((widgets[0]!.data as { result: string }).result).toBe('{"success": true}') + // Tool-call widgets are filtered out from the display + expect(widgets).toHaveLength(0) }) - it('should keep completed tool-call widgets visible', () => { + it('should allow other widget types to remain visible', () => { const baseTime = new Date() const events: ChatEvent[] = [ + { + id: 'ai-response-1', + type: 'widget_created', + payload: { + widget: { + id: 'ai-response-123', + type: 'ai-response', + data: { jobId: 'job_1' }, + priority: 'info', + timestamp: baseTime + } + }, + timestamp: baseTime, + actor: 'assistant', + }, { id: 'tool-call-create', type: 'widget_created', @@ -514,24 +524,14 @@ describe('Widget Selector Tool Call Handling', () => { }, timestamp: baseTime, actor: 'assistant', - }, - { - id: 'tool-call-update', - type: 'widget_updated', - payload: { - widgetId: 'tool-call-call_abc123', - updates: { status: 'completed' } - }, - timestamp: new Date(baseTime.getTime() + 100), - actor: 'assistant', } ] const widgets = deriveActiveWidgets(events) - // Completed tool calls should still be visible (auto-hide is a UI concern) + // Only non-tool-call widgets should be visible expect(widgets).toHaveLength(1) - expect((widgets[0]!.data as { status: string }).status).toBe('completed') + expect(widgets[0]!.type).toBe('ai-response') }) }) }) @@ -620,29 +620,28 @@ describe('Streaming Message Flow Integration', () => { timestamp: new Date(baseTime.getTime() + 100), actor: 'assistant', }, - // Tool call start (widget) + // Tool call start event (tracked in streaming state) { - id: 'tool-widget-create', - type: 'widget_created', + id: 'tool-call-start-1', + type: 'tool_call_start', payload: { - widget: { - id: 'tool-call-call_1', - type: 'tool-call', - data: { toolCallId: 'call_1', toolName: 'addItem', status: 'running' }, - priority: 'info', - timestamp: new Date(baseTime.getTime() + 200) - } + streamId: 'stream_1', + toolCallId: 'call_1', + toolName: 'addItem', + arguments: { title: 'Test' } }, timestamp: new Date(baseTime.getTime() + 200), actor: 'assistant', }, // Tool call completes { - id: 'tool-widget-update', - type: 'widget_updated', + id: 'tool-call-end-1', + type: 'tool_call_end', payload: { - widgetId: 'tool-call-call_1', - updates: { status: 'completed', result: '{"id": "tile_1"}' } + streamId: 'stream_1', + toolCallId: 'call_1', + result: '{"id": "tile_1"}', + success: true }, timestamp: new Date(baseTime.getTime() + 400), actor: 'assistant', @@ -668,10 +667,18 @@ describe('Streaming Message Flow Integration', () => { const messages = deriveVisibleMessages(events) const widgets = deriveActiveWidgets(events) + // Message should have tool calls embedded expect(messages).toHaveLength(1) expect(messages[0]!.content).toBe('Let me create a tile...\n\nDone!') - expect(widgets).toHaveLength(1) - expect(widgets[0]!.type).toBe('tool-call') + expect(messages[0]!.toolCalls).toHaveLength(1) + expect(messages[0]!.toolCalls![0]).toMatchObject({ + toolCallId: 'call_1', + toolName: 'addItem', + status: 'completed' + }) + + // No separate tool-call widgets (they're embedded in messages now) + expect(widgets).toHaveLength(0) }) }) diff --git a/src/app/map/Chat/_state/_events/event.types.ts b/src/app/map/Chat/_state/_events/event.types.ts index d2f3174c3..3d8019ca8 100644 --- a/src/app/map/Chat/_state/_events/event.types.ts +++ b/src/app/map/Chat/_state/_events/event.types.ts @@ -42,6 +42,17 @@ export interface ChatUIState { visibleMessages: Message[]; } +/** + * Data for a tool call associated with a message + */ +export interface ToolCallData { + toolCallId: string; + toolName: string; + arguments: Record<string, unknown>; + status: 'running' | 'completed' | 'failed'; + result?: string; +} + export interface Message { id: string; content: string; @@ -49,11 +60,13 @@ export interface Message { timestamp: Date; /** The hexecute prompt for task executions (only set for @-mention triggered messages) */ prompt?: string; + /** Tool calls that occurred during this message's generation */ + toolCalls?: ToolCallData[]; } export interface Widget { id: string; - type: 'tile' | 'creation' | 'delete' | 'delete_children' | 'login' | 'loading' | 'error' | 'ai-response' | 'mcp-keys' | 'debug-logs' | 'favorites' | 'tool-call'; + type: 'tile' | 'creation' | 'delete' | 'delete_children' | 'login' | 'loading' | 'error' | 'ai-response' | 'mcp-keys' | 'debug-logs' | 'favorites' | 'tool-call' | 'run' | 'runs-list'; data: unknown; priority: 'info' | 'action' | 'critical'; timestamp: Date; diff --git a/src/app/map/Chat/_state/_events/event.validators.ts b/src/app/map/Chat/_state/_events/event.validators.ts index ec44807fa..97202f85a 100644 --- a/src/app/map/Chat/_state/_events/event.validators.ts +++ b/src/app/map/Chat/_state/_events/event.validators.ts @@ -18,6 +18,7 @@ import { mapDeleteChildrenRequestedEventSchema, mapCreateRequestedEventSchema, mapFavoritesWidgetRequestedEventSchema, + mapRunWidgetRequestedEventSchema, safeValidateEvent, } from '~/app/map/types'; @@ -401,6 +402,27 @@ function _transformOperationEvents(validEvent: AppEvent, baseEvent: Partial<Chat } as ChatEvent; } + case 'map.run_widget_requested': { + const payload = mapRunWidgetRequestedEventSchema.parse(validEvent).payload; + // Create run widget for SYSTEM tile execution + return { + ...baseEvent, + type: 'widget_created', + payload: { + widget: { + id: `run-${Date.now()}`, + type: 'run', + data: { + tileCoords: payload.tileCoords, + tileTitle: payload.tileTitle, + }, + priority: 'action', + timestamp: baseEvent.timestamp, + }, + }, + } as ChatEvent; + } + default: return null; } diff --git a/src/app/map/Chat/_state/_events/index.ts b/src/app/map/Chat/_state/_events/index.ts index 5bb5b3d0d..26cf743da 100644 --- a/src/app/map/Chat/_state/_events/index.ts +++ b/src/app/map/Chat/_state/_events/index.ts @@ -29,6 +29,7 @@ export type { ToolCallStartPayload, ToolCallEndPayload, ToolCallWidgetData, + ToolCallData, } from '~/app/map/Chat/_state/_events/event.types'; // Event creators diff --git a/src/app/map/Chat/_state/_operations/widget-operations.ts b/src/app/map/Chat/_state/_operations/widget-operations.ts index a0aa9b81c..7e361e46a 100644 --- a/src/app/map/Chat/_state/_operations/widget-operations.ts +++ b/src/app/map/Chat/_state/_operations/widget-operations.ts @@ -1,4 +1,4 @@ -import type { ChatEvent, ToolCallWidgetData, WidgetResolvedPayload } from '~/app/map/Chat/_state/_events'; +import type { ChatEvent, ToolCallWidgetData } from '~/app/map/Chat/_state/_events'; /** * Widget-related operations @@ -69,6 +69,38 @@ export function createWidgetOperations(dispatch: (event: ChatEvent) => void) { actor: 'system' as const }); }, + showRunWidget(data: { tileCoords: string; tileTitle: string }) { + const widget = { + id: `run-${Date.now()}`, + type: 'run' as const, + data, + priority: 'action' as const, + timestamp: new Date() + } + dispatch({ + type: 'widget_created' as const, + payload: { widget }, + id: `chat-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + timestamp: new Date(), + actor: 'system' as const + }); + }, + showRunsListWidget() { + const widget = { + id: `runs-list-${Date.now()}`, + type: 'runs-list' as const, + data: {}, + priority: 'action' as const, + timestamp: new Date() + } + dispatch({ + type: 'widget_created' as const, + payload: { widget }, + id: `chat-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + timestamp: new Date(), + actor: 'system' as const + }); + }, closeWidget(widgetId: string) { dispatch({ type: 'widget_closed', @@ -97,15 +129,20 @@ export function createWidgetOperations(dispatch: (event: ChatEvent) => void) { actor: 'assistant' as const, }); }, - updateToolCallWidget(toolCallId: string, result: string, success: boolean) { - const payload: WidgetResolvedPayload = { - widgetId: `tool-call-${toolCallId}`, - result, - status: success ? 'completed' : 'failed', - }; + updateToolCallWidget(toolCallId: string, result: string, success: boolean, finalArguments?: Record<string, unknown>) { + // Use widget_updated instead of widget_resolved to keep the widget visible + // widget_resolved marks widgets as 'completed' which removes them from view dispatch({ - type: 'widget_resolved' as const, - payload, + type: 'widget_updated' as const, + payload: { + widgetId: `tool-call-${toolCallId}`, + updates: { + result, + status: success ? 'completed' : 'failed', + // Update arguments with final accumulated value (args are streamed via deltas) + ...(finalArguments && Object.keys(finalArguments).length > 0 ? { arguments: finalArguments } : {}), + }, + }, id: `chat-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, timestamp: new Date(), actor: 'assistant' as const, diff --git a/src/app/map/Chat/_state/_selectors/streaming-message-handlers.ts b/src/app/map/Chat/_state/_selectors/streaming-message-handlers.ts index 3f3ecb24d..6c89e74f2 100644 --- a/src/app/map/Chat/_state/_selectors/streaming-message-handlers.ts +++ b/src/app/map/Chat/_state/_selectors/streaming-message-handlers.ts @@ -5,8 +5,22 @@ import type { StreamingMessageDeltaPayload, StreamingMessageEndPayload, StreamingMessagePromptPayload, + ToolCallStartPayload, + ToolCallEndPayload, + ToolCallData, } from '~/app/map/Chat/_state/_events'; +/** + * State for tracking a single tool call during streaming + */ +interface StreamingToolCallState { + toolCallId: string; + toolName: string; + arguments: Record<string, unknown>; + status: 'running' | 'completed' | 'failed'; + result?: string; +} + /** * State for tracking streaming messages during derivation */ @@ -17,6 +31,8 @@ export interface StreamingMessageState { model?: string; /** The hexecute prompt for task executions */ prompt?: string; + /** Tool calls that occurred during this stream */ + toolCalls: Map<string, StreamingToolCallState>; } /** @@ -44,6 +60,7 @@ export function handleStreamingMessageEvents( startEventId: event.id, timestamp: event.timestamp, model: payload.model, + toolCalls: new Map(), }); break; } @@ -73,6 +90,41 @@ export function handleStreamingMessageEvents( break; } + case 'tool_call_start': { + const payload = event.payload as ToolCallStartPayload; + if (!payload || typeof payload !== 'object' || !('streamId' in payload) || !('toolCallId' in payload)) { + return; + } + const currentState = streamingState.get(payload.streamId); + if (currentState) { + // Add new tool call to the stream's tool calls + currentState.toolCalls.set(payload.toolCallId, { + toolCallId: payload.toolCallId, + toolName: payload.toolName, + arguments: payload.arguments ?? {}, + status: 'running', + }); + } + break; + } + + case 'tool_call_end': { + const payload = event.payload as ToolCallEndPayload; + if (!payload || typeof payload !== 'object' || !('streamId' in payload) || !('toolCallId' in payload)) { + return; + } + const currentState = streamingState.get(payload.streamId); + if (currentState) { + const toolCall = currentState.toolCalls.get(payload.toolCallId); + if (toolCall) { + // Update tool call status and result + toolCall.status = payload.success ? 'completed' : 'failed'; + toolCall.result = payload.result; + } + } + break; + } + case 'streaming_message_end': { const payload = event.payload as StreamingMessageEndPayload; if (!payload || typeof payload !== 'object' || !('streamId' in payload)) { @@ -84,6 +136,11 @@ export function handleStreamingMessageEvents( const messageTimestamp = streamState?.timestamp ?? event.timestamp; const messageId = streamState?.startEventId ?? event.id; + // Convert tool calls Map to array for the message + const toolCalls: ToolCallData[] = streamState?.toolCalls + ? Array.from(streamState.toolCalls.values()) + : []; + // Add finalized message to messages array with isStreaming: false const finalizedMessage: Message & { isStreaming: false } = { id: messageId, @@ -92,6 +149,7 @@ export function handleStreamingMessageEvents( timestamp: messageTimestamp, isStreaming: false, prompt: streamState?.prompt, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, }; messages.push(finalizedMessage); @@ -112,6 +170,11 @@ export function buildInProgressStreamingMessages( const inProgressMessages: (Message & { isStreaming?: boolean })[] = []; for (const [streamId, state] of streamingState.entries()) { + // Convert tool calls Map to array for the message + const toolCalls: ToolCallData[] = state.toolCalls + ? Array.from(state.toolCalls.values()) + : []; + // Include all streaming messages, even if empty (to show "streaming" indicator) inProgressMessages.push({ id: `streaming-${streamId}`, @@ -120,6 +183,7 @@ export function buildInProgressStreamingMessages( timestamp: state.timestamp, isStreaming: true, prompt: state.prompt, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, }); } diff --git a/src/app/map/Chat/_state/_selectors/widget-selectors.ts b/src/app/map/Chat/_state/_selectors/widget-selectors.ts index 2090180e9..8c75b5c5f 100644 --- a/src/app/map/Chat/_state/_selectors/widget-selectors.ts +++ b/src/app/map/Chat/_state/_selectors/widget-selectors.ts @@ -425,12 +425,15 @@ export function deriveActiveWidgets(events: ChatEvent[]): Widget[] { // Apply updates to widgets widgets = _applyWidgetUpdates(widgets, widgetUpdates); - // Return only the most recent widget of each type (except AI responses and tool-call which should all persist) + // Filter out tool-call widgets - they are now embedded inside messages + const widgetsWithoutToolCalls = widgets.filter(widget => widget.type !== 'tool-call'); + + // Return only the most recent widget of each type (except AI responses which should all persist) const latestWidgets = new Map<string, Widget>(); - for (const widget of widgets) { - // Each AI response and tool-call widget should be unique (keep all of them) - const key = widget.type === 'ai-response' || widget.type === 'tool-call' + for (const widget of widgetsWithoutToolCalls) { + // Each AI response widget should be unique (keep all of them) + const key = widget.type === 'ai-response' ? widget.id // Use widget ID to keep all of these : widget.type === 'tile' ? `${widget.type}-${(widget.data as TileSelectedPayload).tileId}` diff --git a/src/app/map/Chat/_state/index.ts b/src/app/map/Chat/_state/index.ts index 5811520aa..160228101 100644 --- a/src/app/map/Chat/_state/index.ts +++ b/src/app/map/Chat/_state/index.ts @@ -5,4 +5,4 @@ export * from '~/app/map/Chat/_state/core'; export type { useChatOperations } from '~/app/map/Chat/_state/_operations'; // Re-export types from events -export type { Message } from '~/app/map/Chat/_state/_events'; \ No newline at end of file +export type { Message, ToolCallWidgetData } from '~/app/map/Chat/_state/_events'; \ No newline at end of file diff --git a/src/app/map/_components/MapUI.tsx b/src/app/map/_components/MapUI.tsx index 1097f6df7..93bcc6fe4 100644 --- a/src/app/map/_components/MapUI.tsx +++ b/src/app/map/_components/MapUI.tsx @@ -84,19 +84,6 @@ function _createMapUIHandlers( }); }; - const handleDeleteHexplanClick = (tileData: TileData) => { - eventBus.emit({ - type: 'map.delete_children_requested', - source: 'canvas', - payload: { - tileId: tileData.metadata.coordId, - tileName: tileData.data.title, - directionType: 'hexPlan', - }, - timestamp: new Date(), - }); - }; - const handleCreateClick = (_tileData: TileData) => { // TODO: Implement create functionality }; @@ -109,7 +96,6 @@ function _createMapUIHandlers( handleDeleteClick, handleDeleteChildrenClick, handleDeleteComposedClick, - handleDeleteHexplanClick, handleCreateClick, }; } @@ -239,7 +225,6 @@ export function MapUI({ centerParam: _centerParam }: MapUIProps) { handleDeleteClick, handleDeleteChildrenClick, handleDeleteComposedClick, - handleDeleteHexplanClick, handleCreateClick, } = _createMapUIHandlers( navigateToItem, @@ -304,6 +289,19 @@ export function MapUI({ centerParam: _centerParam }: MapUIProps) { }); }, [eventBus]); + // Run handler for SYSTEM tiles - emits event to open RunWidget + const handleRunClick = useCallback((tileData: TileData) => { + eventBus.emit({ + type: 'map.run_widget_requested', + source: 'canvas', + payload: { + tileCoords: tileData.metadata.coordId, + tileTitle: tileData.data.title, + }, + timestamp: new Date(), + }); + }, [eventBus]); + // Composition state checkers const hasComposition = (coordId: string): boolean => { // Check if tile has any composed children (negative directions) @@ -322,10 +320,6 @@ export function MapUI({ centerParam: _centerParam }: MapUIProps) { const isCenterTile = tileData.metadata.coordId === centerCoordinate; if (!isCenterTile) return false; - // User tiles (tiles with empty path) cannot have composition - const isUserTile = tileData.metadata.coordinates.path.length === 0; - if (isUserTile) return false; - // Check if tile has composition children const hasComp = hasComposition(tileData.metadata.coordId); @@ -348,7 +342,6 @@ export function MapUI({ centerParam: _centerParam }: MapUIProps) { onDeleteClick={handleDeleteClick} onDeleteChildrenClick={handleDeleteChildrenClick} onDeleteComposedClick={handleDeleteComposedClick} - onDeleteHexplanClick={handleDeleteHexplanClick} onCompositionToggle={handleCompositionToggle} onSetVisibility={handleSetVisibility} onSetVisibilityWithDescendants={handleSetVisibilityWithDescendants} @@ -359,6 +352,7 @@ export function MapUI({ centerParam: _centerParam }: MapUIProps) { onRemoveFavorite={handleRemoveFavorite} isFavorited={isFavorited} onEditShortcut={handleEditShortcut} + onRunClick={handleRunClick} > <> {/* Canvas layer - extends full width, positioned behind chat panel */} diff --git a/src/app/map/_hooks/__tests__/use-run.test.ts b/src/app/map/_hooks/__tests__/use-run.test.ts new file mode 100644 index 000000000..a959f0f3f --- /dev/null +++ b/src/app/map/_hooks/__tests__/use-run.test.ts @@ -0,0 +1,453 @@ +import '~/test/setup' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' + +/** + * Unit tests for useRun hook + * + * This hook manages SYSTEM tile execution via the agentic.run tRPC mutation. + * It handles: + * - Single step execution per call (external orchestration) + * - Status tracking (idle, running, blocked, complete, error) + * - Callbacks for step completion, run completion, blocked state, and errors + * + * Test categories: + * 1. Hook initialization and return values + * 2. Run function behavior + * 3. Completion handling + * 4. Blocked state handling + * 5. Error handling + */ + +// ============================================================================= +// Mock tRPC +// ============================================================================= + +const mockMutateAsync = vi.fn() +const mockMutate = vi.fn() + +vi.mock('~/commons/trpc/react', () => ({ + api: { + agentic: { + run: { + useMutation: vi.fn(() => ({ + mutateAsync: mockMutateAsync, + mutate: mockMutate, + isPending: false, + isError: false, + error: null, + })), + }, + }, + }, +})) + +// Import AFTER mock setup +import { useRun } from '~/app/map/_hooks/use-run' + +describe('useRun', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMutateAsync.mockReset() + }) + + // =========================================================================== + // 1. Hook Initialization and Return Values + // =========================================================================== + describe('Hook Initialization', () => { + it('should return initial state with runStatus idle', () => { + const { result } = renderHook(() => useRun()) + + expect(result.current.runStatus).toBe('idle') + expect(result.current.currentStep).toBeNull() + expect(result.current.blockageReason).toBeNull() + expect(result.current.isRunning).toBe(false) + expect(result.current.error).toBeNull() + expect(typeof result.current.run).toBe('function') + }) + + it('should accept optional callbacks', () => { + const callbacks = { + onStepComplete: vi.fn(), + onRunComplete: vi.fn(), + onBlocked: vi.fn(), + onError: vi.fn(), + } + + const { result } = renderHook(() => useRun(callbacks)) + + expect(result.current.runStatus).toBe('idle') + }) + }) + + // =========================================================================== + // 2. Run Function Behavior + // =========================================================================== + describe('run function', () => { + it('should call agentic.run with coords', async () => { + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'open', + stepExecuted: 'userId,0:1,1', + stepResult: 'completed', + blockageReason: null, + response: 'Step completed', + isComplete: false, + }) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + coords: 'userId,0:1', + instruction: undefined, + }) + }) + + it('should call agentic.run with coords and instruction', async () => { + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'open', + stepExecuted: 'userId,0:1,1', + stepResult: 'completed', + blockageReason: null, + response: 'Step completed', + isComplete: false, + }) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1', 'Focus on error handling') + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + coords: 'userId,0:1', + instruction: 'Focus on error handling', + }) + }) + + it('should update runStatus to running during execution', async () => { + let resolvePromise: (value: unknown) => void + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve + }) + mockMutateAsync.mockReturnValueOnce(pendingPromise) + + const { result } = renderHook(() => useRun()) + + act(() => { + void result.current.run('userId,0:1') + }) + + await waitFor(() => { + expect(result.current.runStatus).toBe('running') + expect(result.current.isRunning).toBe(true) + }) + + // Resolve the promise to cleanup + await act(async () => { + resolvePromise!({ + runId: 'run_123', + runStatus: 'open', + stepExecuted: 'userId,0:1,1', + stepResult: 'completed', + blockageReason: null, + response: 'Done', + isComplete: true, + }) + }) + }) + + it('should update currentStep from response', async () => { + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'open', + stepExecuted: 'userId,0:1,2', + stepResult: 'completed', + blockageReason: null, + response: 'Step completed', + isComplete: false, + }) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.currentStep).toBe('userId,0:1,2') + }) + + it('should call onStepComplete callback', async () => { + const onStepComplete = vi.fn() + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'open', + stepExecuted: 'userId,0:1,2', + stepTitle: 'Test Step', + stepResult: 'completed', + blockageReason: null, + response: 'Step completed', + isComplete: false, + hexecutePrompt: 'Test prompt', + stepHexplanContent: 'Hexplan content', + }) + + const { result } = renderHook(() => useRun({ onStepComplete })) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(onStepComplete).toHaveBeenCalledWith('userId,0:1,2', 'Test Step', 'Step completed', 'Hexplan content') + }) + }) + + // =========================================================================== + // 3. Completion Handling + // =========================================================================== + describe('completion', () => { + it('should set runStatus to complete when isComplete is true', async () => { + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'closed', + stepExecuted: null, + stepResult: null, + blockageReason: null, + response: null, + isComplete: true, + }) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.runStatus).toBe('complete') + expect(result.current.isRunning).toBe(false) + }) + + it('should call onRunComplete callback when complete', async () => { + const onRunComplete = vi.fn() + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'closed', + stepExecuted: null, + stepResult: null, + blockageReason: null, + response: null, + isComplete: true, + }) + + const { result } = renderHook(() => useRun({ onRunComplete })) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(onRunComplete).toHaveBeenCalled() + }) + + it('should return to idle status after step completes without isComplete', async () => { + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'open', + stepExecuted: 'userId,0:1,1', + stepResult: 'completed', + blockageReason: null, + response: 'Done', + isComplete: false, + }) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.runStatus).toBe('idle') + expect(result.current.isRunning).toBe(false) + }) + }) + + // =========================================================================== + // 4. Blocked State Handling + // =========================================================================== + describe('blocked handling', () => { + it('should set runStatus to blocked when stepResult is blocked', async () => { + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'blocked', + stepExecuted: 'userId,0:1,1', + stepResult: 'blocked', + blockageReason: 'Missing API key', + response: 'Could not proceed', + isComplete: false, + }) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.runStatus).toBe('blocked') + }) + + it('should store blockageReason from response', async () => { + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'blocked', + stepExecuted: 'userId,0:1,1', + stepResult: 'blocked', + blockageReason: 'Missing API key', + response: 'Could not proceed', + isComplete: false, + }) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.blockageReason).toBe('Missing API key') + }) + + it('should call onBlocked callback with reason', async () => { + const onBlocked = vi.fn() + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'blocked', + stepExecuted: 'userId,0:1,1', + stepTitle: 'Blocked Step', + stepResult: 'blocked', + blockageReason: 'Missing API key', + response: 'Could not proceed', + isComplete: false, + hexecutePrompt: 'Blocked prompt', + stepHexplanContent: 'Blocked hexplan', + }) + + const { result } = renderHook(() => useRun({ onBlocked })) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(onBlocked).toHaveBeenCalledWith( + 'Missing API key', + 'userId,0:1,1', + 'Blocked Step', + 'Blocked prompt', + 'Could not proceed', + 'Blocked hexplan', + 'run_123' + ) + }) + }) + + // =========================================================================== + // 5. Error Handling + // =========================================================================== + describe('error handling', () => { + it('should set runStatus to error on tRPC error', async () => { + const error = new Error('Network error') + mockMutateAsync.mockRejectedValueOnce(error) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.runStatus).toBe('error') + expect(result.current.isRunning).toBe(false) + }) + + it('should store error object', async () => { + const error = new Error('Network error') + mockMutateAsync.mockRejectedValueOnce(error) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.error).toBe(error) + }) + + it('should call onError callback', async () => { + const onError = vi.fn() + const error = new Error('Network error') + mockMutateAsync.mockRejectedValueOnce(error) + + const { result } = renderHook(() => useRun({ onError })) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(onError).toHaveBeenCalledWith(error) + }) + + it('should handle validation error from server', async () => { + const error = new Error('Only SYSTEM tiles or custom types can be run') + mockMutateAsync.mockRejectedValueOnce(error) + + const { result } = renderHook(() => useRun()) + + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.runStatus).toBe('error') + expect(result.current.error?.message).toBe('Only SYSTEM tiles or custom types can be run') + }) + }) + + // =========================================================================== + // 6. Reset Functionality + // =========================================================================== + describe('reset functionality', () => { + it('should clear error and blockage state on new run', async () => { + const error = new Error('Network error') + mockMutateAsync.mockRejectedValueOnce(error) + + const { result } = renderHook(() => useRun()) + + // First run fails + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.error).toBe(error) + expect(result.current.runStatus).toBe('error') + + // Reset mock for second call + mockMutateAsync.mockResolvedValueOnce({ + runId: 'run_123', + runStatus: 'open', + stepExecuted: 'userId,0:1,1', + stepResult: 'completed', + blockageReason: null, + response: 'Done', + isComplete: false, + }) + + // Second run succeeds - should clear error + await act(async () => { + await result.current.run('userId,0:1') + }) + + expect(result.current.error).toBeNull() + expect(result.current.runStatus).toBe('idle') + }) + }) +}) diff --git a/src/app/map/_hooks/use-run.ts b/src/app/map/_hooks/use-run.ts new file mode 100644 index 000000000..46b1bf80d --- /dev/null +++ b/src/app/map/_hooks/use-run.ts @@ -0,0 +1,152 @@ +import { useState, useCallback } from 'react' +import { api } from '~/commons/trpc/react' + +/** + * Options for configuring the useRun hook callbacks + */ +interface UseRunOptions { + /** Called when a step starts executing with its prompt */ + onStepStart?: (stepCoords: string, stepTitle: string, prompt: string) => void + /** Called when a step completes successfully */ + onStepComplete?: (stepCoords: string, stepTitle: string, response?: string, hexplanContent?: string) => void + /** Called when the entire run completes */ + onRunComplete?: () => void + /** Called when execution is blocked */ + onBlocked?: (reason: string, stepCoords: string, stepTitle: string, prompt: string, response?: string, hexplanContent?: string, runId?: string) => void + /** Called when an error occurs */ + onError?: (error: Error) => void +} + +type RunStatus = 'idle' | 'running' | 'blocked' | 'complete' | 'error' + +/** + * Return type for the useRun hook + */ +interface UseRunReturn { + /** Execute a single step of the run */ + run: (coords: string, instruction?: string) => Promise<void> + /** Current run status */ + runStatus: RunStatus + /** Currently executing step coordinates */ + currentStep: string | null + /** Reason for blockage (if blocked) */ + blockageReason: string | null + /** Whether a run is in progress */ + isRunning: boolean + /** Error that occurred during execution */ + error: Error | null + /** The hexecute prompt used for the last executed step */ + lastPrompt: string | null +} + +/** + * Hook for managing SYSTEM tile execution via the agentic.run tRPC mutation. + * + * Each call to `run()` executes ONE step and returns. The caller controls + * the execution loop, allowing for UI updates between steps. + * + * @example + * ```typescript + * const { run, runStatus, currentStep, blockageReason } = useRun({ + * onStepComplete: (step) => console.log('Completed:', step), + * onRunComplete: () => toast.success('Run finished!'), + * onBlocked: (reason) => toast.error(`Blocked: ${reason}`) + * }) + * + * // Trigger execution + * await run('userId,0:6') + * + * // Check status + * if (runStatus === 'blocked') { + * // Show blockage UI + * } + * ``` + */ +export function useRun(options?: UseRunOptions): UseRunReturn { + const [runStatus, setRunStatus] = useState<RunStatus>('idle') + const [currentStep, setCurrentStep] = useState<string | null>(null) + const [blockageReason, setBlockageReason] = useState<string | null>(null) + const [error, setError] = useState<Error | null>(null) + const [lastPrompt, setLastPrompt] = useState<string | null>(null) + + const runMutation = api.agentic.run.useMutation() + + const run = useCallback( + async (coords: string, instruction?: string) => { + // Reset state before new run + setError(null) + setBlockageReason(null) + setRunStatus('running') + + try { + const result = await runMutation.mutateAsync({ + coords, + instruction, + }) + + // Extract values for cleaner access (avoids TypeScript narrowing issues) + const stepExecuted = result.stepExecuted + const stepTitle = result.stepTitle ?? stepExecuted ?? '' + const hexecutePrompt = result.hexecutePrompt + const agentResponse = result.response + const stepHexplanContent = result.stepHexplanContent + + // Update current step and prompt + setCurrentStep(stepExecuted) + setLastPrompt(hexecutePrompt ?? null) + + // Notify step started with prompt + if (stepExecuted && hexecutePrompt) { + options?.onStepStart?.(stepExecuted, stepTitle, hexecutePrompt) + } + + // Handle completion + if (result.isComplete) { + setRunStatus('complete') + options?.onRunComplete?.() + return + } + + // Handle blocked state + if (result.stepResult === 'blocked') { + setRunStatus('blocked') + setBlockageReason(result.blockageReason) + options?.onBlocked?.( + result.blockageReason ?? 'Unknown blockage', + stepExecuted ?? '', + stepTitle, + hexecutePrompt ?? '', + agentResponse ?? undefined, + stepHexplanContent ?? undefined, + result.runId + ) + return + } + + // Step completed successfully + if (stepExecuted) { + options?.onStepComplete?.(stepExecuted, stepTitle, agentResponse ?? undefined, stepHexplanContent ?? undefined) + } + + // Return to idle - caller decides whether to continue + setRunStatus('idle') + } catch (err) { + const errorObj = err instanceof Error ? err : new Error('Unknown error') + setError(errorObj) + setRunStatus('error') + options?.onError?.(errorObj) + } + }, + [runMutation, options] + ) + + return { + run, + runStatus, + currentStep, + blockageReason, + isRunning: runStatus === 'running', + error, + lastPrompt, + } +} diff --git a/src/app/map/types/event-schemas.ts b/src/app/map/types/event-schemas.ts index 354ddc99e..558c0e8a8 100644 --- a/src/app/map/types/event-schemas.ts +++ b/src/app/map/types/event-schemas.ts @@ -160,6 +160,11 @@ const mapFavoritesWidgetRequestedPayloadSchema = z.object({ editShortcutForMapItemId: z.string().optional(), }); +const mapRunWidgetRequestedPayloadSchema = z.object({ + tileCoords: z.string(), + tileTitle: z.string(), +}); + // Specific event schemas export const mapTileSelectedEventSchema = baseEventSchema.extend({ type: z.literal('map.tile_selected'), @@ -312,6 +317,12 @@ export const mapFavoritesWidgetRequestedEventSchema = baseEventSchema.extend({ payload: mapFavoritesWidgetRequestedPayloadSchema, }); +export const mapRunWidgetRequestedEventSchema = baseEventSchema.extend({ + type: z.literal('map.run_widget_requested'), + source: z.literal('canvas'), + payload: mapRunWidgetRequestedPayloadSchema, +}); + // Union of all event schemas for validation export const appEventSchema = z.discriminatedUnion('type', [ // Notification events @@ -341,6 +352,7 @@ export const appEventSchema = z.discriminatedUnion('type', [ mapDeleteChildrenRequestedEventSchema, mapCreateRequestedEventSchema, mapFavoritesWidgetRequestedEventSchema, + mapRunWidgetRequestedEventSchema, ]); // Helper function to validate events diff --git a/src/app/map/types/index.ts b/src/app/map/types/index.ts index 584c409cb..4c111dc26 100644 --- a/src/app/map/types/index.ts +++ b/src/app/map/types/index.ts @@ -64,6 +64,7 @@ export { mapDeleteChildrenRequestedEventSchema, mapCreateRequestedEventSchema, mapFavoritesWidgetRequestedEventSchema, + mapRunWidgetRequestedEventSchema, appEventSchema, validateEvent, safeValidateEvent diff --git a/src/app/services/mcp/handlers/tools.ts b/src/app/services/mcp/handlers/tools.ts index 37d99bee5..ea4463eb2 100644 --- a/src/app/services/mcp/handlers/tools.ts +++ b/src/app/services/mcp/handlers/tools.ts @@ -747,7 +747,7 @@ PROMPT STRUCTURE: 1. <context>: Context children (-1 to -6) providing reference materials, constraints, templates 2. <subtasks>: Subtask children (1-6) showing decomposed work units 3. <task>: The tile's goal (title) and requirements (content) -4. <hexplan>: Direction-0 child tracking execution state and guiding agent decisions +4. <hexplan>: Run-linked execution state and guiding agent decisions HEXPLAN GENERATION: - Parent tiles (with subtasks): Hexplan is auto-generated listing children as steps @@ -756,7 +756,7 @@ HEXPLAN GENERATION: COORDINATES FORMAT: "userId,groupId:path" (e.g., "abc123,0:6" or "userIdString,0:1,1") -HEXPLAN UPDATES: Agents update direction-0 tiles using standard updateItem MCP tool with emoji-prefixed status: +HEXPLAN UPDATES: When runId is provided, agents update hexplans using updateRunHexplan MCP tool with emoji-prefixed status: - 🟑 STARTED: Task began - βœ… COMPLETED: Task finished - πŸ”΄ BLOCKED: Task stuck @@ -773,10 +773,9 @@ Returns XML-formatted prompt ready for agent execution.`, type: "string", description: "Optional runtime instruction to include in execution" }, - deleteHexplan: { - type: "boolean", - description: "If true, removes all hexplan tiles (direction-0) before execution for a fresh start. Default: false", - default: false + runId: { + type: "string", + description: "Run ID for run-linked hexplan storage. When provided, hexplans are stored per-run instead of as direction-0 tiles." } }, required: ["taskCoords"], @@ -785,7 +784,7 @@ Returns XML-formatted prompt ready for agent execution.`, const argsObj = args as Record<string, unknown>; const taskCoords = argsObj?.taskCoords as string; const instruction = argsObj?.instruction as string | undefined; - const deleteHexplan = argsObj?.deleteHexplan as boolean | undefined; + const runId = argsObj?.runId as string | undefined; if (!taskCoords) { throw new Error("taskCoords parameter is required"); @@ -794,7 +793,131 @@ Returns XML-formatted prompt ready for agent execution.`, const result = await caller.agentic.hexecute({ taskCoords, instruction, - deleteHexplan: deleteHexplan ?? false + runId + }); + + return result; + }, + }, + + { + name: "updateRunHexplan", + description: `Update hexplan content for a specific run and coordinates. + +Use this tool to update the hexplan when executing tasks with a runId. The hexplan tracks execution state and progress. + +HEXPLAN STATUS MARKERS: +- 🟑 STARTED: Task began +- βœ… COMPLETED: Task finished successfully +- πŸ”΄ BLOCKED: Task stuck, needs intervention + +Example usage: + updateRunHexplan({ + runId: "abc123", + coords: "userId,0:6,3", + content: "βœ… COMPLETED: Implemented the feature\\nπŸ“‹ Review code changes" + })`, + inputSchema: { + type: "object", + properties: { + runId: { + type: "string", + description: "The run ID to update hexplan for" + }, + coords: { + type: "string", + description: "Coordinates of the tile whose hexplan to update (format: 'userId,groupId:path')" + }, + content: { + type: "string", + description: "New hexplan content with status markers" + } + }, + required: ["runId", "coords", "content"], + }, + handler: async (args: unknown, caller: TRPCCaller) => { + const argsObj = args as Record<string, unknown>; + const runId = argsObj?.runId as string; + const coords = argsObj?.coords as string; + const content = argsObj?.content as string; + + if (!runId) { + throw new Error("runId parameter is required"); + } + if (!coords) { + throw new Error("coords parameter is required"); + } + if (content === undefined) { + throw new Error("content parameter is required"); + } + + const result = await caller.agentic.updateRunHexplan({ + runId, + coords, + content + }); + + return result; + }, + }, + + { + name: "run", + description: `Execute the next step of a SYSTEM tile run. + +Each call executes ONE leaf tile and returns. Call repeatedly until isComplete is true. + +WORKFLOW: +1. First call creates a run and executes the first leaf tile +2. Subsequent calls execute the next incomplete leaf +3. When all leaves complete, run is closed and isComplete=true +4. If a step blocks, runStatus='blocked' and you can resume with the same coords + +Returns: +- runId: Unique identifier for this run +- runStatus: Current run state (open/blocked/closed) +- stepExecuted: Coordinates of the step just executed +- stepResult: Whether the step completed or blocked +- blockageReason: Why the step blocked (if applicable) +- response: Agent's response text +- isComplete: True when all steps are done + +Usage pattern: +\`\`\` +let result = run({ coords: "userId,0:6" }) +while (!result.isComplete) { + if (result.runStatus === 'blocked') { + // Handle blockage (wait for human fix) + } + result = run({ coords: "userId,0:6" }) +} +\`\`\``, + inputSchema: { + type: "object", + properties: { + coords: { + type: "string", + description: "Root SYSTEM tile coordinates (e.g., 'userId,0:6,3')" + }, + instruction: { + type: "string", + description: "Optional instruction for the current step" + } + }, + required: ["coords"], + }, + handler: async (args: unknown, caller: TRPCCaller) => { + const argsObj = args as Record<string, unknown>; + const coords = argsObj?.coords as string; + const instruction = argsObj?.instruction as string | undefined; + + if (!coords) { + throw new Error("coords parameter is required"); + } + + const result = await caller.agentic.run({ + coords, + instruction }); return result; diff --git a/src/lib/debug/with-logging.ts b/src/lib/debug/with-logging.ts new file mode 100644 index 000000000..9788c0784 --- /dev/null +++ b/src/lib/debug/with-logging.ts @@ -0,0 +1,11 @@ +/** + * Wraps a service export with logging instrumentation. + * + * All *Service exports from domain index files must go through this wrapper. + * This ensures a single extension point for adding observability to service calls + * without changing any consumer code. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function withLogging<T>(serviceName: string, service: T): T { + return service; +} diff --git a/src/lib/domains/agentic/index.ts b/src/lib/domains/agentic/index.ts index 8c48e3e45..7622cfc95 100644 --- a/src/lib/domains/agentic/index.ts +++ b/src/lib/domains/agentic/index.ts @@ -1,22 +1,44 @@ /** * Public API for Agentic Domain - * + * * Consumers: App layer (Chat), tRPC API, EventBus handlers */ +import { withLogging } from '~/lib/debug/with-logging'; + // Domain services -export { AgenticService } from '~/lib/domains/agentic/services/agentic.service'; -export { createAgenticService as AgenticFactory } from '~/lib/domains/agentic/services/agentic.factory'; -export { createAgenticService, createAgenticServiceAsync } from '~/lib/domains/agentic/services'; -export { PreviewGeneratorService } from '~/lib/domains/agentic/services/preview-generator.service'; +import { AgenticService as _AgenticService } from '~/lib/domains/agentic/services/agentic.service'; +export const AgenticService = withLogging("AgenticService", _AgenticService); + +import { createAgenticService as _createAgenticService } from '~/lib/domains/agentic/services/agentic.factory'; +export { _createAgenticService as AgenticFactory }; +export const createAgenticService = withLogging("createAgenticService", _createAgenticService); + +import { createAgenticServiceAsync as _createAgenticServiceAsync } from '~/lib/domains/agentic/services'; +export const createAgenticServiceAsync = withLogging("createAgenticServiceAsync", _createAgenticServiceAsync); + +import { PreviewGeneratorService as _PreviewGeneratorService } from '~/lib/domains/agentic/services/preview-generator.service'; +export const PreviewGeneratorService = withLogging("PreviewGeneratorService", _PreviewGeneratorService); export type { GeneratePreviewInput, GeneratePreviewResult } from '~/lib/domains/agentic/services/preview-generator.service'; +import { RunService as _RunService } from '~/lib/domains/agentic/services/_run-services'; +export const RunService = withLogging("RunService", _RunService); +export type { Run, RunStatus, ExecutionLogEntry, ToolCallEntry } from '~/lib/domains/agentic/services/_run-services'; + // Context builders -export { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service'; -export { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service'; -export { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service'; -export { ContextSerializerService } from '~/lib/domains/agentic/services/context-serializer.service'; -export type { TokenizerService } from '~/lib/domains/agentic/services/tokenizer.service'; +import { CanvasContextBuilder as _CanvasContextBuilder } from '~/lib/domains/agentic/services/_context/canvas-context-builder.service'; +export const CanvasContextBuilder = withLogging("CanvasContextBuilder", _CanvasContextBuilder); + +import { ChatContextBuilder as _ChatContextBuilder } from '~/lib/domains/agentic/services/_context/chat-context-builder.service'; +export const ChatContextBuilder = withLogging("ChatContextBuilder", _ChatContextBuilder); + +import { ContextCompositionService as _ContextCompositionService } from '~/lib/domains/agentic/services/_context/context-composition.service'; +export const ContextCompositionService = withLogging("ContextCompositionService", _ContextCompositionService); + +import { ContextSerializerService as _ContextSerializerService } from '~/lib/domains/agentic/services/_context/context-serializer.service'; +export const ContextSerializerService = withLogging("ContextSerializerService", _ContextSerializerService); + +export type { TokenizerService } from '~/lib/domains/agentic/services/_context/tokenizer.service'; // Repository implementations (for service instantiation) export { OpenRouterRepository, ClaudeAgentSDKRepository, QueuedLLMRepository } from '~/lib/domains/agentic/repositories'; @@ -86,7 +108,8 @@ export { sandboxSessionManager, SandboxSessionManager } from '~/lib/domains/agen export type { SandboxSession, SandboxSessionManagerConfig, ISandboxSessionManager } from '~/lib/domains/agentic/services/sandbox-session'; // Task execution (pure agentic streaming) -export { executeTaskStreaming } from '~/lib/domains/agentic/services/task-execution.service'; +import { executeTaskStreaming as _executeTaskStreaming } from '~/lib/domains/agentic/services/task-execution.service'; +export const executeTaskStreaming = withLogging("executeTaskStreaming", _executeTaskStreaming); export type { TaskExecutionInput, TaskExecutionCallbacks, @@ -98,4 +121,31 @@ export type { } from '~/lib/domains/agentic/services/task-execution.service'; // Note: For hexplan generation utilities (generateParentHexplanContent, generateLeafHexplanContent), -// import directly from '~/lib/domains/agentic/utils' - the domain index should not reexport utils. \ No newline at end of file +// import directly from '~/lib/domains/agentic/utils' - the domain index should not reexport utils. + +// Template services +import { + TemplateAllowlistService as _TemplateAllowlistService, + TemplateResolverService as _TemplateResolverService, + PromptTemplateService as _PromptTemplateService, +} from '~/lib/domains/agentic/services/_templates'; +export const TemplateAllowlistService = withLogging("TemplateAllowlistService", _TemplateAllowlistService); +export const TemplateResolverService = withLogging("TemplateResolverService", _TemplateResolverService); +export const PromptTemplateService = withLogging("PromptTemplateService", _PromptTemplateService); +export { + TemplateNotAllowedError, + TemplateVisibilityError, + BUILT_IN_TEMPLATES, + TemplateNotFoundError, +} from '~/lib/domains/agentic/services/_templates'; +export type { + Visibility as TemplateVisibility, + UserAllowlist, + TemplateAllowlistRepository, + TemplateData, + TemplateWithChildren, + TemplateRepository, +} from '~/lib/domains/agentic/services/_templates'; + +// Infrastructure repositories +export { DrizzleTemplateAllowlistRepository } from '~/lib/domains/agentic/infrastructure/template-allowlist'; diff --git a/src/lib/domains/agentic/infrastructure/dependencies.json b/src/lib/domains/agentic/infrastructure/dependencies.json index 9476f4772..4693b95f4 100644 --- a/src/lib/domains/agentic/infrastructure/dependencies.json +++ b/src/lib/domains/agentic/infrastructure/dependencies.json @@ -8,7 +8,8 @@ "~/server/db" ], "subsystems": [ - "./inngest" + "./inngest", + "./template-allowlist" ], "exceptions": {} } diff --git a/src/lib/domains/agentic/infrastructure/index.ts b/src/lib/domains/agentic/infrastructure/index.ts index 6b9d15c98..9636747f3 100644 --- a/src/lib/domains/agentic/infrastructure/index.ts +++ b/src/lib/domains/agentic/infrastructure/index.ts @@ -8,4 +8,7 @@ export { inngest } from '~/lib/domains/agentic/infrastructure/inngest/client'; // Background job functions -export { inngestFunctions } from '~/lib/domains/agentic/infrastructure/inngest/functions'; \ No newline at end of file +export { inngestFunctions } from '~/lib/domains/agentic/infrastructure/inngest/functions'; + +// Repository implementations +export { DrizzleTemplateAllowlistRepository } from '~/lib/domains/agentic/infrastructure/template-allowlist'; \ No newline at end of file diff --git a/src/lib/domains/agentic/infrastructure/template-allowlist/dependencies.json b/src/lib/domains/agentic/infrastructure/template-allowlist/dependencies.json new file mode 100644 index 000000000..f7a5ae6c1 --- /dev/null +++ b/src/lib/domains/agentic/infrastructure/template-allowlist/dependencies.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../../../scripts/checks/architecture/dependencies.schema.json", + "allowed": [ + "drizzle-orm", + "nanoid", + "~/server/db", + "~/lib/domains/agentic/services/_templates/template-allowlist.service" + ], + "exceptions": {} +} diff --git a/src/lib/domains/agentic/infrastructure/template-allowlist/drizzle-allowlist-repository.ts b/src/lib/domains/agentic/infrastructure/template-allowlist/drizzle-allowlist-repository.ts new file mode 100644 index 000000000..55ec43ecb --- /dev/null +++ b/src/lib/domains/agentic/infrastructure/template-allowlist/drizzle-allowlist-repository.ts @@ -0,0 +1,59 @@ +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { type db as dbType, schema } from "~/server/db"; +const { userTemplateAllowlist } = schema; +import type { + TemplateAllowlistRepository, + UserAllowlist, + Visibility, +} from "~/lib/domains/agentic/services/_templates/template-allowlist.service"; + +/** + * Drizzle ORM implementation of TemplateAllowlistRepository + * + * Handles persistence of user template allowlists using PostgreSQL via Drizzle. + */ +export class DrizzleTemplateAllowlistRepository implements TemplateAllowlistRepository { + constructor(private readonly db: typeof dbType) {} + + async getUserAllowlist(userId: string): Promise<UserAllowlist | null> { + const [result] = await this.db + .select() + .from(userTemplateAllowlist) + .where(eq(userTemplateAllowlist.userId, userId)) + .limit(1); + + if (!result) return null; + + return { + userId: result.userId, + allowedTemplates: result.allowedTemplates, + }; + } + + async saveUserAllowlist(allowlist: UserAllowlist): Promise<void> { + const existing = await this.getUserAllowlist(allowlist.userId); + + if (existing) { + await this.db + .update(userTemplateAllowlist) + .set({ + allowedTemplates: allowlist.allowedTemplates, + updatedAt: new Date(), + }) + .where(eq(userTemplateAllowlist.userId, allowlist.userId)); + } else { + await this.db.insert(userTemplateAllowlist).values({ + id: nanoid(), + userId: allowlist.userId, + allowedTemplates: allowlist.allowedTemplates, + }); + } + } + + async getTemplateVisibility(_templateName: string): Promise<Visibility> { + // For now, all templates are public + // Future: Look up template tile and return its visibility + return 'public'; + } +} diff --git a/src/lib/domains/agentic/infrastructure/template-allowlist/index.ts b/src/lib/domains/agentic/infrastructure/template-allowlist/index.ts new file mode 100644 index 000000000..756240056 --- /dev/null +++ b/src/lib/domains/agentic/infrastructure/template-allowlist/index.ts @@ -0,0 +1 @@ +export { DrizzleTemplateAllowlistRepository } from "~/lib/domains/agentic/infrastructure/template-allowlist/drizzle-allowlist-repository"; diff --git a/src/lib/domains/agentic/repositories/_helpers/stream-event-extractors.ts b/src/lib/domains/agentic/repositories/_helpers/stream-event-extractors.ts new file mode 100644 index 000000000..16b4be9aa --- /dev/null +++ b/src/lib/domains/agentic/repositories/_helpers/stream-event-extractors.ts @@ -0,0 +1,174 @@ +/** + * Helper functions for extracting data from Claude SDK stream events. + */ + +import type { ToolCallStartEvent } from '~/lib/domains/agentic/types/stream.types' + +// Return type for extractToolCallStart including content block index for correlation +export interface ToolCallStartExtraction { + event: ToolCallStartEvent + contentBlockIndex: number +} + +// Track active tool calls to correlate start/end +export interface ActiveToolCall { + toolCallId: string + toolName: string + inputJson: string + contentBlockIndex: number +} + +// Return type for input_json_delta extraction +export interface InputJsonDeltaExtraction { + contentBlockIndex: number + partialJson: string +} + +/** + * Safely extract delta text from SDK stream events. + */ +export function extractDeltaText(event: unknown): string | undefined { + if ( + event && + typeof event === 'object' && + 'type' in event && + event.type === 'content_block_delta' && + 'delta' in event && + event.delta && + typeof event.delta === 'object' && + 'text' in event.delta && + typeof event.delta.text === 'string' + ) { + return event.delta.text + } + return undefined +} + +/** + * Extract tool_use content block start events. + */ +export function extractToolCallStart(event: unknown): ToolCallStartExtraction | undefined { + if ( + event && + typeof event === 'object' && + 'type' in event && + event.type === 'content_block_start' && + 'index' in event && + typeof event.index === 'number' && + 'content_block' in event && + event.content_block && + typeof event.content_block === 'object' && + 'type' in event.content_block && + event.content_block.type === 'tool_use' + ) { + const block = event.content_block as { id?: string; name?: string; input?: unknown } + return { + event: { + type: 'tool_call_start', + toolCallId: block.id ?? '', + toolName: block.name ?? '', + arguments: JSON.stringify(block.input ?? {}) + }, + contentBlockIndex: event.index + } + } + return undefined +} + +/** + * Extract content_block_stop events that signal tool call completion. + */ +export function extractContentBlockStop(event: unknown): number | undefined { + if ( + event && + typeof event === 'object' && + 'type' in event && + event.type === 'content_block_stop' && + 'index' in event && + typeof event.index === 'number' + ) { + return event.index + } + return undefined +} + +/** + * Extract input_json_delta from content_block_delta events. + */ +export function extractInputJsonDelta(event: unknown): InputJsonDeltaExtraction | undefined { + if ( + event && + typeof event === 'object' && + 'type' in event && + event.type === 'content_block_delta' && + 'index' in event && + typeof event.index === 'number' && + 'delta' in event && + event.delta && + typeof event.delta === 'object' && + 'type' in event.delta && + event.delta.type === 'input_json_delta' && + 'partial_json' in event.delta && + typeof event.delta.partial_json === 'string' + ) { + return { + contentBlockIndex: event.index, + partialJson: event.delta.partial_json + } + } + return undefined +} + +// Return type for tool result extraction +export interface ToolResultExtraction { + toolUseId: string + isError: boolean + content: string + contentBlockIndex: number +} + +/** + * Extract tool_result content block start events. + * These appear after the SDK executes a tool and receives the result. + * Tool results come as content_block_start with type 'tool_result'. + */ +export function extractToolResult(event: unknown): ToolResultExtraction | undefined { + if ( + event && + typeof event === 'object' && + 'type' in event && + event.type === 'content_block_start' && + 'index' in event && + typeof event.index === 'number' && + 'content_block' in event && + event.content_block && + typeof event.content_block === 'object' && + 'type' in event.content_block && + event.content_block.type === 'tool_result' + ) { + const block = event.content_block as { + tool_use_id?: string + is_error?: boolean + content?: string | Array<{ type: string; text?: string }> + } + + // Content can be a string or an array of content blocks + let content = '' + if (typeof block.content === 'string') { + content = block.content + } else if (Array.isArray(block.content)) { + content = block.content + .filter((c): c is { type: string; text: string } => c.type === 'text' && typeof c.text === 'string') + .map(c => c.text) + .join('') + } + + return { + toolUseId: block.tool_use_id ?? '', + isError: block.is_error ?? false, + content, + contentBlockIndex: event.index + } + } + return undefined +} diff --git a/src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts b/src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts index 812adb435..a8d8b4ea6 100644 --- a/src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts +++ b/src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts @@ -8,7 +8,6 @@ import type { ModelInfo, LLMError } from '~/lib/domains/agentic/types/llm.types' -import type { ToolCallStartEvent } from '~/lib/domains/agentic/types/stream.types' import { loggers } from '~/lib/debug/debug-logger' import { extractSystemPrompt, @@ -17,113 +16,14 @@ import { getClaudeModels } from '~/lib/domains/agentic/repositories/_helpers/sdk-helpers' import { installAnthropicNetworkInterceptor } from '~/lib/domains/agentic/repositories/_helpers/network-interceptor' - -// Helper function to safely extract delta text from SDK events -function extractDeltaText(event: unknown): string | undefined { - if ( - event && - typeof event === 'object' && - 'type' in event && - event.type === 'content_block_delta' && - 'delta' in event && - event.delta && - typeof event.delta === 'object' && - 'text' in event.delta && - typeof event.delta.text === 'string' - ) { - return event.delta.text - } - return undefined -} - -// Return type for extractToolCallStart including content block index for correlation -interface ToolCallStartExtraction { - event: ToolCallStartEvent - contentBlockIndex: number -} - -// Helper function to extract tool_use content block start -function extractToolCallStart(event: unknown): ToolCallStartExtraction | undefined { - if ( - event && - typeof event === 'object' && - 'type' in event && - event.type === 'content_block_start' && - 'index' in event && - typeof event.index === 'number' && - 'content_block' in event && - event.content_block && - typeof event.content_block === 'object' && - 'type' in event.content_block && - event.content_block.type === 'tool_use' - ) { - const block = event.content_block as { id?: string; name?: string; input?: unknown } - return { - event: { - type: 'tool_call_start', - toolCallId: block.id ?? '', - toolName: block.name ?? '', - arguments: JSON.stringify(block.input ?? {}) - }, - contentBlockIndex: event.index - } - } - return undefined -} - -// Track active tool calls to correlate start/end -interface ActiveToolCall { - toolCallId: string - toolName: string - inputJson: string - contentBlockIndex: number -} - -// Helper to extract content_block_stop events that signal tool call completion -function extractContentBlockStop(event: unknown): number | undefined { - if ( - event && - typeof event === 'object' && - 'type' in event && - event.type === 'content_block_stop' && - 'index' in event && - typeof event.index === 'number' - ) { - return event.index - } - return undefined -} - -// Return type for input_json_delta extraction -interface InputJsonDeltaExtraction { - contentBlockIndex: number - partialJson: string -} - -// Helper function to extract input_json_delta from content_block_delta events -function extractInputJsonDelta(event: unknown): InputJsonDeltaExtraction | undefined { - if ( - event && - typeof event === 'object' && - 'type' in event && - event.type === 'content_block_delta' && - 'index' in event && - typeof event.index === 'number' && - 'delta' in event && - event.delta && - typeof event.delta === 'object' && - 'type' in event.delta && - event.delta.type === 'input_json_delta' && - 'partial_json' in event.delta && - typeof event.delta.partial_json === 'string' - ) { - return { - contentBlockIndex: event.index, - partialJson: event.delta.partial_json - } - } - return undefined -} +import { + extractDeltaText, + extractToolCallStart, + extractContentBlockStop, + extractInputJsonDelta, + extractToolResult, + type ActiveToolCall +} from '~/lib/domains/agentic/repositories/_helpers/stream-event-extractors' export class ClaudeAgentSDKRepository implements ILLMRepository { private readonly apiKey: string @@ -370,6 +270,8 @@ export class ClaudeAgentSDKRepository implements ILLMRepository { let fullContent = '' // Track active tool calls by content block index to correlate start/stop events const activeToolCalls = new Map<number, ActiveToolCall>() + // Track pending tool calls waiting for results (by toolCallId -> toolName and arguments) + const pendingToolCalls = new Map<string, { toolName: string; inputJson: string }>() // Stream chunks via callback for await (const msg of queryResult) { @@ -403,18 +305,33 @@ export class ClaudeAgentSDKRepository implements ILLMRepository { } } - // Extract content_block_stop events to signal tool call end + // Extract content_block_stop events - move tool call to pending state const stoppedBlockIndex = extractContentBlockStop(msg.event) - if (stoppedBlockIndex !== undefined && callbacks?.onToolCallEnd) { + if (stoppedBlockIndex !== undefined) { const activeCall = activeToolCalls.get(stoppedBlockIndex) if (activeCall) { + // Move to pending - we'll emit tool_call_end when we get the result + pendingToolCalls.set(activeCall.toolCallId, { + toolName: activeCall.toolName, + inputJson: activeCall.inputJson + }) activeToolCalls.delete(stoppedBlockIndex) + } + } + + // Extract tool_result events to get results/errors + const toolResult = extractToolResult(msg.event) + if (toolResult && callbacks?.onToolCallEnd) { + const pendingCall = pendingToolCalls.get(toolResult.toolUseId) + if (pendingCall) { + pendingToolCalls.delete(toolResult.toolUseId) callbacks.onToolCallEnd({ type: 'tool_call_end', - toolCallId: activeCall.toolCallId, - toolName: activeCall.toolName, - arguments: activeCall.inputJson || undefined - // Note: We don't have the result here; it comes later in the stream + toolCallId: toolResult.toolUseId, + toolName: pendingCall.toolName, + arguments: pendingCall.inputJson || undefined, + result: toolResult.isError ? undefined : toolResult.content, + error: toolResult.isError ? toolResult.content : undefined }) } } diff --git a/src/lib/domains/agentic/services/README.md b/src/lib/domains/agentic/services/README.md index 38bd06c81..0ea4d2ecb 100644 --- a/src/lib/domains/agentic/services/README.md +++ b/src/lib/domains/agentic/services/README.md @@ -13,12 +13,79 @@ Like a translation bureau that takes hexagonal map context and chat history, con - Serialize complex domain data into AI-readable formats - Select and configure LLM repositories (OpenRouter, Claude Agent SDK, or Sandbox) - Integrate with sandbox session manager for persistent sandbox reuse via `createAgenticServiceAsync` +- Resolve template tiles by name for {{@TemplateName}} expansion in prompts +- Validate user template allowlists before executing templates + +## Subsystems +- `_context/` - Context building and composition services (tokenization, serialization, canvas/chat context builders) +- `_templates/` - Template services for resolution, rendering, and allowlist validation +- `canvas-strategies/` - Strategies for selecting which canvas tiles to include +- `chat-strategies/` - Strategies for selecting which chat messages to include +- `serializers/` - Format converters (XML, narrative, minimal, structured) +- `sandbox-session/` - Sandbox session lifecycle management + +## Key Services +- `agentic.service.ts` - Main orchestrator for AI conversations +- `agentic.factory.ts` - Factory for creating AgenticService instances with optional sandbox +- `preview-generator.service.ts` - Generates previews for tiles +- `task-execution.service.ts` - Executes tasks within the agentic framework + +## Template Services (`_templates/`) + +The `_templates/` subfolder contains services for template management: + +### TemplateAllowlistService + +Validates that users can only execute templates they have explicitly allowed. Implements the User Template Allowlist Enforcement feature. + +**Purpose**: Security layer that prevents unauthorized template execution. + +**Methods**: +- `validateAllowlist(userId, templateName)` - Throws `TemplateNotAllowedError` if template is not allowed +- `isBuiltInTemplate(templateName)` - Checks if a template is built-in (always allowed) +- `getUserAllowlist(userId)` - Gets user's custom allowlist +- `getEffectiveAllowlist(userId)` - Gets combined built-in + custom templates +- `validateVisibility(templateName, tileVisibility, templateVisibility)` - Enforces visibility constraints + +**Built-in Templates** (`BUILT_IN_TEMPLATES` constant): +- `system`, `user`, `organizational`, `context` - Always allowed for all users + +**Default Behavior**: +- Anonymous users: Only built-in templates allowed +- New users (no allowlist): Only built-in templates allowed +- Users with allowlist: Built-in templates + custom allowed templates + +### TemplateResolverService + +Resolves template tiles by name from the database. Used by `buildPrompt()` to retrieve template content for `{{@TemplateName}}` expansion. + +**Methods**: +- `getTemplateByName(templateName)` - Gets template data by name +- `getTemplateWithSubTemplates(templateName)` - Gets template with its structural children + +### PromptTemplateService + +Renders prompt templates from the `prompts.constants` registry. + +**Methods**: +- `renderTemplate(templateName, variables)` - Renders a template with variable substitution + +### Error Classes + +| Error | When Thrown | +|-------|-------------| +| `TemplateNotAllowedError` | User attempts to use a template not in their allowlist | +| `TemplateVisibilityError` | Public tile attempts to use a private template | +| `TemplateNotFoundError` | Template does not exist in the database | + +### Integration with Hexecute + +When `hexecute` resolves a template reference (`{{@TemplateName}}`): +1. `TemplateAllowlistService.validateAllowlist()` checks user permission +2. `TemplateResolverService.getTemplateByName()` fetches the template content +3. Template content is substituted into the prompt ## Non-Responsibilities -- Canvas strategy implementations -> See `./canvas-strategies/` -- Chat strategy implementations -> See `./chat-strategies/` -- Context serialization formats -> See `./serializers/` -- Sandbox session lifecycle management -> See `./sandbox-session/` - Unit tests -> See `./__tests__/` - Direct AI model communication -> See `~/lib/domains/agentic/repositories` - Intent classification logic -> See `~/lib/domains/agentic/intent-classification` diff --git a/src/lib/domains/agentic/services/__tests__/agentic.service.test.ts b/src/lib/domains/agentic/services/__tests__/agentic.service.test.ts index 309db07ee..163303031 100644 --- a/src/lib/domains/agentic/services/__tests__/agentic.service.test.ts +++ b/src/lib/domains/agentic/services/__tests__/agentic.service.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { AgenticService } from '~/lib/domains/agentic/services/agentic.service' import type { ILLMRepository } from '~/lib/domains/agentic/repositories/llm.repository.interface' -import type { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service' +import type { ContextCompositionService } from '~/lib/domains/agentic/services/_context/context-composition.service' import type { EventBus } from '~/lib/utils/event-bus' import type { ComposedContext, LLMResponse, StreamChunk, ChatMessageContract } from '~/lib/domains/agentic/types' import { createMockMapContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' diff --git a/src/lib/domains/agentic/services/__tests__/canvas-context-builder.test.ts b/src/lib/domains/agentic/services/__tests__/canvas-context-builder.test.ts index eb5e5a386..d1c351a40 100644 --- a/src/lib/domains/agentic/services/__tests__/canvas-context-builder.test.ts +++ b/src/lib/domains/agentic/services/__tests__/canvas-context-builder.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' +import { CanvasContextBuilder } from '~/lib/domains/agentic/services/_context/canvas-context-builder.service' import { createMockMapContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' import type { ICanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/strategy.interface' import type { CanvasContextOptions, TileContextItem, CanvasContextStrategy } from '~/lib/domains/agentic/types' diff --git a/src/lib/domains/agentic/services/__tests__/chat-context-builder.test.ts b/src/lib/domains/agentic/services/__tests__/chat-context-builder.test.ts index abbcd6af5..9498c25eb 100644 --- a/src/lib/domains/agentic/services/__tests__/chat-context-builder.test.ts +++ b/src/lib/domains/agentic/services/__tests__/chat-context-builder.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' +import { ChatContextBuilder } from '~/lib/domains/agentic/services/_context/chat-context-builder.service' import type { IChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/strategy.interface' import type { ChatContextOptions, ChatContextMessage, ChatContextStrategy, ChatMessageContract } from '~/lib/domains/agentic/types' diff --git a/src/lib/domains/agentic/services/__tests__/context-composition.test.ts b/src/lib/domains/agentic/services/__tests__/context-composition.test.ts index c7cd89c63..e7f148d52 100644 --- a/src/lib/domains/agentic/services/__tests__/context-composition.test.ts +++ b/src/lib/domains/agentic/services/__tests__/context-composition.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service' +import { ContextCompositionService } from '~/lib/domains/agentic/services/_context/context-composition.service' import { createMockMapContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' -import type { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' -import type { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' -import type { TokenizerService } from '~/lib/domains/agentic/services/tokenizer.service' +import type { CanvasContextBuilder } from '~/lib/domains/agentic/services/_context/canvas-context-builder.service' +import type { ChatContextBuilder } from '~/lib/domains/agentic/services/_context/chat-context-builder.service' +import type { TokenizerService } from '~/lib/domains/agentic/services/_context/tokenizer.service' import type { CompositionConfig, ChatMessageContract } from '~/lib/domains/agentic/types' import { createMockCanvasContext, createMockChatContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' diff --git a/src/lib/domains/agentic/services/__tests__/context-serializer.test.ts b/src/lib/domains/agentic/services/__tests__/context-serializer.test.ts index 640aced37..260e22860 100644 --- a/src/lib/domains/agentic/services/__tests__/context-serializer.test.ts +++ b/src/lib/domains/agentic/services/__tests__/context-serializer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { ContextSerializerService } from '~/lib/domains/agentic/services/context-serializer.service' +import { ContextSerializerService } from '~/lib/domains/agentic/services/_context/context-serializer.service' import type { ComposedContext, CanvasContext, ChatContext, TileContextItem, ChatContextMessage } from '~/lib/domains/agentic/types' describe('ContextSerializerService', () => { diff --git a/src/lib/domains/agentic/services/__tests__/template-allowlist.service.test.ts b/src/lib/domains/agentic/services/__tests__/template-allowlist.service.test.ts new file mode 100644 index 000000000..6c4845097 --- /dev/null +++ b/src/lib/domains/agentic/services/__tests__/template-allowlist.service.test.ts @@ -0,0 +1,525 @@ +/** + * Template Allowlist Service Tests (TDD) + * + * These tests define the expected behavior of the User Template Allowlist Enforcement + * feature before implementation. The service validates that users can only execute + * templates they have explicitly allowed. + * + * See: docs/features/TEMPLATES_AS_TILES.md for feature specification + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + TemplateAllowlistService, + TemplateNotAllowedError, + TemplateVisibilityError, + type TemplateAllowlistRepository, + type UserAllowlist, + BUILT_IN_TEMPLATES +} from '~/lib/domains/agentic/services/_templates/template-allowlist.service' + +describe('TemplateAllowlistService', () => { + let mockRepository: TemplateAllowlistRepository + let service: TemplateAllowlistService + + const testUserId = 'user-123' + + const mockUserAllowlist: UserAllowlist = { + userId: testUserId, + allowedTemplates: ['my-agent', 'research-assistant', 'custom-workflow'] + } + + beforeEach(() => { + mockRepository = { + getUserAllowlist: vi.fn(), + saveUserAllowlist: vi.fn(), + getTemplateVisibility: vi.fn() + } + + service = new TemplateAllowlistService(mockRepository) + }) + + // ==================== CORE VALIDATION LOGIC ==================== + + describe('validateAllowlist', () => { + it('should allow built-in templates without explicit allowlist entry', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mockUserAllowlist) + + // Built-in templates should always be allowed + await expect(service.validateAllowlist(testUserId, 'system')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'user')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'organizational')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'context')).resolves.not.toThrow() + }) + + it('should allow custom templates that are in the user allowlist', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mockUserAllowlist) + + await expect(service.validateAllowlist(testUserId, 'my-agent')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'research-assistant')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'custom-workflow')).resolves.not.toThrow() + }) + + it('should throw TemplateNotAllowedError when template is not in allowlist', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mockUserAllowlist) + + await expect(service.validateAllowlist(testUserId, 'unknown-template')) + .rejects + .toThrow(TemplateNotAllowedError) + + await expect(service.validateAllowlist(testUserId, 'unknown-template')) + .rejects + .toThrow('Template "unknown-template" is not allowed for user "user-123"') + }) + + it('should include allowed templates in the error message', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mockUserAllowlist) + + try { + await service.validateAllowlist(testUserId, 'disallowed-template') + expect.fail('Expected TemplateNotAllowedError to be thrown') + } catch (error) { + expect(error).toBeInstanceOf(TemplateNotAllowedError) + const templateError = error as TemplateNotAllowedError + expect(templateError.templateName).toBe('disallowed-template') + expect(templateError.userId).toBe(testUserId) + expect(templateError.allowedTemplates).toContain('my-agent') + expect(templateError.allowedTemplates).toContain('research-assistant') + expect(templateError.allowedTemplates).toContain('custom-workflow') + // Built-in templates should also be in the allowed list + expect(templateError.allowedTemplates).toContain('system') + expect(templateError.allowedTemplates).toContain('user') + expect(templateError.allowedTemplates).toContain('organizational') + expect(templateError.allowedTemplates).toContain('context') + } + }) + + it('should handle repository errors gracefully', async () => { + const repositoryError = new Error('Database connection failed') + vi.mocked(mockRepository.getUserAllowlist).mockRejectedValue(repositoryError) + + await expect(service.validateAllowlist(testUserId, 'my-agent')) + .rejects + .toThrow('Database connection failed') + }) + }) + + // ==================== BUILT-IN TEMPLATE DETECTION ==================== + + describe('isBuiltInTemplate', () => { + it('should return true for system template', () => { + expect(service.isBuiltInTemplate('system')).toBe(true) + }) + + it('should return true for user template', () => { + expect(service.isBuiltInTemplate('user')).toBe(true) + }) + + it('should return true for organizational template', () => { + expect(service.isBuiltInTemplate('organizational')).toBe(true) + }) + + it('should return true for context template', () => { + expect(service.isBuiltInTemplate('context')).toBe(true) + }) + + it('should return false for custom templates', () => { + expect(service.isBuiltInTemplate('my-agent')).toBe(false) + expect(service.isBuiltInTemplate('research-assistant')).toBe(false) + expect(service.isBuiltInTemplate('custom-workflow')).toBe(false) + }) + + it('should return false for unknown templates', () => { + expect(service.isBuiltInTemplate('random-template')).toBe(false) + expect(service.isBuiltInTemplate('')).toBe(false) + }) + + it('should handle case-insensitive matching', () => { + // Built-in templates should match regardless of case + expect(service.isBuiltInTemplate('SYSTEM')).toBe(true) + expect(service.isBuiltInTemplate('System')).toBe(true) + expect(service.isBuiltInTemplate('USER')).toBe(true) + expect(service.isBuiltInTemplate('User')).toBe(true) + expect(service.isBuiltInTemplate('ORGANIZATIONAL')).toBe(true) + expect(service.isBuiltInTemplate('Organizational')).toBe(true) + expect(service.isBuiltInTemplate('CONTEXT')).toBe(true) + expect(service.isBuiltInTemplate('Context')).toBe(true) + }) + }) + + // ==================== USER ALLOWLIST RETRIEVAL ==================== + + describe('getUserAllowlist', () => { + it('should return user allowlist from repository', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mockUserAllowlist) + + const result = await service.getUserAllowlist(testUserId) + + expect(result).toEqual(mockUserAllowlist.allowedTemplates) + expect(mockRepository.getUserAllowlist).toHaveBeenCalledWith(testUserId) + }) + + it('should return only built-in templates when user has no custom allowlist', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(null) + + const result = await service.getUserAllowlist(testUserId) + + expect(result).toEqual(BUILT_IN_TEMPLATES) + }) + + it('should return only built-in templates when allowlist is undefined', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(undefined as unknown as UserAllowlist | null) + + const result = await service.getUserAllowlist(testUserId) + + expect(result).toEqual(BUILT_IN_TEMPLATES) + }) + + it('should combine built-in templates with user custom templates', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mockUserAllowlist) + + const result = await service.getEffectiveAllowlist(testUserId) + + // Should include both built-in and custom templates + expect(result).toContain('system') + expect(result).toContain('user') + expect(result).toContain('organizational') + expect(result).toContain('context') + expect(result).toContain('my-agent') + expect(result).toContain('research-assistant') + expect(result).toContain('custom-workflow') + }) + + it('should handle empty allowlist array', async () => { + const emptyAllowlist: UserAllowlist = { + userId: testUserId, + allowedTemplates: [] + } + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(emptyAllowlist) + + const result = await service.getEffectiveAllowlist(testUserId) + + // Should only have built-in templates + expect(result).toEqual(BUILT_IN_TEMPLATES) + }) + + it('should deduplicate templates if user explicitly added built-in templates', async () => { + const duplicateAllowlist: UserAllowlist = { + userId: testUserId, + allowedTemplates: ['system', 'user', 'my-agent'] // system and user are built-in + } + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(duplicateAllowlist) + + const result = await service.getEffectiveAllowlist(testUserId) + + // Should not have duplicates + const systemCount = result.filter(t => t === 'system').length + const userCount = result.filter(t => t === 'user').length + expect(systemCount).toBe(1) + expect(userCount).toBe(1) + }) + }) + + // ==================== VISIBILITY VALIDATION ==================== + + describe('validateVisibility', () => { + it('should allow public tile with public template', async () => { + vi.mocked(mockRepository.getTemplateVisibility).mockResolvedValue('public') + + await expect(service.validateVisibility('my-template', 'public', 'public')) + .resolves.not.toThrow() + }) + + it('should allow private tile with public template', async () => { + vi.mocked(mockRepository.getTemplateVisibility).mockResolvedValue('public') + + await expect(service.validateVisibility('my-template', 'private', 'public')) + .resolves.not.toThrow() + }) + + it('should allow private tile with private template', async () => { + vi.mocked(mockRepository.getTemplateVisibility).mockResolvedValue('private') + + await expect(service.validateVisibility('my-template', 'private', 'private')) + .resolves.not.toThrow() + }) + + it('should throw TemplateVisibilityError when public tile uses private template', async () => { + await expect(service.validateVisibility('my-private-template', 'public', 'private')) + .rejects + .toThrow(TemplateVisibilityError) + + await expect(service.validateVisibility('my-private-template', 'public', 'private')) + .rejects + .toThrow('Cannot use private template "my-private-template" for public tile') + }) + + it('should include template and visibility info in error', async () => { + try { + await service.validateVisibility('secret-template', 'public', 'private') + expect.fail('Expected TemplateVisibilityError to be thrown') + } catch (error) { + expect(error).toBeInstanceOf(TemplateVisibilityError) + const visibilityError = error as TemplateVisibilityError + expect(visibilityError.templateName).toBe('secret-template') + expect(visibilityError.tileVisibility).toBe('public') + expect(visibilityError.templateVisibility).toBe('private') + } + }) + + it('should allow built-in templates for any visibility', async () => { + // Built-in templates are always considered public/available + await expect(service.validateVisibility('system', 'public', 'public')) + .resolves.not.toThrow() + await expect(service.validateVisibility('user', 'public', 'public')) + .resolves.not.toThrow() + await expect(service.validateVisibility('organizational', 'public', 'public')) + .resolves.not.toThrow() + await expect(service.validateVisibility('context', 'public', 'public')) + .resolves.not.toThrow() + }) + }) + + // ==================== ANONYMOUS USER HANDLING ==================== + + describe('anonymous user handling', () => { + it('should allow only built-in templates for anonymous users', async () => { + const anonymousUserId = null + + // Built-in templates should work + await expect(service.validateAllowlist(anonymousUserId, 'system')).resolves.not.toThrow() + await expect(service.validateAllowlist(anonymousUserId, 'user')).resolves.not.toThrow() + await expect(service.validateAllowlist(anonymousUserId, 'organizational')).resolves.not.toThrow() + await expect(service.validateAllowlist(anonymousUserId, 'context')).resolves.not.toThrow() + }) + + it('should reject custom templates for anonymous users', async () => { + const anonymousUserId = null + + await expect(service.validateAllowlist(anonymousUserId, 'my-agent')) + .rejects + .toThrow(TemplateNotAllowedError) + }) + + it('should not call repository for anonymous users', async () => { + const anonymousUserId = null + + await service.validateAllowlist(anonymousUserId, 'system') + + expect(mockRepository.getUserAllowlist).not.toHaveBeenCalled() + }) + + it('should return only built-in templates for anonymous user allowlist', async () => { + const anonymousUserId = null + + const result = await service.getUserAllowlist(anonymousUserId) + + expect(result).toEqual(BUILT_IN_TEMPLATES) + }) + + it('should handle undefined userId same as null', async () => { + const undefinedUserId = undefined + + await expect(service.validateAllowlist(undefinedUserId as unknown as string | null, 'system')) + .resolves.not.toThrow() + await expect(service.validateAllowlist(undefinedUserId as unknown as string | null, 'my-agent')) + .rejects + .toThrow(TemplateNotAllowedError) + }) + }) + + // ==================== CASE SENSITIVITY ==================== + + describe('case-insensitive template name matching', () => { + it('should match template names case-insensitively in allowlist', async () => { + const mixedCaseAllowlist: UserAllowlist = { + userId: testUserId, + allowedTemplates: ['My-Agent', 'RESEARCH-ASSISTANT'] + } + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mixedCaseAllowlist) + + // All case variations should be allowed + await expect(service.validateAllowlist(testUserId, 'my-agent')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'MY-AGENT')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'My-Agent')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'research-assistant')).resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'Research-Assistant')).resolves.not.toThrow() + }) + + it('should normalize template names when checking built-in templates', () => { + expect(service.isBuiltInTemplate('SYSTEM')).toBe(true) + expect(service.isBuiltInTemplate('sYsTeM')).toBe(true) + expect(service.isBuiltInTemplate('OrGaNiZaTiOnAl')).toBe(true) + }) + }) + + // ==================== EDGE CASES ==================== + + describe('edge cases', () => { + it('should handle template name with special characters', async () => { + const specialAllowlist: UserAllowlist = { + userId: testUserId, + allowedTemplates: ['my-template_v2.1', 'agent.research.v3'] + } + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(specialAllowlist) + + await expect(service.validateAllowlist(testUserId, 'my-template_v2.1')) + .resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'agent.research.v3')) + .resolves.not.toThrow() + }) + + it('should reject empty string template name', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mockUserAllowlist) + + await expect(service.validateAllowlist(testUserId, '')) + .rejects + .toThrow() + }) + + it('should reject whitespace-only template name', async () => { + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(mockUserAllowlist) + + await expect(service.validateAllowlist(testUserId, ' ')) + .rejects + .toThrow() + }) + + it('should handle very long template names', async () => { + const longTemplateName = 'a'.repeat(200) + const longAllowlist: UserAllowlist = { + userId: testUserId, + allowedTemplates: [longTemplateName] + } + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(longAllowlist) + + await expect(service.validateAllowlist(testUserId, longTemplateName)) + .resolves.not.toThrow() + }) + + it('should handle user with large allowlist efficiently', async () => { + const largeAllowlist: UserAllowlist = { + userId: testUserId, + allowedTemplates: Array.from({ length: 1000 }, (_, i) => `template-${i}`) + } + vi.mocked(mockRepository.getUserAllowlist).mockResolvedValue(largeAllowlist) + + // Should still perform efficiently + await expect(service.validateAllowlist(testUserId, 'template-999')) + .resolves.not.toThrow() + await expect(service.validateAllowlist(testUserId, 'template-not-in-list')) + .rejects + .toThrow(TemplateNotAllowedError) + }) + }) +}) + +// ==================== ERROR CLASSES ==================== + +describe('TemplateNotAllowedError', () => { + it('should be an instance of Error', () => { + const error = new TemplateNotAllowedError('test-template', 'user-123', ['allowed-1', 'allowed-2']) + expect(error).toBeInstanceOf(Error) + }) + + it('should have correct name property', () => { + const error = new TemplateNotAllowedError('test-template', 'user-123', ['allowed-1']) + expect(error.name).toBe('TemplateNotAllowedError') + }) + + it('should include template name in message', () => { + const error = new TemplateNotAllowedError('my-special-template', 'user-456', ['other']) + expect(error.message).toContain('my-special-template') + expect(error.message).toContain('user-456') + }) + + it('should expose templateName property', () => { + const error = new TemplateNotAllowedError('custom-agent', 'user-789', ['allowed']) + expect(error.templateName).toBe('custom-agent') + }) + + it('should expose userId property', () => { + const error = new TemplateNotAllowedError('custom-agent', 'user-789', ['allowed']) + expect(error.userId).toBe('user-789') + }) + + it('should expose allowedTemplates property', () => { + const allowedList = ['template-1', 'template-2', 'template-3'] + const error = new TemplateNotAllowedError('disallowed', 'user-123', allowedList) + expect(error.allowedTemplates).toEqual(allowedList) + }) + + it('should have proper stack trace', () => { + const error = new TemplateNotAllowedError('test-template', 'user-123', []) + expect(error.stack).toBeDefined() + expect(error.stack).toContain('TemplateNotAllowedError') + }) +}) + +describe('TemplateVisibilityError', () => { + it('should be an instance of Error', () => { + const error = new TemplateVisibilityError('test-template', 'public', 'private') + expect(error).toBeInstanceOf(Error) + }) + + it('should have correct name property', () => { + const error = new TemplateVisibilityError('test-template', 'public', 'private') + expect(error.name).toBe('TemplateVisibilityError') + }) + + it('should include template name and visibility in message', () => { + const error = new TemplateVisibilityError('secret-agent', 'public', 'private') + expect(error.message).toContain('secret-agent') + expect(error.message).toContain('private') + expect(error.message).toContain('public') + }) + + it('should expose templateName property', () => { + const error = new TemplateVisibilityError('my-template', 'public', 'private') + expect(error.templateName).toBe('my-template') + }) + + it('should expose tileVisibility property', () => { + const error = new TemplateVisibilityError('my-template', 'public', 'private') + expect(error.tileVisibility).toBe('public') + }) + + it('should expose templateVisibility property', () => { + const error = new TemplateVisibilityError('my-template', 'public', 'private') + expect(error.templateVisibility).toBe('private') + }) + + it('should have proper stack trace', () => { + const error = new TemplateVisibilityError('test-template', 'public', 'private') + expect(error.stack).toBeDefined() + expect(error.stack).toContain('TemplateVisibilityError') + }) +}) + +// ==================== BUILT-IN TEMPLATES CONSTANT ==================== + +describe('BUILT_IN_TEMPLATES', () => { + it('should contain system template', () => { + expect(BUILT_IN_TEMPLATES).toContain('system') + }) + + it('should contain user template', () => { + expect(BUILT_IN_TEMPLATES).toContain('user') + }) + + it('should contain organizational template', () => { + expect(BUILT_IN_TEMPLATES).toContain('organizational') + }) + + it('should contain context template', () => { + expect(BUILT_IN_TEMPLATES).toContain('context') + }) + + it('should be immutable (frozen)', () => { + expect(Object.isFrozen(BUILT_IN_TEMPLATES)).toBe(true) + }) + + it('should contain exactly 4 built-in templates', () => { + expect(BUILT_IN_TEMPLATES).toHaveLength(4) + }) +}) diff --git a/src/lib/domains/agentic/services/__tests__/template-resolver.service.test.ts b/src/lib/domains/agentic/services/__tests__/template-resolver.service.test.ts new file mode 100644 index 000000000..75739330a --- /dev/null +++ b/src/lib/domains/agentic/services/__tests__/template-resolver.service.test.ts @@ -0,0 +1,355 @@ +/** + * Template Resolver Service Tests (TDD) + * + * These tests define the expected behavior of the Template Tile Lookup Service + * before implementation. The service allows buildPrompt() to retrieve template + * content by templateName from the tile database. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + TemplateResolverService, + TemplateNotFoundError, + type TemplateData, + type TemplateWithChildren, + type TemplateRepository +} from '~/lib/domains/agentic/services/_templates/template-resolver.service' + +describe('TemplateResolverService', () => { + let mockRepository: TemplateRepository + let service: TemplateResolverService + + const mockTemplateData: TemplateData = { + templateName: 'task-breakdown', + title: 'Task Breakdown Template', + content: 'This template helps break down complex tasks into subtasks.', + coords: 'template-user,0:1' + } + + const mockTemplateWithChildren: TemplateWithChildren = { + ...mockTemplateData, + subTemplates: [ + { + templateName: 'task-breakdown-step', + title: 'Step Template', + content: 'A single step in the breakdown.', + coords: 'template-user,0:1,1' + }, + { + templateName: 'task-breakdown-context', + title: 'Context Template', + content: 'Additional context for the step.', + coords: 'template-user,0:1,2' + } + ] + } + + beforeEach(() => { + mockRepository = { + findByTemplateName: vi.fn(), + findByTemplateNameWithChildren: vi.fn() + } + + service = new TemplateResolverService(mockRepository) + }) + + describe('getTemplateByName', () => { + it('should return template content when template exists', async () => { + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(mockTemplateData) + + const result = await service.getTemplateByName('task-breakdown') + + expect(result).toEqual(mockTemplateData) + expect(mockRepository.findByTemplateName).toHaveBeenCalledWith('task-breakdown') + }) + + it('should throw TemplateNotFoundError when template does not exist', async () => { + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(null) + + await expect(service.getTemplateByName('non-existent-template')) + .rejects + .toThrow(TemplateNotFoundError) + + await expect(service.getTemplateByName('non-existent-template')) + .rejects + .toThrow('Template "non-existent-template" not found') + }) + + it('should return template with empty content when content is empty string', async () => { + const emptyContentTemplate: TemplateData = { + ...mockTemplateData, + content: '' + } + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(emptyContentTemplate) + + const result = await service.getTemplateByName('task-breakdown') + + expect(result.content).toBe('') + expect(result.templateName).toBe('task-breakdown') + }) + + it('should handle template names with special characters', async () => { + const specialNameTemplate: TemplateData = { + ...mockTemplateData, + templateName: 'my-template_v2.1' + } + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(specialNameTemplate) + + const result = await service.getTemplateByName('my-template_v2.1') + + expect(result.templateName).toBe('my-template_v2.1') + expect(mockRepository.findByTemplateName).toHaveBeenCalledWith('my-template_v2.1') + }) + + it('should throw error for empty template name', async () => { + await expect(service.getTemplateByName('')) + .rejects + .toThrow() + }) + + it('should propagate repository errors with descriptive message', async () => { + const repositoryError = new Error('Database connection failed') + vi.mocked(mockRepository.findByTemplateName).mockRejectedValue(repositoryError) + + await expect(service.getTemplateByName('task-breakdown')) + .rejects + .toThrow('Database connection failed') + }) + }) + + describe('getTemplateWithSubTemplates', () => { + it('should return template with sub-templates when they exist', async () => { + vi.mocked(mockRepository.findByTemplateNameWithChildren).mockResolvedValue(mockTemplateWithChildren) + + const result = await service.getTemplateWithSubTemplates('task-breakdown') + + expect(result).toEqual(mockTemplateWithChildren) + expect(result.subTemplates).toHaveLength(2) + expect(result.subTemplates[0]!.templateName).toBe('task-breakdown-step') + expect(result.subTemplates[1]!.templateName).toBe('task-breakdown-context') + }) + + it('should throw TemplateNotFoundError when template does not exist', async () => { + vi.mocked(mockRepository.findByTemplateNameWithChildren).mockResolvedValue(null) + + await expect(service.getTemplateWithSubTemplates('non-existent-template')) + .rejects + .toThrow(TemplateNotFoundError) + }) + + it('should return template with empty subTemplates array when no children exist', async () => { + const templateWithNoChildren: TemplateWithChildren = { + ...mockTemplateData, + subTemplates: [] + } + vi.mocked(mockRepository.findByTemplateNameWithChildren).mockResolvedValue(templateWithNoChildren) + + const result = await service.getTemplateWithSubTemplates('task-breakdown') + + expect(result.subTemplates).toEqual([]) + expect(result.subTemplates).toHaveLength(0) + }) + + it('should retrieve sub-templates with their full content', async () => { + vi.mocked(mockRepository.findByTemplateNameWithChildren).mockResolvedValue(mockTemplateWithChildren) + + const result = await service.getTemplateWithSubTemplates('task-breakdown') + + // Verify sub-templates have all required fields + for (const subTemplate of result.subTemplates) { + expect(subTemplate).toHaveProperty('templateName') + expect(subTemplate).toHaveProperty('title') + expect(subTemplate).toHaveProperty('content') + expect(subTemplate).toHaveProperty('coords') + } + }) + + it('should handle deeply nested sub-template structures', async () => { + const nestedTemplate: TemplateWithChildren = { + ...mockTemplateData, + subTemplates: [ + { + templateName: 'nested-1', + title: 'Nested 1', + content: 'First level nesting', + coords: 'template-user,0:1,1' + }, + { + templateName: 'nested-2', + title: 'Nested 2', + content: 'Second nested item', + coords: 'template-user,0:1,2' + }, + { + templateName: 'nested-3', + title: 'Nested 3', + content: 'Third nested item', + coords: 'template-user,0:1,3' + }, + { + templateName: 'nested-4', + title: 'Nested 4', + content: 'Fourth nested item', + coords: 'template-user,0:1,4' + }, + { + templateName: 'nested-5', + title: 'Nested 5', + content: 'Fifth nested item', + coords: 'template-user,0:1,5' + }, + { + templateName: 'nested-6', + title: 'Nested 6', + content: 'Sixth nested item (max structural children)', + coords: 'template-user,0:1,6' + } + ] + } + vi.mocked(mockRepository.findByTemplateNameWithChildren).mockResolvedValue(nestedTemplate) + + const result = await service.getTemplateWithSubTemplates('task-breakdown') + + expect(result.subTemplates).toHaveLength(6) + }) + + it('should propagate repository errors with descriptive message', async () => { + const repositoryError = new Error('Query timeout exceeded') + vi.mocked(mockRepository.findByTemplateNameWithChildren).mockRejectedValue(repositoryError) + + await expect(service.getTemplateWithSubTemplates('task-breakdown')) + .rejects + .toThrow('Query timeout exceeded') + }) + }) + + describe('error handling', () => { + it('should provide descriptive error for template not found', async () => { + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(null) + + try { + await service.getTemplateByName('missing-template') + expect.fail('Expected TemplateNotFoundError to be thrown') + } catch (error) { + expect(error).toBeInstanceOf(TemplateNotFoundError) + expect((error as Error).message).toContain('missing-template') + expect((error as Error).name).toBe('TemplateNotFoundError') + } + }) + + it('should handle whitespace-only template names as invalid', async () => { + await expect(service.getTemplateByName(' ')) + .rejects + .toThrow() + }) + + it('should handle null template name gracefully', async () => { + // TypeScript would prevent this, but runtime should handle it + await expect(service.getTemplateByName(null as unknown as string)) + .rejects + .toThrow() + }) + + it('should handle undefined template name gracefully', async () => { + // TypeScript would prevent this, but runtime should handle it + await expect(service.getTemplateByName(undefined as unknown as string)) + .rejects + .toThrow() + }) + }) + + describe('caching behavior (future consideration)', () => { + it('should call repository for each request without caching by default', async () => { + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(mockTemplateData) + + await service.getTemplateByName('task-breakdown') + await service.getTemplateByName('task-breakdown') + await service.getTemplateByName('task-breakdown') + + // Without caching, repository should be called each time + expect(mockRepository.findByTemplateName).toHaveBeenCalledTimes(3) + }) + }) + + describe('built-in vs custom templates', () => { + it('should work for built-in templates', async () => { + const builtInTemplate: TemplateData = { + templateName: 'hexplan-default', + title: 'Default Hexplan Template', + content: 'Standard hexplan structure for task execution.', + coords: 'system,0:1' + } + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(builtInTemplate) + + const result = await service.getTemplateByName('hexplan-default') + + expect(result.templateName).toBe('hexplan-default') + }) + + it('should work for custom user-created templates', async () => { + const customTemplate: TemplateData = { + templateName: 'my-custom-workflow', + title: 'Custom Workflow Template', + content: 'User-defined workflow for specific use case.', + coords: 'user123,0:3' + } + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(customTemplate) + + const result = await service.getTemplateByName('my-custom-workflow') + + expect(result.templateName).toBe('my-custom-workflow') + }) + }) + + describe('integration points', () => { + it('should return data compatible with template pre-processor TileData', async () => { + vi.mocked(mockRepository.findByTemplateName).mockResolvedValue(mockTemplateData) + + const result = await service.getTemplateByName('task-breakdown') + + // Verify structure matches TileData interface used by pre-processor + expect(result).toMatchObject({ + title: expect.any(String), + content: expect.any(String), + coords: expect.any(String) + }) + }) + + it('should return sub-templates in structural child order (directions 1-6)', async () => { + vi.mocked(mockRepository.findByTemplateNameWithChildren).mockResolvedValue(mockTemplateWithChildren) + + const result = await service.getTemplateWithSubTemplates('task-breakdown') + + // Sub-templates should maintain their order based on coordinate path + const coordinatePaths = result.subTemplates.map(sub => sub.coords) + expect(coordinatePaths).toEqual([ + 'template-user,0:1,1', + 'template-user,0:1,2' + ]) + }) + }) +}) + +describe('TemplateNotFoundError', () => { + it('should be an instance of Error', () => { + const error = new TemplateNotFoundError('test-template') + expect(error).toBeInstanceOf(Error) + }) + + it('should have correct name property', () => { + const error = new TemplateNotFoundError('test-template') + expect(error.name).toBe('TemplateNotFoundError') + }) + + it('should include template name in message', () => { + const error = new TemplateNotFoundError('my-special-template') + expect(error.message).toContain('my-special-template') + expect(error.message).toBe('Template "my-special-template" not found') + }) + + it('should have proper stack trace', () => { + const error = new TemplateNotFoundError('test-template') + expect(error.stack).toBeDefined() + expect(error.stack).toContain('TemplateNotFoundError') + }) +}) diff --git a/src/lib/domains/agentic/services/canvas-context-builder.service.ts b/src/lib/domains/agentic/services/_context/canvas-context-builder.service.ts similarity index 100% rename from src/lib/domains/agentic/services/canvas-context-builder.service.ts rename to src/lib/domains/agentic/services/_context/canvas-context-builder.service.ts diff --git a/src/lib/domains/agentic/services/chat-context-builder.service.ts b/src/lib/domains/agentic/services/_context/chat-context-builder.service.ts similarity index 100% rename from src/lib/domains/agentic/services/chat-context-builder.service.ts rename to src/lib/domains/agentic/services/_context/chat-context-builder.service.ts diff --git a/src/lib/domains/agentic/services/context-composition.service.ts b/src/lib/domains/agentic/services/_context/context-composition.service.ts similarity index 97% rename from src/lib/domains/agentic/services/context-composition.service.ts rename to src/lib/domains/agentic/services/_context/context-composition.service.ts index 6df7b7d56..a7286eaec 100644 --- a/src/lib/domains/agentic/services/context-composition.service.ts +++ b/src/lib/domains/agentic/services/_context/context-composition.service.ts @@ -7,9 +7,9 @@ import type { SerializationFormat } from '~/lib/domains/agentic/types' import type { MapContext } from '~/lib/domains/mapping/utils' -import type { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' -import type { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' -import type { TokenizerService } from '~/lib/domains/agentic/services/tokenizer.service' +import type { CanvasContextBuilder } from '~/lib/domains/agentic/services/_context/canvas-context-builder.service' +import type { ChatContextBuilder } from '~/lib/domains/agentic/services/_context/chat-context-builder.service' +import type { TokenizerService } from '~/lib/domains/agentic/services/_context/tokenizer.service' import type { ChatMessageContract } from '~/lib/domains/agentic/types' export class ContextCompositionService { diff --git a/src/lib/domains/agentic/services/context-serializer.service.ts b/src/lib/domains/agentic/services/_context/context-serializer.service.ts similarity index 100% rename from src/lib/domains/agentic/services/context-serializer.service.ts rename to src/lib/domains/agentic/services/_context/context-serializer.service.ts diff --git a/src/lib/domains/agentic/services/tokenizer.service.ts b/src/lib/domains/agentic/services/_context/tokenizer.service.ts similarity index 100% rename from src/lib/domains/agentic/services/tokenizer.service.ts rename to src/lib/domains/agentic/services/_context/tokenizer.service.ts diff --git a/src/lib/domains/agentic/services/_run-services/README.md b/src/lib/domains/agentic/services/_run-services/README.md new file mode 100644 index 000000000..f45a69b58 --- /dev/null +++ b/src/lib/domains/agentic/services/_run-services/README.md @@ -0,0 +1,20 @@ +# Run Services + +## Mental Model +Like a **race director's clipboard** - it tracks who started, who finished, who's stuck, and keeps an ordered log of every step. Only one race can be "open" for a given course at a time. + +## Responsibilities +- Create and manage Run objects in the database +- Track execution state: open, blocked, closed +- Maintain ordered execution log with timestamps +- Enforce one-open-run-per-rootCoords constraint +- Store blockage reasons for resumption context + +## Non-Responsibilities +- Deciding which tile to execute next β†’ See `~/lib/domains/mapping/services/_traversal-services` +- Actually executing tiles β†’ See API layer +- Managing tile content β†’ See `~/lib/domains/mapping` + +## Interface +See `index.ts` for public API. +See `dependencies.json` for allowed imports. diff --git a/src/lib/domains/agentic/services/_run-services/__tests__/run.integration.test.ts b/src/lib/domains/agentic/services/_run-services/__tests__/run.integration.test.ts new file mode 100644 index 000000000..83c46e0a6 --- /dev/null +++ b/src/lib/domains/agentic/services/_run-services/__tests__/run.integration.test.ts @@ -0,0 +1,304 @@ +import { describe, beforeEach, it, expect } from "vitest"; +import { db } from "~/server/db"; +import { sql } from "drizzle-orm"; +import { RunService } from "~/lib/domains/agentic/services/_run-services"; + +const TEST_USER_ID = "test-user-run-service"; + +let testRunCounter = 0; + +function createUniqueTestParams() { + const counter = testRunCounter++; + const timestamp = Date.now(); + return { + userId: `${TEST_USER_ID}-${timestamp}-${counter}`, + rootCoords: `${TEST_USER_ID}-${timestamp}-${counter},0:1`, + }; +} + +async function cleanupTestRuns() { + try { + await db.execute(sql`DELETE FROM vde_runs WHERE user_id LIKE ${`${TEST_USER_ID}%`}`); + } catch { + // Table might not exist yet + } +} + +describe("RunService [Integration - DB]", () => { + let runService: RunService; + + beforeEach(async () => { + await cleanupTestRuns(); + runService = new RunService(db); + }); + + describe("getOrCreateRun", () => { + it("creates new run when no open run exists", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + + const run = await runService.getOrCreateRun(userId, rootCoords); + + expect(run).toBeDefined(); + expect(run.id).toBeDefined(); + expect(run.userId).toBe(userId); + expect(run.rootCoords).toBe(rootCoords); + expect(run.status).toBe("open"); + }); + + it("returns existing open run for same rootCoords", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + + const run1 = await runService.getOrCreateRun(userId, rootCoords); + const run2 = await runService.getOrCreateRun(userId, rootCoords); + + expect(run1.id).toBe(run2.id); + }); + + it("creates new run if previous run is closed", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + + const run1 = await runService.getOrCreateRun(userId, rootCoords); + await runService.closeRun(run1.id); + const run2 = await runService.getOrCreateRun(userId, rootCoords); + + expect(run1.id).not.toBe(run2.id); + expect(run2.status).toBe("open"); + }); + + it("resumes blocked run instead of creating new one", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + + const run1 = await runService.getOrCreateRun(userId, rootCoords); + await runService.startStep(run1.id, `${rootCoords},1`); + await runService.markStepBlocked(run1.id, `${rootCoords},1`, "Test block"); + const run2 = await runService.getOrCreateRun(userId, rootCoords); + + // Should return the same run, now resumed + expect(run1.id).toBe(run2.id); + expect(run2.status).toBe("open"); + expect(run2.blockageReason).toBeNull(); + }); + + it("sets initial status to open", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + + const run = await runService.getOrCreateRun(userId, rootCoords); + + expect(run.status).toBe("open"); + }); + + it("initializes executionLog as empty array", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + + const run = await runService.getOrCreateRun(userId, rootCoords); + + expect(run.executionLog).toEqual([]); + }); + }); + + describe("getResumableRun", () => { + it("returns null when no resumable run exists", async () => { + const { rootCoords } = createUniqueTestParams(); + + const run = await runService.getResumableRun(rootCoords); + + expect(run).toBeNull(); + }); + + it("returns open run for rootCoords", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const created = await runService.getOrCreateRun(userId, rootCoords); + + const run = await runService.getResumableRun(rootCoords); + + expect(run).not.toBeNull(); + expect(run?.id).toBe(created.id); + }); + + it("returns blocked runs", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const created = await runService.getOrCreateRun(userId, rootCoords); + await runService.startStep(created.id, `${rootCoords},1`); + await runService.markStepBlocked(created.id, `${rootCoords},1`, "Test block"); + + const run = await runService.getResumableRun(rootCoords); + + expect(run).not.toBeNull(); + expect(run?.id).toBe(created.id); + expect(run?.status).toBe("blocked"); + }); + + it("does not return closed runs", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const created = await runService.getOrCreateRun(userId, rootCoords); + await runService.closeRun(created.id); + + const run = await runService.getResumableRun(rootCoords); + + expect(run).toBeNull(); + }); + }); + + describe("startStep", () => { + it("adds entry to executionLog with startedAt timestamp", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + + const updatedRun = await runService.startStep(run.id, stepCoords); + + expect(updatedRun.executionLog).toHaveLength(1); + expect(updatedRun.executionLog[0]!.stepCoords).toBe(stepCoords); + expect(updatedRun.executionLog[0]!.startedAt).toBeDefined(); + }); + + it("does not change run status", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + + const updatedRun = await runService.startStep(run.id, `${rootCoords},1`); + + expect(updatedRun.status).toBe("open"); + }); + }); + + describe("markStepCompleted", () => { + it("updates entry with completedAt timestamp", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + await runService.startStep(run.id, stepCoords); + + const updatedRun = await runService.markStepCompleted(run.id, stepCoords); + + expect(updatedRun.executionLog[0]!.completedAt).toBeDefined(); + }); + + it("sets entry status to completed", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + await runService.startStep(run.id, stepCoords); + + const updatedRun = await runService.markStepCompleted(run.id, stepCoords); + + expect(updatedRun.executionLog[0]!.status).toBe("completed"); + }); + + it("keeps run status as open", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + await runService.startStep(run.id, stepCoords); + + const updatedRun = await runService.markStepCompleted(run.id, stepCoords); + + expect(updatedRun.status).toBe("open"); + }); + }); + + describe("markStepBlocked", () => { + it("updates entry with blockageReason", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + const reason = "External API unavailable"; + await runService.startStep(run.id, stepCoords); + + const updatedRun = await runService.markStepBlocked(run.id, stepCoords, reason); + + expect(updatedRun.executionLog[0]!.blockageReason).toBe(reason); + }); + + it("sets entry status to blocked", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + await runService.startStep(run.id, stepCoords); + + const updatedRun = await runService.markStepBlocked(run.id, stepCoords, "Test reason"); + + expect(updatedRun.executionLog[0]!.status).toBe("blocked"); + }); + + it("sets run status to blocked", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + await runService.startStep(run.id, stepCoords); + + const updatedRun = await runService.markStepBlocked(run.id, stepCoords, "Test reason"); + + expect(updatedRun.status).toBe("blocked"); + }); + + it("sets run blockageReason", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + const reason = "External API unavailable"; + await runService.startStep(run.id, stepCoords); + + const updatedRun = await runService.markStepBlocked(run.id, stepCoords, reason); + + expect(updatedRun.blockageReason).toBe(reason); + }); + }); + + describe("closeRun", () => { + it("sets status to closed", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + + const closedRun = await runService.closeRun(run.id); + + expect(closedRun.status).toBe("closed"); + }); + + it("preserves executionLog", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + await runService.startStep(run.id, stepCoords); + await runService.markStepCompleted(run.id, stepCoords); + + const closedRun = await runService.closeRun(run.id); + + expect(closedRun.executionLog).toHaveLength(1); + expect(closedRun.executionLog[0]!.status).toBe("completed"); + }); + }); + + describe("resumeBlockedRun", () => { + it("sets status back to open", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + await runService.startStep(run.id, stepCoords); + await runService.markStepBlocked(run.id, stepCoords, "Test block"); + + const resumedRun = await runService.resumeBlockedRun(run.id); + + expect(resumedRun.status).toBe("open"); + }); + + it("clears blockageReason", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + const stepCoords = `${rootCoords},1`; + await runService.startStep(run.id, stepCoords); + await runService.markStepBlocked(run.id, stepCoords, "Test block"); + + const resumedRun = await runService.resumeBlockedRun(run.id); + + expect(resumedRun.blockageReason).toBeNull(); + }); + + it("throws if run is not blocked", async () => { + const { userId, rootCoords } = createUniqueTestParams(); + const run = await runService.getOrCreateRun(userId, rootCoords); + + await expect(runService.resumeBlockedRun(run.id)).rejects.toThrow(); + }); + }); +}); diff --git a/src/lib/domains/agentic/services/_run-services/_run-hexplan.repository.ts b/src/lib/domains/agentic/services/_run-services/_run-hexplan.repository.ts new file mode 100644 index 000000000..9a6bacc68 --- /dev/null +++ b/src/lib/domains/agentic/services/_run-services/_run-hexplan.repository.ts @@ -0,0 +1,77 @@ +import { eq, and } from "drizzle-orm"; +import { schema } from "~/server/db"; +import type { DrizzleClient } from "~/lib/domains/agentic/services/_run-services/_run.repository"; +import type { RunHexplan } from "~/lib/domains/agentic/services/_run-services/_run.types"; + +const { runHexplans } = schema; + +type DbRunHexplan = typeof runHexplans.$inferSelect; + +function _mapDbToRunHexplan(dbHexplan: DbRunHexplan): RunHexplan { + return { + runId: dbHexplan.runId, + coords: dbHexplan.coords, + content: dbHexplan.content, + createdAt: dbHexplan.createdAt, + updatedAt: dbHexplan.updatedAt, + }; +} + +export class RunHexplanRepository { + constructor(private readonly db: DrizzleClient) {} + + async findByRunAndCoords( + runId: string, + coords: string + ): Promise<RunHexplan | null> { + const result = await this.db.query.runHexplans.findFirst({ + where: and( + eq(runHexplans.runId, runId), + eq(runHexplans.coords, coords) + ), + }); + + return result ? _mapDbToRunHexplan(result) : null; + } + + async findAllByRunId(runId: string): Promise<RunHexplan[]> { + const results = await this.db + .select() + .from(runHexplans) + .where(eq(runHexplans.runId, runId)); + + return results.map(_mapDbToRunHexplan); + } + + async upsert(runId: string, coords: string, content: string): Promise<RunHexplan> { + const [result] = await this.db + .insert(runHexplans) + .values({ + runId, + coords, + content, + }) + .onConflictDoUpdate({ + target: [runHexplans.runId, runHexplans.coords], + set: { + content, + updatedAt: new Date(), + }, + }) + .returning(); + + if (!result) { + throw new Error(`Failed to upsert run hexplan: ${runId}, ${coords}`); + } + + return _mapDbToRunHexplan(result); + } + + async delete(runId: string, coords: string): Promise<void> { + await this.db + .delete(runHexplans) + .where( + and(eq(runHexplans.runId, runId), eq(runHexplans.coords, coords)) + ); + } +} diff --git a/src/lib/domains/agentic/services/_run-services/_run.repository.ts b/src/lib/domains/agentic/services/_run-services/_run.repository.ts new file mode 100644 index 000000000..dd307c20e --- /dev/null +++ b/src/lib/domains/agentic/services/_run-services/_run.repository.ts @@ -0,0 +1,114 @@ +import { eq, and, or, inArray, desc } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { schema } from "~/server/db"; +import type { Run, ExecutionLogEntry, RunStatus } from "~/lib/domains/agentic/services/_run-services/_run.types"; + +const { runs } = schema; + +export type DrizzleClient = PostgresJsDatabase<typeof schema>; + +type DbRun = typeof runs.$inferSelect; + +function _mapDbRunToRun(dbRun: DbRun): Run { + return { + id: dbRun.id, + userId: dbRun.userId, + rootCoords: dbRun.rootCoords, + status: dbRun.status as RunStatus, + blockageReason: dbRun.blockageReason, + executionLog: dbRun.executionLog as ExecutionLogEntry[], + createdAt: dbRun.createdAt, + updatedAt: dbRun.updatedAt, + }; +} + +export class RunRepository { + constructor(private readonly db: DrizzleClient) {} + + async findById(id: string): Promise<Run | null> { + const result = await this.db.query.runs.findFirst({ + where: eq(runs.id, id), + }); + + return result ? _mapDbRunToRun(result) : null; + } + + async findResumableByRootCoords(rootCoords: string): Promise<Run | null> { + const result = await this.db.query.runs.findFirst({ + where: and( + eq(runs.rootCoords, rootCoords), + or(eq(runs.status, "open"), eq(runs.status, "blocked")) + ), + }); + + return result ? _mapDbRunToRun(result) : null; + } + + async findByUserId( + userId: string, + statusFilter: RunStatus[], + limit: number, + offset: number + ): Promise<Run[]> { + const results = await this.db + .select() + .from(runs) + .where( + and(eq(runs.userId, userId), inArray(runs.status, statusFilter)) + ) + .orderBy(desc(runs.updatedAt)) + .limit(limit) + .offset(offset); + + return results.map(_mapDbRunToRun); + } + + async create(data: { + id: string; + userId: string; + rootCoords: string; + status: RunStatus; + executionLog: ExecutionLogEntry[]; + }): Promise<Run> { + const [result] = await this.db + .insert(runs) + .values({ + id: data.id, + userId: data.userId, + rootCoords: data.rootCoords, + status: data.status, + executionLog: data.executionLog, + }) + .returning(); + + if (!result) { + throw new Error("Failed to create run"); + } + + return _mapDbRunToRun(result); + } + + async update( + id: string, + data: Partial<{ + status: RunStatus; + blockageReason: string | null; + executionLog: ExecutionLogEntry[]; + }> + ): Promise<Run> { + const [result] = await this.db + .update(runs) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(runs.id, id)) + .returning(); + + if (!result) { + throw new Error(`Failed to update run: ${id}`); + } + + return _mapDbRunToRun(result); + } +} diff --git a/src/lib/domains/agentic/services/_run-services/_run.service.ts b/src/lib/domains/agentic/services/_run-services/_run.service.ts new file mode 100644 index 000000000..721c0cd00 --- /dev/null +++ b/src/lib/domains/agentic/services/_run-services/_run.service.ts @@ -0,0 +1,203 @@ +import { nanoid } from "nanoid"; +import { RunRepository, type DrizzleClient } from "~/lib/domains/agentic/services/_run-services/_run.repository"; +import { RunHexplanRepository } from "~/lib/domains/agentic/services/_run-services/_run-hexplan.repository"; +import type { Run, ExecutionLogEntry, ToolCallEntry, RunStatus, RunHexplan } from "~/lib/domains/agentic/services/_run-services/_run.types"; + +export class RunService { + private readonly repository: RunRepository; + private readonly hexplanRepository: RunHexplanRepository; + + constructor(db: DrizzleClient) { + this.repository = new RunRepository(db); + this.hexplanRepository = new RunHexplanRepository(db); + } + + async getOrCreateRun(userId: string, rootCoords: string): Promise<Run> { + const existingRun = await this.repository.findResumableByRootCoords(rootCoords); + if (existingRun) { + // If the run was blocked, resume it automatically + if (existingRun.status === "blocked") { + return this.repository.update(existingRun.id, { + status: "open", + blockageReason: null, + }); + } + return existingRun; + } + + return this.repository.create({ + id: nanoid(), + userId, + rootCoords, + status: "open", + executionLog: [], + }); + } + + async getResumableRun(rootCoords: string): Promise<Run | null> { + return this.repository.findResumableByRootCoords(rootCoords); + } + + async getRunById(runId: string): Promise<Run | null> { + return this.repository.findById(runId); + } + + async listRunsForUser( + userId: string, + options: { + statusFilter?: RunStatus[]; + limit?: number; + offset?: number; + } = {} + ): Promise<Run[]> { + const { + statusFilter = ["open", "blocked"], + limit = 20, + offset = 0, + } = options; + + return this.repository.findByUserId(userId, statusFilter, limit, offset); + } + + async startStep( + runId: string, + stepCoords: string, + stepTitle?: string, + hexecutePrompt?: string + ): Promise<Run> { + const run = await this._getRunOrThrow(runId); + + const newEntry: ExecutionLogEntry = { + stepCoords, + stepTitle, + status: "completed", + startedAt: new Date().toISOString(), + hexecutePrompt, + }; + + return this.repository.update(runId, { + executionLog: [...run.executionLog, newEntry], + }); + } + + async markStepCompleted( + runId: string, + stepCoords: string, + agentResponse?: string, + hexplanContent?: string, + toolCalls?: ToolCallEntry[] + ): Promise<Run> { + const run = await this._getRunOrThrow(runId); + + const updatedLog = run.executionLog.map((entry) => { + if (entry.stepCoords === stepCoords) { + return { + ...entry, + status: "completed" as const, + completedAt: new Date().toISOString(), + agentResponse, + hexplanContent, + toolCalls, + }; + } + return entry; + }); + + return this.repository.update(runId, { + executionLog: updatedLog, + }); + } + + async markStepBlocked( + runId: string, + stepCoords: string, + reason: string, + agentResponse?: string, + hexplanContent?: string, + toolCalls?: ToolCallEntry[] + ): Promise<Run> { + const run = await this._getRunOrThrow(runId); + + const updatedLog = run.executionLog.map((entry) => { + if (entry.stepCoords === stepCoords) { + return { + ...entry, + status: "blocked" as const, + blockageReason: reason, + agentResponse, + hexplanContent, + toolCalls, + }; + } + return entry; + }); + + return this.repository.update(runId, { + status: "blocked", + blockageReason: reason, + executionLog: updatedLog, + }); + } + + async closeRun(runId: string): Promise<Run> { + await this._getRunOrThrow(runId); + return this.repository.update(runId, { status: "closed" }); + } + + async reopenRun(runId: string): Promise<Run> { + const run = await this._getRunOrThrow(runId); + + if (run.status !== "closed") { + throw new Error(`Cannot reopen run ${runId}: status is ${run.status}, expected 'closed'`); + } + + return this.repository.update(runId, { status: "open" }); + } + + async resumeBlockedRun(runId: string): Promise<Run> { + const run = await this._getRunOrThrow(runId); + + if (run.status !== "blocked") { + throw new Error(`Cannot resume run ${runId}: status is ${run.status}, expected 'blocked'`); + } + + return this.repository.update(runId, { + status: "open", + blockageReason: null, + }); + } + + // Hexplan methods + + async getHexplan(runId: string, coords: string): Promise<string | null> { + const hexplan = await this.hexplanRepository.findByRunAndCoords(runId, coords); + return hexplan?.content ?? null; + } + + async setHexplan(runId: string, coords: string, content: string): Promise<RunHexplan> { + return this.hexplanRepository.upsert(runId, coords, content); + } + + async getHexplansForRun(runId: string): Promise<Map<string, string>> { + const hexplans = await this.hexplanRepository.findAllByRunId(runId); + const hexplanMap = new Map<string, string>(); + for (const hexplan of hexplans) { + hexplanMap.set(hexplan.coords, hexplan.content); + } + return hexplanMap; + } + + async getActiveRunForCoords(coords: string): Promise<Run | null> { + // Find any open or blocked run where rootCoords matches or is an ancestor of coords + // For now, simple exact match - can be extended for ancestry check if needed + return this.repository.findResumableByRootCoords(coords); + } + + private async _getRunOrThrow(runId: string): Promise<Run> { + const run = await this.repository.findById(runId); + if (!run) { + throw new Error(`Run not found: ${runId}`); + } + return run; + } +} diff --git a/src/lib/domains/agentic/services/_run-services/_run.types.ts b/src/lib/domains/agentic/services/_run-services/_run.types.ts new file mode 100644 index 000000000..3bcf988c9 --- /dev/null +++ b/src/lib/domains/agentic/services/_run-services/_run.types.ts @@ -0,0 +1,44 @@ +export type RunStatus = "open" | "blocked" | "closed"; + +export interface ToolCallEntry { + toolCallId: string; + toolName: string; + arguments?: string; + result?: string; + error?: string; + startedAt: string; + completedAt?: string; + durationMs?: number; +} + +export interface ExecutionLogEntry { + stepCoords: string; + stepTitle?: string; + status: "completed" | "blocked"; + startedAt: string; + completedAt?: string; + blockageReason?: string; + agentResponse?: string; + hexecutePrompt?: string; + hexplanContent?: string; + toolCalls?: ToolCallEntry[]; +} + +export interface Run { + id: string; + userId: string; + rootCoords: string; + status: RunStatus; + blockageReason: string | null; + executionLog: ExecutionLogEntry[]; + createdAt: Date; + updatedAt: Date; +} + +export interface RunHexplan { + runId: string; + coords: string; + content: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/lib/domains/agentic/services/_run-services/dependencies.json b/src/lib/domains/agentic/services/_run-services/dependencies.json new file mode 100644 index 000000000..54558e101 --- /dev/null +++ b/src/lib/domains/agentic/services/_run-services/dependencies.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../../../scripts/checks/architecture/dependencies.schema.json", + "allowed": [ + "~/server/db", + "drizzle-orm", + "nanoid" + ], + "subsystems": [], + "exceptions": {} +} diff --git a/src/lib/domains/agentic/services/_run-services/index.ts b/src/lib/domains/agentic/services/_run-services/index.ts new file mode 100644 index 000000000..9abb29309 --- /dev/null +++ b/src/lib/domains/agentic/services/_run-services/index.ts @@ -0,0 +1,7 @@ +export { RunService } from "~/lib/domains/agentic/services/_run-services/_run.service"; +export type { + Run, + RunStatus, + ExecutionLogEntry, + ToolCallEntry, +} from "~/lib/domains/agentic/services/_run-services/_run.types"; diff --git a/src/lib/domains/agentic/services/_templates/index.ts b/src/lib/domains/agentic/services/_templates/index.ts new file mode 100644 index 000000000..0352f1d43 --- /dev/null +++ b/src/lib/domains/agentic/services/_templates/index.ts @@ -0,0 +1,32 @@ +/** + * Template Services for Agentic Domain + * + * Consolidates template-related services for: + * - Prompt template rendering (PromptTemplateService) + * - Template resolution from database (TemplateResolverService) + * - User template allowlist validation (TemplateAllowlistService) + */ + +export { PromptTemplateService } from '~/lib/domains/agentic/services/_templates/prompt-template.service' + +export { + TemplateResolverService, + TemplateNotFoundError +} from '~/lib/domains/agentic/services/_templates/template-resolver.service' +export type { + TemplateData, + TemplateWithChildren, + TemplateRepository +} from '~/lib/domains/agentic/services/_templates/template-resolver.service' + +export { + TemplateAllowlistService, + TemplateNotAllowedError, + TemplateVisibilityError, + BUILT_IN_TEMPLATES +} from '~/lib/domains/agentic/services/_templates/template-allowlist.service' +export type { + Visibility, + UserAllowlist, + TemplateAllowlistRepository +} from '~/lib/domains/agentic/services/_templates/template-allowlist.service' diff --git a/src/lib/domains/agentic/services/prompt-template.service.ts b/src/lib/domains/agentic/services/_templates/prompt-template.service.ts similarity index 100% rename from src/lib/domains/agentic/services/prompt-template.service.ts rename to src/lib/domains/agentic/services/_templates/prompt-template.service.ts diff --git a/src/lib/domains/agentic/services/_templates/template-allowlist.service.ts b/src/lib/domains/agentic/services/_templates/template-allowlist.service.ts new file mode 100644 index 000000000..2484e01a4 --- /dev/null +++ b/src/lib/domains/agentic/services/_templates/template-allowlist.service.ts @@ -0,0 +1,219 @@ +/** + * Template Allowlist Service (TDD Stub) + * + * Validates that users can only execute templates they have explicitly allowed. + * Implements the User Template Allowlist Enforcement feature. + * + * See: docs/features/TEMPLATES_AS_TILES.md for feature specification + * + * TODO: Implement this service - tests are written in __tests__/template-allowlist.service.test.ts + */ + +// ==================== CONSTANTS ==================== + +/** + * Built-in templates that are always allowed for all users. + * These correspond to the core tile types in Hexframe. + */ +export const BUILT_IN_TEMPLATES: readonly string[] = Object.freeze([ + 'system', + 'user', + 'organizational', + 'context' +]) + +// ==================== TYPES ==================== + +/** + * Visibility options for tiles and templates. + */ +export type Visibility = 'public' | 'private' + +/** + * User's template allowlist configuration. + */ +export interface UserAllowlist { + userId: string + allowedTemplates: string[] +} + +// ==================== ERRORS ==================== + +/** + * Error thrown when a user attempts to use a template not in their allowlist. + */ +export class TemplateNotAllowedError extends Error { + constructor( + public readonly templateName: string, + public readonly userId: string | null, + public readonly allowedTemplates: string[] + ) { + super(`Template "${templateName}" is not allowed for user "${userId}"`) + this.name = 'TemplateNotAllowedError' + } +} + +/** + * Error thrown when a public tile attempts to use a private template. + */ +export class TemplateVisibilityError extends Error { + constructor( + public readonly templateName: string, + public readonly tileVisibility: Visibility, + public readonly templateVisibility: Visibility + ) { + super(`Cannot use private template "${templateName}" for public tile`) + this.name = 'TemplateVisibilityError' + } +} + +// ==================== REPOSITORY INTERFACE ==================== + +/** + * Repository interface for template allowlist queries. + */ +export interface TemplateAllowlistRepository { + getUserAllowlist(userId: string): Promise<UserAllowlist | null> + saveUserAllowlist(allowlist: UserAllowlist): Promise<void> + getTemplateVisibility(templateName: string): Promise<Visibility> +} + +// ==================== SERVICE ==================== + +/** + * Service for validating user template allowlists. + * + * Provides methods to: + * - Validate if a user can use a specific template + * - Check if a template is built-in + * - Get a user's effective allowlist (built-in + custom) + * - Validate visibility constraints between tiles and templates + */ +export class TemplateAllowlistService { + constructor(private readonly _repository: TemplateAllowlistRepository) {} + + /** + * Validate that a user is allowed to use a specific template. + * + * @param userId - The user ID (null for anonymous users) + * @param templateName - The template name to validate + * @throws TemplateNotAllowedError if template is not allowed + * @throws Error for invalid input (empty, whitespace-only) + */ + async validateAllowlist(userId: string | null | undefined, templateName: string): Promise<void> { + this._validateTemplateName(templateName) + + if (this.isBuiltInTemplate(templateName)) { + return + } + + const isAnonymousUser = userId === null || userId === undefined + if (isAnonymousUser) { + throw new TemplateNotAllowedError(templateName, null, [...BUILT_IN_TEMPLATES]) + } + + const effectiveAllowlist = await this.getEffectiveAllowlist(userId) + const normalizedTemplateName = templateName.toLowerCase() + const isTemplateAllowed = effectiveAllowlist.some( + allowedTemplate => allowedTemplate.toLowerCase() === normalizedTemplateName + ) + + if (!isTemplateAllowed) { + throw new TemplateNotAllowedError(templateName, userId, effectiveAllowlist) + } + } + + /** + * Check if a template is a built-in template. + * Built-in templates are always allowed for all users. + * + * @param templateName - The template name to check + * @returns true if the template is built-in + */ + isBuiltInTemplate(templateName: string): boolean { + const normalizedTemplateName = templateName.toLowerCase() + return BUILT_IN_TEMPLATES.some( + builtInTemplate => builtInTemplate.toLowerCase() === normalizedTemplateName + ) + } + + /** + * Get the user's custom allowlist (without built-in templates). + * + * @param userId - The user ID (null for anonymous users) + * @returns The user's custom allowed templates, or built-in templates for anonymous + */ + async getUserAllowlist(userId: string | null | undefined): Promise<string[]> { + const isAnonymousUser = userId === null || userId === undefined + if (isAnonymousUser) { + return [...BUILT_IN_TEMPLATES] + } + + const userAllowlist = await this._repository.getUserAllowlist(userId) + if (!userAllowlist) { + return [...BUILT_IN_TEMPLATES] + } + + return userAllowlist.allowedTemplates + } + + /** + * Get the user's effective allowlist (built-in + custom templates). + * + * @param userId - The user ID (null for anonymous users) + * @returns Combined list of all allowed templates + */ + async getEffectiveAllowlist(userId: string | null | undefined): Promise<string[]> { + const isAnonymousUser = userId === null || userId === undefined + if (isAnonymousUser) { + return [...BUILT_IN_TEMPLATES] + } + + const userAllowlist = await this._repository.getUserAllowlist(userId) + const customTemplates = userAllowlist?.allowedTemplates ?? [] + + const combinedTemplates = [...BUILT_IN_TEMPLATES] + for (const customTemplate of customTemplates) { + const normalizedCustomTemplate = customTemplate.toLowerCase() + const isDuplicate = combinedTemplates.some( + existingTemplate => existingTemplate.toLowerCase() === normalizedCustomTemplate + ) + if (!isDuplicate) { + combinedTemplates.push(customTemplate) + } + } + + return combinedTemplates + } + + /** + * Validate that a tile's visibility is compatible with its template's visibility. + * Public tiles cannot use private templates (transparency principle). + * + * @param templateName - The template name + * @param tileVisibility - The tile's visibility + * @param templateVisibility - The template's visibility + * @throws TemplateVisibilityError if public tile uses private template + */ + async validateVisibility( + templateName: string, + tileVisibility: Visibility, + templateVisibility: Visibility + ): Promise<void> { + const isPublicTileUsingPrivateTemplate = + tileVisibility === 'public' && templateVisibility === 'private' + + if (isPublicTileUsingPrivateTemplate) { + throw new TemplateVisibilityError(templateName, tileVisibility, templateVisibility) + } + } + + /** + * Validate that the template name is valid (not empty or whitespace-only). + */ + private _validateTemplateName(templateName: string): void { + if (!templateName || templateName.trim() === '') { + throw new Error('Template name cannot be empty or whitespace-only') + } + } +} diff --git a/src/lib/domains/agentic/services/_templates/template-resolver.service.ts b/src/lib/domains/agentic/services/_templates/template-resolver.service.ts new file mode 100644 index 000000000..e00f981aa --- /dev/null +++ b/src/lib/domains/agentic/services/_templates/template-resolver.service.ts @@ -0,0 +1,125 @@ +/** + * Template Resolver Service + * + * Resolves template tiles by name from the database. + * Used by buildPrompt() to retrieve template content for {{@TemplateName}} expansion. + */ + +// ==================== TYPES ==================== + +/** + * Data structure for a template tile. + */ +export interface TemplateData { + templateName: string + title: string + content: string + coords: string +} + +/** + * Template with its structural children (sub-templates). + */ +export interface TemplateWithChildren extends TemplateData { + subTemplates: TemplateData[] +} + +// ==================== ERRORS ==================== + +/** + * Error thrown when a template is not found by name. + */ +export class TemplateNotFoundError extends Error { + constructor(templateName: string) { + super(`Template "${templateName}" not found`) + this.name = 'TemplateNotFoundError' + } +} + +// ==================== REPOSITORY INTERFACE ==================== + +/** + * Repository interface for template queries. + * Implementations should query map_items WHERE templateName = ? AND itemType = 'template'. + */ +export interface TemplateRepository { + findByTemplateName(templateName: string): Promise<TemplateData | null> + findByTemplateNameWithChildren(templateName: string): Promise<TemplateWithChildren | null> +} + +// ==================== SERVICE ==================== + +/** + * Service for resolving template tiles by name. + * + * Provides methods to: + * - Look up a template by its templateName + * - Look up a template with its structural children (sub-templates) + */ +export class TemplateResolverService { + constructor(private readonly repository: TemplateRepository) {} + + /** + * Get template data by name. + * + * @param templateName - The template name to look up + * @returns The template data + * @throws TemplateNotFoundError if template does not exist + * @throws Error for invalid input (empty, whitespace-only, null, undefined) + */ + async getTemplateByName(templateName: string): Promise<TemplateData> { + this._validateTemplateName(templateName) + + const templateData = await this.repository.findByTemplateName(templateName) + + if (templateData === null) { + throw new TemplateNotFoundError(templateName) + } + + return templateData + } + + /** + * Get template data with its structural children (sub-templates). + * + * @param templateName - The template name to look up + * @returns The template with its sub-templates + * @throws TemplateNotFoundError if template does not exist + * @throws Error for invalid input (empty, whitespace-only, null, undefined) + */ + async getTemplateWithSubTemplates(templateName: string): Promise<TemplateWithChildren> { + this._validateTemplateName(templateName) + + const templateWithChildren = await this.repository.findByTemplateNameWithChildren(templateName) + + if (templateWithChildren === null) { + throw new TemplateNotFoundError(templateName) + } + + return templateWithChildren + } + + /** + * Validate that the template name is valid. + * + * @param templateName - The template name to validate + * @throws Error if template name is invalid + */ + private _validateTemplateName(templateName: string): void { + if (templateName === null || templateName === undefined) { + throw new Error('Template name cannot be null or undefined') + } + + if (typeof templateName !== 'string') { + throw new Error('Template name must be a string') + } + + if (templateName.length === 0) { + throw new Error('Template name cannot be empty') + } + + if (templateName.trim().length === 0) { + throw new Error('Template name cannot be whitespace only') + } + } +} diff --git a/src/lib/domains/agentic/services/agentic.factory.ts b/src/lib/domains/agentic/services/agentic.factory.ts index d102b4f30..bfb0a3b6e 100644 --- a/src/lib/domains/agentic/services/agentic.factory.ts +++ b/src/lib/domains/agentic/services/agentic.factory.ts @@ -3,10 +3,10 @@ import { ClaudeAgentSDKRepository } from '~/lib/domains/agentic/repositories/cla import { ClaudeAgentSDKSandboxRepository } from '~/lib/domains/agentic/repositories/claude-agent-sdk-sandbox.repository' import type { SandboxInstance } from '~/lib/domains/agentic/repositories/claude-agent-sdk-sandbox.repository' import { QueuedLLMRepository } from '~/lib/domains/agentic/repositories/queued-llm.repository' -import { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' -import { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' -import { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service' -import { SimpleTokenizerService } from '~/lib/domains/agentic/services/tokenizer.service' +import { CanvasContextBuilder } from '~/lib/domains/agentic/services/_context/canvas-context-builder.service' +import { ChatContextBuilder } from '~/lib/domains/agentic/services/_context/chat-context-builder.service' +import { ContextCompositionService } from '~/lib/domains/agentic/services/_context/context-composition.service' +import { SimpleTokenizerService } from '~/lib/domains/agentic/services/_context/tokenizer.service' import { AgenticService } from '~/lib/domains/agentic/services/agentic.service' import { sandboxSessionManager } from '~/lib/domains/agentic/services/sandbox-session' import { inngest } from '~/lib/domains/agentic/infrastructure' diff --git a/src/lib/domains/agentic/services/agentic.service.ts b/src/lib/domains/agentic/services/agentic.service.ts index ed3590b44..6d89f448e 100644 --- a/src/lib/domains/agentic/services/agentic.service.ts +++ b/src/lib/domains/agentic/services/agentic.service.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'crypto' import type { ILLMRepository, StreamCallbacks } from '~/lib/domains/agentic/repositories/llm.repository.interface' -import type { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service' -import { PromptTemplateService } from '~/lib/domains/agentic/services/prompt-template.service' +import type { ContextCompositionService } from '~/lib/domains/agentic/services/_context/context-composition.service' +import { PromptTemplateService } from '~/lib/domains/agentic/services/_templates' // import { IntentClassifierService } from '../intent-classification/intent-classifier.service' import type { EventBusService } from '~/lib/utils/event-bus' import type { diff --git a/src/lib/domains/agentic/services/dependencies.json b/src/lib/domains/agentic/services/dependencies.json index a2a964ced..0ff0586a7 100644 --- a/src/lib/domains/agentic/services/dependencies.json +++ b/src/lib/domains/agentic/services/dependencies.json @@ -10,5 +10,8 @@ "~/lib/utils/event-bus", "~/server/db" ], + "subsystems": [ + "./_run-services" + ], "exceptions": {} } \ No newline at end of file diff --git a/src/lib/domains/agentic/services/index.ts b/src/lib/domains/agentic/services/index.ts index 8efcc93ef..9829998ac 100644 --- a/src/lib/domains/agentic/services/index.ts +++ b/src/lib/domains/agentic/services/index.ts @@ -3,12 +3,15 @@ export type { GenerateResponseOptions, SubagentConfig } from '~/lib/domains/agen export { createAgenticService, createAgenticServiceAsync } from '~/lib/domains/agentic/services/agentic.factory' export type { CreateAgenticServiceOptions, LLMConfig } from '~/lib/domains/agentic/services/agentic.factory' -export { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' -export { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' -export { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service' -export { ContextSerializerService } from '~/lib/domains/agentic/services/context-serializer.service' -export { SimpleTokenizerService } from '~/lib/domains/agentic/services/tokenizer.service' -export type { TokenizerService } from '~/lib/domains/agentic/services/tokenizer.service' +export { CanvasContextBuilder } from '~/lib/domains/agentic/services/_context/canvas-context-builder.service' +export { ChatContextBuilder } from '~/lib/domains/agentic/services/_context/chat-context-builder.service' +export { ContextCompositionService } from '~/lib/domains/agentic/services/_context/context-composition.service' +export { ContextSerializerService } from '~/lib/domains/agentic/services/_context/context-serializer.service' +export { SimpleTokenizerService } from '~/lib/domains/agentic/services/_context/tokenizer.service' +export type { TokenizerService } from '~/lib/domains/agentic/services/_context/tokenizer.service' export { PreviewGeneratorService } from '~/lib/domains/agentic/services/preview-generator.service' -export type { GeneratePreviewInput, GeneratePreviewResult } from '~/lib/domains/agentic/services/preview-generator.service' \ No newline at end of file +export type { GeneratePreviewInput, GeneratePreviewResult } from '~/lib/domains/agentic/services/preview-generator.service' + +export { RunService } from '~/lib/domains/agentic/services/_run-services' +export type { Run, RunStatus, ExecutionLogEntry } from '~/lib/domains/agentic/services/_run-services' \ No newline at end of file diff --git a/src/lib/domains/agentic/services/preview-generator.service.ts b/src/lib/domains/agentic/services/preview-generator.service.ts index 35f4389ab..396dc2532 100644 --- a/src/lib/domains/agentic/services/preview-generator.service.ts +++ b/src/lib/domains/agentic/services/preview-generator.service.ts @@ -1,5 +1,5 @@ import type { ILLMRepository } from '~/lib/domains/agentic/repositories/llm.repository.interface' -import { PromptTemplateService } from '~/lib/domains/agentic/services/prompt-template.service' +import { PromptTemplateService } from '~/lib/domains/agentic/services/_templates' import type { LLMGenerationParams } from '~/lib/domains/agentic/types' import { env } from '~/env' diff --git a/src/lib/domains/agentic/services/sandbox-session/index.ts b/src/lib/domains/agentic/services/sandbox-session/index.ts index 8e087b8a2..6545a9ab6 100644 --- a/src/lib/domains/agentic/services/sandbox-session/index.ts +++ b/src/lib/domains/agentic/services/sandbox-session/index.ts @@ -1,5 +1,5 @@ import { SandboxSessionManager } from '~/lib/domains/agentic/services/sandbox-session/sandbox-session-manager.service' -import { createSessionStore } from '~/lib/domains/agentic/services/sandbox-session/redis-session-store' +import { createSessionStore } from '~/lib/domains/agentic/services/sandbox-session/session-store-factory' export { SandboxSessionManager } export type { @@ -8,7 +8,8 @@ export type { ISandboxSessionManager } from '~/lib/domains/agentic/services/sandbox-session/sandbox-session.types' export type { ISessionStore } from '~/lib/domains/agentic/services/sandbox-session/redis-session-store' -export { createSessionStore, MemorySessionStore, RedisSessionStore } from '~/lib/domains/agentic/services/sandbox-session/redis-session-store' +export { MemorySessionStore, RedisSessionStore } from '~/lib/domains/agentic/services/sandbox-session/redis-session-store' +export { createSessionStore } from '~/lib/domains/agentic/services/sandbox-session/session-store-factory' const DEFAULT_TIMEOUT_SECONDS = 5 * 60 // 5 minutes diff --git a/src/lib/domains/agentic/services/sandbox-session/redis-session-store.ts b/src/lib/domains/agentic/services/sandbox-session/redis-session-store.ts index 8f1907384..0847616d3 100644 --- a/src/lib/domains/agentic/services/sandbox-session/redis-session-store.ts +++ b/src/lib/domains/agentic/services/sandbox-session/redis-session-store.ts @@ -1,4 +1,4 @@ -import { Redis } from '@upstash/redis' +import type { Redis } from '@upstash/redis' import type { SandboxSession } from '~/lib/domains/agentic/services/sandbox-session/sandbox-session.types' /** @@ -122,23 +122,4 @@ export class RedisSessionStore implements ISessionStore { } } -/** - * Create a session store based on environment configuration. - * Uses Redis if UPSTASH_REDIS_REST_URL is configured, otherwise falls back to memory. - */ -export function createSessionStore(defaultTtlSeconds = 300): ISessionStore { - const redisUrl = process.env.UPSTASH_REDIS_REST_URL - const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN - - if (redisUrl && redisToken) { - console.log('[SessionStore] Using Redis-backed session store') - const redis = new Redis({ - url: redisUrl, - token: redisToken - }) - return new RedisSessionStore(redis, defaultTtlSeconds) - } - - console.log('[SessionStore] Redis not configured, using in-memory store (sessions will not persist across cold starts)') - return new MemorySessionStore() -} +// Factory function moved to session-store-factory.ts to follow Rule of 6 diff --git a/src/lib/domains/agentic/services/sandbox-session/session-store-factory.ts b/src/lib/domains/agentic/services/sandbox-session/session-store-factory.ts new file mode 100644 index 000000000..f148bfbaa --- /dev/null +++ b/src/lib/domains/agentic/services/sandbox-session/session-store-factory.ts @@ -0,0 +1,30 @@ +/** + * Session store factory. + * + * Creates the appropriate session store based on environment configuration. + */ + +import { Redis } from '@upstash/redis' +import type { ISessionStore } from '~/lib/domains/agentic/services/sandbox-session/redis-session-store' +import { MemorySessionStore, RedisSessionStore } from '~/lib/domains/agentic/services/sandbox-session/redis-session-store' + +/** + * Create a session store based on environment configuration. + * Uses Redis if UPSTASH_REDIS_REST_URL is configured, otherwise falls back to memory. + */ +export function createSessionStore(defaultTtlSeconds = 300): ISessionStore { + const redisUrl = process.env.UPSTASH_REDIS_REST_URL + const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN + + if (redisUrl && redisToken) { + console.log('[SessionStore] Using Redis-backed session store') + const redis = new Redis({ + url: redisUrl, + token: redisToken + }) + return new RedisSessionStore(redis, defaultTtlSeconds) + } + + console.log('[SessionStore] Redis not configured, using in-memory store (sessions will not persist across cold starts)') + return new MemorySessionStore() +} diff --git a/src/lib/domains/agentic/services/task-execution.service.ts b/src/lib/domains/agentic/services/task-execution.service.ts index a1221c167..377702a59 100644 --- a/src/lib/domains/agentic/services/task-execution.service.ts +++ b/src/lib/domains/agentic/services/task-execution.service.ts @@ -17,7 +17,7 @@ import type { CompositionConfig, ChatMessageContract } from '~/lib/domains/agent import type { LLMResponse, StreamChunk } from '~/lib/domains/agentic/types/llm.types' import type { StreamCallbacks } from '~/lib/domains/agentic/repositories/llm.repository.interface' // Type-only imports from mapping domain (no runtime dependency) -import type { MapItemType, Visibility } from '~/lib/domains/mapping' +import type { ItemTypeValue, Visibility } from '~/lib/domains/mapping' // ============================================================================= // Types @@ -34,7 +34,7 @@ export interface TaskTile { content: string | undefined preview: string | undefined link: string | undefined - itemType: MapItemType + itemType: ItemTypeValue visibility: Visibility depth: number parentId: string | null diff --git a/src/lib/domains/agentic/templates/README.md b/src/lib/domains/agentic/templates/README.md index 2169dc461..7fa9e0970 100644 --- a/src/lib/domains/agentic/templates/README.md +++ b/src/lib/domains/agentic/templates/README.md @@ -21,6 +21,60 @@ Think of it like a document generator: you provide the data, it picks the right - Resolving hexplan content β†’ See `~/server/api/routers/agentic.ts` - Streaming LLM responses β†’ See `~/lib/domains/agentic/services` - Hexplan generation logic β†’ See `~/lib/domains/agentic/utils/prompt-builder.ts` +- Template tile CRUD operations β†’ See `~/lib/domains/agentic/services/template-resolver.service.ts` + +## Built-in Templates + +Built-in templates are stored as database tiles at well-known coordinates, making them inspectable and modifiable. This follows the "Templates as Tiles" design (see `docs/features/TEMPLATES_AS_TILES.md`). + +### Well-Known Coordinates + +| Template | Location | Description | +|----------|----------|-------------| +| System User ID | `D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK` | Internal system user that owns built-in templates | +| Templates Parent | `D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK,0:1,2` | Organizational tile containing all templates | +| System Template | `D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK,0:1,2,1` | SYSTEM tile execution template | +| User Template | `D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK,0:1,2,2` | USER interlocutor template | + +### Template Tile Structure + +Template tiles use a special `itemType: "template"` and include: +- `title`: Human-readable name (e.g., "System Task Template") +- `content`: Mustache template markup +- `templateName`: Lookup identifier (e.g., "system", "user") +- `visibility`: "public" (templates are globally accessible) + +### Available Templates + +**SYSTEM Template** (`_system-template.ts`) +- Used for executable task tiles +- 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 `<status>` 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 + +### Seeding Built-in Templates + +Templates are seeded to the database using a dedicated script: + +```bash +# With environment variables loaded +dotenv -e .env -e .env.local -- pnpm tsx drizzle/seeds/templates.seed.ts + +# Or if DATABASE_URL is already set +pnpm tsx drizzle/seeds/templates.seed.ts +``` + +The seed script is **idempotent**: +- Creates new templates if they do not exist +- Updates existing templates if content has changed +- Skips templates that are already up-to-date + +See `drizzle/seeds/templates.seed.ts` for implementation details. ## Interface @@ -40,6 +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 + userMessage?: string // Optional instruction for execution + wasBlocked?: boolean // Whether resuming from blocked state + blockageReason?: string // Reason for previous blockage } ``` @@ -47,14 +105,135 @@ interface PromptData { - `mustache` - Template rendering engine - `~/lib/domains/mapping` - For `MapItemType` enum -**Internal Files** (prefixed with `_`): -- `_prompt-builder.ts` - Core implementation (template lookup, data transformation, rendering) -- `_system-template.ts` - SYSTEM tile template string and constants +## File Structure + +``` +templates/ +β”œβ”€β”€ index.ts # Public API exports +β”œβ”€β”€ _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 +β”œβ”€β”€ _pre-processor/ # {{@Template}} tag expansion +β”‚ β”œβ”€β”€ index.ts +β”‚ β”œβ”€β”€ _parser.ts +β”‚ └── _resolver.ts +β”œβ”€β”€ _templates/ # Rendering primitive functions +β”‚ β”œβ”€β”€ index.ts # Template registry +β”‚ β”œβ”€β”€ _generic-tile.ts # GenericTile() renderer +β”‚ β”œβ”€β”€ _folder.ts # Folder() renderer +β”‚ β”œβ”€β”€ _tile-or-folder.ts # TileOrFolder() renderer +β”‚ └── _hexplan.ts # HexPlan() renderer +β”œβ”€β”€ _internals/ # Shared utilities +β”‚ β”œβ”€β”€ types.ts # PromptData and related types +β”‚ β”œβ”€β”€ utils.ts # XML escaping, content checks +β”‚ └── section-builders.ts # Context/subtask/ancestor section builders +└── __tests__/ # Test files +``` + +## Pre-processor Tags + +The pre-processor expands special tags before Mustache rendering: + +| Tag | Description | +|-----|-------------| +| `{{@HexPlan}}` | Renders hexplan section with status handling and execution instructions | +| `{{@GenericTile(...)}}` | Renders a tile with specified fields | +| `{{@Folder(...)}}` | Renders organizational tile structure | +| `{{@TileOrFolder(...)}}` | Conditionally renders as tile or folder based on itemType | +| `{{@ChildTemplateName}}` | Expands to content of structural child template tile (see below) | + +### Sub-template Expansion with `{{@ChildTemplateName}}` + +Template tiles can reference their structural children (directions 1-6) as sub-templates. When the pre-processor encounters `{{@SomeChildName}}`, it: + +1. Looks up a structural child tile with `templateName: "SomeChildName"` +2. Retrieves that child's content +3. Recursively pre-processes the child content (supporting nested templates) +4. Inserts the expanded result in place of the tag + +**Example template hierarchy:** +``` +Template "my-agent-template" (parent) +β”œβ”€β”€ [1] Sub-template "HeaderSection" +β”œβ”€β”€ [2] Sub-template "ContextBlock" +└── [3] Sub-template "TaskSection" +``` + +**Parent template content:** +```mustache +{{@HeaderSection}} + +{{@ContextBlock}} + +{{@TaskSection}} +``` + +**Benefits:** +- Template structure is visible in the map +- Self-contained: copying a template tile includes all sub-templates +- Overridable: fork and modify individual sub-templates + +**Error handling:** +- If a referenced child template is not found, throws `TemplateError` +- Circular template references are detected and throw `CircularTemplateError` + +## Tile-Based Template Lookup + +The `buildPrompt` function supports looking up templates from tile storage, enabling user-created templates and transparent prompt inspection. + +### TemplateResolverService Integration + +The `TemplateResolverService` (located at `~/lib/domains/agentic/services/template-resolver.service.ts`) provides the interface for retrieving templates from tile storage: + +```typescript +interface TemplateRepository { + findByTemplateName(templateName: string): Promise<TemplateData | null> + findByTemplateNameWithChildren(templateName: string): Promise<TemplateWithChildren | null> +} + +class TemplateResolverService { + // Look up a template by its templateName field + async getTemplateByName(templateName: string): Promise<TemplateData> + + // Look up a template with its structural children (sub-templates) + async getTemplateWithSubTemplates(templateName: string): Promise<TemplateWithChildren> +} +``` + +**Key types:** +- `TemplateData`: Contains `templateName`, `title`, `content`, and `coords` +- `TemplateWithChildren`: Extends `TemplateData` with `subTemplates` array +- `TemplateNotFoundError`: Thrown when a template lookup fails + +### Fallback Behavior to TypeScript Constants + +When a template is not found in tile storage, `buildPrompt` falls back to the built-in TypeScript constants: + +1. **SYSTEM tiles**: Falls back to `SYSTEM_TEMPLATE` from `_system-template.ts` +2. **USER tiles**: Falls back to `USER_TEMPLATE` from `_user-template.ts` +3. **Custom types**: Throws `TemplateNotFoundError` if no tile-based template exists + +This ensures backward compatibility while enabling the transition to tile-based templates. + +**Lookup order:** +1. Query tile storage by `itemType` (lowercased) +2. If not found, check built-in TypeScript constants +3. If still not found, throw appropriate error + +### Template Tile Discovery + +Templates are discovered by their `templateName` field, which maps to tile `itemType`: +- Tile with `itemType: "system"` β†’ looks for template with `templateName: "system"` +- Tile with `itemType: "my-agent"` β†’ looks for template with `templateName: "my-agent"` + +Built-in template tiles are stored at well-known coordinates under the system user (see "Built-in Templates" section above). ## Key Principles -- **Template per ItemType**: Each `MapItemType` has its own template. Currently only SYSTEM is implemented. +- **Template per ItemType**: Each `MapItemType` has its own template. SYSTEM and USER are implemented. +- **Templates as Tiles**: Built-in templates are stored in the database, not just code (transparency principle). - **Clean Separation**: Templates know nothing about database, HTTP, or LLM concerns. - **Fail Fast**: Unimplemented item types throw clear errors rather than falling back silently. - **XML Output**: Prompts use XML tags for structured sections that agents can parse. -- **Minimal Public API**: Only `buildPrompt` and `PromptData` are exported. All internals are hidden. +- **Minimal Public API**: Only essential functions and types are exported. All internals are hidden. +- **Fallback to Constants**: Built-in templates in TypeScript constants serve as fallback when tile-based templates are unavailable. diff --git a/src/lib/domains/agentic/templates/__tests__/builtin-templates.test.ts b/src/lib/domains/agentic/templates/__tests__/builtin-templates.test.ts new file mode 100644 index 000000000..76d583d25 --- /dev/null +++ b/src/lib/domains/agentic/templates/__tests__/builtin-templates.test.ts @@ -0,0 +1,423 @@ +/** + * Built-in Template Tiles Tests (TDD) + * + * These tests define the expected behavior for migrating built-in templates + * (SYSTEM_TEMPLATE, USER_TEMPLATE) from TypeScript code to tile storage. + * + * Design reference: docs/features/TEMPLATES_AS_TILES.md + * + * Well-known coordinates: + * - System Templates parent: D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK,0:1,2 (Templates organizational tile) + * - Built-in templates are structural children (directions 1-6) of this tile + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + TemplateResolverService, + type TemplateData, + type TemplateRepository, +} from '~/lib/domains/agentic/services/_templates/template-resolver.service' +import { SYSTEM_TEMPLATE } from '~/lib/domains/agentic/templates/_system-template' +import { USER_TEMPLATE } from '~/lib/domains/agentic/templates/_user-template' + +// ==================== CONSTANTS ==================== + +/** + * Well-known user ID for system-owned templates. + * This is Hexframe's internal system user that owns built-in templates. + */ +const SYSTEM_USER_ID = 'D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK' + +/** + * Well-known coordinates for the Templates organizational tile. + * Built-in templates are stored as children of this tile. + */ +const TEMPLATES_PARENT_PATH = [1, 2] // Direction 1 (NW), then 2 (NE) + +/** + * Built-in template names that must exist. + * These map to the tile type they render. + */ +const BUILTIN_TEMPLATE_NAMES = { + SYSTEM: 'system', + USER: 'user', + ORGANIZATIONAL: 'organizational', + CONTEXT: 'context', +} as const + +// ==================== TEST DATA ==================== + +/** + * Expected template tile structure for built-in templates. + */ +interface BuiltinTemplateSpec { + templateName: string + title: string + expectedContent: string + direction: number // Direction from Templates parent tile +} + +const BUILTIN_TEMPLATE_SPECS: BuiltinTemplateSpec[] = [ + { + templateName: BUILTIN_TEMPLATE_NAMES.SYSTEM, + title: 'System Task Template', + expectedContent: SYSTEM_TEMPLATE, + direction: 1, + }, + { + templateName: BUILTIN_TEMPLATE_NAMES.USER, + title: 'User Interlocutor Template', + expectedContent: USER_TEMPLATE, + direction: 2, + }, + // organizational and context templates will be added when their TypeScript templates exist +] + +// ==================== SEED SCRIPT TESTS ==================== + +describe('BuiltinTemplateSeed', () => { + describe('template tile creation', () => { + it('should create template tiles at well-known coordinates', async () => { + // This test verifies the seed script creates tiles at the expected locations + // The seed script should: + // 1. Create or find the Templates organizational tile at TEMPLATES_PARENT_PATH + // 2. Create template tiles as structural children (directions 1-6) + + // Expected coordinates for system template: Templates parent path + direction 1 + const expectedSystemTemplatePath = [...TEMPLATES_PARENT_PATH, 1] + + // This is a specification - implementation will provide the actual seed function + expect(expectedSystemTemplatePath).toEqual([1, 2, 1]) + }) + + it('should create system template tile with correct templateName', async () => { + // Template tiles must have templateName set to enable lookup by TemplateResolverService + const expectedTemplateName = BUILTIN_TEMPLATE_NAMES.SYSTEM + + expect(expectedTemplateName).toBe('system') + }) + + it('should create user template tile with correct templateName', async () => { + const expectedTemplateName = BUILTIN_TEMPLATE_NAMES.USER + + expect(expectedTemplateName).toBe('user') + }) + + it('should set visibility to public for built-in templates', async () => { + // Built-in templates must be public so all users can access them + // This follows the transparency principle from TEMPLATES_AS_TILES.md + const expectedVisibility = 'public' + + expect(expectedVisibility).toBe('public') + }) + + it('should store SYSTEM_TEMPLATE content in system template tile', async () => { + // The template tile content should exactly match the TypeScript template + const systemTemplateSpec = BUILTIN_TEMPLATE_SPECS.find( + spec => spec.templateName === 'system' + ) + + expect(systemTemplateSpec?.expectedContent).toBe(SYSTEM_TEMPLATE) + expect(systemTemplateSpec?.expectedContent).toContain('{{{hexrunIntro}}}') + expect(systemTemplateSpec?.expectedContent).toContain('{{@HexPlan}}') + }) + + it('should store USER_TEMPLATE content in user template tile', async () => { + const userTemplateSpec = BUILTIN_TEMPLATE_SPECS.find( + spec => spec.templateName === 'user' + ) + + expect(userTemplateSpec?.expectedContent).toBe(USER_TEMPLATE) + // Pool-based template uses template context and RenderChildren + expect(userTemplateSpec?.expectedContent).toContain('{{{template[-1].content}}}') + expect(userTemplateSpec?.expectedContent).toContain('{{@RenderChildren') + }) + }) + + describe('idempotency', () => { + it('should not duplicate templates when run multiple times', async () => { + // Seed script must be idempotent - running it twice should not create duplicates + // It should either: + // 1. Check if template exists before creating + // 2. Use upsert semantics + + // This is a behavioral specification for the seed script + const firstRunTemplateCount = 4 // system, user, organizational, context + const secondRunTemplateCount = 4 // same - no duplicates + + expect(firstRunTemplateCount).toBe(secondRunTemplateCount) + }) + + it('should update existing template content if changed', async () => { + // When TypeScript template changes, re-running seed should update tile content + // This ensures built-in templates stay in sync with code + + const originalContent = SYSTEM_TEMPLATE + const updatedContent = SYSTEM_TEMPLATE // Same in this test, but could differ + + // Seed should detect content mismatch and update + expect(originalContent).toBe(updatedContent) + }) + }) +}) + +// ==================== TEMPLATE RESOLVER INTEGRATION TESTS ==================== + +describe('TemplateResolverService with built-in templates', () => { + let mockRepository: TemplateRepository + let service: TemplateResolverService + + /** + * Mock data representing built-in templates as they would be stored in the database. + */ + const mockBuiltinTemplates: Record<string, TemplateData> = { + system: { + templateName: 'system', + title: 'System Task Template', + content: SYSTEM_TEMPLATE, + coords: `${SYSTEM_USER_ID},0:1,2,1`, + }, + user: { + templateName: 'user', + title: 'User Interlocutor Template', + content: USER_TEMPLATE, + coords: `${SYSTEM_USER_ID},0:1,2,2`, + }, + } + + beforeEach(() => { + mockRepository = { + findByTemplateName: vi.fn().mockImplementation((name: string) => { + return Promise.resolve(mockBuiltinTemplates[name] ?? null) + }), + findByTemplateNameWithChildren: vi.fn().mockImplementation((name: string) => { + const template = mockBuiltinTemplates[name] + return Promise.resolve(template ? { ...template, subTemplates: [] } : null) + }), + } + + service = new TemplateResolverService(mockRepository) + }) + + describe('fetching built-in templates by name', () => { + it('should fetch system template by name "system"', async () => { + const result = await service.getTemplateByName('system') + + expect(result.templateName).toBe('system') + expect(result.content).toBe(SYSTEM_TEMPLATE) + expect(mockRepository.findByTemplateName).toHaveBeenCalledWith('system') + }) + + it('should fetch user template by name "user"', async () => { + const result = await service.getTemplateByName('user') + + expect(result.templateName).toBe('user') + expect(result.content).toBe(USER_TEMPLATE) + expect(mockRepository.findByTemplateName).toHaveBeenCalledWith('user') + }) + + it('should return template content matching TypeScript constant for system', async () => { + const result = await service.getTemplateByName('system') + + // Verify the tile content exactly matches the TypeScript template + expect(result.content).toBe(SYSTEM_TEMPLATE) + expect(result.content).toContain('<task>') + expect(result.content).toContain('{{@HexPlan}}') + }) + + it('should return template content matching TypeScript constant for user', async () => { + const result = await service.getTemplateByName('user') + + expect(result.content).toBe(USER_TEMPLATE) + expect(result.content).toContain('<user-message>') + expect(result.content).toContain('{{{userMessage}}}') + }) + + it('should return template with correct coordinates under system user', async () => { + const systemResult = await service.getTemplateByName('system') + const userResult = await service.getTemplateByName('user') + + // Both templates should be owned by the system user + expect(systemResult.coords).toContain(SYSTEM_USER_ID) + expect(userResult.coords).toContain(SYSTEM_USER_ID) + + // Both should be children of the Templates organizational tile + expect(systemResult.coords).toContain('1,2,') // Path includes Templates parent + expect(userResult.coords).toContain('1,2,') + }) + }) + + describe('template resolution for buildPrompt', () => { + it('should provide template data compatible with Mustache rendering', async () => { + const result = await service.getTemplateByName('system') + + // The content should be valid Mustache template syntax + expect(result.content).toContain('{{{') // Triple braces for unescaped + expect(result.content).toContain('{{#') // Section opening + expect(result.content).toContain('{{/') // Section closing + }) + + it('should provide template data with required fields for pre-processor', async () => { + const result = await service.getTemplateByName('system') + + // Template data should have all fields needed by the pre-processor + expect(result).toHaveProperty('templateName') + expect(result).toHaveProperty('title') + expect(result).toHaveProperty('content') + expect(result).toHaveProperty('coords') + }) + }) +}) + +// ==================== TEMPLATE CONTENT VERIFICATION ==================== + +describe('Built-in template content verification', () => { + describe('SYSTEM_TEMPLATE', () => { + it('should contain hexrunIntro section', () => { + expect(SYSTEM_TEMPLATE).toContain('{{{hexrunIntro}}}') + }) + + it('should contain ancestor context section', () => { + expect(SYSTEM_TEMPLATE).toContain('{{#hasAncestorsWithContent}}') + expect(SYSTEM_TEMPLATE).toContain('{{{ancestorContextSection}}}') + }) + + it('should contain composed children context section', () => { + expect(SYSTEM_TEMPLATE).toContain('{{#hasComposedChildren}}') + expect(SYSTEM_TEMPLATE).toContain('{{{contextSection}}}') + }) + + it('should contain subtasks section', () => { + expect(SYSTEM_TEMPLATE).toContain('{{#hasSubtasks}}') + expect(SYSTEM_TEMPLATE).toContain('{{{subtasksSection}}}') + }) + + it('should contain task section with goal and content', () => { + expect(SYSTEM_TEMPLATE).toContain('<task>') + expect(SYSTEM_TEMPLATE).toContain('<goal>{{{task.title}}}</goal>') + expect(SYSTEM_TEMPLATE).toContain('{{#task.hasContent}}') + expect(SYSTEM_TEMPLATE).toContain('{{{task.content}}}') + }) + + it('should contain HexPlan pre-processor tag', () => { + expect(SYSTEM_TEMPLATE).toContain('{{@HexPlan}}') + }) + }) + + describe('USER_TEMPLATE (pool-based)', () => { + it('should contain template context reference for user intro', () => { + // Pool-based template uses template[-1].content for intro + expect(USER_TEMPLATE).toContain('{{{template[-1].content}}}') + }) + + it('should contain RenderChildren for composed children context', () => { + // Pool-based dispatch for context children + expect(USER_TEMPLATE).toContain('{{@RenderChildren range=[-6..-1]') + expect(USER_TEMPLATE).toContain('<context>') + }) + + it('should contain RenderChildren for sections navigation', () => { + // Pool-based dispatch for structural children + expect(USER_TEMPLATE).toContain('{{@RenderChildren range=[1..6]') + expect(USER_TEMPLATE).toContain('<sections>') + }) + + it('should contain RenderChildren for recent history (direction-0)', () => { + // Pool-based dispatch for direction-0 (recent history) + expect(USER_TEMPLATE).toContain('{{@RenderChildren range=[0..0]') + }) + + it('should contain discussion section', () => { + expect(USER_TEMPLATE).toContain('{{#hasDiscussion}}') + expect(USER_TEMPLATE).toContain('<discussion>') + expect(USER_TEMPLATE).toContain('{{{discussion}}}') + }) + + it('should contain user message section', () => { + expect(USER_TEMPLATE).toContain('{{#hasUserMessage}}') + expect(USER_TEMPLATE).toContain('<user-message>') + expect(USER_TEMPLATE).toContain('{{{userMessage}}}') + }) + + it('should NOT contain HexPlan tag (user tiles use recent-history via RenderChildren)', () => { + expect(USER_TEMPLATE).not.toContain('{{@HexPlan}}') + }) + }) +}) + +// ==================== TEMPLATE TYPE SPECIFICATION ==================== + +describe('Template tile type specification', () => { + it('should define "template" as a valid itemType for template tiles', () => { + // Template tiles must have itemType="template" to be identifiable + // This is specified in TEMPLATES_AS_TILES.md + const expectedItemType = 'template' + + expect(expectedItemType).toBe('template') + }) + + it('should reserve built-in template names', () => { + // Built-in template names should be reserved and not creatable by users + const reservedNames = ['system', 'user', 'organizational', 'context'] + + expect(reservedNames).toContain(BUILTIN_TEMPLATE_NAMES.SYSTEM) + expect(reservedNames).toContain(BUILTIN_TEMPLATE_NAMES.USER) + expect(reservedNames).toContain(BUILTIN_TEMPLATE_NAMES.ORGANIZATIONAL) + expect(reservedNames).toContain(BUILTIN_TEMPLATE_NAMES.CONTEXT) + }) + + it('should enforce unique templateName constraint', () => { + // Each templateName should be unique across the system + // This is enforced via database constraint: UNIQUE(templateName) + const templateNames = new Set([ + BUILTIN_TEMPLATE_NAMES.SYSTEM, + BUILTIN_TEMPLATE_NAMES.USER, + BUILTIN_TEMPLATE_NAMES.ORGANIZATIONAL, + BUILTIN_TEMPLATE_NAMES.CONTEXT, + ]) + + expect(templateNames.size).toBe(4) // All unique + }) +}) + +// ==================== SEED SCRIPT INTERFACE SPECIFICATION ==================== + +describe('Template seed script interface', () => { + /** + * Specification for the seed function that will be implemented. + * This describes the expected interface and behavior. + */ + interface TemplateSeedResult { + created: string[] // templateNames of newly created templates + updated: string[] // templateNames of updated templates + skipped: string[] // templateNames that already existed and were unchanged + } + + it('should define seed function signature', () => { + // The seed function should: + // 1. Accept a database/repository connection + // 2. Return information about what was created/updated + + // This is a type-level test - just verifying the interface shape + const mockResult: TemplateSeedResult = { + created: ['system', 'user'], + updated: [], + skipped: [], + } + + expect(mockResult.created).toContain('system') + expect(mockResult.created).toContain('user') + }) + + it('should seed all built-in templates', () => { + // The seed should create all four built-in templates + const expectedTemplates = [ + BUILTIN_TEMPLATE_NAMES.SYSTEM, + BUILTIN_TEMPLATE_NAMES.USER, + BUILTIN_TEMPLATE_NAMES.ORGANIZATIONAL, + BUILTIN_TEMPLATE_NAMES.CONTEXT, + ] + + expect(expectedTemplates).toHaveLength(4) + }) +}) diff --git a/src/lib/domains/agentic/templates/__tests__/compiler.test.ts b/src/lib/domains/agentic/templates/__tests__/compiler.test.ts new file mode 100644 index 000000000..034b7866e --- /dev/null +++ b/src/lib/domains/agentic/templates/__tests__/compiler.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect } from 'vitest' +import { + parseChildPath, + parseChildRange, + formatChildPath, + isChildRef +} from '~/lib/domains/agentic/templates/_pre-processor/_path-parser' +import { + resolveChildPath, + getChildrenInRange, + type TileData +} from '~/lib/domains/agentic/templates/_pre-processor/_resolver' +import { prefixVariables } from '~/lib/domains/agentic/templates/_compiler/_variable-prefixer' +import { compileTemplate } from '~/lib/domains/agentic/templates/_compiler' +import { buildTemplatePool } from '~/lib/domains/agentic/templates/_pool' + +describe('Path Parser', () => { + describe('parseChildPath', () => { + it('parses single direction', () => { + const result = parseChildPath('child[-3]') + expect(result).toEqual({ directions: [-3], field: undefined }) + }) + + it('parses single direction with field', () => { + const result = parseChildPath('child[-3].title') + expect(result).toEqual({ directions: [-3], field: 'title' }) + }) + + it('parses nested path', () => { + const result = parseChildPath('child[-2,-1]') + expect(result).toEqual({ directions: [-2, -1], field: undefined }) + }) + + it('parses nested path with field', () => { + const result = parseChildPath('child[-2,-1].content') + expect(result).toEqual({ directions: [-2, -1], field: 'content' }) + }) + + it('returns null for invalid syntax', () => { + expect(parseChildPath('notachild')).toBeNull() + expect(parseChildPath('child[]')).toBeNull() + expect(parseChildPath('child[7]')).toBeNull() // out of range + }) + + it('parses direction 0 for hexplan', () => { + const result = parseChildPath('child[0]') + expect(result).toEqual({ directions: [0], field: undefined }) + }) + }) + + describe('parseChildRange', () => { + it('parses composed range', () => { + const result = parseChildRange('child[-6..-1]') + expect(result).toEqual({ start: -6, end: -1 }) + }) + + it('parses structural range', () => { + const result = parseChildRange('child[1..6]') + expect(result).toEqual({ start: 1, end: 6 }) + }) + + it('returns null for invalid range', () => { + expect(parseChildRange('child[-7..6]')).toBeNull() // -7 is out of range + expect(parseChildRange('child[0..7]')).toBeNull() // 7 is out of range + }) + + it('parses range including 0 for hexplan', () => { + const result = parseChildRange('child[0..0]') + expect(result).toEqual({ start: 0, end: 0 }) + }) + }) + + describe('formatChildPath', () => { + it('formats path without field', () => { + expect(formatChildPath({ directions: [-3], field: undefined })) + .toBe('child[-3]') + }) + + it('formats path with field', () => { + expect(formatChildPath({ directions: [-2, -1], field: 'title' })) + .toBe('child[-2,-1].title') + }) + }) + + describe('isChildRef', () => { + it('identifies child references', () => { + expect(isChildRef('child[-3]')).toBe(true) + expect(isChildRef('child[1..6]')).toBe(true) + expect(isChildRef('task')).toBe(false) + }) + }) +}) + +describe('Path Resolution', () => { + const mockTile: TileData = { + title: 'Root', + content: 'Root content', + coords: 'user,0:1', + children: [ + { + title: 'Child -3', + content: 'Child -3 content', + coords: 'user,0:1,-3', + direction: -3, + children: [ + { + title: 'Grandchild -1', + content: 'Grandchild content', + coords: 'user,0:1,-3,-1', + direction: -1 + } + ] + }, + { + title: 'Child -2', + content: 'Child -2 content', + coords: 'user,0:1,-2', + direction: -2 + } + ] + } + + describe('resolveChildPath', () => { + it('resolves single-level path', () => { + const result = resolveChildPath({ directions: [-3] }, mockTile) + expect(result?.title).toBe('Child -3') + }) + + it('resolves nested path', () => { + const result = resolveChildPath({ directions: [-3, -1] }, mockTile) + expect(result?.title).toBe('Grandchild -1') + }) + + it('returns undefined for non-existent path', () => { + const result = resolveChildPath({ directions: [-5] }, mockTile) + expect(result).toBeUndefined() + }) + }) + + describe('getChildrenInRange', () => { + it('gets children in range', () => { + const children = getChildrenInRange(mockTile, -6, -1) + expect(children).toHaveLength(2) + expect(children[0]?.title).toBe('Child -3') + expect(children[1]?.title).toBe('Child -2') + }) + + it('returns empty for no matching children', () => { + const children = getChildrenInRange(mockTile, 1, 6) + expect(children).toHaveLength(0) + }) + }) +}) + +describe('Variable Prefixer', () => { + it('prefixes field variables', () => { + const result = prefixVariables('{{title}}', [-3]) + expect(result).toBe('{{child[-3].title}}') + }) + + it('prefixes triple-brace variables', () => { + const result = prefixVariables('{{{content}}}', [-2, -1]) + expect(result).toBe('{{{child[-2,-1].content}}}') + }) + + it('does not prefix reserved variables', () => { + const result = prefixVariables('{{task}}', [-3]) + expect(result).toBe('{{task}}') + }) + + it('does not prefix non-field variables', () => { + const result = prefixVariables('{{customVar}}', [-3]) + expect(result).toBe('{{customVar}}') + }) +}) + +describe('Template Compiler', () => { + const mockTaskTile: TileData = { + title: 'Task', + content: 'Task content', + coords: 'user,0:1', + itemType: 'system', + children: [ + { + title: 'Context Item', + content: 'Context content', + coords: 'user,0:1,-1', + direction: -1, + itemType: 'context' + }, + { + title: 'Generic Item', + content: 'Generic content', + coords: 'user,0:1,-2', + direction: -2, + itemType: 'unknown' + } + ] + } + + const mockTemplateChildren: TileData[] = [ + { + title: 'Context Template', + content: '<item>{{title}}</item>', + coords: 'template,0:1', + direction: 1, + itemType: 'context' + }, + { + title: 'Generic Template', + content: '<generic>{{title}}</generic>', + coords: 'template,0:2', + direction: 2, + itemType: 'generic' + } + ] + + it('compiles template with RenderChildren', () => { + const template = '{{@RenderChildren range=[-6..-1] fallback=\'generic\'}}' + const pool = buildTemplatePool(mockTemplateChildren, 'generic') + + const result = compileTemplate(template, mockTaskTile, pool) + + // Should contain prefixed variables for both children + expect(result).toContain('child[-1]') + expect(result).toContain('child[-2]') + }) + + it('returns template unchanged if no RenderChildren tags', () => { + const template = '<task>{{task.title}}</task>' + const pool = buildTemplatePool(mockTemplateChildren, 'generic') + + const result = compileTemplate(template, mockTaskTile, pool) + + expect(result).toBe(template) + }) +}) diff --git a/src/lib/domains/agentic/templates/__tests__/prompt-builder-tile-templates.test.ts b/src/lib/domains/agentic/templates/__tests__/prompt-builder-tile-templates.test.ts new file mode 100644 index 000000000..917271424 --- /dev/null +++ b/src/lib/domains/agentic/templates/__tests__/prompt-builder-tile-templates.test.ts @@ -0,0 +1,651 @@ +/** + * TDD Tests: buildPrompt() Using Tile-Based Templates + * + * These tests define the expected behavior for buildPrompt() to read templates + * from tile storage instead of TypeScript constants. + * + * Design reference: docs/features/TEMPLATES_AS_TILES.md + * + * Key behaviors: + * 1. buildPrompt() looks up template by tile's itemType from tile storage + * 2. Mustache rendering with tile data as context + * 3. Pre-processor expands {{@ChildName}} to structural children of template + * 4. Fallback behavior when template tile not found + * 5. Error handling for invalid templates + * 6. Backward compatibility with existing behavior + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { PromptData } from '~/lib/domains/agentic/templates/_internals/types' +import { MapItemType } from '~/lib/domains/mapping' +import { SYSTEM_TEMPLATE } from '~/lib/domains/agentic/templates/_system-template' +import { USER_TEMPLATE } from '~/lib/domains/agentic/templates/_user-template' + +// ==================== CONSTANTS ==================== + +const SYSTEM_USER_ID = 'D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK' +const MCP_SERVER_NAME = 'hexframe' + +// ==================== TEST FIXTURES ==================== + +/** + * Minimal valid PromptData for SYSTEM tile. + */ +function createSystemPromptData(overrides: Partial<PromptData> = {}): PromptData { + return { + task: { + title: 'Test Task', + content: 'Test task content', + coords: 'user123,0:1,2,3', + }, + ancestors: [], + composedChildren: [], + structuralChildren: [], + hexPlan: '', + mcpServerName: MCP_SERVER_NAME, + itemType: MapItemType.SYSTEM, + ...overrides, + } +} + +/** + * Minimal valid PromptData for USER tile. + */ +function createUserPromptData(overrides: Partial<PromptData> = {}): PromptData { + return { + task: { + title: 'User Root', + content: undefined, + coords: 'user123,0:', + }, + ancestors: [], + composedChildren: [], + structuralChildren: [], + hexPlan: '', + mcpServerName: MCP_SERVER_NAME, + itemType: MapItemType.USER, + ...overrides, + } +} + +// ==================== TEMPLATE LOOKUP TESTS ==================== + +describe('buildPrompt template lookup from tiles', () => { + /** + * This describes the new interface that buildPrompt will use. + * The TemplateStore provides templates from tile storage. + */ + interface TemplateStore { + getTemplateByItemType(itemType: string): Promise<string | null> + getTemplateWithChildren(itemType: string): Promise<{ + content: string + children: Array<{ templateName: string; content: string }> + } | null> + } + + let mockTemplateStore: TemplateStore + + beforeEach(() => { + mockTemplateStore = { + getTemplateByItemType: vi.fn(), + getTemplateWithChildren: vi.fn(), + } + }) + + describe('when template tile exists', () => { + it('should look up template by itemType name', async () => { + // GIVEN a SYSTEM tile with itemType + const promptData = createSystemPromptData() + + // WHEN buildPrompt is called with a template store + // The implementation should call getTemplateByItemType with 'system' + const expectedLookupKey = 'system' // MapItemType.SYSTEM lowercased + + // THEN the store should be queried with the lowercase itemType + expect(expectedLookupKey).toBe('system') + expect(promptData.itemType).toBe(MapItemType.SYSTEM) + }) + + it('should use template content from tile storage for SYSTEM tiles', async () => { + // GIVEN a template tile with SYSTEM_TEMPLATE content + vi.mocked(mockTemplateStore.getTemplateByItemType).mockResolvedValue(SYSTEM_TEMPLATE) + + // WHEN buildPrompt renders a SYSTEM tile + const promptData = createSystemPromptData() + + // THEN the rendered output should match current behavior + // This ensures backward compatibility + expect(SYSTEM_TEMPLATE).toContain('{{{hexrunIntro}}}') + expect(SYSTEM_TEMPLATE).toContain('{{@HexPlan}}') + expect(promptData.itemType).toBe(MapItemType.SYSTEM) + }) + + it('should use template content from tile storage for USER tiles', async () => { + // GIVEN a template tile with USER_TEMPLATE content + vi.mocked(mockTemplateStore.getTemplateByItemType).mockResolvedValue(USER_TEMPLATE) + + // WHEN buildPrompt renders a USER tile + const promptData = createUserPromptData() + + // THEN the rendered output should match current pool-based behavior + expect(USER_TEMPLATE).toContain('{{{template[-1].content}}}') + expect(USER_TEMPLATE).toContain('{{@RenderChildren') + expect(promptData.itemType).toBe(MapItemType.USER) + }) + + it('should pass tile data to Mustache for rendering', async () => { + // GIVEN a simple template + const simpleTemplate = '<task><goal>{{{task.title}}}</goal></task>' + + // WHEN rendered with task data + const promptData = createSystemPromptData({ + task: { title: 'My Custom Task', content: undefined, coords: 'abc,0:1' }, + }) + + // THEN the task title should appear in rendered output + // The implementation will render: '<task><goal>My Custom Task</goal></task>' + expect(promptData.task.title).toBe('My Custom Task') + expect(simpleTemplate).toContain('{{{task.title}}}') + }) + }) + + describe('when template tile is missing', () => { + it('should throw TemplateNotFoundError when template tile does not exist', async () => { + // GIVEN no template exists for the itemType + vi.mocked(mockTemplateStore.getTemplateByItemType).mockResolvedValue(null) + + // WHEN buildPrompt is called with a tile that has no matching template + const promptData = createSystemPromptData() + + // THEN it should throw a clear error + // Expected behavior: throw new TemplateNotFoundError('system') + const expectedError = 'Template "system" not found' + expect(expectedError).toContain('system') + expect(promptData.itemType).toBe(MapItemType.SYSTEM) + }) + + it('should include itemType in error message for debugging', async () => { + // GIVEN a custom itemType with no template + const customItemType = 'my-custom-agent' + + // WHEN template lookup fails + // THEN error should identify which template was missing + const expectedError = `Template "${customItemType}" not found` + expect(expectedError).toContain(customItemType) + }) + }) +}) + +// ==================== PRE-PROCESSOR CHILD EXPANSION TESTS ==================== + +describe('buildPrompt pre-processor with tile-based sub-templates', () => { + describe('{{@ChildTemplateName}} expansion', () => { + it('should expand child template references from structural children', async () => { + // GIVEN a parent template with {{@HeaderSection}} tag + const parentTemplate = `<header>{{@HeaderSection}}</header> +<content>Main content here</content>` + + // AND a child template tile named "HeaderSection" + const headerSectionContent = '<h1>Welcome to Hexframe</h1>' + + // WHEN the template is pre-processed + // THEN {{@HeaderSection}} should be replaced with child content + + // This test specifies that child templates are looked up by templateName + // from the template tile's structural children + expect(parentTemplate).toContain('{{@HeaderSection}}') + expect(headerSectionContent).toContain('Welcome to Hexframe') + }) + + it('should resolve multiple child template references', async () => { + // GIVEN a template with multiple child references + const parentTemplate = `{{@IntroSection}} + +{{@ContextSection}} + +{{@TaskSection}}` + + // WHEN the template has three child templates as structural children + const expectedChildNames = ['IntroSection', 'ContextSection', 'TaskSection'] + + // THEN all three should be expanded in order + for (const childName of expectedChildNames) { + expect(parentTemplate).toContain(`{{@${childName}}}`) + } + }) + + it('should throw error when referenced child template not found', async () => { + // GIVEN a template referencing a non-existent child + const templateWithMissingChild = `<content>{{@NonExistentSection}}</content>` + + // WHEN the template is pre-processed + // THEN it should throw a TemplateError + + expect(templateWithMissingChild).toMatch(/\{\{@NonExistentSection\}\}/) + expect(templateWithMissingChild.includes('NonExistentSection')).toBe(true) + }) + + it('should support nested child template expansion', async () => { + // GIVEN a parent template with a child that has its own children + // Parent: {{@OuterSection}} + // OuterSection: <outer>{{@InnerSection}}</outer> + // InnerSection: <inner>Deepest content</inner> + + // WHEN pre-processor runs recursively + // THEN the final output should have all nested content expanded + + const expectedFinalOutput = '<outer><inner>Deepest content</inner></outer>' + expect(expectedFinalOutput).toContain('<inner>Deepest content</inner>') + }) + + it('should detect circular template references and throw error', async () => { + // GIVEN template A references B and B references A + // A: <a>{{@TemplateB}}</a> + // B: <b>{{@TemplateA}}</b> + + // WHEN the template is pre-processed + // THEN it should detect the cycle and throw a clear error + + const expectedErrorType = 'CircularTemplateError' + expect(expectedErrorType).toBe('CircularTemplateError') + }) + }) + + describe('rendering primitives remain available', () => { + it('should still expand {{@GenericTile}} from code', async () => { + // GIVEN a tile-based template using built-in primitives + const templateWithPrimitive = `<context> +{{@GenericTile item=. fields=['title', 'content'] wrapper='context'}} +</context>` + + // WHEN the template is pre-processed + // THEN {{@GenericTile}} should be expanded by the code-based registry + + expect(templateWithPrimitive).toContain('{{@GenericTile') + }) + + it('should still expand {{@Folder}} from code', async () => { + const templateWithFolder = `<sections> +{{@Folder item=. fields=['title', 'preview'] depth=2}} +</sections>` + + expect(templateWithFolder).toContain('{{@Folder') + }) + + it('should still expand {{@HexPlan}} from code', async () => { + const templateWithHexPlan = `<task>Do the thing</task> + +{{@HexPlan}}` + + expect(templateWithHexPlan).toContain('{{@HexPlan}}') + }) + + it('should still expand {{@TileOrFolder}} from code', async () => { + const templateWithTileOrFolder = `{{@TileOrFolder item=. fields=['title', 'content'] wrapper='context' depth=3}}` + + expect(templateWithTileOrFolder).toContain('{{@TileOrFolder') + }) + }) +}) + +// ==================== ERROR HANDLING TESTS ==================== + +describe('buildPrompt error handling for tile-based templates', () => { + describe('invalid template syntax', () => { + it('should throw error for unclosed Mustache tags', async () => { + // GIVEN a template with unclosed Mustache section + const invalidTemplate = `{{#hasContent} +<content>This section is never closed` + + // WHEN Mustache rendering is attempted + // THEN it should throw a clear syntax error + + expect(invalidTemplate).not.toContain('{{/hasContent}}') + }) + + it('should throw error for invalid pre-processor tag syntax', async () => { + // GIVEN a template with malformed pre-processor tag + const invalidTag = `{{@InvalidTag param=}}` + + // WHEN pre-processor parses the template + // THEN it should throw a parse error + + expect(invalidTag).toContain('param=') + }) + + it('should include template name in error for debugging', async () => { + // GIVEN an error occurs while processing a named template + const templateName = 'my-broken-template' + + // WHEN the error is thrown + // THEN it should include the template name for debugging + + const expectedErrorMessage = `Template error in ${templateName}` + expect(expectedErrorMessage).toContain(templateName) + }) + }) + + describe('missing required template data', () => { + it('should handle missing task gracefully', async () => { + // GIVEN a template that references task.title + const templateReferencingTask = `<goal>{{{task.title}}}</goal>` + + // WHEN task is undefined + // THEN Mustache should render empty string (not throw) + + expect(templateReferencingTask).toContain('{{{task.title}}}') + }) + + it('should handle missing optional sections gracefully', async () => { + // GIVEN a template with conditional sections + const templateWithConditionals = `{{#hasSubtasks}} +<subtasks>{{{subtasksSection}}}</subtasks> +{{/hasSubtasks}}` + + // WHEN hasSubtasks is false + // THEN the section should be omitted (not throw) + + expect(templateWithConditionals).toContain('{{#hasSubtasks}}') + expect(templateWithConditionals).toContain('{{/hasSubtasks}}') + }) + }) +}) + +// ==================== BACKWARD COMPATIBILITY TESTS ==================== + +describe('buildPrompt backward compatibility', () => { + describe('existing SYSTEM template behavior', () => { + it('should render hexrun intro section', async () => { + const promptData = createSystemPromptData() + + // WHEN buildPrompt is called + // THEN the output should contain hexrun intro + + // Verify template structure matches expected behavior + expect(SYSTEM_TEMPLATE).toContain('{{{hexrunIntro}}}') + expect(promptData.itemType).toBe(MapItemType.SYSTEM) + }) + + it('should render ancestor context when ancestors have content', async () => { + const promptData = createSystemPromptData({ + ancestors: [ + { + title: 'Parent Task', + content: 'Parent context information', + coords: 'user123,0:1', + itemType: MapItemType.SYSTEM, + }, + ], + }) + + expect(SYSTEM_TEMPLATE).toContain('{{#hasAncestorsWithContent}}') + expect(SYSTEM_TEMPLATE).toContain('{{{ancestorContextSection}}}') + expect(promptData.ancestors).toHaveLength(1) + }) + + it('should render context section for composed children', async () => { + const promptData = createSystemPromptData({ + composedChildren: [ + { + title: 'Reference Doc', + content: 'Important reference material', + coords: 'user123,0:1,-1', + itemType: MapItemType.CONTEXT, + }, + ], + }) + + expect(SYSTEM_TEMPLATE).toContain('{{#hasComposedChildren}}') + expect(SYSTEM_TEMPLATE).toContain('{{{contextSection}}}') + expect(promptData.composedChildren).toHaveLength(1) + }) + + it('should render subtasks section for structural children', async () => { + const promptData = createSystemPromptData({ + structuralChildren: [ + { + title: 'Subtask 1', + preview: 'First subtask to complete', + coords: 'user123,0:1,2,3,1', + itemType: MapItemType.SYSTEM, + }, + ], + }) + + expect(SYSTEM_TEMPLATE).toContain('{{#hasSubtasks}}') + expect(SYSTEM_TEMPLATE).toContain('{{{subtasksSection}}}') + expect(promptData.structuralChildren).toHaveLength(1) + }) + + it('should render task section with goal and content', async () => { + expect(SYSTEM_TEMPLATE).toContain('<task>') + expect(SYSTEM_TEMPLATE).toContain('<goal>{{{task.title}}}</goal>') + expect(SYSTEM_TEMPLATE).toContain('{{#task.hasContent}}') + expect(SYSTEM_TEMPLATE).toContain('{{{task.content}}}') + expect(SYSTEM_TEMPLATE).toContain('</task>') + }) + + it('should render HexPlan section via pre-processor', async () => { + expect(SYSTEM_TEMPLATE).toContain('{{@HexPlan}}') + }) + }) + + describe('existing USER template behavior (pool-based)', () => { + it('should render user intro via template context', async () => { + // Pool-based template uses template[-1].content for intro + expect(USER_TEMPLATE).toContain('{{{template[-1].content}}}') + }) + + it('should render context section via RenderChildren', async () => { + // Pool-based dispatch for composed children + expect(USER_TEMPLATE).toContain('{{@RenderChildren range=[-6..-1]') + expect(USER_TEMPLATE).toContain('<context>') + }) + + it('should render sections via RenderChildren', async () => { + // Pool-based dispatch for structural children + expect(USER_TEMPLATE).toContain('{{@RenderChildren range=[1..6]') + expect(USER_TEMPLATE).toContain('<sections>') + }) + + it('should render recent history via RenderChildren', async () => { + // Pool-based dispatch for direction-0 + expect(USER_TEMPLATE).toContain('{{@RenderChildren range=[0..0]') + }) + + it('should render discussion section', async () => { + expect(USER_TEMPLATE).toContain('{{#hasDiscussion}}') + expect(USER_TEMPLATE).toContain('<discussion>') + expect(USER_TEMPLATE).toContain('{{{discussion}}}') + }) + + it('should render user message section', async () => { + expect(USER_TEMPLATE).toContain('{{#hasUserMessage}}') + expect(USER_TEMPLATE).toContain('<user-message>') + expect(USER_TEMPLATE).toContain('{{{userMessage}}}') + }) + }) + + describe('output format consistency', () => { + it('should produce identical output for existing SYSTEM tiles', async () => { + // GIVEN the same PromptData + const promptData = createSystemPromptData({ + task: { + title: 'Complete the Integration', + content: 'Integrate all components following the specification.', + coords: 'abc123,0:1,2', + }, + }) + + // WHEN buildPrompt is called with tile-based templates + // THEN the output should be identical to the current code-based implementation + + // This is a behavioral specification - implementation will verify actual output + expect(promptData.task.title).toBe('Complete the Integration') + }) + + it('should produce identical output for existing USER tiles', async () => { + const promptData = createUserPromptData({ + discussion: 'User: Can you help me organize my project?\nAssistant: Of course!', + userMessage: 'Where should I start?', + }) + + expect(promptData.discussion).toContain('Can you help me organize') + expect(promptData.userMessage).toBe('Where should I start?') + }) + + it('should normalize whitespace consistently', async () => { + // The buildPrompt function normalizes multiple newlines and trailing whitespace + // This behavior should be preserved + + // Current normalization: .replace(/\n{3,}/g, '\n\n').replace(/\n\n$/g, '').trim() + const expectedNormalization = (output: string) => + output.replace(/\n{3,}/g, '\n\n').replace(/\n\n$/g, '').trim() + + const testOutput = 'line1\n\n\n\nline2\n\n' + expect(expectedNormalization(testOutput)).toBe('line1\n\nline2') + }) + }) +}) + +// ==================== TEMPLATE STORE INTERFACE TESTS ==================== + +describe('TemplateStore interface specification', () => { + /** + * This section specifies the interface that the template storage layer must implement. + */ + + describe('getTemplateByItemType', () => { + it('should return template content for valid itemType', async () => { + // GIVEN a template exists for itemType 'system' + // WHEN getTemplateByItemType('system') is called + // THEN it should return the template content string + + interface TemplateStoreResponse { + content: string + templateName: string + coords: string + } + + const expectedResponse: TemplateStoreResponse = { + content: SYSTEM_TEMPLATE, + templateName: 'system', + coords: `${SYSTEM_USER_ID},0:1,2,1`, + } + + expect(expectedResponse.content).toBe(SYSTEM_TEMPLATE) + expect(expectedResponse.templateName).toBe('system') + }) + + it('should return null for unknown itemType', async () => { + // GIVEN no template exists for itemType 'unknown-type' + // WHEN getTemplateByItemType('unknown-type') is called + // THEN it should return null + + const expectedResult = null + expect(expectedResult).toBeNull() + }) + + it('should be case-insensitive for built-in types', async () => { + // GIVEN built-in types may be requested in different cases + // 'SYSTEM', 'System', 'system' should all resolve to the same template + + const builtInTypes = ['system', 'user', 'organizational', 'context'] + for (const typeName of builtInTypes) { + expect(typeName.toLowerCase()).toBe(typeName) + } + }) + }) + + describe('getTemplateWithChildren', () => { + it('should return template with sub-templates for parent templates', async () => { + // GIVEN a template with structural children (sub-templates) + // WHEN getTemplateWithChildren('system') is called + // THEN it should return template content and child templates + + interface TemplateWithChildrenResponse { + content: string + templateName: string + coords: string + children: Array<{ + templateName: string + content: string + coords: string + }> + } + + const expectedResponse: TemplateWithChildrenResponse = { + content: SYSTEM_TEMPLATE, + templateName: 'system', + coords: `${SYSTEM_USER_ID},0:1,2,1`, + children: [], + } + + expect(expectedResponse.children).toHaveLength(0) + }) + + it('should return empty children array for leaf templates', async () => { + // GIVEN a template with no structural children + // WHEN getTemplateWithChildren is called + // THEN children should be an empty array + + const emptyChildren: Array<{ templateName: string; content: string }> = [] + expect(emptyChildren).toHaveLength(0) + }) + }) +}) + +// ==================== INTEGRATION SPECIFICATION ==================== + +describe('buildPrompt integration with tile-based templates', () => { + /** + * These tests specify the integration points between buildPrompt and the template store. + * They document how the function signature may need to change. + */ + + describe('function signature evolution', () => { + it('should accept optional templateStore parameter for dependency injection', async () => { + // The new signature should support: + // buildPrompt(data: PromptData, options?: { templateStore?: TemplateStore }): string | Promise<string> + + interface BuildPromptOptions { + templateStore?: { + getTemplateByItemType: (itemType: string) => Promise<string | null> + } + } + + const options: BuildPromptOptions = {} + expect(options.templateStore).toBeUndefined() + }) + + it('should fall back to built-in templates when store not provided', async () => { + // GIVEN buildPrompt is called without a templateStore + // WHEN itemType is 'system' + // THEN it should use the built-in SYSTEM_TEMPLATE constant + + // This ensures backward compatibility with existing callers + const builtInFallback = SYSTEM_TEMPLATE + expect(builtInFallback).toContain('{{{hexrunIntro}}}') + }) + + it('should be sync or async based on template source', async () => { + // GIVEN template is fetched from tile storage + // WHEN buildPrompt is called + // THEN return type should be Promise<string> + + // GIVEN template is from built-in constants + // WHEN buildPrompt is called + // THEN return type can remain string (sync) + + // Implementation note: May need two variants: + // buildPrompt (sync, uses constants) + // buildPromptAsync (async, uses store) + + const syncResult = 'sync prompt output' + const asyncResult = Promise.resolve('async prompt output') + + expect(typeof syncResult).toBe('string') + expect(asyncResult).toBeInstanceOf(Promise) + }) + }) +}) 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..fd362ee1f --- /dev/null +++ b/src/lib/domains/agentic/templates/__tests__/system-template.test.ts @@ -0,0 +1,127 @@ +/** + * 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<SystemTemplateData> = { + wasBlocked: true, + blockageReason: 'Missing API key' + } + + const rendered = Mustache.render(EXECUTION_CONTEXT_SECTION, data) + + expect(rendered).toContain('<execution-context>') + expect(rendered).toContain('<previous-blockage>') + expect(rendered).toContain('Missing API key') + expect(rendered).toContain('blocker has been addressed') + }) + + it('omits blockage context when wasBlocked is false', () => { + const data: Partial<SystemTemplateData> = { + 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<SystemTemplateData> = { + wasBlocked: true, + blockageReason: '<script>alert("xss")</script>' + } + + const rendered = Mustache.render(EXECUTION_CONTEXT_SECTION, escapedData) + + expect(rendered).toContain('<script>') + expect(rendered).not.toContain('<script>') + }) + }) + + describe('execution instructions', () => { + it('includes status block instructions', () => { + expect(EXECUTION_INSTRUCTIONS_SECTION).toContain('<status>') + expect(EXECUTION_INSTRUCTIONS_SECTION).toContain('"result": "completed"') + expect(EXECUTION_INSTRUCTIONS_SECTION).toContain('"result": "blocked"') + }) + + it('includes hexplan tracking guidance', () => { + expect(EXECUTION_INSTRUCTIONS_SECTION).toContain('Track progress in the hexplan') + expect(EXECUTION_INSTRUCTIONS_SECTION).toContain('execution-instructions') + }) + }) + + describe('full template integration', () => { + it('includes execution instructions in full template', () => { + const fullData: SystemTemplateData = { + hexrunIntro: HEXRUN_INTRO, + hasAncestorsWithContent: false, + ancestorContextSection: '', + hasComposedChildren: false, + contextSection: '', + hasSubtasks: false, + subtasksSection: '', + task: { + title: 'Test Task', + hasContent: false, + content: '' + }, + hasHexplan: false, + hexplanCoords: 'test,0:1,0', + hexPlan: '', + wasBlocked: false, + blockageReason: '' + } + + const rendered = Mustache.render(SYSTEM_TEMPLATE, fullData) + + expect(rendered).toContain('<execution-instructions>') + expect(rendered).toContain('<status>') + }) + + it('includes blockage context in full template when wasBlocked', () => { + const fullData: SystemTemplateData = { + hexrunIntro: HEXRUN_INTRO, + hasAncestorsWithContent: false, + ancestorContextSection: '', + hasComposedChildren: false, + contextSection: '', + hasSubtasks: false, + subtasksSection: '', + task: { + title: 'Test Task', + hasContent: false, + content: '' + }, + hasHexplan: false, + hexplanCoords: 'test,0:1,0', + hexPlan: '', + wasBlocked: true, + blockageReason: 'Database connection failed' + } + + const rendered = Mustache.render(SYSTEM_TEMPLATE, fullData) + + expect(rendered).toContain('<execution-context>') + expect(rendered).toContain('Database connection failed') + }) + }) +}) diff --git a/src/lib/domains/agentic/templates/_compiler/_data-builder.ts b/src/lib/domains/agentic/templates/_compiler/_data-builder.ts new file mode 100644 index 000000000..5369d0b2a --- /dev/null +++ b/src/lib/domains/agentic/templates/_compiler/_data-builder.ts @@ -0,0 +1,241 @@ +/** + * Data builder for Phase 2 (Mustache render). + * + * Builds a flat data object from tile tree where paths like + * `child[-3].title` or `child[-2,-1].content` can be resolved. + */ + +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import type { PromptData, PromptDataTile } from '~/lib/domains/agentic/templates/_internals/types' + +// ==================== PUBLIC TYPES ==================== + +/** + * Flattened data object for Mustache rendering. + * Keys are like: 'task', 'child[-3]', 'child[-3,-1]', etc. + */ +export interface MustacheData { + task: { + title: string + content: string + coords: string + } + [key: string]: { + title: string + content: string + preview: string + coords: string + itemType: string + } | MustacheData['task'] +} + +// ==================== PUBLIC FUNCTIONS ==================== + +/** + * Convert PromptData to TileData with merged children and direction. + * + * Merges composedChildren and structuralChildren into a single children array + * with direction field populated based on their original source. + */ +export function promptDataToTileData(data: PromptData): TileData { + // Merge children with direction + const children: TileData[] = [] + + // Add composed children (negative directions) + // We need to infer direction from the original data or coords + for (const child of data.composedChildren) { + children.push(_convertPromptDataTile(child)) + } + + // Add structural children (positive directions) + for (const child of data.structuralChildren) { + children.push(_convertPromptDataTile(child)) + } + + return { + title: data.task.title, + content: data.task.content, + coords: data.task.coords, + itemType: data.itemType, + children: children.length > 0 ? children : undefined + } +} + +/** + * Build Mustache data object from a tile tree. + * + * Flattens the tile hierarchy into a single object where: + * - `task` contains the root tile data + * - `child[-3]` contains data for child at direction -3 + * - `child[-3,-1]` contains data for nested child + * - `ancestor[-1]` contains data for parent (closest ancestor) + * - `ancestor[-2]` contains data for grandparent, etc. + * - `template[-1]` contains data for template tile's composed child at -1 + * + * @param taskTile - The root task tile with children + * @param ancestors - Optional ancestors array (from root to parent) + * @param templateTile - Optional template tile (for accessing template context children) + * @returns A flat data object for Mustache rendering + */ +export function buildMustacheData( + taskTile: TileData, + ancestors: TileData[] = [], + templateTile?: TileData +): MustacheData { + const data: MustacheData = { + task: { + title: taskTile.title, + content: taskTile.content ?? '', + coords: taskTile.coords + } + } + + // Recursively flatten all children + _flattenChildren(taskTile, [], data) + + // Flatten ancestors with negative indexing + _flattenAncestors(ancestors, data) + + // Flatten template's composed children (if template tile provided) + if (templateTile) { + _flattenTemplateContext(templateTile, data) + } + + return data +} + +/** + * Extract direction from coords string. + * + * @param coords - Coords like "userId,groupId:1,2,-3" + * @returns The last direction in the path, or undefined + */ +export function extractDirectionFromCoords(coords: string): number | undefined { + const pathPart = coords.split(':')[1] + if (!pathPart) { + return undefined + } + + const directions = pathPart.split(',').map(d => parseInt(d, 10)) + const lastDir = directions[directions.length - 1] + + if (lastDir === undefined || isNaN(lastDir)) { + return undefined + } + + return lastDir +} + +// ==================== INTERNAL FUNCTIONS ==================== + +/** + * Convert PromptDataTile to TileData, inferring direction from coords if needed. + */ +function _convertPromptDataTile(tile: PromptDataTile): TileData { + // Get direction from tile or infer from coords + const direction = tile.direction ?? extractDirectionFromCoords(tile.coords) + + const result: TileData = { + title: tile.title, + content: tile.content, + preview: tile.preview, + coords: tile.coords, + itemType: tile.itemType, + direction + } + + // Recursively convert children + if (tile.children && tile.children.length > 0) { + result.children = tile.children.map(_convertPromptDataTile) + } + + return result +} + +/** + * Recursively flatten children into the data object. + */ +function _flattenChildren( + tile: TileData, + currentPath: number[], + data: MustacheData +): void { + if (!tile.children) { + return + } + + for (const child of tile.children) { + if (child.direction === undefined) { + continue + } + + const childPath = [...currentPath, child.direction] + const pathKey = `child[${childPath.join(',')}]` + + // Add child data to the flat object + data[pathKey] = { + title: child.title, + content: child.content ?? '', + preview: child.preview ?? '', + coords: child.coords, + itemType: String(child.itemType ?? '') + } + + // Recursively process grandchildren + _flattenChildren(child, childPath, data) + } +} + +/** + * Flatten ancestors into the data object using negative indexing. + * ancestor[-1] = parent (closest), ancestor[-2] = grandparent, etc. + */ +function _flattenAncestors(ancestors: TileData[], data: MustacheData): void { + if (!ancestors || ancestors.length === 0) { + return + } + + // ancestors[0] = root, ancestors[length-1] = parent + // We use negative indexing: -1 = parent, -2 = grandparent + for (let arrayIndex = 0; arrayIndex < ancestors.length; arrayIndex++) { + const ancestor = ancestors[arrayIndex]! + // Convert array index to negative index from current tile + const negativeIndex = arrayIndex - ancestors.length + const pathKey = `ancestor[${negativeIndex}]` + + data[pathKey] = { + title: ancestor.title, + content: ancestor.content ?? '', + preview: ancestor.preview ?? '', + coords: ancestor.coords, + itemType: String(ancestor.itemType ?? '') + } + } +} + +/** + * Flatten template tile's composed children (directions -1 to -6). + * Accessible via template[-1], template[-2], etc. + */ +function _flattenTemplateContext(templateTile: TileData, data: MustacheData): void { + if (!templateTile.children) { + return + } + + // Filter to composed children only (negative directions) + const composedChildren = templateTile.children.filter( + child => child.direction !== undefined && child.direction < 0 + ) + + for (const child of composedChildren) { + const pathKey = `template[${child.direction}]` + + data[pathKey] = { + title: child.title, + content: child.content ?? '', + preview: child.preview ?? '', + coords: child.coords, + itemType: String(child.itemType ?? '') + } + } +} diff --git a/src/lib/domains/agentic/templates/_compiler/_render-ancestors.ts b/src/lib/domains/agentic/templates/_compiler/_render-ancestors.ts new file mode 100644 index 000000000..2ca15145d --- /dev/null +++ b/src/lib/domains/agentic/templates/_compiler/_render-ancestors.ts @@ -0,0 +1,80 @@ +/** + * RenderAncestors tag handler for template compilation. + * + * Expands {{@RenderAncestors fallback='generic'}} into + * concrete template structure with proper variable prefixes. + * + * Ancestor indexing uses negative numbers from current tile: + * - ancestor[-1] = parent (closest) + * - ancestor[-2] = grandparent + * - ancestor[-N] = N levels up + */ + +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import { findTemplateForItemType } from '~/lib/domains/agentic/templates/_pool' +import type { CompileContext } from '~/lib/domains/agentic/templates/_compiler/types' +import { prefixAncestorVariables } from '~/lib/domains/agentic/templates/_compiler/_variable-prefixer' + +// ==================== PUBLIC FUNCTIONS ==================== + +/** + * Expand a RenderAncestors tag into compiled template structure. + * + * @param fallbackName - Name of fallback template + * @param context - The compilation context (must include ancestors array) + * @param compileTemplate - Recursive compile function (to avoid circular imports) + * @returns The expanded template string + */ +export function expandRenderAncestors( + fallbackName: string | undefined, + context: CompileContext, + compileTemplate: (template: string, context: CompileContext) => string +): string { + const { ancestors } = context + + if (!ancestors || ancestors.length === 0) { + return '' + } + + // Render each ancestor using appropriate template from pool + // ancestors[0] = root, ancestors[ancestors.length-1] = parent + // We use negative indexing: -1 = parent, -2 = grandparent, etc. + const renderedAncestors = ancestors.map((ancestor, arrayIndex) => { + // Convert array index to negative index from current tile + // arrayIndex 0 (root) when ancestors.length=3 β†’ -3 + // arrayIndex 2 (parent) when ancestors.length=3 β†’ -1 + const negativeIndex = arrayIndex - ancestors.length + return _renderAncestor(ancestor, negativeIndex, fallbackName, context, compileTemplate) + }) + + return renderedAncestors.filter(Boolean).join('\n') +} + +// ==================== INTERNAL FUNCTIONS ==================== + +/** + * Render a single ancestor using the appropriate template from the pool. + */ +function _renderAncestor( + ancestor: TileData, + negativeIndex: number, + fallbackName: string | undefined, + context: CompileContext, + compileTemplate: (template: string, context: CompileContext) => string +): string { + // Find matching template in pool + const itemTypeStr = ancestor.itemType ? String(ancestor.itemType) : undefined + const template = findTemplateForItemType(context.templatePool, itemTypeStr) + ?? (fallbackName ? findTemplateForItemType(context.templatePool, fallbackName) : undefined) + + if (!template) { + // No template found, skip this ancestor + return '' + } + + // Compile the sub-template (no recursion for ancestors - they're flat) + const compiledSubTemplate = compileTemplate(template.content, context) + + // Prefix variables with the ancestor index + return prefixAncestorVariables(compiledSubTemplate, negativeIndex) +} diff --git a/src/lib/domains/agentic/templates/_compiler/_render-children.ts b/src/lib/domains/agentic/templates/_compiler/_render-children.ts new file mode 100644 index 000000000..b2539dafa --- /dev/null +++ b/src/lib/domains/agentic/templates/_compiler/_render-children.ts @@ -0,0 +1,131 @@ +/** + * RenderChildren tag handler for template compilation. + * + * Expands {{@RenderChildren range=[-6..-1] fallback='generic'}} into + * concrete template structure with proper variable prefixes. + */ + +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import { getChildrenInRange, resolveChildPath } from '~/lib/domains/agentic/templates/_pre-processor' +import type { ChildPath } from '~/lib/domains/agentic/templates/_pre-processor/_path-parser' +import { buildTemplatePool } from '~/lib/domains/agentic/templates/_pool' +import type { findTemplateForItemType } from '~/lib/domains/agentic/templates/_pool' +import type { CompileContext } from '~/lib/domains/agentic/templates/_compiler/types' +import { prefixVariables } from '~/lib/domains/agentic/templates/_compiler/_variable-prefixer' + +// ==================== PUBLIC FUNCTIONS ==================== + +/** + * Expand a RenderChildren tag into compiled template structure. + * + * @param range - The direction range to iterate (e.g., { start: -6, end: -1 }) + * @param fallbackName - Name of fallback template + * @param context - The compilation context + * @param compileTemplate - Recursive compile function (to avoid circular imports) + * @returns The expanded template string + */ +export function expandRenderChildren( + range: { start: number; end: number }, + fallbackName: string | undefined, + context: CompileContext, + compileTemplate: (template: string, context: CompileContext) => string +): string { + // Check recursion depth + if (context.currentDepth >= context.maxDepth) { + return '<!-- max depth reached -->' + } + + // Get the current tile (either task or nested child) + const currentTile = _getCurrentTile(context) + if (!currentTile) { + return '' + } + + // Get children in the specified range + const children = getChildrenInRange(currentTile, range.start, range.end) + if (children.length === 0) { + return '' + } + + // Render each child using appropriate template from pool + const renderedChildren = children.map(child => { + return _renderChild(child, fallbackName, context, compileTemplate) + }) + + return renderedChildren.filter(Boolean).join('\n') +} + +// ==================== INTERNAL FUNCTIONS ==================== + +/** + * Get the current tile being rendered (following the current path). + */ +function _getCurrentTile(context: CompileContext): TileData | undefined { + if (context.currentPath.length === 0) { + return context.taskTile + } + + const path: ChildPath = { directions: context.currentPath } + return resolveChildPath(path, context.taskTile) +} + +/** + * Render a single child using the appropriate template from the pool. + */ +function _renderChild( + child: TileData, + fallbackName: string | undefined, + context: CompileContext, + compileTemplate: (template: string, context: CompileContext) => string +): string { + // Find matching template in pool + const itemTypeStr = child.itemType ? String(child.itemType) : undefined + let template: ReturnType<typeof findTemplateForItemType> + + // Try exact match by itemType first (don't use pool's default fallback) + if (itemTypeStr) { + template = context.templatePool.templates.get(itemTypeStr) + } + + // If no exact match, use the tag's specified fallback + // This allows tags like {{@RenderChildren fallback='section'}} to specify + // which template to use for items that don't have a matching itemType template + if (!template && fallbackName) { + template = context.templatePool.templates.get(fallbackName) + } + + // Final fallback: use the pool's default fallback (if neither exact match nor tag fallback worked) + template ??= context.templatePool.fallback + + if (!template) { + // No template found, skip this child + return '' + } + + // Build child path + const childDirection = child.direction + if (childDirection === undefined) { + return '' + } + + const childPath = [...context.currentPath, childDirection] + + // Build next-level pool from template's children + const nextPool = buildTemplatePool(template.children, fallbackName) + + // Create child compilation context + const childContext: CompileContext = { + currentPath: childPath, + taskTile: context.taskTile, + ancestors: context.ancestors, + templatePool: nextPool, + maxDepth: context.maxDepth, + currentDepth: context.currentDepth + 1 + } + + // Compile the sub-template recursively + const compiledSubTemplate = compileTemplate(template.content, childContext) + + // Prefix variables with the child path + return prefixVariables(compiledSubTemplate, childPath) +} diff --git a/src/lib/domains/agentic/templates/_compiler/_two-phase-render.ts b/src/lib/domains/agentic/templates/_compiler/_two-phase-render.ts new file mode 100644 index 000000000..8906f56d7 --- /dev/null +++ b/src/lib/domains/agentic/templates/_compiler/_two-phase-render.ts @@ -0,0 +1,105 @@ +/** + * Two-phase template rendering. + * + * Phase 1 (Compile): Template + pool + tile structure β†’ Structural template + * Phase 2 (Render): Structural template + tile data β†’ Final XML + */ + +import Mustache from 'mustache' +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import { buildPoolFromTemplateTile } from '~/lib/domains/agentic/templates/_pool' +import { compileTemplate } from '~/lib/domains/agentic/templates/_compiler/index' +import { buildMustacheData } from '~/lib/domains/agentic/templates/_compiler/_data-builder' + +// ==================== PUBLIC TYPES ==================== + +export interface TwoPhaseRenderOptions { + /** The root template tile with sub-templates as children */ + templateTile: TileData + /** The task tile to render */ + taskTile: TileData + /** Ancestors array (from root to parent) */ + ancestors?: TileData[] + /** Name of the fallback template (default: 'generic') */ + fallbackTemplateName?: string + /** Additional data to merge into Mustache context */ + additionalData?: Record<string, unknown> +} + +export interface TwoPhaseRenderResult { + /** The compiled structural template (inspectable) */ + compiledTemplate: string + /** The final rendered output */ + rendered: string +} + +// ==================== PUBLIC FUNCTIONS ==================== + +/** + * Render a template using two-phase approach. + * + * @param options - Render options + * @returns Both the compiled template and final rendered output + */ +export function twoPhaseRender(options: TwoPhaseRenderOptions): TwoPhaseRenderResult { + const { + templateTile, + taskTile, + ancestors = [], + fallbackTemplateName = 'generic', + additionalData = {} + } = options + + // Build template pool from template's structural children + const templatePool = buildPoolFromTemplateTile(templateTile, fallbackTemplateName) + + // Phase 1: Compile template + const templateContent = templateTile.content ?? '' + const compiledTemplate = compileTemplate(templateContent, taskTile, templatePool, ancestors) + + // Phase 2: Build data and render with Mustache + // Pass templateTile to make its composed children accessible via template[-1], etc. + const mustacheData = buildMustacheData(taskTile, ancestors, templateTile) + const mergedData = { ...mustacheData, ...additionalData } + const rendered = Mustache.render(compiledTemplate, mergedData) + + // Clean up excessive newlines + const cleanedRendered = rendered + .replace(/\n{3,}/g, '\n\n') + .replace(/\n\n$/g, '') + .trim() + + return { + compiledTemplate, + rendered: cleanedRendered + } +} + +/** + * Compile a template without rendering (for inspection). + * + * @param templateTile - The template tile with sub-templates + * @param taskTile - The task tile structure + * @param fallbackTemplateName - Fallback template name + * @param ancestors - Optional ancestors array + * @returns The compiled structural template + */ +export function compileOnly( + templateTile: TileData, + taskTile: TileData, + fallbackTemplateName = 'generic', + ancestors: TileData[] = [] +): string { + const templatePool = buildPoolFromTemplateTile(templateTile, fallbackTemplateName) + return compileTemplate(templateTile.content ?? '', taskTile, templatePool, ancestors) +} + +/** + * Check if a template uses pool-based rendering. + * + * @param templateContent - The template content + * @returns True if the template contains {{@RenderChildren}} or {{@RenderAncestors}} + */ +export function usesPoolBasedRendering(templateContent: string): boolean { + return templateContent.includes('{{@RenderChildren') || templateContent.includes('{{@RenderAncestors') +} diff --git a/src/lib/domains/agentic/templates/_compiler/_variable-prefixer.ts b/src/lib/domains/agentic/templates/_compiler/_variable-prefixer.ts new file mode 100644 index 000000000..141c16397 --- /dev/null +++ b/src/lib/domains/agentic/templates/_compiler/_variable-prefixer.ts @@ -0,0 +1,134 @@ +/** + * Variable prefixer for template compilation. + * + * Transforms template variables to include path prefixes: + * - {{title}} β†’ {{child[-3].title}} + * - {{content}} β†’ {{child[-3].content}} + * - {{coords}} β†’ {{child[-3].coords}} + * - {{title}} β†’ {{ancestor[-1].title}} (for ancestors) + */ + +// ==================== INTERNAL CONSTANTS ==================== + +/** + * Pattern for Mustache variables (both double and triple braces). + * Captures: {{varName}} or {{{varName}}} + */ +const MUSTACHE_VAR_PATTERN = /(\{\{\{?)(\w+)(\}\}\}?)/g + +/** + * Fields that should be prefixed with the current path. + */ +const PREFIXABLE_FIELDS = new Set(['title', 'content', 'preview', 'coords', 'itemType']) + +/** + * Variables that should NOT be prefixed (they have special meaning). + */ +const RESERVED_VARS = new Set(['task', 'hexPlan', 'mcpServerName']) + +// ==================== PUBLIC FUNCTIONS ==================== + +/** + * Prefix all applicable variables in a template with a path. + * + * @param template - The template string to transform + * @param pathPrefix - The path prefix to add (e.g., [-3, -1]) + * @returns The template with prefixed variables + * + * @example + * prefixVariables('{{title}}', [-3]) β†’ '{{child[-3].title}}' + * prefixVariables('{{{content}}}', [-3, -1]) β†’ '{{{child[-3,-1].content}}}' + */ +export function prefixVariables(template: string, pathPrefix: number[]): string { + if (pathPrefix.length === 0) { + return template + } + + const pathStr = `child[${pathPrefix.join(',')}]` + + return template.replace(MUSTACHE_VAR_PATTERN, ( + match: string, + openBraces: string, + varName: string, + closeBraces: string + ) => { + // Don't prefix reserved variables + if (RESERVED_VARS.has(varName)) { + return match + } + + // Don't prefix non-field variables (they might be conditionals or other constructs) + if (!PREFIXABLE_FIELDS.has(varName)) { + return match + } + + // Transform {{varName}} to {{child[path].varName}} + return `${openBraces}${pathStr}.${varName}${closeBraces}` + }) +} + +/** + * Format a path array as a child reference string. + * + * @param path - The path array (e.g., [-3, -1]) + * @returns The formatted string (e.g., 'child[-3,-1]') + */ +export function formatPath(path: number[]): string { + return `child[${path.join(',')}]` +} + +/** + * Format a path with a field as a full variable reference. + * + * @param path - The path array (e.g., [-3, -1]) + * @param field - The field name (e.g., 'title') + * @returns The formatted reference (e.g., 'child[-3,-1].title') + */ +export function formatPathWithField(path: number[], field: string): string { + return `child[${path.join(',')}].${field}` +} + +/** + * Prefix all applicable variables in a template with an ancestor index. + * + * @param template - The template string to transform + * @param ancestorIndex - The ancestor index (e.g., -1 for parent, -2 for grandparent) + * @returns The template with prefixed variables + * + * @example + * prefixAncestorVariables('{{title}}', -1) β†’ '{{ancestor[-1].title}}' + * prefixAncestorVariables('{{{content}}}', -2) β†’ '{{{ancestor[-2].content}}}' + */ +export function prefixAncestorVariables(template: string, ancestorIndex: number): string { + const pathStr = `ancestor[${ancestorIndex}]` + + return template.replace(MUSTACHE_VAR_PATTERN, ( + match: string, + openBraces: string, + varName: string, + closeBraces: string + ) => { + // Don't prefix reserved variables + if (RESERVED_VARS.has(varName)) { + return match + } + + // Don't prefix non-field variables (they might be conditionals or other constructs) + if (!PREFIXABLE_FIELDS.has(varName)) { + return match + } + + // Transform {{varName}} to {{ancestor[index].varName}} + return `${openBraces}${pathStr}.${varName}${closeBraces}` + }) +} + +/** + * Format an ancestor index as a reference string. + * + * @param index - The ancestor index (e.g., -1) + * @returns The formatted string (e.g., 'ancestor[-1]') + */ +export function formatAncestorIndex(index: number): string { + return `ancestor[${index}]` +} diff --git a/src/lib/domains/agentic/templates/_compiler/index.ts b/src/lib/domains/agentic/templates/_compiler/index.ts new file mode 100644 index 000000000..44b7f513c --- /dev/null +++ b/src/lib/domains/agentic/templates/_compiler/index.ts @@ -0,0 +1,233 @@ +/** + * Template compiler module. + * + * Phase 1 of two-phase rendering: compiles templates with pool dispatch + * into structural templates with variable placeholders. + */ + +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import type { TemplatePool } from '~/lib/domains/agentic/templates/_pool' +import type { CompileContext } from '~/lib/domains/agentic/templates/_compiler/types' +import { expandRenderChildren } from '~/lib/domains/agentic/templates/_compiler/_render-children' +import { expandRenderAncestors } from '~/lib/domains/agentic/templates/_compiler/_render-ancestors' + +export type { CompileContext, CompileResult } from '~/lib/domains/agentic/templates/_compiler/types' +export { prefixVariables, formatPath, formatPathWithField } from '~/lib/domains/agentic/templates/_compiler/_variable-prefixer' +export { + buildMustacheData, + promptDataToTileData, + extractDirectionFromCoords, + type MustacheData +} from '~/lib/domains/agentic/templates/_compiler/_data-builder' + +export { + twoPhaseRender, + compileOnly, + usesPoolBasedRendering, + type TwoPhaseRenderOptions, + type TwoPhaseRenderResult +} from '~/lib/domains/agentic/templates/_compiler/_two-phase-render' + +// ==================== INTERNAL CONSTANTS ==================== + +/** + * Pattern for RenderChildren tags. + * Matches: {{@RenderChildren range=[-6..-1] fallback='generic'}} + */ +const RENDER_CHILDREN_PATTERN = /\{\{@RenderChildren\s+([^}]+)\}\}/g + +/** + * Pattern for RenderAncestors tags. + * Matches: {{@RenderAncestors fallback='generic'}} or {{@RenderAncestors}} + */ +const RENDER_ANCESTORS_PATTERN = /\{\{@RenderAncestors(?:\s+([^}]*))?\}\}/g + +/** + * Pattern for parsing range parameter. + */ +const RANGE_PATTERN = /range=\[(-?\d+)\.\.(-?\d+)\]/ + +/** + * Pattern for parsing fallback parameter. + */ +const FALLBACK_PATTERN = /fallback='([^']+)'/ + +// ==================== PUBLIC FUNCTIONS ==================== + +/** + * Compile a template with pool-based dispatch. + * + * This is Phase 1 of two-phase rendering: + * - Expands {{@RenderChildren}} and {{@RenderAncestors}} tags based on tile structure + * - Prefixes variables with child/ancestor paths + * - Produces an inspectable structural template + * + * @param template - The template string to compile + * @param taskTile - The task tile with full children tree + * @param templatePool - The template pool for dispatch + * @param ancestors - Optional ancestors array (from root to parent) + * @returns The compiled structural template + */ +export function compileTemplate( + template: string, + taskTile: TileData, + templatePool: TemplatePool, + ancestors: TileData[] = [] +): string { + const context: CompileContext = { + currentPath: [], + taskTile, + ancestors, + templatePool, + maxDepth: 10, + currentDepth: 0 + } + + return _compileWithContext(template, context) +} + +/** + * Compile a template with an existing context. + * + * @param template - The template string to compile + * @param context - The compilation context + * @returns The compiled template + */ +export function compileWithContext( + template: string, + context: CompileContext +): string { + return _compileWithContext(template, context) +} + +// ==================== INTERNAL FUNCTIONS ==================== + +/** + * Internal compile function that processes RenderChildren and RenderAncestors tags. + */ +function _compileWithContext(template: string, context: CompileContext): string { + // Find all tags of both types + const childrenMatches = _findRenderChildrenTags(template) + const ancestorMatches = _findRenderAncestorsTags(template) + + // Combine and sort by position (descending to preserve positions when replacing) + const allMatches: Array<{ start: number; end: number; type: 'children' | 'ancestors'; data: unknown }> = [ + ...childrenMatches.map(m => ({ start: m.start, end: m.end, type: 'children' as const, data: m })), + ...ancestorMatches.map(m => ({ start: m.start, end: m.end, type: 'ancestors' as const, data: m })) + ].sort((a, b) => b.start - a.start) + + if (allMatches.length === 0) { + return template + } + + // Process tags in reverse order to preserve string positions + let result = template + for (const match of allMatches) { + const expanded = match.type === 'children' + ? _expandChildrenTag(match.data as ChildrenTagMatch, context) + : _expandAncestorsTag(match.data as AncestorsTagMatch, context) + result = result.slice(0, match.start) + expanded + result.slice(match.end) + } + + return result +} + +interface ChildrenTagMatch { + start: number + end: number + range: { start: number; end: number } + fallback?: string +} + +interface AncestorsTagMatch { + start: number + end: number + fallback?: string +} + +/** + * Find all RenderChildren tags in a template. + */ +function _findRenderChildrenTags(template: string): ChildrenTagMatch[] { + const matches: ChildrenTagMatch[] = [] + + RENDER_CHILDREN_PATTERN.lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = RENDER_CHILDREN_PATTERN.exec(template)) !== null) { + const params = match[1]! + + // Parse range parameter + const rangeMatch = RANGE_PATTERN.exec(params) + if (!rangeMatch) { + continue + } + + const range = { + start: parseInt(rangeMatch[1]!, 10), + end: parseInt(rangeMatch[2]!, 10) + } + + // Parse optional fallback parameter + const fallbackMatch = FALLBACK_PATTERN.exec(params) + const fallback = fallbackMatch?.[1] + + matches.push({ + start: match.index, + end: match.index + match[0].length, + range, + fallback + }) + } + + return matches +} + +/** + * Find all RenderAncestors tags in a template. + */ +function _findRenderAncestorsTags(template: string): AncestorsTagMatch[] { + const matches: AncestorsTagMatch[] = [] + + RENDER_ANCESTORS_PATTERN.lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = RENDER_ANCESTORS_PATTERN.exec(template)) !== null) { + const params = match[1] ?? '' + + // Parse optional fallback parameter + const fallbackMatch = FALLBACK_PATTERN.exec(params) + const fallback = fallbackMatch?.[1] + + matches.push({ + start: match.index, + end: match.index + match[0].length, + fallback + }) + } + + return matches +} + +/** + * Expand a single RenderChildren tag. + */ +function _expandChildrenTag(match: ChildrenTagMatch, context: CompileContext): string { + return expandRenderChildren( + match.range, + match.fallback, + context, + _compileWithContext + ) +} + +/** + * Expand a single RenderAncestors tag. + */ +function _expandAncestorsTag(match: AncestorsTagMatch, context: CompileContext): string { + return expandRenderAncestors( + match.fallback, + context, + _compileWithContext + ) +} diff --git a/src/lib/domains/agentic/templates/_compiler/types.ts b/src/lib/domains/agentic/templates/_compiler/types.ts new file mode 100644 index 000000000..93bb0c157 --- /dev/null +++ b/src/lib/domains/agentic/templates/_compiler/types.ts @@ -0,0 +1,36 @@ +/** + * Compiler types for two-phase template rendering. + */ + +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import type { TemplatePool } from '~/lib/domains/agentic/templates/_pool' + +// ==================== PUBLIC TYPES ==================== + +/** + * Context for template compilation. + */ +export interface CompileContext { + /** Current path prefix for variable naming (e.g., [-3, -1]) */ + currentPath: number[] + /** The task tile with full children tree */ + taskTile: TileData + /** Ancestors array (from root to parent) */ + ancestors: TileData[] + /** Template pool for itemType-based dispatch */ + templatePool: TemplatePool + /** Maximum recursion depth (default: 10) */ + maxDepth: number + /** Current recursion depth */ + currentDepth: number +} + +/** + * Result of template compilation. + */ +export interface CompileResult { + /** The compiled structural template */ + template: string + /** All variable paths found during compilation */ + variablePaths: string[] +} diff --git a/src/lib/domains/agentic/templates/_hexrun-orchestrator-template.ts b/src/lib/domains/agentic/templates/_hexrun-orchestrator-template.ts deleted file mode 100644 index 8d58da4ba..000000000 --- a/src/lib/domains/agentic/templates/_hexrun-orchestrator-template.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * HEXRUN Orchestrator Mustache Template (Internal) - * - * This template produces the prompt for orchestrating SYSTEM tile execution - * when triggered via @-mention in chat. Instead of direct hexecute execution, - * the agent runs an orchestration loop using MCP tools. - */ - -import Mustache from 'mustache' -import { MapItemType } from '~/lib/domains/mapping' - -// ==================== PUBLIC TYPES ==================== - -/** - * Data shape expected by the HEXRUN_ORCHESTRATOR template. - */ -export interface HexrunOrchestratorTemplateData { - orchestratorIntro: string - taskTitle: string - taskCoords: string - hasInstruction: boolean - instruction: string - mcpServerName: string - hasDiscussion: boolean - discussion: string -} - -/** - * Input data for building orchestrator prompts. - */ -export interface OrchestratorPromptInput { - task: { - title: string - coords: string - } - mcpServerName: string - itemType: MapItemType - userMessage?: string - discussion?: string -} - -/** - * Static orchestrator introduction text. - */ -export const HEXRUN_ORCHESTRATOR_INTRO = `<hexrun-orchestrator> -You are orchestrating the execution of a SYSTEM tile using the hexrun pattern. - -Your job is to execute the task step-by-step using MCP tools until completion or a blocker is encountered. -</hexrun-orchestrator>` - -/** - * Mustache template for HEXRUN orchestration. - * Uses triple braces {{{value}}} for pre-escaped content. - */ -export const HEXRUN_ORCHESTRATOR_TEMPLATE = `{{{orchestratorIntro}}} - -<task-info> -<title>{{{taskTitle}}} -{{{taskCoords}}} -{{#hasInstruction}} -{{{instruction}}} -{{/hasInstruction}} - -{{#hasDiscussion}} - - -Previous messages in this conversation: - -{{{discussion}}} - -{{/hasDiscussion}} - - -Execute this loop until complete or blocked: - -1. **Get the next step**: Call \`mcp__{{{mcpServerName}}}__hexecute\` with taskCoords="{{{taskCoords}}}" - -2. **Check the response**: - - If \`COMPLETE\` β†’ Report success to user - - If \`BLOCKED\` β†’ Report blocker to user - - Otherwise β†’ Continue to step 3 - -3. **Execute the step**: Follow the \`\` in the hexecute response. - The instructions will tell you to execute ONE step (the first pending step in the hexplan). - -4. **Update the hexplan**: After completing the step, update the hexplan tile to mark the step as completed or blocked. - -5. **Repeat**: Go back to step 1 until complete. - -**Important**: -- Execute one step at a time -- Always update the hexplan after each step -- Stop immediately if you encounter a blocker -- Report progress to the user as you go -` - -// ==================== INTERNAL UTILITIES ==================== - -function _hasContent(text: string | undefined): boolean { - return !!text && text.trim().length > 0 -} - -// ==================== PUBLIC FUNCTIONS ==================== - -/** - * Check if we should use the orchestrator template for this request. - * Use orchestrator for SYSTEM tiles when triggered via @-mention (has userMessage). - */ -export function shouldUseOrchestrator(itemType: MapItemType, userMessage: string | undefined): boolean { - return itemType === MapItemType.SYSTEM && _hasContent(userMessage) -} - -/** - * Build the HEXRUN orchestrator prompt for SYSTEM tiles triggered via @-mention. - */ -export function buildOrchestratorPrompt(data: OrchestratorPromptInput): string { - const templateData: HexrunOrchestratorTemplateData = { - orchestratorIntro: HEXRUN_ORCHESTRATOR_INTRO, - taskTitle: data.task.title, - taskCoords: data.task.coords, - hasInstruction: _hasContent(data.userMessage), - instruction: data.userMessage ?? '', - mcpServerName: data.mcpServerName, - hasDiscussion: _hasContent(data.discussion), - discussion: data.discussion ?? '' - } - - const rendered = Mustache.render(HEXRUN_ORCHESTRATOR_TEMPLATE, templateData) - - return rendered - .replace(/\n{3,}/g, '\n\n') - .replace(/\n\n$/g, '') - .trim() -} diff --git a/src/lib/domains/agentic/templates/_internals/section-builders.ts b/src/lib/domains/agentic/templates/_internals/section-builders.ts new file mode 100644 index 000000000..cff9c36d1 --- /dev/null +++ b/src/lib/domains/agentic/templates/_internals/section-builders.ts @@ -0,0 +1,138 @@ +/** + * Internal section builder functions for prompt generation. + * + * Extracted from _prompt-builder.ts to follow Rule of 6. + */ + +import { MapItemType } from '~/lib/domains/mapping' +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import { GenericTile, TileOrFolder } from '~/lib/domains/agentic/templates/_templates' +import { ANCESTOR_INTRO } from '~/lib/domains/agentic/templates/_system-template' +import { _escapeXML, _hasContent } from '~/lib/domains/agentic/templates/_internals/utils' +import type { PromptDataTile, PromptData } from '~/lib/domains/agentic/templates/_internals/types' + +/** + * Build context section using GenericTile or TileOrFolder for organizational tiles. + */ +export function _buildContextSection( + composedChildren: PromptDataTile[], + supportFolders: boolean +): string { + const validChildren = composedChildren.filter(child => + _hasContent(child.content) || child.itemType === MapItemType.ORGANIZATIONAL + ) + + if (validChildren.length === 0) { + return '' + } + + const contexts = validChildren.map(child => { + if (supportFolders && child.itemType === MapItemType.ORGANIZATIONAL) { + return TileOrFolder(child as TileData, ['title', 'content'], 'context', 3) + } + return GenericTile(child as TileData, ['title', 'content'], 'context') + }) + + return contexts.filter(c => c.length > 0).join('\n\n') +} + +/** + * Build subtasks section using GenericTile or TileOrFolder for organizational tiles. + */ +export function _buildSubtasksSection( + structuralChildren: PromptDataTile[], + supportFolders: boolean +): string { + if (structuralChildren.length === 0) { + return '' + } + + const subtasks = structuralChildren.map(child => { + if (supportFolders && child.itemType === MapItemType.ORGANIZATIONAL) { + return TileOrFolder(child as TileData, ['title', 'preview'], 'subtask-preview', 3) + } + return GenericTile(child as TileData, ['title', 'preview'], 'subtask-preview') + }) + + return `\n${subtasks.filter(s => s.length > 0).join('\n\n')}\n` +} + +/** + * Filter ancestors to only include consecutive SYSTEM ancestors from the parent backwards. + */ +export function _filterSystemAncestors(ancestors: PromptData['ancestors']): PromptData['ancestors'] { + const systemAncestors: PromptData['ancestors'] = [] + + for (let i = ancestors.length - 1; i >= 0; i--) { + if (ancestors[i]?.itemType === MapItemType.SYSTEM) { + systemAncestors.unshift(ancestors[i]!) + } else { + break + } + } + + return systemAncestors +} + +/** + * Build ancestor context section with hexplan. + * + * For each ancestor, renders the content and hexplan (if any). + * The hexplan contains instructions that propagate to subtask execution. + */ +export function _buildAncestorContextSection(ancestors: PromptData['ancestors']): string { + const systemAncestors = _filterSystemAncestors(ancestors) + // Include ancestors that have content OR hexplan + const relevantAncestors = systemAncestors.filter(ancestor => + _hasContent(ancestor.content) || _hasContent(ancestor.hexplan) + ) + + if (relevantAncestors.length === 0) { + return '' + } + + const ancestorBlocks = relevantAncestors.map(ancestor => { + const titleAttr = _hasContent(ancestor.title) ? ` title="${_escapeXML(ancestor.title)}"` : '' + const coordsAttr = _hasContent(ancestor.coords) ? ` coords="${_escapeXML(ancestor.coords)}"` : '' + + const parts: string[] = [] + + // Add content if present (NOT escaped - preserve markdown/code) + if (_hasContent(ancestor.content)) { + parts.push(ancestor.content!) + } + + // Add hexplan if present (contains instruction that propagates to subtasks) + if (_hasContent(ancestor.hexplan)) { + parts.push(`\n${ancestor.hexplan}\n`) + } + + const content = parts.join('\n\n') + + if (!content.trim()) { + return `` + } + + return `\n${content.trim()}\n` + }) + + return `\n${ANCESTOR_INTRO}\n\n${ancestorBlocks.join('\n\n')}\n` +} + +/** + * Build sections for USER template. + */ +export function _buildSectionsSection(structuralChildren: PromptDataTile[]): string { + if (structuralChildren.length === 0) { + return '' + } + + const sections = structuralChildren.map(child => { + if (child.itemType === MapItemType.ORGANIZATIONAL) { + return `
\n${_escapeXML(child.preview ?? '')}\n
` + } + return `
\n${_escapeXML(child.preview ?? '')}\n
` + }) + + return `\n${sections.join('\n\n')}\n` +} diff --git a/src/lib/domains/agentic/templates/_internals/types.ts b/src/lib/domains/agentic/templates/_internals/types.ts new file mode 100644 index 000000000..ecba00b39 --- /dev/null +++ b/src/lib/domains/agentic/templates/_internals/types.ts @@ -0,0 +1,50 @@ +/** + * Shared types for prompt building. + */ + +import type { ItemTypeValue } from '~/lib/domains/mapping' + +export interface PromptDataTile { + title: string + content?: string + preview?: string + coords: string + itemType?: ItemTypeValue + direction?: number // -6 to 6, for child lookup by direction + children?: PromptDataTile[] +} + +export interface PromptData { + task: { + title: string + content: string | undefined + coords: string + } + /** Ancestors from root to parent - content flows top-down */ + ancestors: Array<{ + title: string + content: string | undefined + coords: string + itemType?: ItemTypeValue + hexplan?: string + }> + composedChildren: Array + structuralChildren: Array + hexPlan: string + mcpServerName: string + allLeafTasks?: Array<{ + title: string + coords: string + }> + itemType: ItemTypeValue + /** For USER tiles: the current discussion/conversation state */ + discussion?: string + /** For USER tiles: the user's current message/instruction */ + userMessage?: string + /** Whether this run is resuming from a blocked state */ + wasBlocked?: boolean + /** The reason for the previous blockage (if wasBlocked is true) */ + blockageReason?: string + /** Run ID for hexplan storage - agents use this to update hexplan via updateRunHexplan */ + runId?: string +} diff --git a/src/lib/domains/agentic/templates/_internals/utils.ts b/src/lib/domains/agentic/templates/_internals/utils.ts new file mode 100644 index 000000000..ad2ba1d7a --- /dev/null +++ b/src/lib/domains/agentic/templates/_internals/utils.ts @@ -0,0 +1,24 @@ +/** + * Shared utilities for template rendering. + * + * Internal module - not exported from the domain. + */ + +/** + * Escape XML special characters in text. + */ +export function _escapeXML(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * Check if text has non-empty content. + */ +export function _hasContent(text: string | undefined): boolean { + return !!text && text.trim().length > 0 +} diff --git a/src/lib/domains/agentic/templates/_pool/builder.ts b/src/lib/domains/agentic/templates/_pool/builder.ts new file mode 100644 index 000000000..d9caf08e7 --- /dev/null +++ b/src/lib/domains/agentic/templates/_pool/builder.ts @@ -0,0 +1,125 @@ +/** + * Template pool builder. + * + * Builds a TemplatePool from template tile children. + */ + +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import type { TemplatePool, PooledTemplate } from '~/lib/domains/agentic/templates/_pool/types' + +// ==================== PUBLIC FUNCTIONS ==================== + +/** + * Build a template pool from template tile children. + * + * @param templateChildren - Children of the template tile (structural children 1-6) + * @param fallbackTemplateName - Optional name of fallback template (default: 'generic') + * @returns A TemplatePool for rendering tile children + */ +export function buildTemplatePool( + templateChildren: TileData[], + fallbackTemplateName = 'generic' +): TemplatePool { + const templates = new Map() + let fallback: PooledTemplate | undefined + + for (const child of templateChildren) { + // Skip children without content (they're not templates) + if (!child.content) { + continue + } + + // Get templateName from the tile (could be stored in a custom field or title) + // For now, we use itemType as templateName since that's what we match against + const templateName = _getTemplateName(child) + if (!templateName) { + continue + } + + const pooledTemplate: PooledTemplate = { + templateName, + content: child.content, + children: child.children ?? [] + } + + templates.set(templateName, pooledTemplate) + + // Track fallback template + if (templateName === fallbackTemplateName) { + fallback = pooledTemplate + } + } + + return { templates, fallback } +} + +/** + * Build a template pool from a template's structural children only. + * + * @param templateTile - The template tile with children + * @param fallbackTemplateName - Optional name of fallback template + * @returns A TemplatePool for rendering tile children + */ +export function buildPoolFromTemplateTile( + templateTile: TileData, + fallbackTemplateName = 'generic' +): TemplatePool { + const structuralChildren = (templateTile.children ?? []) + .filter(c => (c.direction ?? 0) > 0) + + return buildTemplatePool(structuralChildren, fallbackTemplateName) +} + +/** + * Find a template in the pool that matches a tile's itemType. + * + * @param pool - The template pool to search + * @param itemType - The tile's itemType to match + * @returns The matching template, fallback, or undefined + */ +export function findTemplateForItemType( + pool: TemplatePool, + itemType: string | undefined +): PooledTemplate | undefined { + if (!itemType) { + return pool.fallback + } + + return pool.templates.get(itemType) ?? pool.fallback +} + +// ==================== INTERNAL FUNCTIONS ==================== + +/** + * Extract template name from a tile. + * + * Templates can specify their name via: + * 1. A 'templateName' field (if extended TileData) + * 2. The title (for template tiles, title IS the templateName) + * 3. The itemType field (for non-template tiles) + */ +function _getTemplateName(tile: TileData): string | undefined { + // Check for templateName in extended tile data + const extendedTile = tile as TileData & { templateName?: string } + if (extendedTile.templateName) { + return extendedTile.templateName + } + + // For template tiles, use title as templateName + // (templates have itemType="template" but title indicates what they match) + if (tile.itemType === 'template' && tile.title) { + return tile.title.toLowerCase().replace(/\s+/g, '-') + } + + // For non-template tiles, use itemType + if (tile.itemType && tile.itemType !== 'template') { + return String(tile.itemType) + } + + // Last resort: derive from title + if (tile.title) { + return tile.title.toLowerCase().replace(/\s+/g, '-') + } + + return undefined +} diff --git a/src/lib/domains/agentic/templates/_pool/index.ts b/src/lib/domains/agentic/templates/_pool/index.ts new file mode 100644 index 000000000..553338dba --- /dev/null +++ b/src/lib/domains/agentic/templates/_pool/index.ts @@ -0,0 +1,13 @@ +/** + * Template pool module. + * + * Provides pool-based template dispatch for rendering tile children. + */ + +export type { TemplatePool, PooledTemplate } from '~/lib/domains/agentic/templates/_pool/types' + +export { + buildTemplatePool, + buildPoolFromTemplateTile, + findTemplateForItemType +} from '~/lib/domains/agentic/templates/_pool/builder' diff --git a/src/lib/domains/agentic/templates/_pool/types.ts b/src/lib/domains/agentic/templates/_pool/types.ts new file mode 100644 index 000000000..013ed59cf --- /dev/null +++ b/src/lib/domains/agentic/templates/_pool/types.ts @@ -0,0 +1,32 @@ +/** + * Template pool types for pool-based dispatch. + * + * A template pool is a collection of sub-templates that can be used + * to render tile children based on itemType matching. + */ + +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' + +// ==================== PUBLIC TYPES ==================== + +/** + * A pooled template that can be used to render tiles. + */ +export interface PooledTemplate { + /** The template name (used for itemType matching) */ + templateName: string + /** The template content (Mustache + custom tags) */ + content: string + /** Child templates for building next-level pool */ + children: TileData[] +} + +/** + * A collection of templates for rendering tile children. + */ +export interface TemplatePool { + /** Map of templateName → template */ + templates: Map + /** Fallback template for unmatched itemTypes */ + fallback?: PooledTemplate +} diff --git a/src/lib/domains/agentic/templates/_pre-processor/_path-parser.ts b/src/lib/domains/agentic/templates/_pre-processor/_path-parser.ts new file mode 100644 index 000000000..e474c6a8e --- /dev/null +++ b/src/lib/domains/agentic/templates/_pre-processor/_path-parser.ts @@ -0,0 +1,223 @@ +/** + * Path parser for template variable references. + * + * Parses expressions like: + * - child[-3] → single child at direction -3 + * - child[-3].title → title field of child at -3 + * - child[-2,-1] → nested child: -1 of -2 + * - child[-2,-1].content→ content field of nested child + * - child[-6..-1] → range of children (composed) + * - child[1..6] → range of children (structural) + * - ancestor[-1] → parent (closest ancestor) + * - ancestor[-2].title → grandparent's title + */ + +// ==================== PUBLIC TYPES ==================== + +export interface ChildPath { + directions: number[] + field?: string +} + +export interface AncestorPath { + /** Negative index: -1 = parent, -2 = grandparent, etc. */ + index: number + field?: string +} + +export interface ChildRange { + start: number + end: number +} + +export type ParsedChildRef = + | { type: 'path'; path: ChildPath } + | { type: 'range'; range: ChildRange } + +export type ParsedAncestorRef = + | { type: 'path'; path: AncestorPath } + +// ==================== INTERNAL CONSTANTS ==================== + +/** + * Pattern for single/nested child path with optional field. + * Matches: child[-3], child[-3].title, child[-2,-1], child[-2,-1].content + */ +const CHILD_PATH_PATTERN = /^child\[([-\d,]+)\](?:\.(\w+))?$/ + +/** + * Pattern for child range. + * Matches: child[-6..-1], child[1..6] + */ +const CHILD_RANGE_PATTERN = /^child\[(-?\d+)\.\.(-?\d+)\]$/ + +/** + * Pattern for ancestor path with optional field. + * Matches: ancestor[-1], ancestor[-2].title + */ +const ANCESTOR_PATH_PATTERN = /^ancestor\[(-\d+)\](?:\.(\w+))?$/ + +/** + * Valid field names for child/ancestor access. + */ +const VALID_FIELDS = new Set(['title', 'content', 'preview', 'coords', 'itemType']) + +// ==================== PUBLIC FUNCTIONS ==================== + +/** + * Parse a child reference expression. + * + * @returns ParsedChildRef or null if not a valid child reference + */ +export function parseChildRef(ref: string): ParsedChildRef | null { + const pathResult = _parseChildPath(ref) + if (pathResult) { + return { type: 'path', path: pathResult } + } + + const rangeResult = _parseChildRange(ref) + if (rangeResult) { + return { type: 'range', range: rangeResult } + } + + return null +} + +/** + * Parse a child path expression (single or nested). + * + * @returns ChildPath or null if not a valid path + */ +export function parseChildPath(ref: string): ChildPath | null { + return _parseChildPath(ref) +} + +/** + * Parse a child range expression. + * + * @returns ChildRange or null if not a valid range + */ +export function parseChildRange(ref: string): ChildRange | null { + return _parseChildRange(ref) +} + +/** + * Format a child path back to string representation. + * + * @example formatChildPath({ directions: [-2, -1], field: 'title' }) → 'child[-2,-1].title' + */ +export function formatChildPath(path: ChildPath): string { + const pathStr = `child[${path.directions.join(',')}]` + return path.field ? `${pathStr}.${path.field}` : pathStr +} + +/** + * Check if a string looks like a child reference. + */ +export function isChildRef(ref: string): boolean { + return ref.startsWith('child[') +} + +/** + * Parse an ancestor path expression. + * + * @returns AncestorPath or null if not a valid ancestor path + */ +export function parseAncestorPath(ref: string): AncestorPath | null { + return _parseAncestorPath(ref) +} + +/** + * Format an ancestor path back to string representation. + * + * @example formatAncestorPath({ index: -1, field: 'title' }) → 'ancestor[-1].title' + */ +export function formatAncestorPath(path: AncestorPath): string { + const pathStr = `ancestor[${path.index}]` + return path.field ? `${pathStr}.${path.field}` : pathStr +} + +/** + * Check if a string looks like an ancestor reference. + */ +export function isAncestorRef(ref: string): boolean { + return ref.startsWith('ancestor[') +} + +// ==================== INTERNAL FUNCTIONS ==================== + +function _parseChildPath(ref: string): ChildPath | null { + const match = CHILD_PATH_PATTERN.exec(ref) + if (!match) return null + + const directionsStr = match[1]! + const field = match[2] + + // Parse comma-separated directions + const directions = directionsStr.split(',').map(d => { + const num = parseInt(d.trim(), 10) + return isNaN(num) ? null : num + }) + + // Validate all directions parsed successfully + if (directions.some(d => d === null)) { + return null + } + + // Validate directions are in valid range (-6 to 6) + // Note: 0 is allowed for hexplan tiles + if (directions.some(d => d! < -6 || d! > 6)) { + return null + } + + // Validate field if present + if (field && !VALID_FIELDS.has(field)) { + return null + } + + return { + directions: directions as number[], + field + } +} + +function _parseChildRange(ref: string): ChildRange | null { + const match = CHILD_RANGE_PATTERN.exec(ref) + if (!match) return null + + const start = parseInt(match[1]!, 10) + const end = parseInt(match[2]!, 10) + + // Validate range values + if (isNaN(start) || isNaN(end)) { + return null + } + + // Validate directions are in valid range (-6 to 6) + // Note: 0 is allowed for hexplan tiles + if (start < -6 || start > 6 || end < -6 || end > 6) { + return null + } + + return { start, end } +} + +function _parseAncestorPath(ref: string): AncestorPath | null { + const match = ANCESTOR_PATH_PATTERN.exec(ref) + if (!match) return null + + const index = parseInt(match[1]!, 10) + const field = match[2] + + // Index must be negative (e.g., -1 = parent, -2 = grandparent) + if (isNaN(index) || index >= 0) { + return null + } + + // Validate field if present + if (field && !VALID_FIELDS.has(field)) { + return null + } + + return { index, field } +} diff --git a/src/lib/domains/agentic/templates/_pre-processor/_resolver.ts b/src/lib/domains/agentic/templates/_pre-processor/_resolver.ts index b27c373d2..f5e666865 100644 --- a/src/lib/domains/agentic/templates/_pre-processor/_resolver.ts +++ b/src/lib/domains/agentic/templates/_pre-processor/_resolver.ts @@ -4,7 +4,8 @@ * Resolves item references (., this, path.to.value) to actual data. */ -import type { MapItemType } from '~/lib/domains/mapping' +import type { ItemTypeValue } from '~/lib/domains/mapping' +import { parseChildRef, type ChildPath } from '~/lib/domains/agentic/templates/_pre-processor/_path-parser' // ==================== PUBLIC TYPES ==================== @@ -13,7 +14,8 @@ export interface TileData { content?: string preview?: string coords: string - itemType?: MapItemType + itemType?: ItemTypeValue + direction?: number // -6 to 6, for child lookup by direction children?: TileData[] } @@ -38,6 +40,8 @@ export interface TemplateContext { isParentTile: boolean /** Hexplan status */ hexplanStatus: 'pending' | 'complete' | 'blocked' + /** Run ID for hexplan storage - agents use this to update hexplan via updateRunHexplan */ + runId?: string } // ==================== PUBLIC FUNCTIONS ==================== @@ -51,6 +55,8 @@ export interface TemplateContext { * - `ancestors` - ancestor array * - `composedChildren` - composed children array * - `structuralChildren` - structural children array + * - `child[-3]` - child at direction -3 + * - `child[-2,-1]` - nested child path * - Path expressions like `task.title` */ export function resolveItemReference( @@ -65,6 +71,12 @@ export function resolveItemReference( return context.task } + // Handle child path references like child[-3] or child[-2,-1] + const childRef = parseChildRef(ref) + if (childRef?.type === 'path') { + return resolveChildPath(childRef.path, context.task) + } + // Handle array access like ancestors[0] const arrayMatch = /^(\w+)\[(\d+)\]$/.exec(ref) if (arrayMatch) { @@ -87,6 +99,77 @@ export function resolveItemReference( return undefined } +/** + * Resolve a child path to tile data. + * + * @param path - The parsed child path (e.g., { directions: [-2, -1] }) + * @param rootTile - The tile to start resolution from + * @returns The tile at the path, or undefined if not found + */ +export function resolveChildPath( + path: ChildPath, + rootTile: TileData +): TileData | undefined { + let current: TileData | undefined = rootTile + + for (const direction of path.directions) { + if (!current?.children) { + return undefined + } + current = current.children.find(c => c.direction === direction) + if (!current) { + return undefined + } + } + + return current +} + +/** + * Resolve a child path and extract a specific field value. + * + * @param path - The parsed child path with field (e.g., { directions: [-2], field: 'title' }) + * @param rootTile - The tile to start resolution from + * @returns The field value as string, or undefined if not found + */ +export function resolveChildPathField( + path: ChildPath, + rootTile: TileData +): string | undefined { + const tile = resolveChildPath(path, rootTile) + if (!tile || !path.field) { + return undefined + } + + const value = tile[path.field as keyof TileData] + return typeof value === 'string' ? value : undefined +} + +/** + * Get children in a direction range from a tile. + * + * @param tile - The tile to get children from + * @param start - Start direction (inclusive) + * @param end - End direction (inclusive) + * @returns Array of children in the range, sorted by direction + */ +export function getChildrenInRange( + tile: TileData, + start: number, + end: number +): TileData[] { + if (!tile.children) { + return [] + } + + const minDir = Math.min(start, end) + const maxDir = Math.max(start, end) + + return tile.children + .filter(c => c.direction !== undefined && c.direction >= minDir && c.direction <= maxDir) + .sort((a, b) => (a.direction ?? 0) - (b.direction ?? 0)) +} + /** * Create a child context for loop iterations. */ diff --git a/src/lib/domains/agentic/templates/_pre-processor/index.ts b/src/lib/domains/agentic/templates/_pre-processor/index.ts index 5e326dedc..35802a2d7 100644 --- a/src/lib/domains/agentic/templates/_pre-processor/index.ts +++ b/src/lib/domains/agentic/templates/_pre-processor/index.ts @@ -7,9 +7,33 @@ export { parseCustomTags, parseParams } from '~/lib/domains/agentic/templates/_pre-processor/_parser' export type { CustomTag, ParsedParams } from '~/lib/domains/agentic/templates/_pre-processor/_parser' -export { resolveItemReference, createChildContext } from '~/lib/domains/agentic/templates/_pre-processor/_resolver' +export { + resolveItemReference, + createChildContext, + resolveChildPath, + resolveChildPathField, + getChildrenInRange +} from '~/lib/domains/agentic/templates/_pre-processor/_resolver' export type { TileData, TemplateContext } from '~/lib/domains/agentic/templates/_pre-processor/_resolver' +export { + parseChildRef, + parseChildPath, + parseChildRange, + formatChildPath, + isChildRef, + parseAncestorPath, + formatAncestorPath, + isAncestorRef +} from '~/lib/domains/agentic/templates/_pre-processor/_path-parser' +export type { + ChildPath, + ChildRange, + ParsedChildRef, + AncestorPath, + ParsedAncestorRef +} from '~/lib/domains/agentic/templates/_pre-processor/_path-parser' + import { parseCustomTags, type ParsedParams } from '~/lib/domains/agentic/templates/_pre-processor/_parser' import { resolveItemReference, type TemplateContext } from '~/lib/domains/agentic/templates/_pre-processor/_resolver' import type { TemplateRegistry } from '~/lib/domains/agentic/templates/_templates' @@ -70,53 +94,11 @@ function _expandTag( context: TemplateContext, registry: TemplateRegistry ): string { - const templateFn = registry[templateName as keyof TemplateRegistry] - - if (!templateFn) { - const available = Object.keys(registry).join(', ') - throw new TemplateError( - `Unknown template "${templateName}". Available: ${available}`, - templateName, - params - ) - } - - const item = resolveItemReference(params.item, context) - const fields = params.fields ?? ['title', 'content'] - const wrapper = params.wrapper - const depth = params.depth ?? 3 + _validateTemplateExists(templateName, params, registry) + const resolvedParams = _resolveParams(params, context) try { - // Call the appropriate template function based on its signature - switch (templateName) { - case 'GenericTile': - return registry.GenericTile(item, fields, wrapper) - - case 'Folder': - return registry.Folder(item, fields, depth) - - case 'TileOrFolder': - return registry.TileOrFolder(item, fields, wrapper, depth) - - case 'HexPlan': - return registry.HexPlan( - context.hexplanCoords, - context.hexPlan, - context.hexplanStatus, - { - mcpServerName: context.mcpServerName, - isParentTile: context.isParentTile, - taskCoords: context.task.coords - } - ) - - default: - throw new TemplateError( - `No handler for template "${templateName}"`, - templateName, - params - ) - } + return _invokeTemplate(templateName, resolvedParams, context, registry, params) } catch (error) { if (error instanceof TemplateError) { throw error @@ -129,3 +111,61 @@ function _expandTag( ) } } + +interface ResolvedParams { + item: ReturnType + fields: string[] + wrapper: string | undefined + depth: number +} + +function _validateTemplateExists( + templateName: string, + params: ParsedParams, + registry: TemplateRegistry +): void { + const templateFn = registry[templateName as keyof TemplateRegistry] + if (!templateFn) { + const available = Object.keys(registry).join(', ') + throw new TemplateError( + `Unknown template "${templateName}". Available: ${available}`, + templateName, + params + ) + } +} + +function _resolveParams(params: ParsedParams, context: TemplateContext): ResolvedParams { + return { + item: resolveItemReference(params.item, context), + fields: params.fields ?? ['title', 'content'], + wrapper: params.wrapper, + depth: params.depth ?? 3, + } +} + +function _invokeTemplate( + templateName: string, + resolvedParams: ResolvedParams, + context: TemplateContext, + registry: TemplateRegistry, + originalParams: ParsedParams +): string { + switch (templateName) { + case 'GenericTile': + return registry.GenericTile(resolvedParams.item, resolvedParams.fields, resolvedParams.wrapper) + case 'Folder': + return registry.Folder(resolvedParams.item, resolvedParams.fields, resolvedParams.depth) + case 'TileOrFolder': + return registry.TileOrFolder(resolvedParams.item, resolvedParams.fields, resolvedParams.wrapper, resolvedParams.depth) + case 'HexPlan': + return registry.HexPlan(context.hexplanCoords, context.hexPlan, context.hexplanStatus, { + mcpServerName: context.mcpServerName, + isParentTile: context.isParentTile, + taskCoords: context.task.coords, + runId: context.runId, + }) + default: + throw new TemplateError(`No handler for template "${templateName}"`, templateName, originalParams) + } +} diff --git a/src/lib/domains/agentic/templates/_prompt-builder.ts b/src/lib/domains/agentic/templates/_prompt-builder.ts index 1de2ada37..47500ff7d 100644 --- a/src/lib/domains/agentic/templates/_prompt-builder.ts +++ b/src/lib/domains/agentic/templates/_prompt-builder.ts @@ -2,184 +2,37 @@ * Internal prompt builder implementation. * * Transforms PromptData into XML prompts using: - * 1. Pre-processor for {{@Template}} expansion - * 2. Mustache for conditional sections + * 1. Pre-processor for {{@Template}} expansion (legacy) + * 2. Compiler for {{@RenderChildren}} expansion (pool-based) + * 3. Mustache for conditional sections */ import Mustache from 'mustache' -import { MapItemType } from '~/lib/domains/mapping' -import { SYSTEM_TEMPLATE, type SystemTemplateData, HEXRUN_INTRO, ANCESTOR_INTRO } from '~/lib/domains/agentic/templates/_system-template' -import { USER_TEMPLATE, type UserTemplateData, USER_INTRO } from '~/lib/domains/agentic/templates/_user-template' +import { MapItemType, type ItemTypeValue, isBuiltInItemType } from '~/lib/domains/mapping/utils' +import { SYSTEM_TEMPLATE, type SystemTemplateData, HEXRUN_INTRO } from '~/lib/domains/agentic/templates/_system-template' import { - shouldUseOrchestrator, - buildOrchestratorPrompt -} from '~/lib/domains/agentic/templates/_hexrun-orchestrator-template' + USER_TEMPLATE, + USER_SUB_TEMPLATES, + USER_TEMPLATE_CONTEXT, + type UserTemplateData +} from '~/lib/domains/agentic/templates/_user-template' import { preProcess, type TemplateContext, type TileData } from '~/lib/domains/agentic/templates/_pre-processor' -import { templateRegistry, GenericTile, TileOrFolder } from '~/lib/domains/agentic/templates/_templates' - -// ==================== PUBLIC TYPES ==================== - -export interface PromptDataTile { - title: string - content?: string - preview?: string - coords: string - itemType?: MapItemType - children?: PromptDataTile[] -} - -export interface PromptData { - task: { - title: string - content: string | undefined - coords: string - } - /** Ancestors from root to parent - content flows top-down */ - ancestors: Array<{ - title: string - content: string | undefined - coords: string - itemType?: MapItemType - }> - composedChildren: Array - structuralChildren: Array - hexPlan: string - mcpServerName: string - allLeafTasks?: Array<{ - title: string - coords: string - }> - itemType: MapItemType - /** For USER tiles: the current discussion/conversation state */ - discussion?: string - /** For USER tiles: the user's current message/instruction */ - userMessage?: string -} - -// ==================== INTERNAL UTILITIES ==================== - -function _escapeXML(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function _hasContent(text: string | undefined): boolean { - return !!text && text.trim().length > 0 -} - -// ==================== INTERNAL SECTION BUILDERS ==================== - -/** - * Build context section using GenericTile or TileOrFolder for organizational tiles. - */ -function _buildContextSection( - composedChildren: PromptDataTile[], - supportFolders: boolean -): string { - const validChildren = composedChildren.filter(child => - _hasContent(child.content) || child.itemType === MapItemType.ORGANIZATIONAL - ) - - if (validChildren.length === 0) { - return '' - } - - const contexts = validChildren.map(child => { - if (supportFolders && child.itemType === MapItemType.ORGANIZATIONAL) { - return TileOrFolder(child as TileData, ['title', 'content'], 'context', 3) - } - return GenericTile(child as TileData, ['title', 'content'], 'context') - }) - - return contexts.filter(c => c.length > 0).join('\n\n') -} - -/** - * Build subtasks section using GenericTile or TileOrFolder for organizational tiles. - */ -function _buildSubtasksSection( - structuralChildren: PromptDataTile[], - supportFolders: boolean -): string { - if (structuralChildren.length === 0) { - return '' - } - - const subtasks = structuralChildren.map(child => { - if (supportFolders && child.itemType === MapItemType.ORGANIZATIONAL) { - return TileOrFolder(child as TileData, ['title', 'preview'], 'subtask-preview', 3) - } - return GenericTile(child as TileData, ['title', 'preview'], 'subtask-preview') - }) - - return `\n${subtasks.filter(s => s.length > 0).join('\n\n')}\n` -} - -/** - * Filter ancestors to only include consecutive SYSTEM ancestors from the parent backwards. - * Ancestors are ordered root → parent, so we walk backwards and stop at first non-SYSTEM. - * - * Example: [USER, ORG, SYSTEM1, SYSTEM2, SYSTEM3] → [SYSTEM1, SYSTEM2, SYSTEM3] - * Example: [USER, SYSTEM1, ORG, SYSTEM2] → [SYSTEM2] (stops at ORG) - */ -function _filterSystemAncestors(ancestors: PromptData['ancestors']): PromptData['ancestors'] { - const systemAncestors: PromptData['ancestors'] = [] - - // Walk backward from parent, collecting consecutive SYSTEM ancestors - for (let i = ancestors.length - 1; i >= 0; i--) { - if (ancestors[i]?.itemType === MapItemType.SYSTEM) { - systemAncestors.unshift(ancestors[i]!) // prepend to maintain root→parent order - } else { - break // stop at first non-SYSTEM - } - } - - return systemAncestors -} - -/** - * Build ancestor context section using GenericTile. - * Only includes SYSTEM ancestors for SYSTEM tiles. - */ -function _buildAncestorContextSection(ancestors: PromptData['ancestors']): string { - const systemAncestors = _filterSystemAncestors(ancestors) - const ancestorsWithContent = systemAncestors.filter(ancestor => - _hasContent(ancestor.content) - ) - - if (ancestorsWithContent.length === 0) { - return '' - } - - const ancestorBlocks = ancestorsWithContent.map(ancestor => - GenericTile(ancestor as TileData, ['title', 'content'], 'ancestor') - ) - - return `\n${ANCESTOR_INTRO}\n\n${ancestorBlocks.join('\n\n')}\n` -} - -/** - * Build sections for USER template - shows available tiles without going beyond organizational. - */ -function _buildSectionsSection(structuralChildren: PromptDataTile[]): string { - if (structuralChildren.length === 0) { - return '' - } - - const sections = structuralChildren.map(child => { - if (child.itemType === MapItemType.ORGANIZATIONAL) { - // For organizational tiles, show as folder but don't recurse into children - return `
\n${_escapeXML(child.preview ?? '')}\n
` - } - return `
\n${_escapeXML(child.preview ?? '')}\n
` - }) +import { templateRegistry } from '~/lib/domains/agentic/templates/_templates' +import { _escapeXML, _hasContent } from '~/lib/domains/agentic/templates/_internals/utils' +import { + _buildContextSection, + _buildSubtasksSection, + _filterSystemAncestors, + _buildAncestorContextSection +} from '~/lib/domains/agentic/templates/_internals/section-builders' +import { + usesPoolBasedRendering, + twoPhaseRender +} from '~/lib/domains/agentic/templates/_compiler' - return `\n${sections.join('\n\n')}\n` -} +// Re-export types from _types.ts for backward compatibility +export type { PromptDataTile, PromptData } from '~/lib/domains/agentic/templates/_internals/types' +import type { PromptData } from '~/lib/domains/agentic/templates/_internals/types' // ==================== INTERNAL TEMPLATE LOOKUP ==================== @@ -190,11 +43,17 @@ const TEMPLATE_LOOKUP_ERRORS = { contextNotImplemented: 'CONTEXT tile templates not yet implemented', } as const -function _getTemplateByItemType(itemType: MapItemType | null | undefined): string { +function _getTemplateByItemType(itemType: ItemTypeValue | null | undefined): string { if (itemType === null || itemType === undefined) { throw new Error(TEMPLATE_LOOKUP_ERRORS.noItemType) } + // Handle custom (non-built-in) item types by using SYSTEM template + // Custom types like "template" are treated as executable tiles + if (!isBuiltInItemType(itemType)) { + return SYSTEM_TEMPLATE + } + switch (itemType) { case MapItemType.SYSTEM: return SYSTEM_TEMPLATE @@ -218,6 +77,7 @@ function _getTemplateByItemType(itemType: MapItemType | null | undefined): strin // ==================== INTERNAL DATA TRANSFORMATION ==================== function _getHexplanStatus(hexPlan: string): 'pending' | 'complete' | 'blocked' { + if (!hexPlan.trim()) return 'pending' // Empty = not started yet const hasPendingSteps = hexPlan.includes('πŸ“‹') const hasBlockedSteps = hexPlan.includes('πŸ”΄') @@ -252,28 +112,21 @@ function _prepareSystemTemplateData(data: PromptData): SystemTemplateData { title: _escapeXML(data.task.title), hasContent: _hasContent(data.task.content), content: data.task.content ?? '' - } + }, + + // Simplified HexPlan (for tile-based templates) + hasHexplan: _hasContent(data.hexPlan), + hexplanCoords: `${data.task.coords},0`, + hexPlan: data.hexPlan, + + // Execution context for resumed runs + wasBlocked: data.wasBlocked ?? false, + blockageReason: data.blockageReason ?? '' } } function _prepareUserTemplateData(data: PromptData): UserTemplateData { - const composedChildrenWithContent = data.composedChildren.filter(child => - _hasContent(child.content) || child.itemType === MapItemType.ORGANIZATIONAL - ) - return { - userIntro: USER_INTRO, - - hasComposedChildren: composedChildrenWithContent.length > 0, - contextSection: _buildContextSection(data.composedChildren, true), - - hasSections: data.structuralChildren.length > 0, - sectionsSection: _buildSectionsSection(data.structuralChildren), - - hasRecentHistory: _hasContent(data.hexPlan), - recentHistory: data.hexPlan, - recentHistoryCoords: `${data.task.coords},0`, - hasDiscussion: _hasContent(data.discussion), discussion: data.discussion ?? '', @@ -282,6 +135,30 @@ function _prepareUserTemplateData(data: PromptData): UserTemplateData { } } +/** + * Build a template tile for USER template with sub-templates and context. + */ +function _buildUserTemplateTile(): TileData { + return { + title: 'User Interlocutor Template', + content: USER_TEMPLATE, + coords: '', + itemType: 'template', + children: [ + // Template context at direction -1 + { + title: 'user-intro', + content: USER_TEMPLATE_CONTEXT, + coords: '', + itemType: 'context', + direction: -1 + }, + // Sub-templates at directions 1-5 + ...USER_SUB_TEMPLATES + ] + } +} + function _buildPreProcessorContext(data: PromptData): TemplateContext { const hexplanCoords = `${data.task.coords},0` @@ -294,7 +171,8 @@ function _buildPreProcessorContext(data: PromptData): TemplateContext { hexplanCoords, mcpServerName: data.mcpServerName, isParentTile: data.structuralChildren.length > 0, - hexplanStatus: _getHexplanStatus(data.hexPlan) + hexplanStatus: _getHexplanStatus(data.hexPlan), + runId: data.runId } } @@ -311,29 +189,31 @@ function _buildPreProcessorContext(data: PromptData): TemplateContext { * 5. - Goal (title) + requirements (content) * 6. - Direction-0 content with execution instructions * - * USER template structure: - * 1. - Explains agent role as user's interlocutor - * 2. - Context children with folder support - * 3. - Available tiles (title + preview, stops at organizational) - * 4. - Session continuity (renamed hexplan) - * 5. - Current exchange state + * USER template structure (pool-based): + * 1. - From template context at direction -1 + * 2. - Context children via {{@RenderChildren range=[-6..-1]}} + * 3. - Structural children via {{@RenderChildren range=[1..6]}} + * 4. - Direction-0 via {{@RenderChildren range=[0..0]}} + * 5. - Current exchange state (Mustache variable) + * 6. - User's current message (Mustache variable) * * Empty sections are omitted. */ export function buildPrompt(data: PromptData): string { - // For SYSTEM tiles with userMessage (from @-mention), use orchestrator - if (shouldUseOrchestrator(data.itemType, data.userMessage)) { - return buildOrchestratorPrompt(data) - } - const template = _getTemplateByItemType(data.itemType) + // Check if template uses pool-based rendering ({{@RenderChildren}}) + if (usesPoolBasedRendering(template)) { + return _buildPoolBasedPrompt(data, template) + } + + // Legacy path: pre-rendered sections // Prepare template data based on item type - const templateData = data.itemType === MapItemType.USER + const templateData = data.itemType === (MapItemType.USER as ItemTypeValue) ? _prepareUserTemplateData(data) : _prepareSystemTemplateData(data) - // Pre-process {{@Template}} tags (only for SYSTEM template currently) + // Pre-process {{@Template}} tags (for SYSTEM template) const preProcessorContext = _buildPreProcessorContext(data) const preprocessedTemplate = preProcess(template, preProcessorContext, templateRegistry) @@ -345,3 +225,115 @@ export function buildPrompt(data: PromptData): string { .replace(/\n\n$/g, '') .trim() } + +/** + * Build prompt using pool-based rendering for templates with {{@RenderChildren}}. + */ +function _buildPoolBasedPrompt(data: PromptData, template: string): string { + // Build template tile with sub-templates + const templateTile = data.itemType === (MapItemType.USER as ItemTypeValue) + ? _buildUserTemplateTile() + : { title: 'Template', content: template, coords: '', itemType: 'template' } + + // Convert PromptData to TileData for the compiler + const taskTile = _buildTaskTileForUserTemplate(data) + + // Prepare additional Mustache data (non-tile data like discussion, userMessage) + const userTemplateData = _prepareUserTemplateData(data) + const additionalData: Record = { ...userTemplateData } + + // Use two-phase render: compile + Mustache + const result = twoPhaseRender({ + templateTile, + taskTile, + ancestors: [], // USER tiles have no ancestors + fallbackTemplateName: 'generic', + additionalData + }) + + return result.rendered +} + +/** + * Build a TileData structure for USER template rendering. + * Merges composed children, structural children, and hexplan (direction-0). + */ +function _buildTaskTileForUserTemplate(data: PromptData): TileData { + const children: TileData[] = [] + + // Add composed children (negative directions) + for (const child of data.composedChildren) { + const direction = _extractDirection(child.coords) + children.push({ + title: child.title, + content: child.content, + preview: child.preview, + coords: child.coords, + itemType: child.itemType, + direction, + children: child.children?.map(c => _convertPromptDataTileToTileData(c)) + }) + } + + // Add structural children (positive directions) + for (const child of data.structuralChildren) { + const direction = _extractDirection(child.coords) + children.push({ + title: child.title, + content: child.content, + preview: child.preview, + coords: child.coords, + itemType: child.itemType, + direction, + children: child.children?.map(c => _convertPromptDataTileToTileData(c)) + }) + } + + // Add hexplan as direction-0 child (for recent-history) + // No itemType so it uses the 'recent-history' fallback template + if (_hasContent(data.hexPlan)) { + children.push({ + title: 'Recent History', + content: data.hexPlan, + coords: `${data.task.coords},0`, + direction: 0 + }) + } + + return { + title: data.task.title, + content: data.task.content, + coords: data.task.coords, + itemType: data.itemType, + children: children.length > 0 ? children : undefined + } +} + +/** + * Convert PromptDataTile to TileData recursively. + */ +function _convertPromptDataTileToTileData(tile: PromptData['composedChildren'][0]): TileData { + return { + title: tile.title, + content: tile.content, + preview: tile.preview, + coords: tile.coords, + itemType: tile.itemType, + direction: tile.direction ?? _extractDirection(tile.coords), + children: tile.children?.map(c => _convertPromptDataTileToTileData(c)) + } +} + +/** + * Extract direction from coords string. + * Example: "userId,0:1,2,-3" β†’ -3 + */ +function _extractDirection(coords: string): number | undefined { + const pathPart = coords.split(':')[1] + if (!pathPart) return undefined + + const directions = pathPart.split(',').map(d => parseInt(d, 10)) + const lastDir = directions[directions.length - 1] + + return lastDir !== undefined && !isNaN(lastDir) ? lastDir : undefined +} diff --git a/src/lib/domains/agentic/templates/_system-template.ts b/src/lib/domains/agentic/templates/_system-template.ts index 7b7ee0df1..23550b768 100644 --- a/src/lib/domains/agentic/templates/_system-template.ts +++ b/src/lib/domains/agentic/templates/_system-template.ts @@ -27,14 +27,58 @@ export interface SystemTemplateData { hasContent: boolean content: string } + + // Simplified HexPlan (for tile-based templates) + hasHexplan: boolean + hexplanCoords: string + hexPlan: string + + // Execution context for resumed runs + wasBlocked: boolean + blockageReason: string } +/** + * Execution context section - shown when resuming from a blocked state. + */ +export const EXECUTION_CONTEXT_SECTION = `{{#wasBlocked}} + + +This task was previously blocked with reason: {{{blockageReason}}} +The blocker has been addressed. Continue execution from where you left off. + + +{{/wasBlocked}}` + +/** + * Execution instructions section - guides agents on completion reporting. + */ +export const EXECUTION_INSTRUCTIONS_SECTION = ` +Execute this task. Track progress in the hexplan below. When done: \`{"result": "completed"}\`. When blocked: \`{"result": "blocked", "reason": "..."}\` +` + /** * Mustache template for SYSTEM tiles. * Uses triple braces {{{value}}} for pre-escaped content. * The {{@HexPlan}} tag is expanded by the pre-processor before Mustache. + * + * Structure (optimized for agent context): + * 1. Execution instructions (general guidance) + * 2. Hexrun intro + * 3. Execution context (if blocked) + * 4. Ancestor context + * 5. Context section + * 6. Subtasks section + * 7. Task + * 8. Hexplan (most relevant for immediate action) */ -export const SYSTEM_TEMPLATE = `{{{hexrunIntro}}} +export const SYSTEM_TEMPLATE = `${EXECUTION_INSTRUCTIONS_SECTION} + +{{{hexrunIntro}}} +{{#wasBlocked}} + +${EXECUTION_CONTEXT_SECTION} +{{/wasBlocked}} {{#hasAncestorsWithContent}} {{{ancestorContextSection}}} @@ -60,12 +104,7 @@ export const SYSTEM_TEMPLATE = `{{{hexrunIntro}}} /** * Static hexrun introduction text. */ -export const HEXRUN_INTRO = ` -This prompt was generated from Hexframe tiles. You are executing a HEXRUN - an iterative execution loop where: -- The same tile may be executed multiple times across hexruns -- The hexplan evolves between hexruns with feedback and progress updates -- If the hexplan contains "Feedback from last HEXRUN:" notes, incorporate that guidance -` +export const HEXRUN_INTRO = `This is a HEXRUN - check the hexplan for prior progress and user feedback.` /** * Ancestor context introduction text. diff --git a/src/lib/domains/agentic/templates/_templates/_folder.ts b/src/lib/domains/agentic/templates/_templates/_folder.ts index 637591461..5b3d686e6 100644 --- a/src/lib/domains/agentic/templates/_templates/_folder.ts +++ b/src/lib/domains/agentic/templates/_templates/_folder.ts @@ -7,17 +7,7 @@ import { MapItemType } from '~/lib/domains/mapping' import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' import { GenericTile } from '~/lib/domains/agentic/templates/_templates/_generic-tile' - -// ==================== INTERNAL UTILITIES ==================== - -function _escapeXML(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} +import { _escapeXML } from '~/lib/domains/agentic/templates/_internals/utils' // ==================== PUBLIC FUNCTIONS ==================== diff --git a/src/lib/domains/agentic/templates/_templates/_generic-tile.ts b/src/lib/domains/agentic/templates/_templates/_generic-tile.ts index d3b14af2b..761a26e4b 100644 --- a/src/lib/domains/agentic/templates/_templates/_generic-tile.ts +++ b/src/lib/domains/agentic/templates/_templates/_generic-tile.ts @@ -5,26 +5,12 @@ */ import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' +import { _escapeXML, _hasContent } from '~/lib/domains/agentic/templates/_internals/utils' // ==================== PUBLIC TYPES ==================== export type TileField = 'title' | 'content' | 'preview' | 'coords' -// ==================== INTERNAL UTILITIES ==================== - -function _escapeXML(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function _hasContent(text: string | undefined): boolean { - return !!text && text.trim().length > 0 -} - function _renderField(field: TileField, value: string): string { switch (field) { case 'title': diff --git a/src/lib/domains/agentic/templates/_templates/_hexplan.ts b/src/lib/domains/agentic/templates/_templates/_hexplan.ts index 10d5b8d08..118d2086d 100644 --- a/src/lib/domains/agentic/templates/_templates/_hexplan.ts +++ b/src/lib/domains/agentic/templates/_templates/_hexplan.ts @@ -4,6 +4,8 @@ * Renders the hexplan section with status-based instructions. */ +import { _escapeXML } from '~/lib/domains/agentic/templates/_internals/utils' + // ==================== PUBLIC TYPES ==================== export type HexPlanStatus = 'pending' | 'complete' | 'blocked' @@ -12,17 +14,7 @@ export interface HexPlanParams { mcpServerName: string isParentTile: boolean taskCoords: string -} - -// ==================== INTERNAL UTILITIES ==================== - -function _escapeXML(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') + runId?: string } // ==================== INTERNAL TEMPLATES ==================== @@ -50,9 +42,16 @@ function _renderPendingSection( content: string, params: HexPlanParams ): string { + // Empty hexplan = fresh start, just render empty hexplan without step-based instructions + if (!content.trim()) { + return ` + +` + } + const instructions = params.isParentTile - ? _renderParentInstructions(coords, params.mcpServerName) - : _renderLeafInstructions(coords) + ? _renderParentInstructions(coords, params.mcpServerName, params.runId) + : _renderLeafInstructions(coords, params.mcpServerName, params.runId) return ` ${content} @@ -65,17 +64,21 @@ ${instructions}
` } -function _renderParentInstructions(coords: string, mcpServerName: string): string { +function _renderParentInstructions(coords: string, mcpServerName: string, runId?: string): string { + const updateInstructions = runId + ? `Update the hexplan using mcp__${mcpServerName}__updateRunHexplan with runId="${runId}" and coords="${_escapeXML(coords)}"` + : `Update the hexplan at ${_escapeXML(coords)} using updateItem` + return `To execute a step: 1. Call mcp__${mcpServerName}__hexecute with the child's coords to get its prompt 2. Spawn a subagent using the Task tool with the resulting prompt Example for step "Execute 'Clarify the Task' β†’ userId,0:6,1": - prompt = mcp__${mcpServerName}__hexecute({ taskCoords: "userId,0:6,1" }) + prompt = mcp__${mcpServerName}__hexecute({ taskCoords: "userId,0:6,1"${runId ? `, runId: "${runId}"` : ''} }) Task({ subagent_type: "general-purpose", prompt: prompt }) After the subagent completes: -- Update the hexplan at ${_escapeXML(coords)} using updateItem: +- ${updateInstructions}: - Change the step status from \uD83D\uDCCB to \u2705 - Add a brief note about what was done - Return a SHORT summary (1-2 sentences) of what you accomplished @@ -87,11 +90,15 @@ If you cannot complete the step: IMPORTANT: Execute ONLY ONE step, then return. The orchestrator will call you again for the next step.` } -function _renderLeafInstructions(coords: string): string { +function _renderLeafInstructions(coords: string, mcpServerName: string, runId?: string): string { + const updateInstructions = runId + ? `Update the hexplan using mcp__${mcpServerName}__updateRunHexplan with runId="${runId}" and coords="${_escapeXML(coords)}"` + : `Update the hexplan at ${_escapeXML(coords)} using updateItem` + return `Execute the task directly using the content and above. After completing: -- Update the hexplan at ${_escapeXML(coords)} using updateItem: +- ${updateInstructions}: - Change the step status from \uD83D\uDCCB to \u2705 - Add a brief note about what was done - Return a SHORT summary (1-2 sentences) of what you accomplished diff --git a/src/lib/domains/agentic/templates/_user-template.ts b/src/lib/domains/agentic/templates/_user-template.ts index 9563b04d2..3eef2e31d 100644 --- a/src/lib/domains/agentic/templates/_user-template.ts +++ b/src/lib/domains/agentic/templates/_user-template.ts @@ -9,24 +9,18 @@ * - No subtasks/goal (children are sections to explore, not tasks to execute) * - Uses "recent history" instead of hexplan for session continuity * - Includes discussion section for exchange state + * + * Uses pool-based dispatch via {{@RenderChildren}} for consistent rendering. */ +import type { TileData } from '~/lib/domains/agentic/templates/_pre-processor' + /** * Data shape expected by the USER template. + * Uses Mustache variables for non-tile data (discussion, userMessage). + * Tile data is rendered via {{@RenderChildren}} and accessed via child[direction]. */ export interface UserTemplateData { - userIntro: string - - hasComposedChildren: boolean - contextSection: string - - hasSections: boolean - sectionsSection: string - - hasRecentHistory: boolean - recentHistory: string - recentHistoryCoords: string - hasDiscussion: boolean discussion: string @@ -35,10 +29,10 @@ export interface UserTemplateData { } /** - * Static introduction text for USER tiles. + * Template context content for USER tiles (composed child at -1). * Explains the agent's role as the user's interlocutor. */ -export const USER_INTRO = ` +export const USER_TEMPLATE_CONTEXT = ` You are the default interlocutor for a Hexframe user. Your role is to help the user accomplish what they want to do with Hexframe. Hexframe is a hexagonal knowledge mapping system where users organize their thoughts, plans, and reference materials as interconnected tiles. You can help users: @@ -53,39 +47,103 @@ If the user has expressed a goal in a previous session, check the ` /** - * Mustache template for USER tiles. - * Uses triple braces {{{value}}} for pre-escaped content. + * Mustache template for USER tiles with pool-based dispatch. + * Uses {{@RenderChildren}} for context, sections, and recent-history. */ -export const USER_TEMPLATE = `{{{userIntro}}} -{{#hasComposedChildren}} +export const USER_TEMPLATE = `{{{template[-1].content}}} -{{{contextSection}}} -{{/hasComposedChildren}} -{{#hasSections}} + +{{@RenderChildren range=[-6..-1] fallback='context'}} + -{{{sectionsSection}}} -{{/hasSections}} -{{#hasRecentHistory}} + +{{@RenderChildren range=[1..6] fallback='section'}} + - -This section tracks the user's recent goals and session state. Update it when the user expresses a goal, so future sessions can quickly resume context. +{{@RenderChildren range=[0..0] fallback='recent-history'}} -{{{recentHistory}}} - -{{/hasRecentHistory}} {{#hasDiscussion}} - Previous messages in this conversation: - {{{discussion}}} {{/hasDiscussion}} -{{#hasUserMessage}} +{{#hasUserMessage}} The user's current message that you need to respond to: - {{{userMessage}}} {{/hasUserMessage}}` + +/** + * Sub-templates for USER tile pool-based dispatch. + * These match the tile-based templates at D1i4gEqbi01JWS2F6I7GUN8ekRiU2mjK,0:1,2,2. + */ +export const USER_SUB_TEMPLATES: TileData[] = [ + { + title: 'organizational', + content: ` +{{@RenderChildren range=[1..6] fallback='context'}} +{{@RenderChildren range=[-6..-1] fallback='context'}} +`, + coords: '', + itemType: 'template', + direction: 1, + // Children provide templates for nested rendering inside folders + children: [ + { + title: 'context', + content: ` +{{content}} +`, + coords: '', + itemType: 'template', + direction: 2 + } + ] + }, + { + title: 'context', + content: ` +{{content}} +`, + coords: '', + itemType: 'template', + direction: 2 + }, + { + title: 'generic', + content: `{{title}}`, + coords: '', + itemType: 'template', + direction: 3 + }, + { + title: 'section', + content: `
+{{preview}} +
`, + coords: '', + itemType: 'template', + direction: 4 + }, + { + title: 'recent-history', + content: ` +This section tracks the user's recent goals and session state. Update it when the user expresses a goal, so future sessions can quickly resume context. + +{{content}} +`, + coords: '', + itemType: 'template', + direction: 5 + } +] + +// ==================== BACKWARD COMPATIBILITY ==================== + +/** + * @deprecated Use USER_TEMPLATE_CONTEXT instead + */ +export const USER_INTRO = USER_TEMPLATE_CONTEXT diff --git a/src/lib/domains/agentic/templates/index.ts b/src/lib/domains/agentic/templates/index.ts index 093e6c4a6..c171192fd 100644 --- a/src/lib/domains/agentic/templates/index.ts +++ b/src/lib/domains/agentic/templates/index.ts @@ -6,14 +6,6 @@ * External API: * - buildPrompt(data: PromptData): string * - PromptData type - * - HexrunOrchestratorTemplateData, OrchestratorPromptInput types (for orchestration prompts) - * - shouldUseOrchestrator, buildOrchestratorPrompt functions */ export { buildPrompt, type PromptData } from '~/lib/domains/agentic/templates/_prompt-builder' -export { - shouldUseOrchestrator, - buildOrchestratorPrompt, - type HexrunOrchestratorTemplateData, - type OrchestratorPromptInput -} from '~/lib/domains/agentic/templates/_hexrun-orchestrator-template' diff --git a/src/lib/domains/agentic/utils/README.md b/src/lib/domains/agentic/utils/README.md new file mode 100644 index 000000000..39d259fac --- /dev/null +++ b/src/lib/domains/agentic/utils/README.md @@ -0,0 +1,100 @@ +# Agentic Utils + +## Mental Model + +The utils subsystem provides stateless pure functions for the agentic domain. These utilities handle prompt generation and response parsing without any database, HTTP, or LLM dependencies. + +## Responsibilities + +- Generate hexplan content for parent and leaf tiles +- Parse agent responses to extract structured execution results +- Re-export prompt building functions from templates subsystem + +## Non-Responsibilities + +- Template rendering logic β†’ See `~/lib/domains/agentic/templates` +- LLM communication β†’ See `~/lib/domains/agentic/repositories` +- Database operations β†’ See `~/lib/domains/mapping` + +## Interface + +**Public API** (see `index.ts`): + +```typescript +// Prompt building (re-exported from templates) +function buildPrompt(data: PromptData): string + +// Hexplan generation +function generateParentHexplanContent( + structuralChildren: Array<{ title: string; coords: string }>, + allLeafTasks?: Array<{ title: string; coords: string }> +): string + +function generateLeafHexplanContent( + taskTitle: string, + instruction: string | undefined +): string + +// Response parsing +function parseAgentResponse(response: string): AgentExecutionResult + +interface AgentExecutionResult { + result: 'completed' | 'blocked' + reason?: string // Required if blocked +} +``` + +## Response Parser + +The `parseAgentResponse` utility extracts structured execution results from agent responses. + +### Usage + +```typescript +import { parseAgentResponse } from '~/lib/domains/agentic/utils' + +const result = parseAgentResponse(agentResponse) +if (result.result === 'blocked') { + console.log('Blocked:', result.reason) +} +``` + +### Status Block Format + +Agents must end responses with a status block: + +```xml +{"result": "completed"} + +{"result": "blocked", "reason": "description"} +``` + +### Behavior + +- **Valid status block**: Returns parsed result +- **Multiple status blocks**: Uses the last one (most recent) +- **Status block in code fence**: Ignored (prevents accidental parsing of examples) +- **No status block found**: Returns `{ result: 'completed' }` with console warning +- **Malformed JSON**: Returns `{ result: 'completed' }` with console warning + +### Graceful Degradation + +The parser is designed to be resilient. If parsing fails for any reason, it defaults to `{ result: 'completed' }` to avoid blocking execution. Warnings are logged for debugging. + +## File Structure + +``` +utils/ +β”œβ”€β”€ index.ts # Public API exports +β”œβ”€β”€ prompt-builder.ts # Re-exports from templates + hexplan generators +β”œβ”€β”€ _response-parser.ts # Agent response parsing +└── __tests__/ + β”œβ”€β”€ prompt-builder.test.ts + └── response-parser.test.ts +``` + +## Key Principles + +- **Pure Functions**: No side effects, no external dependencies +- **Graceful Degradation**: Response parser fails safely +- **Single Responsibility**: Each utility has one clear purpose diff --git a/src/lib/domains/agentic/utils/__tests__/prompt-builder.test.ts b/src/lib/domains/agentic/utils/__tests__/prompt-builder.test.ts index fbfff3b5e..ecb472d6a 100644 --- a/src/lib/domains/agentic/utils/__tests__/prompt-builder.test.ts +++ b/src/lib/domains/agentic/utils/__tests__/prompt-builder.test.ts @@ -337,6 +337,19 @@ describe('buildPrompt - v5 Top-Down Context + Root Hexplan', () => { // ==================== HEXPLAN SECTION TESTS ==================== describe('Hexplan Section', () => { describe('Pending Steps', () => { + it('should treat empty hexplan as PENDING status (not complete)', () => { + const data = createTestData({ + task: { title: 'New Task', content: 'Content', coords: 'userId,0:1' }, + hexPlan: '' // Empty hexplan = task not started yet + }) + + const result = buildPrompt(data) + + // Empty hexplan should result in pending instructions, not complete + expect(result).not.toContain('COMPLETE') + expect(result).toContain('') + }) + it('should show hexplan content when plan has pending steps', () => { const data = createTestData({ task: { title: 'Test', content: 'Content', coords: 'userId,0:1' }, @@ -477,7 +490,7 @@ describe('buildPrompt - v5 Top-Down Context + Root Hexplan', () => { // ==================== HEXPLAN CONTENT GENERATORS ==================== describe('Hexplan Content Generators', () => { describe('generateParentHexplanContent', () => { - it('should generate hexplan with numbered steps from children', () => { + it('should return empty string when no instruction provided', () => { const children = [ { title: 'Step One', coords: 'userId,0:1,1' }, { title: 'Step Two', coords: 'userId,0:1,2' } @@ -485,64 +498,54 @@ describe('Hexplan Content Generators', () => { const result = generateParentHexplanContent(children) - expect(result).toContain('🟑 STARTED') - expect(result).toContain('**Steps:**') - expect(result).toContain('πŸ“‹ 1. Execute "Step One" β†’ userId,0:1,1') - expect(result).toContain('πŸ“‹ 2. Execute "Step Two" β†’ userId,0:1,2') - expect(result).toContain('(initialized)') + expect(result).toBe('') }) - it('should handle single child', () => { + it('should include instruction when provided', () => { const children = [{ title: 'Only Child', coords: 'userId,0:1,1' }] - const result = generateParentHexplanContent(children) + const result = generateParentHexplanContent(children, undefined, 'Build the feature') - expect(result).toContain('πŸ“‹ 1. Execute "Only Child" β†’ userId,0:1,1') - expect(result).not.toContain('πŸ“‹ 2.') + expect(result).toBe('**Instruction:** Build the feature') }) - it('should generate leaf tasks list when allLeafTasks provided', () => { + it('should ignore children and allLeafTasks (orchestration is external)', () => { const children = [ { title: 'Parent 1', coords: 'userId,0:1,1' }, { title: 'Parent 2', coords: 'userId,0:1,2' } ] const allLeafTasks = [ { title: 'Leaf A', coords: 'userId,0:1,1,1' }, - { title: 'Leaf B', coords: 'userId,0:1,1,2' }, - { title: 'Leaf C', coords: 'userId,0:1,2,1' } + { title: 'Leaf B', coords: 'userId,0:1,1,2' } ] - const result = generateParentHexplanContent(children, allLeafTasks) + const result = generateParentHexplanContent(children, allLeafTasks, 'Do the work') - expect(result).toContain('🟑 STARTED') - expect(result).toContain('**Leaf Tasks:**') - expect(result).toContain('πŸ“‹ 1. "Leaf A" β†’ userId,0:1,1,1') - expect(result).toContain('πŸ“‹ 2. "Leaf B" β†’ userId,0:1,1,2') - expect(result).toContain('πŸ“‹ 3. "Leaf C" β†’ userId,0:1,2,1') - expect(result).not.toContain('**Steps:**') - expect(result).toContain('**Findings:**') + // Should only contain instruction, no step listing + expect(result).toBe('**Instruction:** Do the work') + expect(result).not.toContain('Parent 1') + expect(result).not.toContain('Leaf A') }) }) describe('generateLeafHexplanContent', () => { - it('should generate hexplan with task title', () => { + it('should return empty string when no instruction provided', () => { const result = generateLeafHexplanContent('My Task', undefined) - expect(result).toContain('🟑 STARTED: "My Task"') - expect(result).toContain('πŸ“‹ Execute the task') - expect(result).toContain('(initialized)') + expect(result).toBe('') }) it('should include instruction when provided', () => { const result = generateLeafHexplanContent('My Task', 'Focus on performance') - expect(result).toContain('**Instruction:** Focus on performance') + expect(result).toBe('**Instruction:** Focus on performance') }) - it('should not include instruction section when undefined', () => { - const result = generateLeafHexplanContent('My Task', undefined) + it('should not include task title (just instruction)', () => { + const result = generateLeafHexplanContent('My Task', 'Do it quickly') - expect(result).not.toContain('**Instruction:**') + expect(result).not.toContain('My Task') + expect(result).toContain('**Instruction:** Do it quickly') }) }) }) @@ -764,8 +767,8 @@ describe('Template System - Pre-processor and Templates', () => { const result = buildPrompt(data) - // Context section should have folders for organizational children - expect(result).toContain('') + // Context section should have folders for organizational children (pool-based includes coords) + expect(result).toContain(' { // USER template uses not expect(result).toContain('') expect(result).not.toContain('') - // Organizational tiles shown as type="folder" - expect(result).toContain('
, others use
+ expect(result).toContain(' { @@ -824,8 +825,9 @@ describe('Template System - Pre-processor and Templates', () => { const result = buildPrompt(data) + // Pool-based rendering uses for regular items and for organizational expect(result).toContain('') + expect(result).toContain(' { @@ -851,130 +853,78 @@ describe('Template System - Pre-processor and Templates', () => { const result = buildPrompt(data) - expect(result).toContain('') + // Pool-based template renders recent-history with coords + expect(result).toContain(' { - it('should use orchestrator template when SYSTEM tile has userMessage', () => { - const data = createTestData({ + describe('SYSTEM template execution instructions', () => { + it('should always use SYSTEM template for SYSTEM tiles (with or without userMessage)', () => { + const dataWithMessage = createTestData({ task: { title: 'Build API', content: 'Build a REST API', coords: 'userId,0:1' }, itemType: MapItemType.SYSTEM, userMessage: 'Please build this quickly', hexPlan: 'πŸ“‹ Step 1' }) - const result = buildPrompt(data) - - // Should use orchestrator template, not SYSTEM template - expect(result).toContain('') - expect(result).toContain('') - expect(result).toContain('') - expect(result).not.toContain('') - // Should not have the section (only mentions hexplan-status in protocol) - expect(result).not.toContain(' { - const data = createTestData({ - task: { title: 'My Task', content: 'Content', coords: 'userId,0:1,2' }, - itemType: MapItemType.SYSTEM, - userMessage: 'Run this task' - }) - - const result = buildPrompt(data) - - expect(result).toContain('My Task') - expect(result).toContain('userId,0:1,2') - }) - - it('should include instruction from userMessage', () => { - const data = createTestData({ - task: { title: 'Task', content: 'Content', coords: 'userId,0:1' }, - itemType: MapItemType.SYSTEM, - userMessage: 'Focus on performance optimization' - }) - - const result = buildPrompt(data) - - expect(result).toContain('Focus on performance optimization') - }) - - it('should include discussion when provided', () => { - const data = createTestData({ - task: { title: 'Task', content: 'Content', coords: 'userId,0:1' }, - itemType: MapItemType.SYSTEM, - userMessage: 'Run this', - discussion: 'User: What does this do?\nAssistant: It builds an API.' - }) - - const result = buildPrompt(data) - - expect(result).toContain('') - expect(result).toContain('Previous messages in this conversation:') - expect(result).toContain('User: What does this do?') - expect(result).toContain('Assistant: It builds an API.') - }) - - it('should not include discussion section when empty', () => { - const data = createTestData({ - task: { title: 'Task', content: 'Content', coords: 'userId,0:1' }, - itemType: MapItemType.SYSTEM, - userMessage: 'Run this', - discussion: '' - }) - - const result = buildPrompt(data) + const result = buildPrompt(dataWithMessage) - expect(result).not.toContain('') + // Should use SYSTEM template, not orchestrator + expect(result).toContain('') + expect(result).toContain('') + expect(result).not.toContain('') + expect(result).not.toContain('') }) - it('should use correct MCP server name in execution protocol', () => { + it('should include concise execution instructions with status block format', () => { const data = createTestData({ task: { title: 'Task', content: 'Content', coords: 'userId,0:1' }, itemType: MapItemType.SYSTEM, - userMessage: 'Run this', - mcpServerName: 'debughexframe' + hexPlan: 'πŸ“‹ Step 1' }) const result = buildPrompt(data) - expect(result).toContain('mcp__debughexframe__hexecute') - expect(result).not.toContain('mcp__hexframe__hexecute') + expect(result).toContain('') + expect(result).toContain('Execute this task') + expect(result).toContain('Track progress in the hexplan') + expect(result).toContain('{"result": "completed"}') + expect(result).toContain('{"result": "blocked"') }) - it('should use default hexframe MCP server', () => { + it('should include blockage context when wasBlocked is true', () => { const data = createTestData({ task: { title: 'Task', content: 'Content', coords: 'userId,0:1' }, itemType: MapItemType.SYSTEM, - userMessage: 'Run this' - // mcpServerName defaults to 'hexframe' in createTestData + hexPlan: 'πŸ“‹ Step 1', + wasBlocked: true, + blockageReason: 'Missing API credentials' }) const result = buildPrompt(data) - expect(result).toContain('mcp__hexframe__hexecute') + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('Missing API credentials') + expect(result).toContain('blocker has been addressed') }) - it('should NOT use orchestrator for SYSTEM tile without userMessage', () => { + it('should NOT include blockage context when wasBlocked is false', () => { const data = createTestData({ task: { title: 'Task', content: 'Content', coords: 'userId,0:1' }, itemType: MapItemType.SYSTEM, - hexPlan: 'πŸ“‹ Step 1' - // No userMessage - direct hexecute execution + hexPlan: 'πŸ“‹ Step 1', + wasBlocked: false }) const result = buildPrompt(data) - // Should use regular SYSTEM template - expect(result).toContain('') - expect(result).toContain('') - expect(result).not.toContain('') + expect(result).not.toContain('') + expect(result).not.toContain('') }) - it('should NOT use orchestrator for USER tile with userMessage', () => { + it('should NOT use SYSTEM template for USER tile', () => { const data = createTestData({ task: { title: 'User Tile', content: '', coords: 'userId,0:' }, itemType: MapItemType.USER, @@ -984,28 +934,10 @@ describe('Template System - Pre-processor and Templates', () => { const result = buildPrompt(data) - // Should use USER template, not orchestrator + // Should use USER template expect(result).toContain('') - expect(result).not.toContain('') - }) - - it('should include execution protocol instructions', () => { - const data = createTestData({ - task: { title: 'Task', content: 'Content', coords: 'userId,0:1' }, - itemType: MapItemType.SYSTEM, - userMessage: 'Run this' - }) - - const result = buildPrompt(data) - - expect(result).toContain('Execute this loop until complete or blocked') - expect(result).toContain('Get the next step') - expect(result).toContain('Check the response') - expect(result).toContain('COMPLETE') - expect(result).toContain('BLOCKED') - expect(result).toContain('Execute the step') - expect(result).toContain('Update the hexplan') - expect(result).toContain('Repeat') + expect(result).not.toContain('') + expect(result).not.toContain('') }) }) diff --git a/src/lib/domains/agentic/utils/__tests__/response-parser.test.ts b/src/lib/domains/agentic/utils/__tests__/response-parser.test.ts new file mode 100644 index 000000000..a6d980ba5 --- /dev/null +++ b/src/lib/domains/agentic/utils/__tests__/response-parser.test.ts @@ -0,0 +1,131 @@ +/** + * Response Parser Tests + * + * Tests for the parseAgentResponse utility that extracts execution status + * from agent responses. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { parseAgentResponse } from '~/lib/domains/agentic/utils/_response-parser' + +describe('parseAgentResponse', () => { + let consoleWarnSpy: ReturnType + + beforeEach(() => { + // Suppress console warnings during tests + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + describe('valid status blocks', () => { + it('parses completed status', () => { + const response = 'Task done.\n{"result": "completed"}' + const result = parseAgentResponse(response) + + expect(result).toEqual({ result: 'completed' }) + }) + + it('parses blocked status with reason', () => { + const response = 'Cannot proceed.\n{"result": "blocked", "reason": "Missing API key"}' + const result = parseAgentResponse(response) + + expect(result).toEqual({ result: 'blocked', reason: 'Missing API key' }) + }) + + it('handles extra whitespace in status block', () => { + const response = ' { "result" : "completed" } ' + const result = parseAgentResponse(response) + + expect(result).toEqual({ result: 'completed' }) + }) + + it('finds status block at end of long response', () => { + const longResponse = ` +I've analyzed the codebase and made the following changes: + +1. Updated the configuration file +2. Added new tests +3. Fixed the bug in the parser + +All tasks completed successfully. + +{"result": "completed"}` + const result = parseAgentResponse(longResponse) + + expect(result).toEqual({ result: 'completed' }) + }) + }) + + describe('graceful fallback', () => { + it('returns completed when no status block found', () => { + const response = 'Task done, no status block here.' + const result = parseAgentResponse(response) + + expect(result).toEqual({ result: 'completed' }) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('No status block found') + ) + }) + + it('returns completed when JSON is malformed', () => { + const response = '{not valid json}' + const result = parseAgentResponse(response) + + expect(result).toEqual({ result: 'completed' }) + expect(consoleWarnSpy).toHaveBeenCalled() + }) + + it('returns completed when result is invalid', () => { + const response = '{"result": "invalid_value"}' + const result = parseAgentResponse(response) + + expect(result).toEqual({ result: 'completed' }) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid status result') + ) + }) + + it('logs warning on fallback', () => { + const response = 'No status here' + parseAgentResponse(response) + + expect(consoleWarnSpy).toHaveBeenCalled() + }) + }) + + describe('edge cases', () => { + it('handles empty response', () => { + const result = parseAgentResponse('') + + expect(result).toEqual({ result: 'completed' }) + expect(consoleWarnSpy).toHaveBeenCalled() + }) + + it('handles multiple status blocks (uses last one)', () => { + const response = ` +{"result": "blocked", "reason": "first"} +Some more work... +{"result": "completed"}` + const result = parseAgentResponse(response) + + expect(result).toEqual({ result: 'completed' }) + }) + + it('handles status block in code fence (ignores it)', () => { + const response = ` +Here's an example of the format: +\`\`\` +{"result": "blocked", "reason": "example"} +\`\`\` + +Now for the real status: +{"result": "completed"}` + const result = parseAgentResponse(response) + + expect(result).toEqual({ result: 'completed' }) + }) + }) +}) diff --git a/src/lib/domains/agentic/utils/_response-parser.ts b/src/lib/domains/agentic/utils/_response-parser.ts new file mode 100644 index 000000000..a3c4483e2 --- /dev/null +++ b/src/lib/domains/agentic/utils/_response-parser.ts @@ -0,0 +1,94 @@ +/** + * Response Parser Utility + * + * Extracts structured execution results from agent responses. + * Agents must end responses with a status block: + * {"result": "completed"} + * {"result": "blocked", "reason": "description"} + */ + +// ==================== TYPES ==================== + +export interface AgentExecutionResult { + result: 'completed' | 'blocked' + reason?: string // Required if blocked +} + +// ==================== INTERNAL HELPERS ==================== + +/** + * Remove content inside code fences to avoid matching status blocks in examples. + */ +function _removeCodeFences(response: string): string { + return response.replace(/```[\s\S]*?```/g, '') +} + +/** + * Find all status blocks and return the last one (most recent). + */ +function _findLastStatusBlock(response: string): string | null { + const sanitizedResponse = _removeCodeFences(response) + const statusBlockRegex = /([\s\S]*?)<\/status>/g + + let lastMatch: string | null = null + let match: RegExpExecArray | null + + while ((match = statusBlockRegex.exec(sanitizedResponse)) !== null) { + lastMatch = match[1] ?? null + } + + return lastMatch +} + +/** + * Parse and validate the JSON content from a status block. + */ +function _parseStatusJson(jsonContent: string): AgentExecutionResult | null { + try { + const parsed = JSON.parse(jsonContent.trim()) as Record + + const result = parsed.result + if (result !== 'completed' && result !== 'blocked') { + console.warn(`parseAgentResponse: Invalid status result "${String(result)}"`) + return null + } + + const executionResult: AgentExecutionResult = { + result + } + + if (parsed.result === 'blocked' && typeof parsed.reason === 'string') { + executionResult.reason = parsed.reason + } + + return executionResult + } catch { + console.warn(`parseAgentResponse: Failed to parse status JSON: ${jsonContent}`) + return null + } +} + +// ==================== PUBLIC API ==================== + +/** + * Parse agent response to extract execution status. + * Looks for {"result": "completed"|"blocked", "reason"?: "..."} + * + * Falls back to 'completed' if no status block found (graceful degradation). + */ +export function parseAgentResponse(response: string): AgentExecutionResult { + const statusContent = _findLastStatusBlock(response) + + if (statusContent === null) { + console.warn('parseAgentResponse: No status block found in response') + return { result: 'completed' } + } + + const parsed = _parseStatusJson(statusContent) + + if (parsed === null) { + return { result: 'completed' } + } + + return parsed +} diff --git a/src/lib/domains/agentic/utils/index.ts b/src/lib/domains/agentic/utils/index.ts index d7eda940c..36d57915a 100644 --- a/src/lib/domains/agentic/utils/index.ts +++ b/src/lib/domains/agentic/utils/index.ts @@ -8,3 +8,6 @@ export { generateLeafHexplanContent } from '~/lib/domains/agentic/utils/prompt-builder'; export type { PromptData } from '~/lib/domains/agentic/utils/prompt-builder'; + +export { parseAgentResponse } from '~/lib/domains/agentic/utils/_response-parser'; +export type { AgentExecutionResult } from '~/lib/domains/agentic/utils/_response-parser'; diff --git a/src/lib/domains/agentic/utils/prompt-builder.ts b/src/lib/domains/agentic/utils/prompt-builder.ts index f6bda2194..3c35c783e 100644 --- a/src/lib/domains/agentic/utils/prompt-builder.ts +++ b/src/lib/domains/agentic/utils/prompt-builder.ts @@ -15,60 +15,33 @@ export { buildPrompt, type PromptData } from '~/lib/domains/agentic/templates' /** * Generates hexplan content for a parent tile (tile with subtasks). - * This is used by the API to create/initialize the hexplan tile before prompting. * - * For root tiles (when allLeafTasks is provided), generates a flat list of ALL leaf tasks - * across the entire hierarchy. This enables single-pass execution tracking. - * - * For intermediate parent tiles (no allLeafTasks), generates steps for direct children only. + * Orchestration is handled externally by RunService - hexplan is just for + * instruction propagation and agent notes. The instruction propagates to + * all subtask prompts via ancestor context. */ export function generateParentHexplanContent( - structuralChildren: Array<{ title: string; coords: string }>, - allLeafTasks?: Array<{ title: string; coords: string }> + _structuralChildren: Array<{ title: string; coords: string }>, + _allLeafTasks?: Array<{ title: string; coords: string }>, + instruction?: string ): string { - const lines: string[] = [] - lines.push('🟑 STARTED') - lines.push('') - - if (allLeafTasks && allLeafTasks.length > 0) { - lines.push('**Leaf Tasks:**') - allLeafTasks.forEach((leaf, index) => { - lines.push(`πŸ“‹ ${index + 1}. "${leaf.title}" β†’ ${leaf.coords}`) - }) - } else { - lines.push('**Steps:**') - structuralChildren.forEach((child, index) => { - lines.push(`πŸ“‹ ${index + 1}. Execute "${child.title}" β†’ ${child.coords}`) - }) + if (!instruction) { + return '' } - - lines.push('') - lines.push('**Progress:**') - lines.push('(initialized)') - lines.push('') - lines.push('**Findings:**') - lines.push('(none yet)') - return lines.join('\n') + return `**Instruction:** ${instruction}` } /** * Generates hexplan content for a leaf tile (tile without subtasks). - * This is used by the API to create/initialize the hexplan tile before prompting. + * + * Just contains the instruction if provided. Agent can add notes during execution. */ export function generateLeafHexplanContent( - taskTitle: string, + _taskTitle: string, instruction: string | undefined ): string { - const lines: string[] = [] - lines.push(`🟑 STARTED: "${taskTitle}"`) - lines.push('') - if (instruction) { - lines.push(`**Instruction:** ${instruction}`) - lines.push('') + if (!instruction) { + return '' } - lines.push('πŸ“‹ Execute the task') - lines.push('') - lines.push('**Progress:**') - lines.push('(initialized)') - return lines.join('\n') + return `**Instruction:** ${instruction}` } diff --git a/src/lib/domains/iam/index.ts b/src/lib/domains/iam/index.ts index 07ea1266f..f25b85355 100644 --- a/src/lib/domains/iam/index.ts +++ b/src/lib/domains/iam/index.ts @@ -1,14 +1,18 @@ /** * Public API for IAM Domain - * + * * Consumers: App layer (auth pages), tRPC API, other domains */ +import { withLogging } from '~/lib/debug/with-logging'; + // Domain entities export { User, type UserProps, type CreateUserProps } from '~/lib/domains/iam/_objects'; // Domain services -export { IAMService, FavoritesService } from '~/lib/domains/iam/services'; +import { IAMService as _IAMService, FavoritesService as _FavoritesService } from '~/lib/domains/iam/services'; +export const IAMService = withLogging("IAMService", _IAMService); +export const FavoritesService = withLogging("FavoritesService", _FavoritesService); // Repository interfaces (for testing/mocking) export type { @@ -51,4 +55,4 @@ export { getOrCreateInternalApiKey, rotateInternalApiKey, validateInternalApiKey, -} from '~/lib/domains/iam/services/internal-api-key.service'; \ No newline at end of file +} from '~/lib/domains/iam/services/internal-api-key.service'; diff --git a/src/lib/domains/mapping/README.md b/src/lib/domains/mapping/README.md index 142bc823a..38afd6480 100644 --- a/src/lib/domains/mapping/README.md +++ b/src/lib/domains/mapping/README.md @@ -86,4 +86,28 @@ const items = await mappingService.items.query.getItems({ const item = await repository.getOne(id, SYSTEM_INTERNAL); ``` -Note: Child subsystems can import from parent freely, but all other subsystems MUST go through index.ts. The CI tool `pnpm check:architecture` enforces this boundary. \ No newline at end of file +Note: Child subsystems can import from parent freely, but all other subsystems MUST go through index.ts. The CI tool `pnpm check:architecture` enforces this boundary. + +## Item Type System + +Tiles have a semantic `itemType` that guides both agent behavior and user categorization. + +### Built-in Types +The `MapItemType` enum provides standard semantic types: +- **USER**: Root tile (system-controlled, one per user) +- **ORGANIZATIONAL**: Structural grouping for navigation +- **CONTEXT**: Reference materials (default for new tiles) +- **SYSTEM**: Executable capabilities + +### Custom Types +Beyond built-in types, arbitrary string values are supported as custom item types. This enables domain-specific classifications like "template", "project", or "workflow". + +**Utilities** (in `infrastructure/map-item/item-type-utils.ts`): +- `isBuiltInItemType()` - Type guard for MapItemType enum values +- `isReservedItemType()` - Check if type is reserved (only "user") +- `isCustomItemType()` - Check if type is custom (non-built-in) + +### Reserved Types +- `user` - Cannot be created via API (system-controlled) + +See `_objects/README.md` for detailed type system documentation. \ No newline at end of file diff --git a/src/lib/domains/mapping/_actions/__tests__/map-item-copy-helpers.test.ts b/src/lib/domains/mapping/_actions/__tests__/map-item-copy-helpers.test.ts index 5b482afd6..06de934f5 100644 --- a/src/lib/domains/mapping/_actions/__tests__/map-item-copy-helpers.test.ts +++ b/src/lib/domains/mapping/_actions/__tests__/map-item-copy-helpers.test.ts @@ -91,7 +91,7 @@ describe("MapItem Copy Helpers", () => { const preparedItems = _prepareBaseItemsForCopy( baseItemsToCopy - .filter(mi => mi.attrs.itemType === MapItemType.CONTEXT) + .filter(mi => (mi.attrs.itemType as MapItemType) === MapItemType.CONTEXT) .map(mi => mi.ref) ); diff --git a/src/lib/domains/mapping/_actions/_map-item-copy-helpers.ts b/src/lib/domains/mapping/_actions/_map-item-copy-helpers.ts index 5fbca6a5a..7b45fd2b8 100644 --- a/src/lib/domains/mapping/_actions/_map-item-copy-helpers.ts +++ b/src/lib/domains/mapping/_actions/_map-item-copy-helpers.ts @@ -2,7 +2,7 @@ import type { BaseItemWithId } from "~/lib/domains/mapping/_objects/base-item"; import type { MapItemWithId } from "~/lib/domains/mapping/_objects/map-item"; import type { Coord, Direction } from "~/lib/domains/mapping/utils"; import type { MapItemAttributes } from "~/lib/domains/mapping/types/item-attributes"; -import type { MapItemType, Visibility } from "~/lib/domains/mapping/_objects"; +import type { ItemTypeValue, Visibility } from "~/lib/domains/mapping/_objects"; /** * Prepare BaseItems for copying by extracting their attributes and setting originId @@ -47,7 +47,7 @@ export function _prepareMapItemsForCopy( sourceMapItemId: number; sourceParentId: number | null; visibility: Visibility; - itemType: MapItemType; + itemType: ItemTypeValue; }> { if (mapItems.length === 0) { return []; @@ -139,7 +139,7 @@ export function _buildMapItemsWithCopiedRefs( parentId: number; sourceRefId: number; visibility: Visibility; - itemType: MapItemType; + itemType: ItemTypeValue; }>, baseItemMapping: Map, copiedBaseItems: BaseItemWithId[] @@ -148,7 +148,7 @@ export function _buildMapItemsWithCopiedRefs( coords: Coord; parentId: number; baseItemId: number; - itemType: MapItemType; + itemType: ItemTypeValue; visibility: Visibility; }; ref: BaseItemWithId; diff --git a/src/lib/domains/mapping/_actions/_map-item-creation-helpers.ts b/src/lib/domains/mapping/_actions/_map-item-creation-helpers.ts index 121631a15..86008b099 100644 --- a/src/lib/domains/mapping/_actions/_map-item-creation-helpers.ts +++ b/src/lib/domains/mapping/_actions/_map-item-creation-helpers.ts @@ -9,6 +9,7 @@ import { MapItem, type MapItemWithId, MapItemType, + type ItemTypeValue, Visibility, } from "~/lib/domains/mapping/_objects"; import { type Coord } from "~/lib/domains/mapping/utils"; @@ -30,7 +31,7 @@ export class MapItemCreationHelpers { parentId, visibility = Visibility.PRIVATE, }: { - itemType: MapItemType; + itemType: ItemTypeValue; coords: Coord; title?: string; content?: string; @@ -57,7 +58,7 @@ export class MapItemCreationHelpers { } private async _validateAndGetParent( - itemType: MapItemType, + itemType: ItemTypeValue, parentId?: number, ): Promise { if (!parentId) { @@ -74,11 +75,11 @@ export class MapItemCreationHelpers { } private _validateItemTypeConstraints( - itemType: MapItemType, + itemType: ItemTypeValue, parent: MapItemWithId | null, coords: Coord, ) { - if (itemType === MapItemType.USER) { + if (itemType === (MapItemType.USER as ItemTypeValue)) { if (parent) { throw new Error("USER type item cannot have a parentId."); } @@ -106,7 +107,7 @@ export class MapItemCreationHelpers { } private _buildMapItem( - itemType: MapItemType, + itemType: ItemTypeValue, coords: Coord, parent: MapItemWithId | null, ref: BaseItemWithId, diff --git a/src/lib/domains/mapping/_actions/_map-item-movement-helpers.ts b/src/lib/domains/mapping/_actions/_map-item-movement-helpers.ts index 811ff740f..6f3c5f5ce 100644 --- a/src/lib/domains/mapping/_actions/_map-item-movement-helpers.ts +++ b/src/lib/domains/mapping/_actions/_map-item-movement-helpers.ts @@ -91,7 +91,7 @@ export class MapItemMovementHelpers { ) { if (CoordSystem.isCenter(oldCoords) || CoordSystem.isCenter(newCoords)) { const item = await getMapItem(oldCoords); - if (item.attrs.itemType === MapItemType.USER) { + if ((item.attrs.itemType as MapItemType) === MapItemType.USER) { throw new Error( "Root USER items cannot be moved to become children, and their userId/groupId cannot change via move.", ); diff --git a/src/lib/domains/mapping/_actions/map-item-actions/index.ts b/src/lib/domains/mapping/_actions/map-item-actions/index.ts index 7e4560024..495c575dc 100644 --- a/src/lib/domains/mapping/_actions/map-item-actions/index.ts +++ b/src/lib/domains/mapping/_actions/map-item-actions/index.ts @@ -62,13 +62,12 @@ export class MapItemActions { } public async updateRef(ref: BaseItemWithId, attrs: UpdateMapItemAttrs) { - // Now using canonical field names throughout - const helperAttrs: Partial = { - title: attrs.title, - content: attrs.content, - preview: attrs.preview, - link: attrs.link, - }; + // Only include defined fields to preserve existing values for unspecified fields + const helperAttrs: Partial = {}; + if (attrs.title !== undefined) helperAttrs.title = attrs.title; + if (attrs.content !== undefined) helperAttrs.content = attrs.content; + if (attrs.preview !== undefined) helperAttrs.preview = attrs.preview; + if (attrs.link !== undefined) helperAttrs.link = attrs.link; const result = await this.creationHelpers.updateRef(ref, helperAttrs); return result; } diff --git a/src/lib/domains/mapping/_actions/map-item-actions/move-orchestrator.ts b/src/lib/domains/mapping/_actions/map-item-actions/move-orchestrator.ts index 12b14deaf..cd177098f 100644 --- a/src/lib/domains/mapping/_actions/map-item-actions/move-orchestrator.ts +++ b/src/lib/domains/mapping/_actions/map-item-actions/move-orchestrator.ts @@ -120,7 +120,7 @@ export class MoveOrchestrator { ): Promise { if (!targetItem) return null; - if (targetItem.attrs.itemType === MapItemType.USER && oldCoords.path.length > 0) { + if ((targetItem.attrs.itemType as MapItemType) === MapItemType.USER && oldCoords.path.length > 0) { throw new Error("Cannot displace a USER (root) item with a child item."); } diff --git a/src/lib/domains/mapping/_actions/map-item-actions/validation-strategy.ts b/src/lib/domains/mapping/_actions/map-item-actions/validation-strategy.ts index 28ed19090..a91c4ce67 100644 --- a/src/lib/domains/mapping/_actions/map-item-actions/validation-strategy.ts +++ b/src/lib/domains/mapping/_actions/map-item-actions/validation-strategy.ts @@ -4,7 +4,7 @@ import { MapItemType } from "~/lib/domains/mapping/_objects"; export class ValidationStrategy { validateUserItemMove(item: MapItemWithId, newCoords: Coord): void { - if (item.attrs.itemType === MapItemType.USER && newCoords.path.length > 0) { + if ((item.attrs.itemType as MapItemType) === MapItemType.USER && newCoords.path.length > 0) { throw new Error( "USER (root) items cannot be moved to become child items." ); @@ -13,7 +13,7 @@ export class ValidationStrategy { validateUserSpaceMove(item: MapItemWithId, newCoords: Coord): void { if ( - item.attrs.itemType === MapItemType.USER && + (item.attrs.itemType as MapItemType) === MapItemType.USER && (item.attrs.coords.userId !== newCoords.userId || item.attrs.coords.groupId !== newCoords.groupId) ) { @@ -32,7 +32,7 @@ export class ValidationStrategy { validateTargetDisplacement(targetItem: MapItemWithId, oldCoords: Coord): void { if ( - targetItem.attrs.itemType === MapItemType.USER && + (targetItem.attrs.itemType as MapItemType) === MapItemType.USER && oldCoords.path.length > 0 ) { throw new Error("Cannot displace a USER (root) item with a child item."); diff --git a/src/lib/domains/mapping/_objects/README.md b/src/lib/domains/mapping/_objects/README.md index 9b71e866f..8f4ce5c6a 100644 --- a/src/lib/domains/mapping/_objects/README.md +++ b/src/lib/domains/mapping/_objects/README.md @@ -28,9 +28,41 @@ This subsystem is the "entity layer" of the mapping domain - defining the core d - `BaseItemVersion`: Type for version history snapshots (immutable records) - `MapItemValidation`: Validates coordinates and parent-child relationships - `MapItemNeighborValidation`: Validates neighbor relationships and direction constraints -- `MapItemType`: Enum for item types (USER, BASE) +- `MapItemType`: Enum for built-in item types (USER, ORGANIZATIONAL, CONTEXT, SYSTEM) +- `NonUserMapItemTypeString`: String literal type for API contracts - Types: `MapItemWithId`, `BaseItemWithId`, `MapItemAttrs`, etc. +## Item Type System + +### Built-in Types (MapItemType enum) +The `MapItemType` enum defines the standard semantic tile types: +- **USER**: Root tile for each user's map (system-controlled, cannot be created via API) +- **ORGANIZATIONAL**: Structural grouping tiles for navigation and categorization +- **CONTEXT**: Reference material tiles to explore on-demand (default for new tiles) +- **SYSTEM**: Executable capability tiles that can be invoked like a skill + +### Custom Item Types +Beyond built-in types, the system supports arbitrary string values as custom item types. This enables users to define their own semantic classifications like "template", "project", or "workflow". + +**Type utilities** (in `infrastructure/map-item/item-type-utils.ts`): +- `isBuiltInItemType(value)`: Type guard checking if value is a MapItemType enum value +- `isReservedItemType(value)`: Check if type is reserved (currently only "user") +- `isCustomItemType(value)`: Check if type is a valid custom (non-built-in) string + +### Reserved Type Names +The following type names are reserved and cannot be used for custom types: +- `user` - Reserved for system-created root tiles + +### API String Types +For external API contracts, use string literal types: +- `NonUserMapItemTypeString`: `"organizational" | "context" | "system"` +- `VisibilityString`: `"public" | "private"` + +### Backward Compatibility +- Existing enum values remain stable (stored in database) +- Code using `MapItemType` enum continues to work unchanged +- Custom types are additive - they don't break existing functionality + **Dependencies**: See parent's `dependencies.json` for allowed imports. **Note**: Child subsystems can access internals. Sibling and parent subsystems must use `index.ts` exports only. The `pnpm check:architecture` tool enforces this boundary. diff --git a/src/lib/domains/mapping/_objects/index.ts b/src/lib/domains/mapping/_objects/index.ts index 32311dc32..ae3f64fe3 100644 --- a/src/lib/domains/mapping/_objects/index.ts +++ b/src/lib/domains/mapping/_objects/index.ts @@ -6,6 +6,7 @@ export { MapItemType, type NonUserMapItemType, type NonUserMapItemTypeString, + type ItemTypeValue, Visibility, type VisibilityString, type MapItemIdr, diff --git a/src/lib/domains/mapping/_objects/map-item-validation.ts b/src/lib/domains/mapping/_objects/map-item-validation.ts index 805f60d45..7b23b46e8 100644 --- a/src/lib/domains/mapping/_objects/map-item-validation.ts +++ b/src/lib/domains/mapping/_objects/map-item-validation.ts @@ -13,7 +13,7 @@ export class MapItemValidation { } public static validateParentChildRelationship(item: MapItem) { - if (item.attrs.itemType === MapItemType.USER) { + if ((item.attrs.itemType as MapItemType) === MapItemType.USER) { if (item.attrs.parentId !== null || item.parent !== null) { throw new Error(MAPPING_ERRORS.USER_ITEM_CANNOT_HAVE_PARENT); } @@ -50,7 +50,7 @@ export class MapItemValidation { if ( item.attrs.parentId === null && - item.attrs.itemType !== MapItemType.USER + (item.attrs.itemType as MapItemType) !== MapItemType.USER ) { throw new Error(MAPPING_ERRORS.NULL_PARENT_MUST_BE_USER_TYPE); } diff --git a/src/lib/domains/mapping/_objects/map-item.ts b/src/lib/domains/mapping/_objects/map-item.ts index bf0580201..99813b0c5 100644 --- a/src/lib/domains/mapping/_objects/map-item.ts +++ b/src/lib/domains/mapping/_objects/map-item.ts @@ -61,12 +61,20 @@ export enum Visibility { */ export type VisibilityString = "public" | "private"; +/** + * Item type can be a built-in enum (USER, ORGANIZATIONAL, CONTEXT, SYSTEM) + * or a custom string type (e.g., "template", "workflow"). + * The "user" type is reserved for system-created root tiles. + */ +export type ItemTypeValue = MapItemType | string; + export interface Attrs extends Record { parentId: number | null; // The parent mapItem this is a child of. coords: Coord; // Updated to new Coord structure baseItemId: number; // Foreign key to the BaseItem containing title, content, etc. - itemType: MapItemType; // Semantic tile type: USER, ORGANIZATIONAL, CONTEXT, or SYSTEM + itemType: ItemTypeValue; // Semantic tile type: built-in enum or custom string visibility: Visibility; // Whether the tile is publicly visible + templateName?: string | null; // Name of template this tile was created from (optional) } export type ShallNotUpdate = { @@ -124,7 +132,7 @@ export class MapItem extends GenericAggregate< // Initial validation for USER type items before super() call if possible, // or ensure these are checked in validate() which is called after super() - if (attrs.itemType === MapItemType.USER) { + if (attrs.itemType === (MapItemType.USER as ItemTypeValue)) { if (parent !== null || attrs.parentId !== null) { throw new Error(MAPPING_ERRORS.USER_ITEM_CANNOT_HAVE_PARENT); } @@ -141,6 +149,7 @@ export class MapItem extends GenericAggregate< baseItemId: attrs.baseItemId ?? ref.id, itemType: attrs.itemType, // itemType is now mandatory visibility: attrs.visibility ?? Visibility.PRIVATE, + templateName: attrs.templateName ?? null, }, relatedLists: { neighbors }, relatedItems: { ref, parent }, @@ -166,7 +175,7 @@ export class MapItem extends GenericAggregate< // "Center" is now relative to a USER's root item. // A root USER item has no parent. return ( - item.attrs.itemType === MapItemType.USER && item.attrs.parentId === null + item.attrs.itemType === (MapItemType.USER as ItemTypeValue) && item.attrs.parentId === null ); } } diff --git a/src/lib/domains/mapping/_repositories/map-item.ts b/src/lib/domains/mapping/_repositories/map-item.ts index 6afa459e2..4053bb76d 100644 --- a/src/lib/domains/mapping/_repositories/map-item.ts +++ b/src/lib/domains/mapping/_repositories/map-item.ts @@ -4,7 +4,7 @@ import { type MapItemRelatedLists, type MapItemWithId, type Visibility, - type MapItemType, + type ItemTypeValue, } from "~/lib/domains/mapping/_objects"; import { type GenericRepository } from "~/lib/domains/utils"; import { type Coord } from "~/lib/domains/mapping/utils"; @@ -239,7 +239,7 @@ export interface MapItemRepository extends BaseMapItemRepository { * @param itemType - New item type * @returns The updated map item */ - updateItemType(itemId: number, itemType: MapItemType): Promise; + updateItemType(itemId: number, itemType: ItemTypeValue): Promise; /** * Batch update the visibility of a tile and all its descendants in a single atomic operation. @@ -265,6 +265,6 @@ export interface MapItemRepository extends BaseMapItemRepository { */ batchUpdateItemTypeWithStructuralDescendants( coords: Coord, - itemType: MapItemType, + itemType: ItemTypeValue, ): Promise; } diff --git a/src/lib/domains/mapping/index.ts b/src/lib/domains/mapping/index.ts index 7b7158f1f..51e8ed02d 100644 --- a/src/lib/domains/mapping/index.ts +++ b/src/lib/domains/mapping/index.ts @@ -7,6 +7,8 @@ * For client-side imports, use './utils' instead. */ +import { withLogging } from '~/lib/debug/with-logging'; + // Re-export all client-safe exports export * from '~/lib/domains/mapping/types'; @@ -14,20 +16,39 @@ export * from '~/lib/domains/mapping/types'; export * from '~/lib/domains/mapping/_objects'; // Domain services (server-only) -export { - MappingService, - MapManagementService, - ItemManagementService, - ItemCrudService, - ItemQueryService, - ItemHistoryService, - ItemContextService, +import { + MappingService as _MappingService, + MapManagementService as _MapManagementService, + ItemManagementService as _ItemManagementService, + ItemCrudService as _ItemCrudService, + ItemQueryService as _ItemQueryService, + ItemHistoryService as _ItemHistoryService, + ItemContextService as _ItemContextService, MappingUtils, - type HexecuteContext, + LeafTraversalService as _LeafTraversalService, } from '~/lib/domains/mapping/services'; +export type { HexecuteContext, NextLeafResult, LeafTraversalServiceDeps } from '~/lib/domains/mapping/services'; + +export const MappingService = withLogging("MappingService", _MappingService); +export type MappingService = InstanceType; +export const MapManagementService = withLogging("MapManagementService", _MapManagementService); +export type MapManagementService = InstanceType; +export const ItemManagementService = withLogging("ItemManagementService", _ItemManagementService); +export type ItemManagementService = InstanceType; +export const ItemCrudService = withLogging("ItemCrudService", _ItemCrudService); +export type ItemCrudService = InstanceType; +export const ItemQueryService = withLogging("ItemQueryService", _ItemQueryService); +export type ItemQueryService = InstanceType; +export const ItemHistoryService = withLogging("ItemHistoryService", _ItemHistoryService); +export type ItemHistoryService = InstanceType; +export const ItemContextService = withLogging("ItemContextService", _ItemContextService); +export type ItemContextService = InstanceType; +export { MappingUtils }; +export const LeafTraversalService = withLogging("LeafTraversalService", _LeafTraversalService); +export type LeafTraversalService = InstanceType; // Infrastructure (server-only - contains database connections) export { DbMapItemRepository, DbBaseItemRepository, -} from '~/lib/domains/mapping/infrastructure'; \ No newline at end of file +} from '~/lib/domains/mapping/infrastructure'; diff --git a/src/lib/domains/mapping/infrastructure/index.ts b/src/lib/domains/mapping/infrastructure/index.ts index ff39a381a..134ddb786 100644 --- a/src/lib/domains/mapping/infrastructure/index.ts +++ b/src/lib/domains/mapping/infrastructure/index.ts @@ -12,4 +12,7 @@ export { DbBaseItemRepository } from '~/lib/domains/mapping/infrastructure/base- export { TransactionManager } from '~/lib/domains/mapping/infrastructure/transaction-manager'; // Utility functions (for testing/debugging) -export { parsePathString } from '~/lib/domains/mapping/infrastructure/map-item/db'; \ No newline at end of file +export { parsePathString } from '~/lib/domains/mapping/infrastructure/map-item/db'; + +// Note: Item type utilities (isBuiltInItemType, etc.) are now in ~/lib/domains/mapping/utils +// to avoid circular dependencies. Import from there for cross-domain access. \ No newline at end of file diff --git a/src/lib/domains/mapping/infrastructure/map-item/README.md b/src/lib/domains/mapping/infrastructure/map-item/README.md index db5756dcb..d10073bd3 100644 --- a/src/lib/domains/mapping/infrastructure/map-item/README.md +++ b/src/lib/domains/mapping/infrastructure/map-item/README.md @@ -135,6 +135,45 @@ This infrastructure layer works with two main database tables: The repository automatically handles joins between these tables to provide complete domain objects. +### Map Items Table Schema + +Key columns in the `map_items` table: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | integer | Primary key, auto-generated | +| `coord_user_id` | varchar(255) | User ID component of hexagonal coordinates | +| `coord_group_id` | integer | Group ID component (default: 0) | +| `path` | varchar(255) | Hex direction path (e.g., "1,2,-3" for NWβ†’NEβ†’ComposedE) | +| `item_type` | varchar(50) | Semantic tile type (USER, ORGANIZATIONAL, CONTEXT, SYSTEM) | +| `visibility` | varchar(20) | Tile visibility (private, public) | +| `parent_id` | integer | Reference to parent map item (null for USER tiles) | +| `ref_item_id` | integer | Reference to the base_items table for content | +| `template_name` | varchar(255) | Template identifier for template provenance tracking | +| `created_at` | timestamp | Creation timestamp | +| `updated_at` | timestamp | Last update timestamp | + +### Template Name Column + +The `template_name` column tracks template provenance for tiles: + +**Purpose:** +- Records which template a tile was created from (if any) +- Enables "created from template X" features in the UI +- Supports the Templates-as-Tiles feature where templates are stored as tile data + +**Constraints:** +- Nullable: Most tiles are not created from templates +- Max length: 255 characters +- No uniqueness constraint: Multiple tiles can reference the same template + +**Usage patterns:** +- When a tile is instantiated from a template, the template's name is stored here +- Template tiles themselves use this field to define their lookup identifier +- Used by the pre-processor to resolve `{{@TemplateName}}` references + +See `docs/features/TEMPLATES_AS_TILES.md` for the full feature specification. + ## Error Handling The repository includes comprehensive error handling: diff --git a/src/lib/domains/mapping/infrastructure/map-item/__tests__/item-type-extension.test.ts b/src/lib/domains/mapping/infrastructure/map-item/__tests__/item-type-extension.test.ts new file mode 100644 index 000000000..7fbf94be6 --- /dev/null +++ b/src/lib/domains/mapping/infrastructure/map-item/__tests__/item-type-extension.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from "vitest"; +import { MapItemType } from "~/lib/domains/mapping/_objects/map-item"; + +import { + isBuiltInItemType, + isReservedItemType, + isCustomItemType, + RESERVED_ITEM_TYPES, +} from "~/lib/domains/mapping/utils/item-type-utils"; + +describe("Item Type Extension - String Values Support", () => { + describe("RESERVED_ITEM_TYPES constant", () => { + it("should include 'user' as a reserved type", () => { + expect(RESERVED_ITEM_TYPES).toContain("user"); + }); + + it("should be a readonly array", () => { + // Attempting to modify should fail at compile time (type check) + // At runtime, we verify it's frozen or the values are correct + expect(RESERVED_ITEM_TYPES).toEqual(["user"]); + }); + + it("should NOT include built-in types like organizational, context, system", () => { + expect(RESERVED_ITEM_TYPES).not.toContain("organizational"); + expect(RESERVED_ITEM_TYPES).not.toContain("context"); + expect(RESERVED_ITEM_TYPES).not.toContain("system"); + }); + }); + + describe("isBuiltInItemType() type guard", () => { + it("should return true for MapItemType.USER", () => { + expect(isBuiltInItemType(MapItemType.USER)).toBe(true); + }); + + it("should return true for MapItemType.ORGANIZATIONAL", () => { + expect(isBuiltInItemType(MapItemType.ORGANIZATIONAL)).toBe(true); + }); + + it("should return true for MapItemType.CONTEXT", () => { + expect(isBuiltInItemType(MapItemType.CONTEXT)).toBe(true); + }); + + it("should return true for MapItemType.SYSTEM", () => { + expect(isBuiltInItemType(MapItemType.SYSTEM)).toBe(true); + }); + + it("should return true for string literals matching enum values", () => { + expect(isBuiltInItemType("user")).toBe(true); + expect(isBuiltInItemType("organizational")).toBe(true); + expect(isBuiltInItemType("context")).toBe(true); + expect(isBuiltInItemType("system")).toBe(true); + }); + + it("should return false for custom type strings", () => { + expect(isBuiltInItemType("my-custom-type")).toBe(false); + expect(isBuiltInItemType("template")).toBe(false); + expect(isBuiltInItemType("project")).toBe(false); + }); + + it("should return false for empty string", () => { + expect(isBuiltInItemType("")).toBe(false); + }); + + it("should return false for null/undefined", () => { + expect(isBuiltInItemType(null as unknown as string)).toBe(false); + expect(isBuiltInItemType(undefined as unknown as string)).toBe(false); + }); + }); + + describe("isReservedItemType() validation", () => { + it("should return true for 'user' type", () => { + expect(isReservedItemType("user")).toBe(true); + }); + + it("should return true for MapItemType.USER", () => { + expect(isReservedItemType(MapItemType.USER)).toBe(true); + }); + + it("should return false for 'organizational' type", () => { + expect(isReservedItemType("organizational")).toBe(false); + }); + + it("should return false for 'context' type", () => { + expect(isReservedItemType("context")).toBe(false); + }); + + it("should return false for 'system' type", () => { + expect(isReservedItemType("system")).toBe(false); + }); + + it("should return false for custom type strings", () => { + expect(isReservedItemType("my-custom-type")).toBe(false); + expect(isReservedItemType("template")).toBe(false); + }); + + it("should return false for empty string", () => { + expect(isReservedItemType("")).toBe(false); + }); + }); + + describe("isCustomItemType() helper", () => { + it("should return true for non-built-in type strings", () => { + expect(isCustomItemType("my-custom-type")).toBe(true); + expect(isCustomItemType("template")).toBe(true); + expect(isCustomItemType("project")).toBe(true); + expect(isCustomItemType("workflow")).toBe(true); + }); + + it("should return false for built-in enum values", () => { + expect(isCustomItemType(MapItemType.USER)).toBe(false); + expect(isCustomItemType(MapItemType.ORGANIZATIONAL)).toBe(false); + expect(isCustomItemType(MapItemType.CONTEXT)).toBe(false); + expect(isCustomItemType(MapItemType.SYSTEM)).toBe(false); + }); + + it("should return false for string literals of built-in types", () => { + expect(isCustomItemType("user")).toBe(false); + expect(isCustomItemType("organizational")).toBe(false); + expect(isCustomItemType("context")).toBe(false); + expect(isCustomItemType("system")).toBe(false); + }); + + it("should return false for empty string", () => { + expect(isCustomItemType("")).toBe(false); + }); + + it("should return false for null/undefined", () => { + expect(isCustomItemType(null as unknown as string)).toBe(false); + expect(isCustomItemType(undefined as unknown as string)).toBe(false); + }); + + it("should handle kebab-case custom types", () => { + expect(isCustomItemType("my-custom-type")).toBe(true); + expect(isCustomItemType("some-other-type")).toBe(true); + }); + + it("should handle snake_case custom types", () => { + expect(isCustomItemType("my_custom_type")).toBe(true); + }); + + it("should handle camelCase custom types", () => { + expect(isCustomItemType("myCustomType")).toBe(true); + }); + }); + + describe("Reserved type rejection for custom creation", () => { + it("should identify 'user' as reserved and not creatable as custom", () => { + // 'user' is reserved - it's for system-created root tiles only + expect(isReservedItemType("user")).toBe(true); + expect(isCustomItemType("user")).toBe(false); + }); + + it("should allow built-in non-reserved types to be used", () => { + // These are built-in but NOT reserved - users can create tiles with these types + expect(isBuiltInItemType("organizational")).toBe(true); + expect(isReservedItemType("organizational")).toBe(false); + + expect(isBuiltInItemType("context")).toBe(true); + expect(isReservedItemType("context")).toBe(false); + + expect(isBuiltInItemType("system")).toBe(true); + expect(isReservedItemType("system")).toBe(false); + }); + }); + + describe("Backward compatibility with MapItemType enum", () => { + it("should recognize all existing MapItemType enum values as built-in", () => { + const allEnumValues = Object.values(MapItemType); + for (const value of allEnumValues) { + expect(isBuiltInItemType(value)).toBe(true); + } + }); + + it("should maintain exact string values for enum members", () => { + // These string values are stored in the database, so they must remain stable + expect(MapItemType.USER).toBe("user"); + expect(MapItemType.ORGANIZATIONAL).toBe("organizational"); + expect(MapItemType.CONTEXT).toBe("context"); + expect(MapItemType.SYSTEM).toBe("system"); + }); + + it("should have exactly 4 built-in types", () => { + const builtInTypes = ["user", "organizational", "context", "system"]; + expect(builtInTypes.length).toBe(4); + + for (const type of builtInTypes) { + expect(isBuiltInItemType(type)).toBe(true); + } + }); + }); + + describe("Type narrowing behavior", () => { + it("should narrow type when isBuiltInItemType returns true", () => { + const someValue = "organizational" as string; + if (isBuiltInItemType(someValue)) { + // TypeScript should narrow this to MapItemType + const narrowedValue: MapItemType = someValue; + expect(narrowedValue).toBe(MapItemType.ORGANIZATIONAL); + } + }); + + it("should not narrow type when isBuiltInItemType returns false", () => { + const someValue = "my-custom-type" as string; + if (!isBuiltInItemType(someValue)) { + // someValue remains string type + expect(typeof someValue).toBe("string"); + expect(someValue).toBe("my-custom-type"); + } + }); + }); +}); diff --git a/src/lib/domains/mapping/infrastructure/map-item/db.ts b/src/lib/domains/mapping/infrastructure/map-item/db.ts index 8b16d4de3..1b1d49e95 100644 --- a/src/lib/domains/mapping/infrastructure/map-item/db.ts +++ b/src/lib/domains/mapping/infrastructure/map-item/db.ts @@ -349,6 +349,7 @@ export class DbMapItemRepository implements MapItemRepository { item_type: attrs.itemType, visibility: attrs.visibility, refItemId: attrs.baseItemId, + templateName: attrs.templateName, }; } @@ -369,7 +370,18 @@ export class DbMapItemRepository implements MapItemRepository { grandchildren: MapItemWithId[]; hexPlan: MapItemWithId | null; }> { - const dbResults = await this.specializedQueries.fetchContextForCenter(config); + const dbResults = await this.specializedQueries.fetchContextForCenter({ + centerPath: config.centerPath, + userId: config.userId, + groupId: config.groupId, + include: { + parent: config.includeParent, + composed: config.includeComposed, + children: config.includeChildren, + grandchildren: config.includeGrandchildren, + }, + requester: config.requester, + }); return { parent: dbResults.parent ? mapJoinedDbToDomain(dbResults.parent, []) : null, diff --git a/src/lib/domains/mapping/infrastructure/map-item/mappers.ts b/src/lib/domains/mapping/infrastructure/map-item/mappers.ts index 6a8b61ada..1edf41f68 100644 --- a/src/lib/domains/mapping/infrastructure/map-item/mappers.ts +++ b/src/lib/domains/mapping/infrastructure/map-item/mappers.ts @@ -70,6 +70,7 @@ function _buildMapItemArgs( itemType: dbMapItem.item_type, visibility: dbMapItem.visibility, baseItemId: dbMapItem.refItemId, + templateName: dbMapItem.templateName, }, ref: baseItem, neighbors: neighbors, diff --git a/src/lib/domains/mapping/infrastructure/map-item/queries/specialized-queries.ts b/src/lib/domains/mapping/infrastructure/map-item/queries/specialized-queries.ts index 40a750f75..c9c6df63f 100644 --- a/src/lib/domains/mapping/infrastructure/map-item/queries/specialized-queries.ts +++ b/src/lib/domains/mapping/infrastructure/map-item/queries/specialized-queries.ts @@ -15,14 +15,18 @@ import { type RequesterContext } from "~/lib/domains/mapping/types"; */ export type FieldSelection = 'minimal' | 'standard' | 'full'; +export interface ContextQueryIncludeOptions { + parent: boolean; + composed: boolean; + children: boolean; + grandchildren: boolean; +} + export interface ContextQueryConfig { centerPath: Direction[]; userId: string; groupId: number; - includeParent: boolean; - includeComposed: boolean; - includeChildren: boolean; - includeGrandchildren: boolean; + include: ContextQueryIncludeOptions; requester: RequesterContext; } @@ -318,7 +322,7 @@ export class SpecializedQueries { fullContentConditions.push(eq(mapItems.path, centerPathString)); // Parent (if requested and not root) - if (config.includeParent && centerPath.length > 0) { + if (config.include.parent && centerPath.length > 0) { const parentPath = centerPath.slice(0, -1); const parentPathString = pathToString(parentPath); fullContentConditions.push(eq(mapItems.path, parentPathString)); @@ -326,7 +330,7 @@ export class SpecializedQueries { // Composed tiles (if requested) - direction 0 + children with negative directions // For center at path "1", fetch "1,0" (orchestration) and "1,-1", "1,-2", etc. (composed children) - if (config.includeComposed) { + if (config.include.composed) { // Fetch direction 0 (orchestration tile) const direction0Path = [...centerPath, 0]; const direction0PathString = pathToString(direction0Path); @@ -363,6 +367,7 @@ export class SpecializedQueries { visibility: mapItems.visibility, parentId: mapItems.parentId, refItemId: mapItems.refItemId, + templateName: mapItems.templateName, createdAt: mapItems.createdAt, updatedAt: mapItems.updatedAt, }, @@ -383,7 +388,7 @@ export class SpecializedQueries { // QUERY 2: Children (title + preview, NO content) let childrenResults: Array<{ map_items: unknown; base_items: unknown }> = []; - if (config.includeChildren) { + if (config.include.children) { const childPattern = centerPathString ? `${centerPathString},%` : '%'; // Build children conditions including visibility filter @@ -409,6 +414,7 @@ export class SpecializedQueries { visibility: mapItems.visibility, parentId: mapItems.parentId, refItemId: mapItems.refItemId, + templateName: mapItems.templateName, createdAt: mapItems.createdAt, updatedAt: mapItems.updatedAt, }, @@ -430,7 +436,7 @@ export class SpecializedQueries { // QUERY 3: Grandchildren (title only, NO content or preview) let grandchildrenResults: Array<{ map_items: unknown; base_items: unknown }> = []; - if (config.includeGrandchildren) { + if (config.include.grandchildren) { const grandchildPattern = centerPathString ? `${centerPathString},%` : '%'; // Build grandchildren conditions including visibility filter @@ -456,6 +462,7 @@ export class SpecializedQueries { visibility: mapItems.visibility, parentId: mapItems.parentId, refItemId: mapItems.refItemId, + templateName: mapItems.templateName, createdAt: mapItems.createdAt, updatedAt: mapItems.updatedAt, }, @@ -481,17 +488,17 @@ export class SpecializedQueries { throw new Error(`Center tile not found at path: ${centerPathString}`); } - const parent = config.includeParent && centerPath.length > 0 + const parent = config.include.parent && centerPath.length > 0 ? this._findParent(fullContentResults, centerPath) : null; - const composed = config.includeComposed + const composed = config.include.composed ? this._filterComposed(fullContentResults, centerPathString, centerDepth) : []; - // Extract hexPlan (direction-0) from the full content results if includeComposed is true + // Extract hexPlan (direction-0) from the full content results if include.composed is true // Direction-0 is already fetched in the same query, we just need to extract it separately - const hexPlan = config.includeComposed + const hexPlan = config.include.composed ? this._findHexPlan(fullContentResults, centerPath) : null; diff --git a/src/lib/domains/mapping/infrastructure/map-item/queries/write-queries.ts b/src/lib/domains/mapping/infrastructure/map-item/queries/write-queries.ts index 24f8c00c3..3869349c1 100644 --- a/src/lib/domains/mapping/infrastructure/map-item/queries/write-queries.ts +++ b/src/lib/domains/mapping/infrastructure/map-item/queries/write-queries.ts @@ -105,6 +105,10 @@ export class WriteQueries { updateValues.refItemId = attrs.baseItemId; } + if (attrs.templateName !== undefined) { + updateValues.templateName = attrs.templateName; + } + return updateValues; } diff --git a/src/lib/domains/mapping/infrastructure/map-item/types.ts b/src/lib/domains/mapping/infrastructure/map-item/types.ts index 02a39ba6f..f51e933c6 100644 --- a/src/lib/domains/mapping/infrastructure/map-item/types.ts +++ b/src/lib/domains/mapping/infrastructure/map-item/types.ts @@ -1,4 +1,4 @@ -import type { MapItemType, Visibility } from "~/lib/domains/mapping/_objects/map-item"; +import type { ItemTypeValue, Visibility } from "~/lib/domains/mapping/_objects/map-item"; // Infer DB types export type DbMapItemSelect = { @@ -7,9 +7,10 @@ export type DbMapItemSelect = { coord_user_id: string; coord_group_id: number; path: string; - item_type: MapItemType; + item_type: ItemTypeValue; visibility: Visibility; refItemId: number; + templateName: string | null; }; export type DbBaseItemSelect = { @@ -32,9 +33,10 @@ export type CreateMapItemDbAttrs = { coord_user_id: string; coord_group_id: number; path: string; - item_type: MapItemType; + item_type: ItemTypeValue; visibility?: Visibility; refItemId: number; + templateName?: string | null; }; export type UpdateMapItemDbAttrs = Partial<{ @@ -42,7 +44,8 @@ export type UpdateMapItemDbAttrs = Partial<{ coord_user_id: string; coord_group_id: number; path: string; - item_type: MapItemType; + item_type: ItemTypeValue; visibility: Visibility; refItemId: number; + templateName: string | null; }>; diff --git a/src/lib/domains/mapping/services/__tests__/item-movement-transaction.integration.test.ts b/src/lib/domains/mapping/services/__tests__/item-movement-transaction.integration.test.ts index 7054fcd6d..f832e28de 100644 --- a/src/lib/domains/mapping/services/__tests__/item-movement-transaction.integration.test.ts +++ b/src/lib/domains/mapping/services/__tests__/item-movement-transaction.integration.test.ts @@ -19,6 +19,7 @@ describe("Item Movement - Transaction Integration Tests", () => { beforeEach(async () => { // Clean up any existing test data thoroughly await _cleanupTestData(); + // Initialize repositories with main db connection mapItemRepo = new DbMapItemRepository(db); baseItemRepo = new DbBaseItemRepository(db); diff --git a/src/lib/domains/mapping/services/_item-services/_item-context.service.ts b/src/lib/domains/mapping/services/_item-services/_item-context.service.ts index 0eb86dcb7..834c0b1dc 100644 --- a/src/lib/domains/mapping/services/_item-services/_item-context.service.ts +++ b/src/lib/domains/mapping/services/_item-services/_item-context.service.ts @@ -6,6 +6,7 @@ import { MapItemActions } from "~/lib/domains/mapping/_actions"; import { adapt, type MapItemContract } from "~/lib/domains/mapping/types/contracts"; import { CoordSystem, + Direction, type ContextStrategy, type MapContext, } from "~/lib/domains/mapping/utils"; @@ -23,6 +24,8 @@ export interface HexecuteContext { title: string; content: string | undefined; coords: string; + hexplan: string | undefined; + itemType: string | undefined; }>; /** Composed children (-1 to -6): context materials, constraints, templates */ composedChildren: MapItemContract[]; @@ -189,6 +192,7 @@ export class ItemContextService { /** * Fetch all ancestors from root to parent (top-down order). * Used for context drilling - parent content flows to children. + * Also fetches each ancestor's hexplan (direction-0 tile) for instruction propagation. */ private async _fetchAncestors( coords: { path: number[]; userId: string; groupId: number }, @@ -208,11 +212,29 @@ export class ItemContextService { requester ); const contract = adapt.mapItem(parent, userId); + + // Fetch hexplan (direction-0 tile) for this ancestor + let hexplanContent: string | undefined; + try { + const hexplanCoord = { ...parentCoord, path: [...parentPath, Direction.Center] }; + const hexplanTile = await this.mapItemRepository.getOneByIdr( + { idr: { attrs: { coords: hexplanCoord } } }, + requester + ); + const hexplanTrimmed = hexplanTile.ref.attrs.content?.trim(); + hexplanContent = (hexplanTrimmed?.length ?? 0) > 0 ? hexplanTrimmed : undefined; + } catch { + // Hexplan doesn't exist for this ancestor - that's fine + hexplanContent = undefined; + } + // Add to beginning to maintain root->parent order (top-down) ancestors.unshift({ title: contract.title, content: contract.content ?? undefined, coords: contract.coords, + hexplan: hexplanContent, + itemType: contract.itemType ?? undefined, }); currentPath = parentPath; } catch (error) { diff --git a/src/lib/domains/mapping/services/_item-services/_item-crud.service.ts b/src/lib/domains/mapping/services/_item-services/_item-crud.service.ts index f572d09a7..a4290be2c 100644 --- a/src/lib/domains/mapping/services/_item-services/_item-crud.service.ts +++ b/src/lib/domains/mapping/services/_item-services/_item-crud.service.ts @@ -8,8 +8,9 @@ import { type Coord, CoordSystem, Direction, + isBuiltInItemType, } from "~/lib/domains/mapping/utils"; -import { MapItemType, Visibility, type MapItemWithId } from "~/lib/domains/mapping/_objects"; +import { MapItemType, Visibility, type MapItemWithId, type ItemTypeValue } from "~/lib/domains/mapping/_objects"; import type { MapItemContract } from "~/lib/domains/mapping/types/contracts"; import { TransactionManager } from "~/lib/domains/mapping/infrastructure"; import { type RequesterContext, SYSTEM_INTERNAL } from "~/lib/domains/mapping/types"; @@ -58,7 +59,7 @@ export class ItemCrudService { preview?: string; link?: string; visibility?: Visibility; - itemType: MapItemType; + itemType: ItemTypeValue; }): Promise { let parentItem = null; if (parentId !== null) { @@ -97,7 +98,7 @@ export class ItemCrudService { } // Validate itemType - USER type is system-controlled and cannot be set via API - if (itemType === MapItemType.USER) { + if (itemType === (MapItemType.USER as ItemTypeValue)) { throw new Error("Cannot set item type to USER - this is system-controlled."); } @@ -168,7 +169,7 @@ export class ItemCrudService { preview?: string; link?: string; visibility?: Visibility; - itemType?: MapItemType; + itemType?: ItemTypeValue; requester?: RequesterContext; }): Promise { const item = await this.actions.getMapItem({ coords, requester }); @@ -192,11 +193,11 @@ export class ItemCrudService { // Validate and update itemType if (itemType !== undefined) { // Cannot change to USER type - that's system-controlled - if (itemType === MapItemType.USER) { + if (itemType === (MapItemType.USER as ItemTypeValue)) { throw new Error("Cannot change item type to USER - this is system-controlled."); } // Cannot change FROM USER type - that's system-controlled - if (item.attrs.itemType === MapItemType.USER) { + if (item.attrs.itemType === (MapItemType.USER as ItemTypeValue)) { throw new Error("Cannot change item type of USER tiles - this is system-controlled."); } @@ -204,7 +205,7 @@ export class ItemCrudService { await this._validateItemTypeForUpdate(coords, itemType); // For SYSTEM and CONTEXT types, cascade the update to all structural descendants - if (itemType === MapItemType.SYSTEM || itemType === MapItemType.CONTEXT) { + if (itemType === (MapItemType.SYSTEM as ItemTypeValue) || itemType === (MapItemType.CONTEXT as ItemTypeValue)) { await this.mapItemRepository.batchUpdateItemTypeWithStructuralDescendants(coords, itemType); } else { // For ORGANIZATIONAL, just update this tile @@ -213,12 +214,11 @@ export class ItemCrudService { } if (title !== undefined || content !== undefined || preview !== undefined || link !== undefined) { - const updateAttrs = { - title, - content, - preview, - link, - }; + const updateAttrs: { title?: string; content?: string; preview?: string; link?: string } = {}; + if (title !== undefined) updateAttrs.title = title; + if (content !== undefined) updateAttrs.content = content; + if (preview !== undefined) updateAttrs.preview = preview; + if (link !== undefined) updateAttrs.link = link; await this.actions.updateRef(item.ref, updateAttrs); } // Use SYSTEM_INTERNAL for fetching the updated item after update @@ -466,18 +466,26 @@ export class ItemCrudService { * - CONTEXT tiles: structural children must also be CONTEXT * - ORGANIZATIONAL tiles: can only be children of USER or ORGANIZATIONAL * - USER tiles: can have any itemType as children + * - Custom types (non-built-in): skip hierarchy validation */ private _validateItemTypeForCreation( - parentItemType: MapItemType | null, - childItemType: MapItemType, + parentItemType: ItemTypeValue | null, + childItemType: ItemTypeValue, ): void { if (!parentItemType) return; - // Rule: ORGANIZATIONAL tiles can only be under USER or ORGANIZATIONAL + // Only validate hierarchy for built-in types + // Custom types (e.g., "template") skip these constraints + if (!isBuiltInItemType(parentItemType) || !isBuiltInItemType(childItemType)) { + return; + } + + // Rule: ORGANIZATIONAL tiles can only be under USER, ORGANIZATIONAL, or CONTEXT if (childItemType === MapItemType.ORGANIZATIONAL) { - if (parentItemType !== MapItemType.USER && parentItemType !== MapItemType.ORGANIZATIONAL) { + const allowedParents = [MapItemType.USER, MapItemType.ORGANIZATIONAL, MapItemType.CONTEXT]; + if (!allowedParents.includes(parentItemType)) { throw new Error( - "ORGANIZATIONAL tiles can only be created under USER or ORGANIZATIONAL parents. " + + "ORGANIZATIONAL tiles can only be created under USER, ORGANIZATIONAL, or CONTEXT parents. " + `Cannot create ORGANIZATIONAL tile under ${parentItemType} parent.` ); } @@ -494,9 +502,10 @@ export class ItemCrudService { break; case MapItemType.CONTEXT: - if (childItemType !== MapItemType.CONTEXT) { + // CONTEXT tiles can have CONTEXT or ORGANIZATIONAL children + if (childItemType !== MapItemType.CONTEXT && childItemType !== MapItemType.ORGANIZATIONAL) { throw new Error( - "Structural children of CONTEXT tiles must also be CONTEXT tiles. " + + "Structural children of CONTEXT tiles must be CONTEXT or ORGANIZATIONAL tiles. " + "Use composition children (negative directions) for supporting materials." ); } @@ -521,7 +530,7 @@ export class ItemCrudService { */ private async _validateItemTypeForUpdate( coords: Coord, - newItemType: MapItemType, + newItemType: ItemTypeValue, ): Promise { // Get the parent to check constraints const parentCoords = CoordSystem.getParentCoord(coords); @@ -544,8 +553,8 @@ export class ItemCrudService { const parentItemType = parent.attrs.itemType; // Rule: ORGANIZATIONAL tiles can only be under USER or ORGANIZATIONAL - if (newItemType === MapItemType.ORGANIZATIONAL) { - if (parentItemType !== MapItemType.USER && parentItemType !== MapItemType.ORGANIZATIONAL) { + if (newItemType === (MapItemType.ORGANIZATIONAL as ItemTypeValue)) { + if (parentItemType !== (MapItemType.USER as ItemTypeValue) && parentItemType !== (MapItemType.ORGANIZATIONAL as ItemTypeValue)) { throw new Error( "Cannot change to ORGANIZATIONAL: parent must be USER or ORGANIZATIONAL. " + `Current parent is ${parentItemType}.` @@ -553,10 +562,10 @@ export class ItemCrudService { } } - // Check constraints based on parent's itemType - switch (parentItemType) { + // Check constraints based on parent's itemType (only for built-in types) + switch (parentItemType as MapItemType) { case MapItemType.SYSTEM: - if (newItemType !== MapItemType.SYSTEM) { + if (newItemType !== (MapItemType.SYSTEM as ItemTypeValue)) { throw new Error( "Cannot change itemType: tile is a structural child of a SYSTEM tile. " + "Structural descendants of SYSTEM tiles must remain SYSTEM." @@ -565,7 +574,7 @@ export class ItemCrudService { break; case MapItemType.CONTEXT: - if (newItemType !== MapItemType.CONTEXT) { + if (newItemType !== (MapItemType.CONTEXT as ItemTypeValue)) { throw new Error( "Cannot change itemType: tile is a structural child of a CONTEXT tile. " + "Structural descendants of CONTEXT tiles must remain CONTEXT." diff --git a/src/lib/domains/mapping/services/_map-management.service.ts b/src/lib/domains/mapping/services/_map-management.service.ts index 220c73477..dd2a5fd64 100644 --- a/src/lib/domains/mapping/services/_map-management.service.ts +++ b/src/lib/domains/mapping/services/_map-management.service.ts @@ -135,7 +135,7 @@ export class MapManagementService { `Map for user ${userId}, group ${groupId} not found for update.`, ); } - if (rootItem.attrs.itemType !== MapItemType.USER) { + if ((rootItem.attrs.itemType as MapItemType) !== MapItemType.USER) { throw new Error("Cannot update map info for a non-root item."); } @@ -163,7 +163,7 @@ export class MapManagementService { ); return; } - if (rootItem.attrs.itemType !== MapItemType.USER) { + if ((rootItem.attrs.itemType as MapItemType) !== MapItemType.USER) { throw new Error("Attempted to remove a non-root item as a map."); } await this.actions.removeItem({ idr: { id: rootItem.id } }); 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..696fbfdd9 --- /dev/null +++ b/src/lib/domains/mapping/services/_traversal-services/__tests__/leaf-traversal.integration.test.ts @@ -0,0 +1,521 @@ +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 root itself as leaf when root has 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); + + // Root with no children is itself the leaf to execute + expect(leaves).toEqual([rootCoordId]); + }); + + 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..e23373600 --- /dev/null +++ b/src/lib/domains/mapping/services/_traversal-services/_leaf-traversal.service.ts @@ -0,0 +1,196 @@ +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. + * If the root itself has no children, it is returned as the only leaf. + * + * @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. + * If the root has no structural children, the root itself is a leaf. + */ + 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, the root itself is the leaf to execute + if (rootStructuralChildren.length === 0) { + leaves.push(rootCoordId); + 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"; diff --git a/src/lib/domains/mapping/types/contracts.ts b/src/lib/domains/mapping/types/contracts.ts index 642fd184a..4540fd1b4 100644 --- a/src/lib/domains/mapping/types/contracts.ts +++ b/src/lib/domains/mapping/types/contracts.ts @@ -46,7 +46,7 @@ export const mapDomainToContractAdapter = ( if (rootItem.id === undefined) { throw new Error("Cannot adapt map root item without an ID"); } - if (rootItem.attrs.itemType !== MapItemType.USER) { + if ((rootItem.attrs.itemType as MapItemType) !== MapItemType.USER) { throw new Error( "MapContract can only be created from a USER type root MapItem.", ); diff --git a/src/lib/domains/mapping/types/item-attributes.ts b/src/lib/domains/mapping/types/item-attributes.ts index 3a308ec9d..b6c77dede 100644 --- a/src/lib/domains/mapping/types/item-attributes.ts +++ b/src/lib/domains/mapping/types/item-attributes.ts @@ -1,7 +1,4 @@ -import type { - NonUserMapItemTypeString, - VisibilityString, -} from "~/lib/domains/mapping/_objects"; +import type { VisibilityString } from "~/lib/domains/mapping/_objects"; /** * Canonical MapItem attribute types - Single Source of Truth @@ -34,8 +31,12 @@ export interface MapItemUpdateAttributes { link?: string; originId?: number | null; visibility?: VisibilityString; - /** Semantic tile type (excludes "user" which is system-controlled) */ - itemType?: NonUserMapItemTypeString; + /** + * Semantic tile type. Built-in types: "organizational", "context", "system". + * Custom types (e.g., "template") are also supported. + * The "user" type is reserved for system-created root tiles. + */ + itemType?: string; } /** diff --git a/src/lib/domains/mapping/types/parameters.ts b/src/lib/domains/mapping/types/parameters.ts index 1b3addfd0..5267e745d 100644 --- a/src/lib/domains/mapping/types/parameters.ts +++ b/src/lib/domains/mapping/types/parameters.ts @@ -16,13 +16,31 @@ const CoordsSchema = z.object({ */ export const VisibilitySchema = z.nativeEnum(Visibility); +/** + * Item type schema that accepts built-in types or custom strings. + * The "user" type is reserved for system-created root tiles. + */ +const ItemTypeSchema = z.string().min(1).refine( + (val) => val.toLowerCase() !== "user", + { message: "The 'user' item type is reserved for system-created root tiles" } +); + +/** + * Item type schema for creation - accepts enum or custom strings. + * Uses union to maintain backward compatibility with enum values. + */ +const CreateItemTypeSchema = z.union([ + z.nativeEnum(MapItemType), + ItemTypeSchema, +]); + /** * Shared schema for creating map items * This ensures type safety and consistency across all layers * Uses canonical field names: title, content, preview, link */ export const CreateMapItemParamsSchema = z.object({ - itemType: z.nativeEnum(MapItemType), + itemType: CreateItemTypeSchema, coords: CoordsSchema, title: z.string().optional(), content: z.string().optional(), @@ -34,18 +52,11 @@ export const CreateMapItemParamsSchema = z.object({ export type CreateMapItemParams = z.infer; -/** - * Item type schema for updates (excludes "user" which is system-controlled) - */ -const EditableItemTypeSchema = z.enum([ - MapItemType.ORGANIZATIONAL, - MapItemType.CONTEXT, - MapItemType.SYSTEM, -]); - /** * Schema for updating map item attributes * Uses canonical field names: title, content, preview, link + * Accepts built-in types (organizational, context, system) or custom type strings. + * The "user" type is reserved and cannot be set via updates. */ export const UpdateMapItemAttrsSchema = z.object({ title: z.string().optional(), @@ -53,8 +64,8 @@ export const UpdateMapItemAttrsSchema = z.object({ preview: z.string().optional(), link: z.string().optional(), visibility: VisibilitySchema.optional(), - /** Semantic tile type (excludes "user" which is system-controlled) */ - itemType: EditableItemTypeSchema.optional(), + /** Semantic tile type - built-in or custom (excludes "user" which is system-controlled) */ + itemType: ItemTypeSchema.optional(), }); export type UpdateMapItemAttrs = z.infer; diff --git a/src/lib/domains/mapping/utils/index.ts b/src/lib/domains/mapping/utils/index.ts index c3824c089..186093b32 100644 --- a/src/lib/domains/mapping/utils/index.ts +++ b/src/lib/domains/mapping/utils/index.ts @@ -29,10 +29,16 @@ export { MapItemType, type NonUserMapItemType, type NonUserMapItemTypeString, + type ItemTypeValue, Visibility, type VisibilityString, } from '~/lib/domains/mapping/_objects'; export type { UpdateMapItemAttrs as MapItemUpdateAttributes } from '~/lib/domains/mapping/types/parameters'; export type { CreateMapItemParams as MapItemCreateAttributes } from '~/lib/domains/mapping/types/parameters'; export type { MapItemContract } from '~/lib/domains/mapping/types/contracts'; -export type { MapItemAttributes } from '~/lib/domains/mapping/types/item-attributes'; \ No newline at end of file +export type { MapItemAttributes } from '~/lib/domains/mapping/types/item-attributes'; + +// Item type utilities - re-exported for cross-domain access +// NOTE: These are lazy-loaded to avoid circular dependency with _objects/map-item.ts +// which imports Coord from this module +export { isBuiltInItemType, isReservedItemType, isCustomItemType, RESERVED_ITEM_TYPES } from '~/lib/domains/mapping/utils/item-type-utils'; \ No newline at end of file diff --git a/src/lib/domains/mapping/utils/item-type-utils.ts b/src/lib/domains/mapping/utils/item-type-utils.ts new file mode 100644 index 000000000..9ed907649 --- /dev/null +++ b/src/lib/domains/mapping/utils/item-type-utils.ts @@ -0,0 +1,73 @@ +/** + * Item Type Utilities (Cross-Domain Safe) + * + * This file provides utilities for working with item types, supporting both + * built-in MapItemType enum values and custom user-defined type strings. + * + * NOTE: This module is placed in utils (not infrastructure) to avoid circular + * dependencies. The _objects/map-item.ts imports from utils, so utils cannot + * import from _objects during module initialization. + */ + +// Import MapItemType directly from the source file to control load order +import { MapItemType } from "~/lib/domains/mapping/_objects/map-item"; + +/** + * Reserved item types that cannot be created by users. + * Currently 'user' is the only reserved type (for system-created root tiles). + */ +export const RESERVED_ITEM_TYPES: readonly string[] = ["user"] as const; + +// Lazy-initialize to avoid circular dependency issues during module loading +let _builtInItemTypes: ReadonlySet | null = null; +let _reservedTypesSet: ReadonlySet | null = null; + +function getBuiltInItemTypes(): ReadonlySet { + _builtInItemTypes ??= new Set(Object.values(MapItemType)); + return _builtInItemTypes; +} + +function getReservedTypesSet(): ReadonlySet { + _reservedTypesSet ??= new Set(RESERVED_ITEM_TYPES); + return _reservedTypesSet; +} + +/** + * Type guard to check if a value is one of the built-in MapItemType enum values. + * + * @param value - The value to check + * @returns true if value is a built-in MapItemType + */ +export function isBuiltInItemType(value: unknown): value is MapItemType { + if (typeof value !== "string" || value === "") { + return false; + } + return getBuiltInItemTypes().has(value); +} + +/** + * Check if a value is a reserved item type (cannot be created by users). + * + * @param value - The value to check + * @returns true if value is a reserved type like 'user' + */ +export function isReservedItemType(value: unknown): boolean { + if (typeof value !== "string" || value === "") { + return false; + } + return getReservedTypesSet().has(value); +} + +/** + * Check if a value is a custom (non-built-in) item type. + * Custom types are non-empty strings that are not part of the built-in enum. + * + * @param value - The value to check + * @returns true if value is a valid custom item type string + */ +export function isCustomItemType(value: unknown): boolean { + if (typeof value !== "string" || value === "") { + return false; + } + return !getBuiltInItemTypes().has(value); +} diff --git a/src/server/api/routers/agentic/README.md b/src/server/api/routers/agentic/README.md index 2285ae424..c8ffd4eea 100644 --- a/src/server/api/routers/agentic/README.md +++ b/src/server/api/routers/agentic/README.md @@ -10,6 +10,58 @@ Like a telephone switchboard operator - receives AI chat requests from the front - Enforce verification-aware rate limiting for AI requests (10 req/5min verified, 3 req/5min unverified) - Manage AI model discovery and listing (`getAvailableModels`) - Bridge frontend chat interface with agentic domain services through proper context preparation +- Orchestrate SYSTEM tile execution via the `run` mutation + +## run Mutation + +Execute the next step of a SYSTEM tile hierarchy. + +### Usage Pattern + +```typescript +// External orchestration loop +let result = await trpc.agentic.run({ coords: 'userId,0:6' }) + +while (!result.isComplete) { + if (result.runStatus === 'blocked') { + // Handle blockage (notify user, wait for fix) + await waitForUserIntervention() + } + result = await trpc.agentic.run({ coords: 'userId,0:6' }) +} + +console.log('Run complete!') +``` + +### How It Works + +1. **Validation**: Verifies the tile is a SYSTEM type (or custom type) +2. **Run Management**: Gets or creates a run object for the root coords +3. **Leaf Discovery**: Uses `LeafTraversalService` to find the next incomplete leaf +4. **Execution**: Builds hexecute prompt and calls agentic service +5. **Status Parsing**: Extracts completion/blockage status from agent response +6. **State Update**: Updates run with step result + +### Return Type + +```typescript +interface RunResult { + runId: string // Unique identifier for this run + runStatus: 'open' | 'blocked' | 'closed' + stepExecuted: string | null // Coords of step just executed + stepResult: 'completed' | 'blocked' | null + blockageReason: string | null + response: string | null // Agent's response text + isComplete: boolean // True if run is now closed +} +``` + +### MCP Usage + +``` +mcp__hexframe__run({ coords: "userId,0:6" }) +// Returns: { runId, runStatus, stepExecuted, isComplete, ... } +``` ## Non-Responsibilities - MCP tool definitions and implementation β†’ See `~/app/services/mcp/` (HTTP MCP server) diff --git a/src/server/api/routers/agentic/__tests__/run.test.ts b/src/server/api/routers/agentic/__tests__/run.test.ts new file mode 100644 index 000000000..d6fc1b214 --- /dev/null +++ b/src/server/api/routers/agentic/__tests__/run.test.ts @@ -0,0 +1,460 @@ +import { describe, beforeEach, it, expect, vi } from "vitest"; +import { z } from "zod"; + +/** + * Unit tests for run mutation endpoint + * + * Tests the tRPC endpoint that orchestrates SYSTEM tile execution: + * 1. Validates tile is SYSTEM type + * 2. Manages run lifecycle (open/blocked/closed) + * 3. Finds and executes next leaf tile + * 4. Parses response and updates run state + */ + +// Mock the modules +vi.mock("~/env", () => ({ + env: { + OPENROUTER_API_KEY: "test-openrouter-key", + ANTHROPIC_API_KEY: "test-anthropic-key", + HEXFRAME_MCP_SERVER: "hexframe", + }, +})); + +// Schema for run input validation testing +const runInputSchema = z.object({ + coords: z.string().describe("Root SYSTEM tile coordinates"), + instruction: z.string().optional().describe("Optional instruction for current step"), +}); + +// Schema for run output validation +const runResultSchema = z.object({ + runId: z.string(), + runStatus: z.enum(["open", "blocked", "closed"]), + stepExecuted: z.string().nullable(), + stepResult: z.enum(["completed", "blocked"]).nullable(), + blockageReason: z.string().nullable(), + response: z.string().nullable(), + isComplete: z.boolean(), +}); + +describe("run Mutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Input Validation", () => { + it("should require coords", () => { + const result = runInputSchema.safeParse({}); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toContain("coords"); + } + }); + + it("should accept valid coords string", () => { + const result = runInputSchema.safeParse({ + coords: "userId123,0:1,2", + }); + expect(result.success).toBe(true); + }); + + it("should accept optional instruction", () => { + const result = runInputSchema.safeParse({ + coords: "userId123,0:1,2", + instruction: "Focus on error handling", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.instruction).toBe("Focus on error handling"); + } + }); + }); + + describe("Output Schema Validation", () => { + it("should validate complete output with open status", () => { + const result = runResultSchema.safeParse({ + runId: "run-123", + runStatus: "open", + stepExecuted: "userId,0:1,1", + stepResult: "completed", + blockageReason: null, + response: "Task completed successfully", + isComplete: false, + }); + expect(result.success).toBe(true); + }); + + it("should validate output with blocked status", () => { + const result = runResultSchema.safeParse({ + runId: "run-123", + runStatus: "blocked", + stepExecuted: "userId,0:1,1", + stepResult: "blocked", + blockageReason: "Missing API key", + response: "Cannot proceed without API key", + isComplete: false, + }); + expect(result.success).toBe(true); + }); + + it("should validate output with closed status", () => { + const result = runResultSchema.safeParse({ + runId: "run-123", + runStatus: "closed", + stepExecuted: null, + stepResult: null, + blockageReason: null, + response: null, + isComplete: true, + }); + expect(result.success).toBe(true); + }); + }); +}); + +describe("run Validation Logic", () => { + /** + * Mock services for testing validation logic + */ + const mockMappingService = { + items: { + query: { + getItemByCoords: vi.fn(), + }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Tile Type Validation", () => { + it("should reject non-SYSTEM built-in tiles", async () => { + // Test that USER, ORGANIZATIONAL, CONTEXT tiles are rejected + const nonSystemTypes = ["user", "organizational", "context"]; + + for (const itemType of nonSystemTypes) { + mockMappingService.items.query.getItemByCoords.mockResolvedValue({ + id: "1", + coords: "userId,0:1", + title: "Test Tile", + itemType, + }); + + // The actual validation would throw BAD_REQUEST error + // This test validates the logic conceptually + expect(itemType).not.toBe("system"); + } + }); + + it("should accept SYSTEM tiles", async () => { + mockMappingService.items.query.getItemByCoords.mockResolvedValue({ + id: "1", + coords: "userId,0:1", + title: "Test Tile", + itemType: "system", + }); + + const tile = await mockMappingService.items.query.getItemByCoords({ + coords: "userId,0:1", + }); + expect(tile.itemType).toBe("system"); + }); + + it("should accept custom (non-built-in) item types", async () => { + // Custom types like "template", "workflow" should be allowed + mockMappingService.items.query.getItemByCoords.mockResolvedValue({ + id: "1", + coords: "userId,0:1", + title: "Test Tile", + itemType: "custom-workflow", + }); + + const tile = await mockMappingService.items.query.getItemByCoords({ + coords: "userId,0:1", + }); + + // Custom types are not built-in types + const builtInTypes = ["user", "organizational", "context", "system"]; + expect(builtInTypes.includes(tile.itemType)).toBe(false); + }); + }); +}); + +describe("run Lifecycle Logic", () => { + /** + * Mock RunService for testing lifecycle logic + */ + const mockRunService = { + getOrCreateRun: vi.fn(), + getOpenRun: vi.fn(), + closeRun: vi.fn(), + startStep: vi.fn(), + markStepCompleted: vi.fn(), + markStepBlocked: vi.fn(), + getRunById: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Run Creation", () => { + it("should create new run on first call", async () => { + const userId = "test-user"; + const rootCoords = "userId,0:1"; + + mockRunService.getOrCreateRun.mockResolvedValue({ + id: "new-run-id", + userId, + rootCoords, + status: "open", + executionLog: [], + blockageReason: null, + }); + + const run = await mockRunService.getOrCreateRun(userId, rootCoords); + + expect(run.id).toBe("new-run-id"); + expect(run.status).toBe("open"); + expect(run.executionLog).toEqual([]); + }); + + it("should reuse existing open run", async () => { + const userId = "test-user"; + const rootCoords = "userId,0:1"; + const existingRunId = "existing-run-id"; + + mockRunService.getOrCreateRun.mockResolvedValue({ + id: existingRunId, + userId, + rootCoords, + status: "open", + executionLog: [ + { stepCoords: "userId,0:1,1", status: "completed", startedAt: "2024-01-01" }, + ], + blockageReason: null, + }); + + const run = await mockRunService.getOrCreateRun(userId, rootCoords); + + expect(run.id).toBe(existingRunId); + expect(run.executionLog).toHaveLength(1); + }); + + it("should return isComplete:true for closed run", async () => { + const userId = "test-user"; + const rootCoords = "userId,0:1"; + + mockRunService.getOrCreateRun.mockResolvedValue({ + id: "closed-run-id", + userId, + rootCoords, + status: "closed", + executionLog: [], + blockageReason: null, + }); + + const run = await mockRunService.getOrCreateRun(userId, rootCoords); + + // When run.status === 'closed', the endpoint should return isComplete: true + expect(run.status).toBe("closed"); + }); + }); +}); + +describe("run Step Execution Logic", () => { + /** + * Mock LeafTraversalService for testing step execution logic + */ + const mockLeafTraversalService = { + getNextIncompleteLeaf: vi.fn(), + getAllLeafTiles: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Leaf Discovery", () => { + it("should execute first leaf on first call", async () => { + const rootCoords = "userId,0:1"; + const completedCoords = new Set(); + + mockLeafTraversalService.getNextIncompleteLeaf.mockResolvedValue({ + leafCoords: "userId,0:1,1", + allLeafCoords: ["userId,0:1,1", "userId,0:1,2", "userId,0:1,3"], + }); + + const result = await mockLeafTraversalService.getNextIncompleteLeaf( + rootCoords, + completedCoords + ); + + expect(result.leafCoords).toBe("userId,0:1,1"); + }); + + it("should execute second leaf after first completes", async () => { + const rootCoords = "userId,0:1"; + const completedCoords = new Set(["userId,0:1,1"]); + + mockLeafTraversalService.getNextIncompleteLeaf.mockResolvedValue({ + leafCoords: "userId,0:1,2", + allLeafCoords: ["userId,0:1,1", "userId,0:1,2", "userId,0:1,3"], + }); + + const result = await mockLeafTraversalService.getNextIncompleteLeaf( + rootCoords, + completedCoords + ); + + expect(result.leafCoords).toBe("userId,0:1,2"); + }); + + it("should return null when all leaves complete", async () => { + const rootCoords = "userId,0:1"; + const completedCoords = new Set([ + "userId,0:1,1", + "userId,0:1,2", + "userId,0:1,3", + ]); + + mockLeafTraversalService.getNextIncompleteLeaf.mockResolvedValue({ + leafCoords: null, + allLeafCoords: ["userId,0:1,1", "userId,0:1,2", "userId,0:1,3"], + }); + + const result = await mockLeafTraversalService.getNextIncompleteLeaf( + rootCoords, + completedCoords + ); + + expect(result.leafCoords).toBeNull(); + // When leafCoords is null, the run should be closed + }); + }); +}); + +describe("run Blockage Handling Logic", () => { + /** + * Mock services for testing blockage handling + */ + const mockRunService = { + markStepBlocked: vi.fn(), + getRunById: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Blockage Detection", () => { + it("should mark run blocked when step returns blocked", async () => { + const runId = "run-123"; + const stepCoords = "userId,0:1,1"; + const blockageReason = "External API unavailable"; + + mockRunService.markStepBlocked.mockResolvedValue({ + id: runId, + status: "blocked", + blockageReason, + executionLog: [ + { + stepCoords, + status: "blocked", + startedAt: "2024-01-01", + blockageReason, + }, + ], + }); + + const updatedRun = await mockRunService.markStepBlocked( + runId, + stepCoords, + blockageReason + ); + + expect(updatedRun.status).toBe("blocked"); + expect(updatedRun.blockageReason).toBe(blockageReason); + }); + + it("should include blockage context on resume", async () => { + const runId = "run-123"; + + // Simulate a blocked run being retrieved + mockRunService.getRunById.mockResolvedValue({ + id: runId, + status: "blocked", + blockageReason: "Previous failure: Missing API key", + executionLog: [ + { + stepCoords: "userId,0:1,1", + status: "blocked", + startedAt: "2024-01-01", + blockageReason: "Missing API key", + }, + ], + }); + + const run = await mockRunService.getRunById(runId); + + // When resuming, the blockageReason should be passed to buildPromptWithContext + expect(run.status).toBe("blocked"); + expect(run.blockageReason).toBeTruthy(); + }); + }); +}); + +describe("run Response Parsing Logic", () => { + // Test parseAgentResponse from the agentic utils + const parseAgentResponse = (response: string): { result: "completed" | "blocked"; reason?: string } => { + // Simplified version of the actual parser + const statusMatch = /([\s\S]*?)<\/status>/.exec(response); + if (!statusMatch?.[1]) { + return { result: "completed" }; + } + + try { + const parsed = JSON.parse(statusMatch[1].trim()) as { result?: string; reason?: string }; + if (parsed.result === "completed") { + return { result: "completed" }; + } + if (parsed.result === "blocked") { + return { result: "blocked", reason: parsed.reason }; + } + return { result: "completed" }; + } catch { + return { result: "completed" }; + } + }; + + describe("Status Parsing", () => { + it("should parse completed status", () => { + const response = 'Task done.\n{"result": "completed"}'; + const result = parseAgentResponse(response); + + expect(result).toEqual({ result: "completed" }); + }); + + it("should parse blocked status with reason", () => { + const response = 'Cannot proceed.\n{"result": "blocked", "reason": "Missing API key"}'; + const result = parseAgentResponse(response); + + expect(result).toEqual({ result: "blocked", reason: "Missing API key" }); + }); + + it("should default to completed when no status block", () => { + const response = "Task completed without status block"; + const result = parseAgentResponse(response); + + expect(result).toEqual({ result: "completed" }); + }); + + it("should handle malformed JSON gracefully", () => { + const response = "{not valid json}"; + const result = parseAgentResponse(response); + + expect(result).toEqual({ result: "completed" }); + }); + }); +}); diff --git a/src/server/api/routers/agentic/agentic.ts b/src/server/api/routers/agentic/agentic.ts index f4aafc968..af1fa4008 100644 --- a/src/server/api/routers/agentic/agentic.ts +++ b/src/server/api/routers/agentic/agentic.ts @@ -1,9 +1,11 @@ import { z } from 'zod' +import { TRPCError } from '@trpc/server' import { createTRPCRouter, protectedProcedure, softAuthProcedure, mappingServiceMiddleware, agenticServiceMiddleware } from '~/server/api/trpc' import { verificationAwareRateLimit, verificationAwareAuthLimit } from '~/server/api/middleware' -import { type CompositionConfig, PreviewGeneratorService, OpenRouterRepository, type ChatMessageContract } from '~/lib/domains/agentic' -import { buildPrompt, generateParentHexplanContent, generateLeafHexplanContent } from '~/lib/domains/agentic/utils' -import { ContextStrategies, CoordSystem, Direction, MapItemType } from '~/lib/domains/mapping/utils' +import { type CompositionConfig, PreviewGeneratorService, OpenRouterRepository, type ChatMessageContract, RunService, type ToolCallEntry } from '~/lib/domains/agentic' +import { buildPrompt, generateParentHexplanContent, generateLeafHexplanContent, parseAgentResponse } from '~/lib/domains/agentic/utils' +import { ContextStrategies, CoordSystem, Direction, MapItemType, isBuiltInItemType, type ItemTypeValue } from '~/lib/domains/mapping/utils' +import { LeafTraversalService } from '~/lib/domains/mapping' import { _getRequesterUserId } from '~/server/api/routers/map' import { _requireConfigured, _requireFound, _requireOwnership, _throwBadRequest, _throwInternalError } from '~/server/api/routers/_error-helpers' import { env } from '~/env' @@ -12,18 +14,70 @@ const { llmJobResults } = schema import { eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import type { MappingService, HexecuteContext } from '~/lib/domains/mapping' +import { TemplateAllowlistService, DrizzleTemplateAllowlistRepository } from '~/lib/domains/agentic' // ============================================================================= // Shared Helpers // ============================================================================= /** - * Creates a hexplan tile if one doesn't exist. - * Returns the hexplan content (either existing or newly created). + * Check if a tile type should have hexplan auto-created. + * + * Only SYSTEM tiles and custom (non-built-in) types should have hexplans. + * USER tiles use "recent-history" at direction-0 instead. + * ORGANIZATIONAL and CONTEXT tiles are not executable. + */ +function _shouldAutoCreateHexplan(itemType: ItemTypeValue | null | undefined): boolean { + if (itemType === null || itemType === undefined) { + return false + } + + // Custom (non-built-in) types are treated like SYSTEM tiles + if (!isBuiltInItemType(itemType)) { + return true + } + + // Only SYSTEM tiles get hexplans among built-in types + return itemType === MapItemType.SYSTEM +} + +/** + * Maximum prompt size in bytes before hitting OS spawn limits. + * The Claude Agent SDK spawns a subprocess with the prompt as arguments. + * Most systems have a limit around 128KB-2MB for command-line args. + * We use 100KB as a conservative limit to avoid E2BIG errors. + */ +const MAX_PROMPT_SIZE_BYTES = 100_000 + +/** + * Check if prompt exceeds safe size limits for subprocess spawning. + * Throws a helpful error if the prompt is too large. + */ +function _checkPromptSize(prompt: string, taskTitle: string): void { + const promptSizeBytes = Buffer.byteLength(prompt, 'utf8') + if (promptSizeBytes > MAX_PROMPT_SIZE_BYTES) { + const sizeKB = Math.round(promptSizeBytes / 1024) + const limitKB = Math.round(MAX_PROMPT_SIZE_BYTES / 1024) + throw new TRPCError({ + code: 'PAYLOAD_TOO_LARGE', + message: `Prompt for "${taskTitle}" is too large (${sizeKB}KB). ` + + `The Claude Agent SDK has a limit of ~${limitKB}KB due to OS subprocess argument limits. ` + + `To fix: move large content to a context child tile and have the agent read it via MCP tools.` + }) + } +} + +/** + * Creates a hexplan tile if one doesn't exist, or prepends instruction to existing hexplan. + * Returns the hexplan content (either existing, newly created, or updated with prepended instruction). * * Handles race conditions: if concurrent calls attempt to create the hexplan, * the second call will detect the unique constraint violation and fetch the * existing hexplan instead. + * + * Prepend behavior: When a hexplan already exists AND instruction is provided, + * the instruction is prepended at the TOP of existing hexplan content. This preserves + * existing progress/state while adding new guidance. */ async function _ensureHexplanExists( hexecuteContext: HexecuteContext, @@ -31,25 +85,38 @@ async function _ensureHexplanExists( instruction: string | undefined, mappingService: MappingService ): Promise { + const taskCoord = CoordSystem.parseId(taskCoords) + const hexplanCoords = { + ...taskCoord, + path: [...taskCoord.path, Direction.Center] + } + + // If hexplan exists, handle user feedback appending if (hexecuteContext.hexPlan) { + // If instruction provided (user feedback), append it to existing hexplan + if (instruction) { + const feedbackEntry = `\n\n---\n\n**User Feedback:** ${instruction}` + const updatedContent = hexecuteContext.hexPlan + feedbackEntry + await mappingService.items.crud.updateItem({ + coords: hexplanCoords, + content: updatedContent + }) + return updatedContent + } + // No instruction, return existing hexplan as-is return hexecuteContext.hexPlan } + // No hexplan exists - create one with instruction const taskId = parseInt(hexecuteContext.task.id, 10) if (Number.isNaN(taskId)) { _throwBadRequest(`Invalid task ID: ${hexecuteContext.task.id}`) } - const taskCoord = CoordSystem.parseId(taskCoords) - const hexplanCoords = { - ...taskCoord, - path: [...taskCoord.path, Direction.Center] - } - // Generate hexplan content based on tile type const hasSubtasks = hexecuteContext.structuralChildren.length > 0 const hexPlanContent = hasSubtasks - ? generateParentHexplanContent(hexecuteContext.structuralChildren, hexecuteContext.allLeafTasks) + ? generateParentHexplanContent(hexecuteContext.structuralChildren, hexecuteContext.allLeafTasks, instruction) : generateLeafHexplanContent(hexecuteContext.task.title, instruction) // Create the hexplan tile, handling race conditions @@ -408,13 +475,11 @@ export const agenticRouter = createTRPCRouter({ if (!hexecuteContext.task.title?.trim()) _throwBadRequest(`Task tile at ${taskCoords} has an empty title. A non-empty title is required for prompt generation.`); - // 3. Ensure hexplan tile exists (create if missing) - const hexPlanContent = await _ensureHexplanExists( - hexecuteContext, - taskCoords, - instruction, - ctx.mappingService - ) + // 3. Ensure hexplan tile exists (create if missing) - only for SYSTEM/custom tiles + // USER tiles don't auto-create hexplans (they use "recent-history" at direction-0) + const hexPlanContent = _shouldAutoCreateHexplan(hexecuteContext.task.itemType) + ? await _ensureHexplanExists(hexecuteContext, taskCoords, instruction, ctx.mappingService) + : hexecuteContext.hexPlan ?? '' // 4. Build the hexecute prompt (pure function, no I/O) let hexecutePrompt: string @@ -442,6 +507,9 @@ export const agenticRouter = createTRPCRouter({ _throwInternalError(`Failed to build prompt for task "${hexecuteContext.task.title}" at ${taskCoords}: ${error instanceof Error ? error.message : 'Unknown error'}`, error); } + // 5. Check prompt size before proceeding (avoid E2BIG spawn errors) + _checkPromptSize(hexecutePrompt, hexecuteContext.task.title) + // Reference the task tile for building minimal map context const taskTile = hexecuteContext.task @@ -534,22 +602,13 @@ export const agenticRouter = createTRPCRouter({ z.object({ taskCoords: z.string(), instruction: z.string().optional(), - deleteHexplan: z.boolean().default(false) + runId: z.string().optional().describe('Run ID for run-linked hexplan storage') }) ) .query(async ({ input, ctx }) => { - const { instruction, taskCoords, deleteHexplan } = input + const { instruction, taskCoords, runId } = input const requester = _getRequesterUserId(ctx.user) - - // If deleteHexplan is true, remove all hexplan tiles (direction-0) before proceeding - // Uses existing service method that handles recursive hexplan deletion - if (deleteHexplan) { - const taskCoord = CoordSystem.parseId(taskCoords) - await ctx.mappingService.items.crud.removeChildrenByType({ - coords: taskCoord, - directionType: 'hexPlan' - }) - } + const runService = new RunService(db) // 1. Get all data from mapping service (single optimized query) const hexecuteContext = await ctx.mappingService.context.getHexecuteContext( @@ -561,13 +620,29 @@ export const agenticRouter = createTRPCRouter({ if (!hexecuteContext.task.title?.trim()) _throwBadRequest(`Task tile at ${taskCoords} has an empty title. A non-empty title is required for prompt generation.`); - // 3. Ensure hexplan tile exists (create if missing) - const hexPlanContent = await _ensureHexplanExists( - hexecuteContext, - taskCoords, - instruction, - ctx.mappingService - ) + // 3. Get hexplan from run_hexplans table if runId provided, otherwise use empty string + // Note: When called via MCP from Claude Code, runId should be provided to fetch run-specific hexplan + let hexPlanContent = '' + if (runId && _shouldAutoCreateHexplan(hexecuteContext.task.itemType)) { + const existingHexplan = await runService.getHexplan(runId, taskCoords) + if (existingHexplan !== null) { + // If instruction provided, append it to existing hexplan + if (instruction) { + const feedbackEntry = `\n\n---\n\n**User Feedback:** ${instruction}` + hexPlanContent = existingHexplan + feedbackEntry + await runService.setHexplan(runId, taskCoords, hexPlanContent) + } else { + hexPlanContent = existingHexplan + } + } else { + // Generate initial hexplan content + const hasSubtasks = hexecuteContext.structuralChildren.length > 0 + hexPlanContent = hasSubtasks + ? generateParentHexplanContent(hexecuteContext.structuralChildren, hexecuteContext.allLeafTasks, instruction) + : generateLeafHexplanContent(hexecuteContext.task.title, instruction) + await runService.setHexplan(runId, taskCoords, hexPlanContent) + } + } // 4. Build prompt (pure function, no I/O) let promptResult: string @@ -588,13 +663,610 @@ export const agenticRouter = createTRPCRouter({ hexPlan: hexPlanContent, mcpServerName: env.HEXFRAME_MCP_SERVER, allLeafTasks: hexecuteContext.allLeafTasks, - itemType: hexecuteContext.task.itemType + itemType: hexecuteContext.task.itemType, + runId }) } catch (error) { console.error(`Failed to build prompt for task at ${taskCoords}:`, error) _throwInternalError(`Failed to build prompt for task "${hexecuteContext.task.title}" at ${taskCoords}: ${error instanceof Error ? error.message : 'Unknown error'}`, error); } + // 5. Check prompt size before returning (warn about potential E2BIG errors) + _checkPromptSize(promptResult, hexecuteContext.task.title) + return { prompt: promptResult } + }), + + // Get effective template allowlist for the current user + // Returns built-in templates + user's custom allowed templates + getEffectiveAllowlist: protectedProcedure + .query(async ({ ctx }) => { + const repository = new DrizzleTemplateAllowlistRepository(db) + const service = new TemplateAllowlistService(repository) + + const userId = ctx.session?.userId ?? null + const allowlist = await service.getEffectiveAllowlist(userId) + + return { allowedTypes: allowlist } + }), + + // Add a template to the user's allowlist + addToAllowlist: protectedProcedure + .input(z.object({ templateName: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + const repository = new DrizzleTemplateAllowlistRepository(db) + const service = new TemplateAllowlistService(repository) + + const userId = ctx.session?.userId + if (!userId) { + _throwBadRequest('User ID is required to modify allowlist') + } + + // Get current allowlist + const currentAllowlist = await service.getUserAllowlist(userId) + const currentTemplates = currentAllowlist.filter( + t => !service.isBuiltInTemplate(t) + ) + + // Add new template if not already present + const normalizedNewTemplate = input.templateName.toLowerCase() + const alreadyExists = currentTemplates.some( + t => t.toLowerCase() === normalizedNewTemplate + ) + + if (!alreadyExists) { + await repository.saveUserAllowlist({ + userId, + allowedTemplates: [...currentTemplates, input.templateName] + }) + } + + // Return updated effective allowlist + const updatedAllowlist = await service.getEffectiveAllowlist(userId) + return { allowedTypes: updatedAllowlist } + }), + + // Remove a template from the user's allowlist + removeFromAllowlist: protectedProcedure + .input(z.object({ templateName: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + const repository = new DrizzleTemplateAllowlistRepository(db) + const service = new TemplateAllowlistService(repository) + + const userId = ctx.session?.userId + if (!userId) { + _throwBadRequest('User ID is required to modify allowlist') + } + + // Can't remove built-in templates + if (service.isBuiltInTemplate(input.templateName)) { + _throwBadRequest('Cannot remove built-in templates from allowlist') + } + + // Get current allowlist and filter out the template to remove + const currentAllowlist = await service.getUserAllowlist(userId) + const normalizedToRemove = input.templateName.toLowerCase() + const updatedTemplates = currentAllowlist.filter( + t => !service.isBuiltInTemplate(t) && t.toLowerCase() !== normalizedToRemove + ) + + await repository.saveUserAllowlist({ + userId, + allowedTemplates: updatedTemplates + }) + + // Return updated effective allowlist + const updatedAllowlist = await service.getEffectiveAllowlist(userId) + return { allowedTypes: updatedAllowlist } + }), + + // List runs for the current user with filtering + listRuns: protectedProcedure + .use(mappingServiceMiddleware) + .input( + z.object({ + statusFilter: z + .array(z.enum(['open', 'blocked', 'closed'])) + .default(['open', 'blocked']), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0) + }) + ) + .query(async ({ input, ctx }) => { + const userId = ctx.session?.userId ?? ctx.user?.id + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User ID required' }) + } + + const runService = new RunService(db) + const runs = await runService.listRunsForUser(userId, { + statusFilter: input.statusFilter, + limit: input.limit, + offset: input.offset + }) + + // Fetch tile titles for each run in parallel + const runsWithTitles = await Promise.all( + runs.map(async (run) => { + let title = run.rootCoords + try { + const coords = CoordSystem.parseId(run.rootCoords) + const tile = await ctx.mappingService.items.query.getItemByCoords({ coords }) + title = tile.title || run.rootCoords + } catch { + // Tile may have been deleted, fall back to coords + } + return { + id: run.id, + rootCoords: run.rootCoords, + title, + status: run.status, + blockageReason: run.blockageReason, + stepsCompleted: run.executionLog.filter(e => e.status === 'completed').length, + totalSteps: run.executionLog.length, + createdAt: run.createdAt, + updatedAt: run.updatedAt + } + }) + ) + + return { runs: runsWithTitles } + }), + + // Get current run state with pre-computed next step prompt + // Used to restore widget state on open and show prompts early + getRunState: protectedProcedure + .use(mappingServiceMiddleware) + .input( + z.object({ + coords: z.string().describe('Root SYSTEM tile coordinates') + }) + ) + .query(async ({ input, ctx }) => { + const userId = ctx.session?.userId ?? ctx.user?.id + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User ID required' }) + } + + const runService = new RunService(db) + const run = await runService.getResumableRun(input.coords) + + // If no run exists, return empty state + if (!run) { + return { + runId: null, + status: null, + blockageReason: null, + executionLog: [], + nextStep: null + } + } + + // Find next incomplete leaf and pre-compute its prompt + const completedCoords = new Set( + run.executionLog.filter(entry => entry.status === 'completed').map(entry => entry.stepCoords) + ) + + const leafTraversalService = new LeafTraversalService({ + itemQueryService: ctx.mappingService.items.query + }) + const { leafCoords } = await leafTraversalService.getNextIncompleteLeaf( + input.coords, + completedCoords + ) + + let nextStep: { coords: string; title: string; prompt: string } | null = null + let currentStepHexplan: { runId: string; coords: string; content: string } | null = null + let parentHexplan: { runId: string; coords: string; content: string } | null = null + + if (leafCoords) { + const requester = _getRequesterUserId(ctx.user) + + // Fetch hexplan from run_hexplans table + const leafHexplanContent = await runService.getHexplan(run.id, leafCoords) + + const hexecuteContext = await ctx.mappingService.context.getHexecuteContext(leafCoords, requester) + const hexPlanContent = leafHexplanContent ?? '' + const prompt = buildPrompt({ + task: { title: hexecuteContext.task.title, content: hexecuteContext.task.content || undefined, coords: leafCoords }, + ancestors: hexecuteContext.ancestors, + composedChildren: hexecuteContext.composedChildren.map(child => ({ title: child.title, content: child.content, coords: child.coords })), + structuralChildren: hexecuteContext.structuralChildren, + hexPlan: hexPlanContent, + mcpServerName: env.HEXFRAME_MCP_SERVER, + allLeafTasks: hexecuteContext.allLeafTasks, + itemType: hexecuteContext.task.itemType, + runId: run.id + }) + nextStep = { coords: leafCoords, title: hexecuteContext.task.title, prompt } + + // Fetch current step hexplan from run_hexplans table + if (leafHexplanContent !== null) { + currentStepHexplan = { + runId: run.id, + coords: leafCoords, + content: leafHexplanContent + } + } + + // Fetch parent hexplan from run_hexplans table - if leaf has a parent + const leafCoord = CoordSystem.parseId(leafCoords) + if (leafCoord.path.length > 0) { + const parentPath = leafCoord.path.slice(0, -1) + const parentCoords = CoordSystem.createId({ ...leafCoord, path: parentPath }) + const parentHexplanContent = await runService.getHexplan(run.id, parentCoords) + if (parentHexplanContent !== null) { + parentHexplan = { + runId: run.id, + coords: parentCoords, + content: parentHexplanContent + } + } + } + } + + return { + runId: run.id, + status: run.status, + blockageReason: run.blockageReason, + executionLog: run.executionLog, + nextStep, + currentStepHexplan, + parentHexplan + } + }), + + // Execute the next step of a SYSTEM tile run + // External orchestration: each call executes ONE leaf and returns + run: protectedProcedure + .use(verificationAwareRateLimit) + .use(mappingServiceMiddleware) + .use(agenticServiceMiddleware) + .input( + z.object({ + coords: z.string().describe('Root SYSTEM tile coordinates'), + instruction: z.string().optional().describe('Optional instruction for current step') + }) + ) + .mutation(async ({ input, ctx }) => { + const { coords, instruction } = input + const userId = ctx.session?.userId ?? ctx.user?.id + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User ID required' }) + } + + // 1. Validate tile type - only SYSTEM and custom types can be run + const tile = await ctx.mappingService.items.query.getItemByCoords({ + coords: CoordSystem.parseId(coords) + }) + + const tileItemType = tile.itemType as ItemTypeValue | null + const isSystemType = tileItemType === MapItemType.SYSTEM + const isCustomType = tileItemType !== null && !isBuiltInItemType(tileItemType) + + if (!isSystemType && !isCustomType) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Only SYSTEM tiles or custom types can be run. Got: ${tileItemType ?? 'null'}` + }) + } + + // 2. Get or create run + const runService = new RunService(db) + const run = await runService.getOrCreateRun(userId, coords) + + // 3. Initialize root hexplan in run_hexplans table if needed + // This must happen BEFORE finding leaves so hexplans are available + const requester = _getRequesterUserId(ctx.user) + const rootHexecuteContext = await ctx.mappingService.context.getHexecuteContext( + coords, + requester + ) + + // Check if root hexplan exists for this run + const existingRootHexplan = await runService.getHexplan(run.id, coords) + if (existingRootHexplan === null) { + // Generate initial hexplan content based on tile structure + const hasSubtasks = rootHexecuteContext.structuralChildren.length > 0 + const initialHexplanContent = hasSubtasks + ? generateParentHexplanContent(rootHexecuteContext.structuralChildren, rootHexecuteContext.allLeafTasks, instruction) + : generateLeafHexplanContent(rootHexecuteContext.task.title, instruction) + await runService.setHexplan(run.id, coords, initialHexplanContent) + } else if (instruction) { + // Append instruction to existing hexplan + const feedbackEntry = `\n\n---\n\n**User Feedback:** ${instruction}` + const updatedContent = existingRootHexplan + feedbackEntry + await runService.setHexplan(run.id, coords, updatedContent) + } + + // 4. If already closed, return completion + if (run.status === 'closed') { + return { + runId: run.id, + runStatus: 'closed' as const, + stepExecuted: null, + stepTitle: null, + stepResult: null, + blockageReason: null, + response: null, + isComplete: true, + hexecutePrompt: null + } + } + + // 5. Get completed coords from execution log + const completedCoords = new Set( + run.executionLog + .filter(entry => entry.status === 'completed') + .map(entry => entry.stepCoords) + ) + + // 6. Find next incomplete leaf + const leafTraversalService = new LeafTraversalService({ + itemQueryService: ctx.mappingService.items.query + }) + const { leafCoords } = await leafTraversalService.getNextIncompleteLeaf( + coords, + completedCoords + ) + + // 7. If no next leaf, close run + if (!leafCoords) { + await runService.closeRun(run.id) + return { + runId: run.id, + runStatus: 'closed' as const, + stepExecuted: null, + stepTitle: null, + stepResult: null, + blockageReason: null, + response: null, + isComplete: true, + hexecutePrompt: null + } + } + + // 8. Build prompt with blockage context if resuming (before startStep to store it) + const wasBlocked = run.status === 'blocked' + + // Get hexecute context for the leaf tile (requester already declared above) + const hexecuteContext = await ctx.mappingService.context.getHexecuteContext( + leafCoords, + requester + ) + + // Ensure hexplan exists for this leaf in run_hexplans table + let leafHexplanContent = await runService.getHexplan(run.id, leafCoords) + if (leafHexplanContent === null) { + // Generate initial hexplan content for leaf tile + const leafHasSubtasks = hexecuteContext.structuralChildren.length > 0 + leafHexplanContent = leafHasSubtasks + ? generateParentHexplanContent(hexecuteContext.structuralChildren, hexecuteContext.allLeafTasks, undefined) + : generateLeafHexplanContent(hexecuteContext.task.title, undefined) + await runService.setHexplan(run.id, leafCoords, leafHexplanContent) + } + + // Build the hexecute prompt + let hexecutePrompt: string + try { + hexecutePrompt = buildPrompt({ + task: { + title: hexecuteContext.task.title, + content: hexecuteContext.task.content || undefined, + coords: leafCoords + }, + ancestors: hexecuteContext.ancestors, + composedChildren: hexecuteContext.composedChildren.map(child => ({ + title: child.title, + content: child.content, + coords: child.coords + })), + structuralChildren: hexecuteContext.structuralChildren, + hexPlan: leafHexplanContent, + mcpServerName: env.HEXFRAME_MCP_SERVER, + allLeafTasks: hexecuteContext.allLeafTasks, + itemType: hexecuteContext.task.itemType, + userMessage: instruction, + wasBlocked, + blockageReason: run.blockageReason ?? undefined, + runId: run.id + }) + } catch (error) { + _throwInternalError( + `Failed to build prompt for leaf at ${leafCoords}: ${error instanceof Error ? error.message : 'Unknown error'}`, + error + ) + } + + // 9. Check prompt size before proceeding (avoid E2BIG spawn errors) + _checkPromptSize(hexecutePrompt, hexecuteContext.task.title) + + // 10. Start step tracking with title and prompt for early visibility + const stepTitle = hexecuteContext.task.title + await runService.startStep(run.id, leafCoords, stepTitle, hexecutePrompt) + + // 10. Execute via agentic service + _requireConfigured(ctx.agenticService.isConfigured(), "OPENROUTER_API_KEY or ANTHROPIC_API_KEY") + + const leafTile = hexecuteContext.task + const minimalMapContext = { + center: { + id: leafTile.id, + ownerId: leafTile.ownerId, + coords: leafCoords, + title: leafTile.title, + content: leafTile.content ?? '', + preview: leafTile.preview ?? undefined, + link: leafTile.link ?? '', + itemType: leafTile.itemType, + visibility: leafTile.visibility, + depth: leafTile.depth, + parentId: leafTile.parentId ?? null, + originId: leafTile.originId ?? null + }, + parent: null, + composed: [], + children: [], + grandchildren: [] + } + + const compositionConfigForRun: CompositionConfig = { + canvas: { enabled: false, strategy: 'minimal' }, + chat: { enabled: true, strategy: 'full' }, + composition: { strategy: 'sequential' } + } + + const chunks: Array<{ content: string; isFinished: boolean }> = [] + const toolCalls: ToolCallEntry[] = [] + const response = await ctx.agenticService.generateStreamingResponse( + { + mapContext: minimalMapContext, + messages: [{ + id: `user-${nanoid()}`, + type: 'user', + content: hexecutePrompt + }], + model: 'claude-haiku-4-5-20251001', + compositionConfig: compositionConfigForRun + }, + (chunk) => { + chunks.push(chunk) + }, + { + onToolCallStart: (event) => { + toolCalls.push({ + toolCallId: event.toolCallId, + toolName: event.toolName, + arguments: event.arguments, + startedAt: new Date().toISOString() + }) + }, + onToolCallEnd: (event) => { + const existingToolCall = toolCalls.find(tc => tc.toolCallId === event.toolCallId) + if (existingToolCall) { + existingToolCall.result = event.result + existingToolCall.error = event.error + existingToolCall.completedAt = new Date().toISOString() + existingToolCall.durationMs = new Date(existingToolCall.completedAt).getTime() - new Date(existingToolCall.startedAt).getTime() + } + } + } + ) + + // 11. Parse response for status + const { result: stepResult, reason } = parseAgentResponse(response.content) + + // 12. Fetch hexplan content after execution (agent may have updated it via updateRunHexplan) + const stepHexplanContent = await runService.getHexplan(run.id, leafCoords) + + // 13. Update run based on result (storing agent response, hexplan content, and tool calls) + const toolCallsToStore = toolCalls.length > 0 ? toolCalls : undefined + if (stepResult === 'completed') { + await runService.markStepCompleted(run.id, leafCoords, response.content, stepHexplanContent ?? undefined, toolCallsToStore) + } else { + await runService.markStepBlocked(run.id, leafCoords, reason ?? 'Unknown blockage', response.content, stepHexplanContent ?? undefined, toolCallsToStore) + } + + // 14. Get updated run and return result + const updatedRun = await runService.getRunById(run.id) + return { + runId: run.id, + runStatus: updatedRun?.status ?? 'open', + stepExecuted: leafCoords, + stepTitle, + stepResult, + blockageReason: stepResult === 'blocked' ? (reason ?? 'Unknown blockage') : null, + response: response.content, + isComplete: false, + hexecutePrompt, + stepHexplanContent + } + }), + + // Close a run manually + closeRun: protectedProcedure + .input(z.object({ runId: z.string() })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session?.userId ?? ctx.user?.id + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User ID required' }) + } + + const runService = new RunService(db) + const run = await runService.getRunById(input.runId) + + _requireFound(run, 'Run') + _requireOwnership(run.userId, userId, 'close runs') + + const updatedRun = await runService.closeRun(input.runId) + return { run: updatedRun } + }), + + // Reopen a closed run + reopenRun: protectedProcedure + .input(z.object({ runId: z.string() })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session?.userId ?? ctx.user?.id + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User ID required' }) + } + + const runService = new RunService(db) + const run = await runService.getRunById(input.runId) + + _requireFound(run, 'Run') + _requireOwnership(run.userId, userId, 'reopen runs') + + const updatedRun = await runService.reopenRun(input.runId) + return { run: updatedRun } + }), + + // Update hexplan content for a specific run and coords + updateRunHexplan: protectedProcedure + .input( + z.object({ + runId: z.string(), + coords: z.string(), + content: z.string() + }) + ) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session?.userId ?? ctx.user?.id + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User ID required' }) + } + + const runService = new RunService(db) + const run = await runService.getRunById(input.runId) + + _requireFound(run, 'Run') + _requireOwnership(run.userId, userId, 'update run hexplans') + + await runService.setHexplan(input.runId, input.coords, input.content) + return { success: true } + }), + + // Get active run for a tile (open or blocked run that includes these coords) + getActiveRunForCoords: protectedProcedure + .input(z.object({ coords: z.string() })) + .query(async ({ input, ctx }) => { + const userId = ctx.session?.userId ?? ctx.user?.id + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User ID required' }) + } + + const runService = new RunService(db) + const run = await runService.getActiveRunForCoords(input.coords) + + if (!run) { + return null + } + + return { + id: run.id, + rootCoords: run.rootCoords, + status: run.status, + blockageReason: run.blockageReason + } }) }) \ No newline at end of file diff --git a/src/server/api/routers/map/__tests__/map-items-itemtype.integration.test.ts b/src/server/api/routers/map/__tests__/map-items-itemtype.integration.test.ts index e86b2b830..1ec76884b 100644 --- a/src/server/api/routers/map/__tests__/map-items-itemtype.integration.test.ts +++ b/src/server/api/routers/map/__tests__/map-items-itemtype.integration.test.ts @@ -220,7 +220,7 @@ describe("tRPC Map Items Router - ItemType API Exposure [Integration - DB]", () ).rejects.toThrow("Structural children of SYSTEM tiles must also be SYSTEM tiles"); }); - it("should enforce CONTEXT children under CONTEXT parent", async () => { + it("should allow ORGANIZATIONAL children under CONTEXT parent", async () => { const testParams = _createUniqueTestParams(); const { userId, groupId } = testParams; const rootMap = await _setupBasicMap(testEnv.service, testParams); @@ -238,21 +238,22 @@ describe("tRPC Map Items Router - ItemType API Exposure [Integration - DB]", () itemType: MapItemType.CONTEXT, }); - // Try to create an ORGANIZATIONAL child under CONTEXT parent - should fail + // Create an ORGANIZATIONAL child under CONTEXT parent - should succeed const childCoords: Coord = _createTestCoordinates({ userId, groupId, path: [Direction.East, Direction.SouthEast], }); - await expect( - testEnv.service.items.crud.addItemToMap({ - parentId: parseInt(parent.id), - coords: childCoords, - title: "Organizational Child", - itemType: MapItemType.ORGANIZATIONAL, - }) - ).rejects.toThrow("ORGANIZATIONAL tiles can only be created under USER or ORGANIZATIONAL parents"); + const child = await createTestItem(testEnv, { + parentId: parseInt(parent.id), + coords: childCoords, + title: "Organizational Child", + itemType: MapItemType.ORGANIZATIONAL, + }); + + expect(child).toBeDefined(); + expect(child.itemType).toBe(MapItemType.ORGANIZATIONAL); }); it("should allow any non-USER itemType under ORGANIZATIONAL parent", async () => { @@ -488,10 +489,10 @@ describe("tRPC Map Items Router - ItemType API Exposure [Integration - DB]", () title: "Organizational Child", itemType: MapItemType.ORGANIZATIONAL, }) - ).rejects.toThrow("ORGANIZATIONAL tiles can only be created under USER or ORGANIZATIONAL parents"); + ).rejects.toThrow("ORGANIZATIONAL tiles can only be created under USER, ORGANIZATIONAL, or CONTEXT parents"); }); - it("should NOT allow creating ORGANIZATIONAL tile under CONTEXT parent", async () => { + it("should allow creating ORGANIZATIONAL tile under CONTEXT parent", async () => { const testParams = _createUniqueTestParams(); const { userId, groupId } = testParams; const rootMap = await _setupBasicMap(testEnv.service, testParams); @@ -509,21 +510,22 @@ describe("tRPC Map Items Router - ItemType API Exposure [Integration - DB]", () itemType: MapItemType.CONTEXT, }); - // Try to create ORGANIZATIONAL child under CONTEXT parent - should fail + // Create ORGANIZATIONAL child under CONTEXT parent - should succeed const childCoords: Coord = _createTestCoordinates({ userId, groupId, path: [Direction.SouthWest, Direction.NorthWest], }); - await expect( - testEnv.service.items.crud.addItemToMap({ - parentId: parseInt(parent.id), - coords: childCoords, - title: "Organizational Child", - itemType: MapItemType.ORGANIZATIONAL, - }) - ).rejects.toThrow("ORGANIZATIONAL tiles can only be created under USER or ORGANIZATIONAL parents"); + const child = await createTestItem(testEnv, { + parentId: parseInt(parent.id), + coords: childCoords, + title: "Organizational Child", + itemType: MapItemType.ORGANIZATIONAL, + }); + + expect(child).toBeDefined(); + expect(child.itemType).toBe(MapItemType.ORGANIZATIONAL); }); }); diff --git a/src/server/api/routers/map/map-items.ts b/src/server/api/routers/map/map-items.ts index f28c62172..16396ef7f 100644 --- a/src/server/api/routers/map/map-items.ts +++ b/src/server/api/routers/map/map-items.ts @@ -7,8 +7,8 @@ import { } from "~/server/api/trpc"; import { contractToApiAdapters } from "~/server/api/types/contracts"; import { type Coord } from "~/lib/domains/mapping/utils"; -import type { NonUserMapItemTypeString, VisibilityString } from '~/lib/domains/mapping/utils'; -import { Visibility, MapItemType } from '~/lib/domains/mapping/utils'; +import type { VisibilityString } from '~/lib/domains/mapping/utils'; +import { Visibility } from '~/lib/domains/mapping/utils'; import { hexCoordSchema, itemCreationSchema, @@ -28,15 +28,12 @@ function toVisibilityEnum(visibility?: VisibilityString): Visibility | undefined } /** - * Convert string itemType to MapItemType enum (excludes USER - system-controlled) + * Validate and return item type string. + * The "user" type is reserved and validated at schema level. + * Custom types are passed through as-is. */ -function toMapItemTypeEnum(itemType: NonUserMapItemTypeString): MapItemType { - const itemTypeMap: Record = { - organizational: MapItemType.ORGANIZATIONAL, - context: MapItemType.CONTEXT, - system: MapItemType.SYSTEM, - }; - return itemTypeMap[itemType]; +function toItemTypeValue(itemType: string): string { + return itemType; } export const mapItemsRouter = createTRPCRouter({ @@ -134,7 +131,7 @@ export const mapItemsRouter = createTRPCRouter({ preview: input.preview, link: input.link, visibility: toVisibilityEnum(input.visibility), - itemType: toMapItemTypeEnum(input.itemType), + itemType: toItemTypeValue(input.itemType), }); return contractToApiAdapters.mapItem(mapItem); }), @@ -178,7 +175,7 @@ export const mapItemsRouter = createTRPCRouter({ preview?: string; link?: string; visibility?: Visibility; - itemType?: MapItemType; + itemType?: string; requester?: typeof requester; } = { coords: input.coords as Coord, @@ -191,7 +188,7 @@ export const mapItemsRouter = createTRPCRouter({ if (input.data.preview !== undefined) updateParams.preview = input.data.preview; if (input.data.link !== undefined) updateParams.link = input.data.link; if (input.data.visibility !== undefined) updateParams.visibility = toVisibilityEnum(input.data.visibility); - if (input.data.itemType !== undefined) updateParams.itemType = toMapItemTypeEnum(input.data.itemType); + if (input.data.itemType !== undefined) updateParams.itemType = toItemTypeValue(input.data.itemType); const item = await ctx.mappingService.items.crud.updateItem(updateParams); return contractToApiAdapters.mapItem(item); diff --git a/src/server/api/routers/map/map-schemas.ts b/src/server/api/routers/map/map-schemas.ts index a68fb84c0..168634211 100644 --- a/src/server/api/routers/map/map-schemas.ts +++ b/src/server/api/routers/map/map-schemas.ts @@ -43,16 +43,21 @@ export const userMapIdentifierSchema = z.object({ /** * Item type schema for API-level tile classification. * - * Defines the semantic classification of tiles that clients can set: + * Accepts any non-empty string except "user" (which is system-controlled). + * + * Built-in types: * - "organizational": Structural grouping tiles (e.g., "Plans", "Interests") * - "context": Reference material tiles to explore on-demand * - "system": Executable capability tiles that can be invoked like a skill * - * Note: "user" type is excluded as it's system-controlled (root tiles only). + * Custom types (e.g., "template") are also supported for user-defined classifications. * - * @see MapItemType in src/lib/domains/mapping/_objects/map-item.ts for full enum + * @see MapItemType in src/lib/domains/mapping/_objects/map-item.ts for built-in enum */ -export const itemTypeSchema = z.enum(["organizational", "context", "system"]); +export const itemTypeSchema = z.string().min(1).refine( + (val) => val.toLowerCase() !== "user", + { message: "The 'user' item type is reserved for system-created root tiles" } +); /** * Item creation schema for adding new tiles to the map. diff --git a/src/server/db/schema/__tests__/template-name-column.integration.test.ts b/src/server/db/schema/__tests__/template-name-column.integration.test.ts new file mode 100644 index 000000000..bb7bd3271 --- /dev/null +++ b/src/server/db/schema/__tests__/template-name-column.integration.test.ts @@ -0,0 +1,639 @@ +import { describe, beforeEach, it, expect } from "vitest"; +import { db } from "~/server/db"; +import { schema } from "~/server/db"; +import { _createUniqueTestParams } from "~/lib/domains/mapping/services/__tests__/helpers/_test-utilities"; +import { DbBaseItemRepository } from "~/lib/domains/mapping/infrastructure/base-item/db"; +import { MapItemType } from "~/lib/domains/mapping"; + +/** + * TDD Tests for templateName column feature. + * + * The templateName column enables "templates as tiles" - storing prompt templates + * as map items rather than TypeScript code. This allows user-created templates + * and transparent prompt inspection. + * + * Requirements from docs/features/TEMPLATES_AS_TILES.md: + * - templateName is a nullable VARCHAR(100) column on map_items + * - templateName has a unique constraint (globally unique) + * - Templates can be looked up by templateName for prompt rendering + * + * These tests are written BEFORE the implementation (TDD Red phase). + * They will fail until the templateName column is added to the schema. + */ +describe("Schema: templateName column [Integration - DB]", () => { + let baseItemRepository: DbBaseItemRepository; + let testBaseItemId: number; + let testUserId: number; + let testParams: { userId: string; groupId: number }; + + beforeEach(async () => { + // Use unique params to avoid conflicts with other tests running in parallel + testParams = _createUniqueTestParams("template-name-test"); + baseItemRepository = new DbBaseItemRepository(db); + + // Create a test base item to reference + const baseItem = await baseItemRepository.create({ + attrs: { + title: "Test Template Item", + content: "Template content with Mustache markup", + link: "", + }, + relatedItems: {}, + relatedLists: {}, + }); + testBaseItemId = baseItem.id; + + // Create a test user item (root) + const userItem = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "", + item_type: MapItemType.USER, + refItemId: testBaseItemId, + parentId: null, + }) + .returning(); + if (!userItem[0]) { + throw new Error('Failed to create test user item'); + } + testUserId = userItem[0].id; + }); + + describe("schema includes templateName field", () => { + it("should accept templateName field when inserting a map item", async () => { + // Arrange: Create a template tile with templateName + const templateName = "system-template"; + + // Act: Insert map item with templateName + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: templateName, + }) + .returning(); + + // Assert: Verify templateName was stored + expect(insertedItem).toBeDefined(); + expect(insertedItem!.templateName).toBe(templateName); + }); + + it("should return templateName when querying map items", async () => { + // Arrange: Insert a template tile + const templateName = "context-template"; + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "2", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: templateName, + }) + .returning(); + + // Act: Query the item from database + const retrievedItem = await db.query.mapItems.findFirst({ + where: (mapItems, { eq }) => eq(mapItems.id, insertedItem!.id), + }); + + // Assert: Verify templateName is returned + expect(retrievedItem).toBeDefined(); + expect(retrievedItem!.templateName).toBe(templateName); + }); + + it("should include templateName in the MapItem type inference", async () => { + // This test verifies the TypeScript type includes templateName + // If templateName is missing from schema, this won't compile + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "3", + item_type: MapItemType.ORGANIZATIONAL, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: "organizational-template", + }) + .returning(); + + // Type assertion: templateName should be string | null + const templateNameValue: string | null = insertedItem!.templateName; + expect(templateNameValue).toBe("organizational-template"); + }); + }); + + describe("unique constraint enforcement", () => { + it("should reject duplicate templateName values", async () => { + // Arrange: Create first template with a unique name + const duplicateTemplateName = "my-unique-template"; + + await db.insert(schema.mapItems).values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: duplicateTemplateName, + }); + + // Act & Assert: Attempting to insert duplicate templateName should fail + await expect( + db.insert(schema.mapItems).values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "2", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: duplicateTemplateName, + }), + ).rejects.toThrow(); + }); + + it("should reject duplicate templateName across different users", async () => { + // Arrange: Create template for first user + const sharedTemplateName = "shared-template-name"; + const otherUserParams = _createUniqueTestParams("other-user"); + + await db.insert(schema.mapItems).values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: sharedTemplateName, + }); + + // Create root for other user + const [otherUserRoot] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: otherUserParams.userId, + coord_group_id: otherUserParams.groupId, + path: "", + item_type: MapItemType.USER, + refItemId: testBaseItemId, + parentId: null, + }) + .returning(); + + // Act & Assert: Same templateName for different user should also fail + await expect( + db.insert(schema.mapItems).values({ + coord_user_id: otherUserParams.userId, + coord_group_id: otherUserParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: otherUserRoot!.id, + templateName: sharedTemplateName, + }), + ).rejects.toThrow(); + }); + + it("should allow different templateName values for different items", async () => { + // Arrange & Act: Insert multiple items with unique templateNames + const items = await db + .insert(schema.mapItems) + .values([ + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: "template-alpha", + }, + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "2", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: "template-beta", + }, + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "3", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: "template-gamma", + }, + ]) + .returning(); + + // Assert: All insertions should succeed + expect(items).toHaveLength(3); + expect(items[0]!.templateName).toBe("template-alpha"); + expect(items[1]!.templateName).toBe("template-beta"); + expect(items[2]!.templateName).toBe("template-gamma"); + }); + }); + + describe("CRUD operations handle templateName correctly", () => { + it("should create item with templateName", async () => { + // Arrange + const templateName = "create-test-template"; + + // Act + const [createdItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: templateName, + }) + .returning(); + + // Assert + expect(createdItem!.templateName).toBe(templateName); + }); + + it("should read item with templateName", async () => { + // Arrange + const templateName = "read-test-template"; + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: templateName, + }) + .returning(); + + // Act + const readItem = await db.query.mapItems.findFirst({ + where: (mapItems, { eq }) => eq(mapItems.id, insertedItem!.id), + }); + + // Assert + expect(readItem!.templateName).toBe(templateName); + }); + + it("should update templateName", async () => { + // Arrange + const originalTemplateName = "original-template"; + const updatedTemplateName = "updated-template"; + + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: originalTemplateName, + }) + .returning(); + + // Act + const { eq } = await import("drizzle-orm"); + const [updatedItem] = await db + .update(schema.mapItems) + .set({ templateName: updatedTemplateName }) + .where(eq(schema.mapItems.id, insertedItem!.id)) + .returning(); + + // Assert + expect(updatedItem!.templateName).toBe(updatedTemplateName); + }); + + it("should delete item with templateName (cascade properly)", async () => { + // Arrange + const templateName = "delete-test-template"; + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: templateName, + }) + .returning(); + + // Act + const { eq } = await import("drizzle-orm"); + await db + .delete(schema.mapItems) + .where(eq(schema.mapItems.id, insertedItem!.id)); + + // Assert: Item should be deleted + const deletedItem = await db.query.mapItems.findFirst({ + where: (mapItems, { eq: whereEq }) => + whereEq(mapItems.id, insertedItem!.id), + }); + expect(deletedItem).toBeUndefined(); + + // Assert: templateName should now be available for reuse + const [reusedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: templateName, + }) + .returning(); + + expect(reusedItem!.templateName).toBe(templateName); + }); + + it("should find items by templateName", async () => { + // Arrange + const targetTemplateName = "findable-template"; + await db.insert(schema.mapItems).values([ + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: targetTemplateName, + }, + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "2", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: "other-template", + }, + ]); + + // Act + const foundItem = await db.query.mapItems.findFirst({ + where: (mapItems, { eq }) => + eq(mapItems.templateName, targetTemplateName), + }); + + // Assert + expect(foundItem).toBeDefined(); + expect(foundItem!.templateName).toBe(targetTemplateName); + expect(foundItem!.path).toBe("1"); + }); + }); + + describe("NULL values are properly handled", () => { + it("should allow NULL templateName (default behavior)", async () => { + // Act: Insert without templateName (should default to null) + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + // templateName intentionally omitted + }) + .returning(); + + // Assert + expect(insertedItem!.templateName).toBeNull(); + }); + + it("should allow explicit NULL templateName", async () => { + // Act: Insert with explicit null templateName + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: null, + }) + .returning(); + + // Assert + expect(insertedItem!.templateName).toBeNull(); + }); + + it("should allow multiple items with NULL templateName (no unique violation)", async () => { + // Act: Insert multiple items without templateName + const items = await db + .insert(schema.mapItems) + .values([ + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: null, + }, + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "2", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: null, + }, + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "3", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + // templateName omitted + }, + ]) + .returning(); + + // Assert: All should succeed (NULL doesn't violate unique constraint) + expect(items).toHaveLength(3); + items.forEach((item) => { + expect(item.templateName).toBeNull(); + }); + }); + + it("should update templateName from value to NULL", async () => { + // Arrange + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: "to-be-nulled", + }) + .returning(); + + // Act + const { eq } = await import("drizzle-orm"); + const [updatedItem] = await db + .update(schema.mapItems) + .set({ templateName: null }) + .where(eq(schema.mapItems.id, insertedItem!.id)) + .returning(); + + // Assert + expect(updatedItem!.templateName).toBeNull(); + }); + + it("should update templateName from NULL to value", async () => { + // Arrange + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: null, + }) + .returning(); + + // Act + const { eq } = await import("drizzle-orm"); + const [updatedItem] = await db + .update(schema.mapItems) + .set({ templateName: "now-has-value" }) + .where(eq(schema.mapItems.id, insertedItem!.id)) + .returning(); + + // Assert + expect(updatedItem!.templateName).toBe("now-has-value"); + }); + + it("should query items with NULL templateName", async () => { + // Arrange: Insert items with and without templateName + await db.insert(schema.mapItems).values([ + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: "has-name", + }, + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "2", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: null, + }, + { + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "3", + item_type: MapItemType.CONTEXT, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: null, + }, + ]); + + // Act: Query for items with NULL templateName + const { and, eq, isNull } = await import("drizzle-orm"); + const nullTemplateItems = await db.query.mapItems.findMany({ + where: and( + eq(schema.mapItems.coord_user_id, testParams.userId), + eq(schema.mapItems.coord_group_id, testParams.groupId), + isNull(schema.mapItems.templateName), + ), + }); + + // Assert: Should find items with NULL templateName (including root) + // Root item has null templateName, plus the 2 items we inserted with null + const nullTemplateItemsExcludingRoot = nullTemplateItems.filter( + (item) => item.path !== "", + ); + expect(nullTemplateItemsExcludingRoot).toHaveLength(2); + }); + }); + + describe("templateName length constraint", () => { + it("should accept templateName up to 100 characters", async () => { + // Arrange: Create a 100-character templateName + const maxLengthTemplateName = "a".repeat(100); + + // Act + const [insertedItem] = await db + .insert(schema.mapItems) + .values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: maxLengthTemplateName, + }) + .returning(); + + // Assert + expect(insertedItem!.templateName).toBe(maxLengthTemplateName); + expect(insertedItem!.templateName!.length).toBe(100); + }); + + it("should reject templateName exceeding 100 characters", async () => { + // Arrange: Create a 101-character templateName + const tooLongTemplateName = "a".repeat(101); + + // Act & Assert: Should fail with length constraint violation + await expect( + db.insert(schema.mapItems).values({ + coord_user_id: testParams.userId, + coord_group_id: testParams.groupId, + path: "1", + item_type: MapItemType.SYSTEM, + refItemId: testBaseItemId, + parentId: testUserId, + templateName: tooLongTemplateName, + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/src/server/db/schema/_relations.ts b/src/server/db/schema/_relations.ts index 18b4e4dc8..9dfbe2c34 100644 --- a/src/server/db/schema/_relations.ts +++ b/src/server/db/schema/_relations.ts @@ -5,6 +5,8 @@ import { baseItemVersions } from "~/server/db/schema/_tables/mapping/base-item-v import { users } from "~/server/db/schema/_tables/auth/users"; import { accounts } from "~/server/db/schema/_tables/auth/accounts"; import { sessions } from "~/server/db/schema/_tables/auth/sessions"; +import { runs } from "~/server/db/schema/_tables/agentic/runs"; +import { runHexplans } from "~/server/db/schema/_tables/agentic/run-hexplans"; /** * Relations for map_items table @@ -84,3 +86,15 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({ references: [users.id], }), })); + +// Relations for Agentic tables +export const runsRelations = relations(runs, ({ many }) => ({ + hexplans: many(runHexplans), +})); + +export const runHexplansRelations = relations(runHexplans, ({ one }) => ({ + run: one(runs, { + fields: [runHexplans.runId], + references: [runs.id], + }), +})); diff --git a/src/server/db/schema/_tables/agentic/run-hexplans.ts b/src/server/db/schema/_tables/agentic/run-hexplans.ts new file mode 100644 index 000000000..acebc82f5 --- /dev/null +++ b/src/server/db/schema/_tables/agentic/run-hexplans.ts @@ -0,0 +1,34 @@ +import { text, timestamp, primaryKey, index } from "drizzle-orm/pg-core"; +import { createTable } from "~/server/db/schema/_utils"; +import { runs } from "~/server/db/schema/_tables/agentic/runs"; + +/** + * RunHexplans table - stores hexplan content per run and coords + * + * A hexplan tracks execution state for a specific task tile within a run. + * This allows: + * - Multiple runs to have independent hexplan state + * - Historical preservation of execution context + * - No cleanup needed between runs + * + * The coords column stores the tile coordinates in string format (e.g., "userId,0:1,2") + */ +export const runHexplans = createTable( + "run_hexplans", + { + runId: text("run_id") + .notNull() + .references(() => runs.id, { onDelete: "cascade" }), + coords: text("coords").notNull(), + content: text("content").notNull().default(""), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.runId, table.coords] }), + runIdIdx: index("idx_run_hexplans_run_id").on(table.runId), + }) +); + +export type RunHexplan = typeof runHexplans.$inferSelect; +export type NewRunHexplan = typeof runHexplans.$inferInsert; diff --git a/src/server/db/schema/_tables/agentic/runs.ts b/src/server/db/schema/_tables/agentic/runs.ts new file mode 100644 index 000000000..160bd1317 --- /dev/null +++ b/src/server/db/schema/_tables/agentic/runs.ts @@ -0,0 +1,41 @@ +import { text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { createTable } from "~/server/db/schema/_utils"; + +/** + * Runs table - tracks execution state of SYSTEM tiles + * + * A "run" represents an autonomous execution session for a task hierarchy. + * Only one open run can exist per rootCoords at any time. + * + * Status lifecycle: + * - open: Execution in progress + * - blocked: Execution paused due to an error or human intervention needed + * - closed: Execution completed or terminated + */ +export const runs = createTable( + "runs", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + rootCoords: text("root_coords").notNull(), + status: text("status").notNull(), + blockageReason: text("blockage_reason"), + executionLog: jsonb("execution_log").notNull().default([]), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => ({ + userRunsIdx: index("idx_user_runs").on(table.userId, table.status), + rootCoordsStatusIdx: index("idx_root_coords_status").on( + table.rootCoords, + table.status + ), + uniqueOpenRun: uniqueIndex("unique_open_run") + .on(table.rootCoords) + .where(sql`status = 'open'`), + }) +); + +export type Run = typeof runs.$inferSelect; +export type NewRun = typeof runs.$inferInsert; diff --git a/src/server/db/schema/_tables/auth/user-template-allowlist.ts b/src/server/db/schema/_tables/auth/user-template-allowlist.ts new file mode 100644 index 000000000..6841b0873 --- /dev/null +++ b/src/server/db/schema/_tables/auth/user-template-allowlist.ts @@ -0,0 +1,38 @@ +import { + pgTable, + text, + timestamp, + uniqueIndex, + index, +} from "drizzle-orm/pg-core"; +import { users } from "~/server/db/schema/_tables/auth/users"; + +/** + * User Template Allowlist table - stores per-user allowed template names + * + * Users can only execute templates that are: + * 1. Built-in (system, user, organizational, context) - always allowed + * 2. In their custom allowlist - stored here + * + * The allowedTemplates column stores an array of template names (strings). + * Template names are case-insensitive for matching purposes. + */ +export const userTemplateAllowlist = pgTable("user_template_allowlist", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + allowedTemplates: text("allowed_templates").array().notNull().default([]), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }) + .notNull() + .defaultNow(), +}, (table) => ({ + uniqueUserAllowlist: uniqueIndex("unique_user_allowlist").on(table.userId), + userIdIdx: index("user_template_allowlist_user_id_idx").on(table.userId), +})); + +export type UserTemplateAllowlistRecord = typeof userTemplateAllowlist.$inferSelect; +export type NewUserTemplateAllowlistRecord = typeof userTemplateAllowlist.$inferInsert; diff --git a/src/server/db/schema/_tables/mapping/map-items.ts b/src/server/db/schema/_tables/mapping/map-items.ts index b68aa8db5..48d467a9c 100644 --- a/src/server/db/schema/_tables/mapping/map-items.ts +++ b/src/server/db/schema/_tables/mapping/map-items.ts @@ -1,6 +1,7 @@ import { integer, varchar, + text, timestamp, index, foreignKey, @@ -11,7 +12,7 @@ import { import { sql } from "drizzle-orm"; import { createTable } from "~/server/db/schema/_utils"; import { baseItems } from "~/server/db/schema/_tables/mapping/base-items"; -import { type MapItemType, type Visibility } from "~/lib/domains/mapping"; +import { type Visibility } from "~/lib/domains/mapping"; /** * Map Items table - stores hexagonal coordinate references to base items @@ -39,11 +40,10 @@ export const mapItems = createTable( "map_items", { id: integer("id").primaryKey().generatedByDefaultAsIdentity(), - coord_user_id: varchar("coord_user_id", { length: 255 }).notNull(), + coord_user_id: text("coord_user_id").notNull(), coord_group_id: integer("coord_group_id").notNull().default(0), path: varchar("path", { length: 255 }).notNull().default(""), item_type: varchar("item_type", { length: 50 }) - .$type() .notNull(), visibility: varchar("visibility", { length: 20 }) .$type() @@ -51,6 +51,7 @@ export const mapItems = createTable( .default("private" as Visibility), parentId: integer("parent_id"), refItemId: integer("ref_item_id").notNull(), + templateName: varchar("template_name", { length: 100 }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, @@ -85,6 +86,9 @@ export const mapItems = createTable( table.coord_group_id, table.path, ), + uniqueTemplateName: uniqueIndex("unique_template_name").on( + table.templateName, + ), }; }, ); diff --git a/src/server/db/schema/index.ts b/src/server/db/schema/index.ts index f1eb0c027..ba5774dec 100644 --- a/src/server/db/schema/index.ts +++ b/src/server/db/schema/index.ts @@ -12,6 +12,8 @@ export { usersRelations, accountsRelations, sessionsRelations, + runsRelations, + runHexplansRelations, } from "~/server/db/schema/_relations"; // Auth tables for better-auth @@ -22,6 +24,7 @@ export * from "~/server/db/schema/_tables/auth/verificationTokens"; export * from "~/server/db/schema/_tables/auth/api-keys"; export * from "~/server/db/schema/_tables/auth/internal-api-keys"; export * from "~/server/db/schema/_tables/auth/tile-favorites"; +export * from "~/server/db/schema/_tables/auth/user-template-allowlist"; // Mapping/domain-specific tables export * from "~/server/db/schema/_tables/mapping/base-items"; @@ -30,3 +33,7 @@ export * from "~/server/db/schema/_tables/mapping/map-items"; // LLM job results table export * from "~/server/db/schema/_tables/llm-job-results"; + +// Agentic domain tables +export * from "~/server/db/schema/_tables/agentic/runs"; +export * from "~/server/db/schema/_tables/agentic/run-hexplans";