diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 0000000..c9e0af3 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,82 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +All commands work via `npx` — no global install required. + +## Commands + +### analyze — Build or refresh the index + +```bash +npx gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. + +### status — Check index freshness + +```bash +npx gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### clean — Delete the index + +```bash +npx gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +npx gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +npx gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 0000000..9510b97 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is this function failing?" +- "Trace where this error comes from" +- "Who calls this method?" +- "This endpoint returns 500" +- Investigating bugs, errors, or unexpected behavior + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] gitnexus_query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `gitnexus_query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code related to error: + +``` +gitnexus_query({query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**gitnexus_context** — full context for a suspect: + +``` +gitnexus_context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. gitnexus_query({query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. gitnexus_context({name: "validatePayment"}) + → Outgoing calls: verifyCard, fetchRates (external API!) + +3. READ gitnexus://repo/my-app/process/CheckoutFlow + → Step 3: validatePayment → calls fetchRates (external) + +4. Root cause: fetchRates calls external API without proper timeout +``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 0000000..927a4e4 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-exploring +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does authentication work?" +- "What's the project structure?" +- "Show me the main components" +- "Where is the database logic?" +- Understanding code you haven't seen before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "validateUser"}) +→ Incoming calls: loginHandler, apiMiddleware +→ Outgoing calls: checkToken, getUserById +→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) +``` + +## Example: "How does payment processing work?" + +``` +1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +2. gitnexus_query({query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. gitnexus_context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 0000000..937ac73 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 0000000..e19af28 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,97 @@ +--- +name: gitnexus-impact-analysis +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to change this function?" +- "What will break if I modify X?" +- "Show me the blast radius" +- "Who uses this code?" +- Before making non-trivial code changes +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (auth, payments) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "validateUser", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - loginHandler (src/auth/login.ts:42) [CALLS, 100%] + - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - authRouter (src/routes/auth.ts:22) [CALLS, 95%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. gitnexus_impact({target: "validateUser", direction: "upstream"}) + → d=1: loginHandler, apiMiddleware (WILL BREAK) + → d=2: authRouter, sessionManager (LIKELY AFFECTED) + +2. READ gitnexus://repo/my-app/processes + → LoginFlow and TokenRefresh touch validateUser + +3. Risk: 2 direct callers, 2 processes = MEDIUM +``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 0000000..f48cc01 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gitnexus-refactoring +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename this function safely" +- "Extract this into a module" +- "Split this service" +- "Move this to a new file" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | gitnexus_query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 ast_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review ast_search edits (config.json: dynamic reference!) + +3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. gitnexus_detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/.gitignore b/.gitignore index 422e4bf..0929216 100644 --- a/.gitignore +++ b/.gitignore @@ -255,3 +255,4 @@ uv.lock .playwright-cli/ / but/ +.gitnexus diff --git a/AGENTS.md b/AGENTS.md index b41e3ba..99cc228 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,3 +25,105 @@ Reference specific personas when requesting work: - `ADMIN_ALLOWED_IPS` restricts `/admin` when set (comma-separated IPs/CIDR); unset or empty allows all client IPs. - In `9agents/agent_interviewer`, empty `MANAGE_ALLOWED_IPS` still requires `MANAGE_AUTH_TOKEN` when that env is set; supervaizer’s admin IP middleware has no equivalent token fallback when the allowlist is empty. + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **supervaizer** (2690 symbols, 8358 relationships, 231 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## When Debugging + +1. `gitnexus_query({query: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation +3. `READ gitnexus://repo/supervaizer/process/{processName}` — trace the full execution flow step by step +4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed + +## When Refactoring + +- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. +- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. +- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Tools Quick Reference + +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | + +## Impact Risk Levels + +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/supervaizer/context` | Codebase overview, check index freshness | +| `gitnexus://repo/supervaizer/clusters` | All functional areas | +| `gitnexus://repo/supervaizer/processes` | All execution flows | +| `gitnexus://repo/supervaizer/process/{name}` | Step-by-step execution trace | + +## Self-Check Before Finishing + +Before completing any code modification task, verify: +1. `gitnexus_impact` was run for all modified symbols +2. No HIGH/CRITICAL risk warnings were ignored +3. `gitnexus_detect_changes()` confirms changes match expected scope +4. All d=1 (WILL BREAK) dependents were updated + +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 204e440..02c86f2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,20 +19,30 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added + +- **`CaseNodeUpdate.upsert` and `Case.patch_step`** — Optional step update path for Studio: when `upsert` is true, the existing case step at the same index is updated instead of appending. `Case.patch_step(index, update)` sets `index` and `upsert` on the update, sends `send_update_case`, and replaces the matching entry in `Case.updates`. Serialized in `CaseNodeUpdate.registration_info` for the controller payload. + +- **Human answer with `casestep_index`** — `POST /jobs/{job_id}/cases/{case_id}/update`: if `request.answer` includes `casestep_index`, the controller calls `case.patch_step(int(casestep_index), update)` and runs `PersistentEntityLifecycle.handle_event(..., INPUT_RECEIVED)` instead of `receive_human_input`. Omit `casestep_index` for the previous append/receive-human-input behavior. + +- **Tests** — `tests/test_routes_case_update.py` covers job 404, workbench-style `human_answer` params (including `casestep_index` stripped from `fields`), single owning agent vs multiple agents, and skip when `job.agent_name` is not on the server. + ### Changed - **Dynamic choices request context** — `POST .../start/dynamic_choices` now passes `workspace_slug` through to `dynamic_choices_callback` alongside `workspace_id` and `mission_id` (Supervaize Studio sends it in the JSON body). +- **`POST /jobs/{job_id}/cases/{case_id}/update` (human_answer)** — Resolves the job with in-memory `Jobs().get_job` first, then persisted (`include_persisted=True`) if missing. Returns **404** when the job does not exist. Dispatches `human_answer` only for the job owner via `server.get_agent_by_name(job.agent_name)` and `agent._execute(...)`, using the same parameter shape as the workbench HITL route (`fields`, `context`, `payload`, `job_id`, `case_id`, optional `message`). Strips `casestep_index` from `fields` for the hook. Runs the hook in a thread pool executor to avoid blocking the event loop. + ### Unit Tests Results `just test` | Status | Count | | ---------- | ----- | -| ✅ Passed | 466 | +| ✅ Passed | 473 | | 🤔 Skipped | 0 | | 🔴 Failed | 0 | -| ⏱️ in | 54s | +| ⏱️ in | ~70s | ## v0.13.1 diff --git a/justfile b/justfile index d464ca5..e43f711 100644 --- a/justfile +++ b/justfile @@ -45,11 +45,14 @@ mypy: uv run python -m pre_commit run mypy --all-files # Sync dependencies (from pyproject.toml) -env_sync: +install: uv sync +upgrade: + uv sync -U + # Sync all dependencies - including dev dependencies -env_sync_all: +install-all: uv sync --all-extras # build @@ -63,24 +66,24 @@ version-dev cmd: uv run python tools/dev_version.py {{cmd}} # Reusable recipe to bump version -_bump_version bump_type: +_bump-version bump_type: @echo "VERSION BUMP IN CICD - not running: hatch version {{bump_type}} " hatch build # Increase 0.0.1 -build_fix: - just _bump_version fix +release-patch: + just _bump-version fix # Increase 0.1.0 -build_minor: - just _bump_version minor +release-minor: + just _bump-version minor # Increase 1.0.0 -build_major: - just _bump_version major +release-major: + just _bump-version major # Push tags to remote -push_tags: +push-tags: git push origin --tags @echo "Tags pushed to remote" @@ -95,7 +98,7 @@ install-hooks: # Git hooks installed # API documentation @http://127.0.0.1:8000/redoc -unicorn: +dev: uvicorn controller:app --reload # Local test mode: no Studio credentials, built-in Hello World agent (for agent workbench) @@ -103,7 +106,7 @@ local: uv run supervaizer start --local # Create git tag for current version - Automated done in post-commit hook -tag_version: +tag-version: bash -c "VERSION=\$(grep '^VERSION = ' src/supervaizer/__version__.py | cut -d'\"' -f2) && TAG=\"v\${VERSION}\" && if git rev-parse -q --verify \"refs/tags/\${TAG}\" >/dev/null; then echo \"Tag \${TAG} already exists - skipping\"; else git tag -a \"\${TAG}\" -m \"Version \${VERSION}\" && echo \"Created tag \${TAG}\"; fi" # Generate RSA private key (PEM) for SUPERVAIZER_PRIVATE_KEY (e.g. Vercel env) @@ -111,11 +114,11 @@ generate-private-key: uv run python -c "from cryptography.hazmat.primitives.asymmetric import rsa; from cryptography.hazmat.primitives import serialization; from cryptography.hazmat.backends import default_backend; k = rsa.generate_private_key(65537, 2048, default_backend()); print(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode())" # Check git history for secret leaks -trufflehog_scan_git_history: +security-scan: trufflehog git file://. --results=verified,unknown --fail # Generate model reference documentation -generate_documentation: +generate-docs: uv run python tools/gen_model_docs.py uv run python tools/export_openapi.py @@ -125,7 +128,7 @@ ready-to-go: just test-no-cov just precommit just version-dev off - just generate_documentation + just generate-docs bash -euc 'branch_id="$(but status --json | uv run python tools/get_applied_but_branch_id.py)"; but commit "$branch_id" -m "chore: update documentation" --json --status-after' # Merge develop to main @@ -148,7 +151,7 @@ push-main: release: just merge-to-main just push-main - just push_tags + just push-tags just gh-release @echo "✅ Release complete! Main branch and tags pushed to remote" diff --git a/src/supervaizer/case.py b/src/supervaizer/case.py index 30cd22d..ae3feb2 100644 --- a/src/supervaizer/case.py +++ b/src/supervaizer/case.py @@ -34,6 +34,7 @@ class CaseNodeUpdate(SvBaseModel): # Todo: test with non-serializable objects. Make sure it works. payload: Optional[Dict[str, Any]] = None is_final: bool = False + upsert: bool = False # if True, Studio updates the existing step at the same index instead of creating a new one error: Optional[str] = None scheduled_at: datetime | None = None # When to execute (UTC) scheduled_method: str | None = None # Agent method dotted path @@ -48,6 +49,7 @@ def __init__( name: str | None = None, payload: Dict[str, Any] | None = None, is_final: bool = False, + upsert: bool = False, index: int | None = None, error: Optional[str] = None, scheduled_at: datetime | None = None, @@ -93,6 +95,7 @@ def __init__( "name": name, "payload": payload, "is_final": is_final, + "upsert": upsert, "index": index, "error": error, "scheduled_at": scheduled_at, @@ -127,6 +130,7 @@ def registration_info(self) -> Dict[str, Any]: "cost": self.cost, "payload": serialized_payload, "is_final": self.is_final, + "upsert": self.upsert, } if self.scheduled_at: info["scheduled_at"] = self.scheduled_at.isoformat() @@ -252,6 +256,24 @@ def update(self, updateCaseNode: CaseNodeUpdate, **kwargs: Any) -> None: storage = StorageManager() storage.save_object("Case", self.to_dict) + def patch_step(self, index: int, updateCaseNode: CaseNodeUpdate) -> None: + """Update an existing step at the given index instead of appending a new one. + + Sets upsert=True so Studio performs an update_or_create on the step at that index. + Use this when a later event should enrich or complete a previously sent step + (e.g. adding interview end time to the interview start step). + """ + updateCaseNode.index = index + updateCaseNode.upsert = True + self.account.send_update_case(self, updateCaseNode) + # Update the matching entry in the in-memory registry + for i, existing in enumerate(self.updates): + if existing.index == index: + self.updates[i] = updateCaseNode + break + storage = StorageManager() + storage.save_object("Case", self.to_dict) + def request_human_input( self, updateCaseNode: CaseNodeUpdate, message: str, **kwargs: Any ) -> None: diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index b21f840..9074125 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -4,19 +4,15 @@ # If a copy of the MPL was not distributed with this file, you can obtain one at # https://mozilla.org/MPL/2.0/. +import asyncio import traceback +from collections.abc import Awaitable, Callable from functools import wraps from pathlib import Path from typing import ( TYPE_CHECKING, Any, - Awaitable, - Callable, - Dict, - List, - Optional, TypeVar, - Union, ) from cryptography.hazmat.primitives import serialization @@ -57,14 +53,14 @@ class CaseUpdateRequest(SvBaseModel): """Request model for updating a case with answer to a question.""" - answer: Dict[str, Any] - message: Optional[str] = None + answer: dict[str, Any] + message: str | None = None def handle_route_errors( job_conflict_check: bool = False, ) -> Callable[ - [Callable[..., Awaitable[T]]], Callable[..., Awaitable[Union[T, JSONResponse]]] + [Callable[..., Awaitable[T]]], Callable[..., Awaitable[T | JSONResponse]] ]: """ Decorator to handle common route error patterns. @@ -76,9 +72,9 @@ def handle_route_errors( def decorator( func: Callable[..., Awaitable[T]], - ) -> Callable[..., Awaitable[Union[T, JSONResponse]]]: + ) -> Callable[..., Awaitable[T | JSONResponse]]: @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Union[T, JSONResponse]: + async def wrapper(*args: Any, **kwargs: Any) -> T | JSONResponse: # log.debug(f"------[DEBUG]----------\n args :{args} \n kwargs :{kwargs}") try: result: T = await func(*args, **kwargs) @@ -150,7 +146,7 @@ async def get_job_status(job_id: str) -> JobResponse: @router.get( "/jobs", - response_model=Dict[str, List[JobResponse]], + response_model=dict[str, list[JobResponse]], dependencies=[Security(server.verify_api_key)], ) @handle_route_errors() @@ -159,13 +155,13 @@ async def get_all_jobs( limit: int = Query( default=100, ge=1, le=1000, description="Number of jobs to return" ), - status: Optional[EntityStatus] = Query( + status: EntityStatus | None = Query( default=None, description="Filter jobs by status" ), - ) -> Dict[str, List[JobResponse]]: + ) -> dict[str, list[JobResponse]]: """Get all jobs across all agents with pagination and optional status filtering""" jobs_registry = Jobs() - all_jobs: Dict[str, List[JobResponse]] = {} + all_jobs: dict[str, list[JobResponse]] = {} for agent_name, agent_jobs in jobs_registry.jobs_by_agent.items(): filtered_jobs = list(agent_jobs.values()) @@ -204,9 +200,9 @@ async def get_all_jobs( "/jobs/{job_id}/cases/{case_id}/update", summary="Update case with answer to question", description="Provide an answer to a question that was requested by a case step", - response_model=Dict[str, str], + response_model=dict[str, str], responses={ - http_status.HTTP_200_OK: {"model": Dict[str, str]}, + http_status.HTTP_200_OK: {"model": dict[str, str]}, http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}, http_status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}, http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, @@ -218,36 +214,41 @@ async def update_case_with_answer( job_id: str, case_id: str, request: CaseUpdateRequest = Body(...), - ) -> Dict[str, str]: + ) -> dict[str, str]: """Update a case with an answer to a question requested by a case step""" log.info( f"📥 POST /jobs/{job_id}/cases/{case_id}/update [Update case with answer]" ) - # Get the job first - job = Jobs().get_job(job_id) - if not job: + jobs_registry = Jobs() + job = jobs_registry.get_job(job_id, include_persisted=False) + if job is None: + job = jobs_registry.get_job(job_id, include_persisted=True) + if job is None: raise HTTPException( status_code=http_status.HTTP_404_NOT_FOUND, - detail=f"Job with ID {job_id} not found §SRCU01", + detail=f"Job with ID {job_id} not found §SRCCWU01", ) - # Get the case from the Cases registry + # In-memory registry only (populated on job_start / startup reload). No case in + # registry ⇒ cannot apply the update or validate lifecycle — do not succeed or + # dispatch human_answer (matches workbench 404 on missing case). case = Cases().get_case(case_id, job_id) - if not case: - log.warning(f"Case with ID {case_id} not found for job {job_id} §SRCU02") + if case is None: + log.warning( + f"[Case update] Case {case_id} not in registry for job {job_id} — " + "stateless replica, wrong replica, or unknown id; rejecting update" + ) raise HTTPException( status_code=http_status.HTTP_404_NOT_FOUND, - detail=f"Case with ID {case_id} not found for job {job_id} §SRCU02", + detail=f"Case '{case_id}' not found §SRCCWU02", ) - # Check if the case is in AWAITING status (waiting for human input) + if case.status != EntityStatus.AWAITING: raise HTTPException( status_code=http_status.HTTP_400_BAD_REQUEST, detail=f"Case {case_id} is not awaiting input. Current status: {case.status.value} §SRC01", ) - - # Create a case node update with the answer update = CaseNodeUpdate( name="Human Input Response", payload={ @@ -257,15 +258,50 @@ async def update_case_with_answer( }, is_final=False, ) + casestep_index = request.answer.get("casestep_index") + if casestep_index is not None: + case.patch_step(int(casestep_index), update) + from supervaizer.lifecycle import EntityEvents + from supervaizer.storage import PersistentEntityLifecycle - # Update the case with the answer - # case.update(update) - Redundant, receive_human_input calls update() - - # Transition the case from AWAITING to IN_PROGRESS - case.receive_human_input(update) - - # TODO CALL CUSTOM HOOKS HERE - AS DEFINED IN THE AGENT CONFIGURATION - # TODO REDEFINE AGENT TO ADD CUSTOM HOOKS HERE + PersistentEntityLifecycle.handle_event(case, EntityEvents.INPUT_RECEIVED) + else: + case.receive_human_input(update) + case_status = case.status.value + + owning_agent = server.get_agent_by_name(job.agent_name) + if owning_agent and owning_agent.methods: + human_answer_def = getattr(owning_agent.methods, "human_answer", None) + if human_answer_def is not None: + human_answer_method = human_answer_def.method + answer_payload = request.answer + if isinstance(answer_payload, dict): + fields = { + k: v + for k, v in answer_payload.items() + if k != "casestep_index" + } + else: + fields = answer_payload + params: dict[str, Any] = { + "fields": fields, + "context": {"job_id": job_id, "case_id": case_id}, + "payload": answer_payload, + "case_id": case_id, + "job_id": job_id, + } + if request.message is not None: + params["message"] = request.message + try: + await asyncio.to_thread( + owning_agent._execute, + human_answer_method, + params, + ) + except Exception as hook_exc: + log.error( + f"[human_answer hook] {owning_agent.name}: {hook_exc}" + ) log.info( f"[Case update] Job {job_id}, Case {case_id} - Answer processed successfully" @@ -276,17 +312,17 @@ async def update_case_with_answer( "message": f"Answer received and processed for case {case_id} in job {job_id}", "job_id": job_id, "case_id": case_id, - "case_status": case.status.value, + "case_status": case_status, } - @router.get("/agents", response_model=List[AgentResponse]) + @router.get("/agents", response_model=list[AgentResponse]) @handle_route_errors() async def get_all_agents( skip: int = Query(default=0, ge=0, description="Number of jobs to skip"), limit: int = Query( default=100, ge=1, le=1000, description="Number of jobs to return" ), - ) -> List[AgentResponse]: + ) -> list[AgentResponse]: """Get all registered agents with pagination""" if not server: raise ValueError("Server instance not found") @@ -438,10 +474,10 @@ async def supervaize_instructions( "/validate-agent-parameters", summary=f"Validate agent parameters for agent: {agent.name}", description="Validate agent configuration parameters (secrets, API keys, etc.) before starting a job", - response_model=Dict[str, Any], + response_model=dict[str, Any], responses={ - http_status.HTTP_200_OK: {"model": Dict[str, Any]}, - http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]}, + http_status.HTTP_200_OK: {"model": dict[str, Any]}, + http_status.HTTP_400_BAD_REQUEST: {"model": dict[str, Any]}, http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, }, dependencies=[Security(server.verify_api_key)], @@ -450,7 +486,7 @@ async def supervaize_instructions( async def validate_agent_parameters( body_params: Any = Body(...), agent: Agent = Depends(get_agent), - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Validate agent parameters for this agent""" log.info( f"📥 POST /validate-agent-parameters [Validate agent parameters] {agent.name}" @@ -471,7 +507,7 @@ async def validate_agent_parameters( encrypted_agent_parameters = body_params.get("encrypted_agent_parameters") - agent_parameters: Dict[str, Any] = {} + agent_parameters: dict[str, Any] = {} if encrypted_agent_parameters: # Basic debug trace log.info( @@ -511,13 +547,13 @@ async def validate_agent_parameters( agent_parameters = {} except Exception as e: - log.error(f"❌ Decryption failed: {type(e).__name__}: {str(e)}") + log.error(f"❌ Decryption failed: {type(e).__name__}: {e!s}") result = { "valid": False, - "message": f"Failed to decrypt agent parameters: {str(e)}", - "errors": [f"Decryption failed: {str(e)}"], + "message": f"Failed to decrypt agent parameters: {e!s}", + "errors": [f"Decryption failed: {e!s}"], "invalid_parameters": { - "encrypted_agent_parameters": f"Decryption failed: {str(e)}" + "encrypted_agent_parameters": f"Decryption failed: {e!s}" }, } log.info(f"📤 Agent {agent.name}: Decryption failed → {result}") @@ -547,10 +583,10 @@ async def validate_agent_parameters( "/validate-method-fields", summary=f"Validate method fields for agent: {agent.name}", description="Validate job input fields against the method's field definitions before starting a job", - response_model=Dict[str, Any], + response_model=dict[str, Any], responses={ - http_status.HTTP_200_OK: {"model": Dict[str, Any]}, - http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]}, + http_status.HTTP_200_OK: {"model": dict[str, Any]}, + http_status.HTTP_400_BAD_REQUEST: {"model": dict[str, Any]}, http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, }, dependencies=[Security(server.verify_api_key)], @@ -559,7 +595,7 @@ async def validate_agent_parameters( async def validate_method_fields( body_params: Any = Body(...), agent: Agent = Depends(get_agent), - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Validate method fields for this agent""" log.info( f"📥 POST /validate-method-fields [Validate method fields] {agent.name}" @@ -622,9 +658,9 @@ async def validate_method_fields( "/start/dynamic_choices", summary=f"Get dynamic choices for agent: {agent.name} start method", description="Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context (including workspace slug) for contextualized choices.", - response_model=Dict[str, Any], + response_model=dict[str, Any], responses={ - http_status.HTTP_200_OK: {"model": Dict[str, Any]}, + http_status.HTTP_200_OK: {"model": dict[str, Any]}, http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}, http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, }, @@ -634,7 +670,7 @@ async def validate_method_fields( async def get_dynamic_choices( body_params: Any = Body(...), agent: Agent = Depends(get_agent), - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get dynamic choices for the start method fields.""" log.info(f"📥 POST /start/dynamic_choices [Dynamic choices] {agent.name}") @@ -675,7 +711,7 @@ async def get_dynamic_choices( description=f"{agent.methods.job_start.description}", responses={ http_status.HTTP_202_ACCEPTED: {"model": Job}, - http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]}, + http_status.HTTP_400_BAD_REQUEST: {"model": dict[str, Any]}, http_status.HTTP_409_CONFLICT: {"model": ErrorResponse}, http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, }, @@ -688,7 +724,7 @@ async def start_job( background_tasks: BackgroundTasks, body_params: Any = Body(...), agent: Agent = Depends(get_agent), - ) -> Union[Job, JSONResponse]: + ) -> Job | JSONResponse: """Start a new job for this agent""" log.info(f"📥 POST /jobs [Start job] {agent.name} with params {body_params}") @@ -721,9 +757,9 @@ async def start_job( "/jobs", summary=f"Get all jobs for agent: {agent.name}", description="Get all jobs for this agent with pagination and optional status filtering", - response_model=List[JobResponse], + response_model=list[JobResponse], responses={ - http_status.HTTP_200_OK: {"model": List[JobResponse]}, + http_status.HTTP_200_OK: {"model": list[JobResponse]}, http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, }, dependencies=[Security(server.verify_api_key)], @@ -738,7 +774,7 @@ async def get_agent_jobs( status: EntityStatus | None = Query( default=None, description="Filter jobs by status" ), - ) -> List[JobResponse] | JSONResponse: + ) -> list[JobResponse] | JSONResponse: """Get all jobs for this agent""" log.info(f"📥 GET /jobs [Get agent jobs] {agent.name}") jobs = list(Jobs().get_agent_jobs(agent.name).values()) @@ -858,8 +894,8 @@ async def status_agent( ) @handle_route_errors() async def server_update_agent( - onboarding_status: Optional[str] = Body(None), - parameters_encrypted: Optional[str] = Body(None), + onboarding_status: str | None = Body(None), + parameters_encrypted: str | None = Body(None), agent: Agent = Depends(get_agent), ) -> AgentResponse: log.info(f"📥 POST /server_update [Server updates agent] {agent.name}") @@ -907,7 +943,7 @@ async def get_agent() -> Agent: response_model=JobResponse, responses={ http_status.HTTP_202_ACCEPTED: {"model": JobResponse}, - http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]}, + http_status.HTTP_400_BAD_REQUEST: {"model": dict[str, Any]}, http_status.HTTP_405_METHOD_NOT_ALLOWED: {"model": ErrorResponse}, }, dependencies=[Security(server.verify_api_key)], @@ -918,7 +954,7 @@ async def custom_method_endpoint( background_tasks: BackgroundTasks, body_params: Any = Body(...), agent: Agent = Depends(get_agent), - ) -> Union[JobResponse, JSONResponse]: + ) -> JobResponse | JSONResponse: log.info( f"📥 POST /custom/{method_name} [custom job] {agent.name} with params {body_params}" ) diff --git a/tests/test_case.py b/tests/test_case.py index 609e14b..bf61450 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -341,3 +341,54 @@ class MockCall: assert update.payload.get("duration_seconds") == duration_seconds assert update.payload.get("extracted_values") == extracted_values assert update.payload.get("metadata") == metadata + + +def test_case_node_update_registration_info_includes_upsert() -> None: + """CaseNodeUpdate.registration_info exposes upsert for Studio payloads.""" + u = CaseNodeUpdate(name="step", payload={"k": "v"}) + assert u.registration_info["upsert"] is False + u.upsert = True + assert u.registration_info["upsert"] is True + + +def test_case_patch_step_replaces_in_memory_step_and_sets_upsert( + case_fixture: Case, + mocker: MockerFixture, +) -> None: + mocker.patch("supervaizer.account_service.send_event", return_value=None) + prior = CaseNodeUpdate(name="Prior", payload={"original": True}) + prior.index = 2 + case_fixture.updates = [prior] + + new_u = CaseNodeUpdate( + name="Human Input Response", + payload={"answer": {"x": 1}, "message": "m", "response_type": "human_input"}, + ) + case_fixture.patch_step(2, new_u) + + assert len(case_fixture.updates) == 1 + assert case_fixture.updates[0] is new_u + assert new_u.index == 2 + assert new_u.upsert is True + + +def test_case_patch_step_no_index_match_still_sends_but_keeps_registry_unchanged( + case_fixture: Case, + mocker: MockerFixture, +) -> None: + """Studio still receives the upsert; only the local updates list is left as-is if no index matches.""" + mock_send = mocker.patch( + "supervaizer.account_service.send_event", return_value=None + ) + prior = CaseNodeUpdate(name="Only", payload={}) + prior.index = 1 + case_fixture.updates = [prior] + + orphan = CaseNodeUpdate(name="Patch", payload={"p": 1}) + case_fixture.patch_step(99, orphan) + + assert mock_send.call_count == 1 + assert len(case_fixture.updates) == 1 + assert case_fixture.updates[0] is prior + assert orphan.index == 99 + assert orphan.upsert is True diff --git a/tests/test_routes_case_update.py b/tests/test_routes_case_update.py index bb2983e..481a91e 100644 --- a/tests/test_routes_case_update.py +++ b/tests/test_routes_case_update.py @@ -1,21 +1,54 @@ # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. # # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. -# If a copy of the MPL was not distributed with this file, you can obtain one at +# If a copy of the MPL was not distributed with this file, You can obtain one at # https://mozilla.org/MPL/2.0/. +from collections.abc import Generator from uuid import uuid4 +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient from pytest_mock import MockerFixture -from supervaizer import Account, Case, Job, Server +from supervaizer import ( + Account, + Agent, + AgentMethod, + AgentMethods, + Case, + CaseNodeUpdate, + Job, + JobResponse, + Parameter, + ParametersSetup, + Server, +) +from supervaizer.job import Jobs from supervaizer.lifecycle import EntityStatus +@pytest.fixture(autouse=True) +def _jobs_registry_isolation() -> Generator[None, None, None]: + Jobs().reset() + yield + Jobs().reset() + + +@pytest.fixture +def job_on_server(job_fixture: Job, server_fixture: Server) -> Job: + """Register job_fixture in Jobs() under the server's agent name (required for POST .../update).""" + # Server startup reloads persisted jobs into Jobs(); clear so this job is authoritative. + Jobs().reset() + job_fixture.agent_name = server_fixture.agents[0].name + Jobs().add_job(job_fixture) + return job_fixture + + def test_update_case_with_answer_success( server_fixture: Server, - job_fixture: Job, + job_on_server: Job, account_fixture: Account, mocker: MockerFixture, ) -> None: @@ -23,7 +56,7 @@ def test_update_case_with_answer_success( test_case = Case( id=f"test-case-{uuid4()}", - job_id=job_fixture.id, + job_id=job_on_server.id, account=account_fixture, status=EntityStatus.AWAITING, # Set to awaiting for testing name="Test Case", @@ -45,7 +78,7 @@ def test_update_case_with_answer_success( } response = client.post( - f"/supervaizer/jobs/{job_fixture.id}/cases/{test_case.id}/update", + f"/supervaizer/jobs/{job_on_server.id}/cases/{test_case.id}/update", headers=headers, # type: ignore json=request_data, ) @@ -53,7 +86,7 @@ def test_update_case_with_answer_success( assert response.status_code == 200 response_data = response.json() assert response_data["status"] == "success" - assert response_data["job_id"] == job_fixture.id + assert response_data["job_id"] == job_on_server.id assert response_data["case_id"] == test_case.id assert response_data["case_status"] == EntityStatus.IN_PROGRESS.value @@ -67,8 +100,57 @@ def test_update_case_with_answer_success( assert mock_send_event.call_count == 1 +def test_update_case_with_casestep_index_patches_step( + server_fixture: Server, + job_on_server: Job, + account_fixture: Account, + mocker: MockerFixture, +) -> None: + """answer.casestep_index routes to Case.patch_step (upsert) instead of receive_human_input.""" + test_case = Case( + id=f"test-case-upsert-{uuid4()}", + job_id=job_on_server.id, + account=account_fixture, + status=EntityStatus.AWAITING, + name="Test Case", + description="Test Case Description", + ) + prior = CaseNodeUpdate(name="Question", payload={"supervaizer_form": {"q": "?"}}) + prior.index = 1 + test_case.updates = [prior] + + client = TestClient(server_fixture.app) + headers = {"X-API-Key": server_fixture.api_key} + + mock_send_event = mocker.patch( + "supervaizer.account_service.send_event", return_value=None + ) + + request_data = { + "answer": {"field1": "value1", "casestep_index": 1}, + "message": "Human reply", + } + + response = client.post( + f"/supervaizer/jobs/{job_on_server.id}/cases/{test_case.id}/update", + headers=headers, # type: ignore + json=request_data, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["case_status"] == EntityStatus.IN_PROGRESS.value + + assert len(test_case.updates) == 1 + assert test_case.updates[0].upsert is True + assert test_case.updates[0].index == 1 + assert test_case.status == EntityStatus.IN_PROGRESS + assert mock_send_event.call_count == 1 + + def test_update_case_job_not_found(server_fixture: Server) -> None: - """Test case update when job is not found.""" + """Unknown job_id returns 404; human_answer is not dispatched.""" client = TestClient(server_fixture.app) headers = {"X-API-Key": server_fixture.api_key} @@ -83,14 +165,15 @@ def test_update_case_job_not_found(server_fixture: Server) -> None: ) assert response.status_code == 404 - assert "Job with ID nonexistent-job not found" in response.json()["detail"] + data = response.json() + assert "nonexistent-job" in data["detail"] def test_update_case_case_not_found( server_fixture: Server, - job_fixture: Job, + job_on_server: Job, ) -> None: - """Test case update when case is not found.""" + """Unknown case_id for the job returns 404; no success body or human_answer path.""" client = TestClient(server_fixture.app) headers = {"X-API-Key": server_fixture.api_key} @@ -99,28 +182,25 @@ def test_update_case_case_not_found( } response = client.post( - f"/supervaizer/jobs/{job_fixture.id}/cases/nonexistent-case/update", + f"/supervaizer/jobs/{job_on_server.id}/cases/nonexistent-case/update", headers=headers, json=request_data, ) assert response.status_code == 404 - assert ( - f"Case with ID nonexistent-case not found for job {job_fixture.id}" - in response.json()["detail"] - ) + assert "nonexistent-case" in response.json()["detail"] def test_update_case_not_awaiting_input( server_fixture: Server, - job_fixture: Job, + job_on_server: Job, account_fixture: Account, ) -> None: """Test case update when case is not in AWAITING status.""" test_case = Case( id=f"test-case-not-awaiting-{uuid4()}", - job_id=job_fixture.id, + job_id=job_on_server.id, account=account_fixture, status=EntityStatus.IN_PROGRESS, # Not awaiting name="Test Case", @@ -135,7 +215,7 @@ def test_update_case_not_awaiting_input( } response = client.post( - f"/supervaizer/jobs/{job_fixture.id}/cases/{test_case.id}/update", + f"/supervaizer/jobs/{job_on_server.id}/cases/{test_case.id}/update", headers=headers, json=request_data, ) @@ -145,13 +225,13 @@ def test_update_case_not_awaiting_input( def test_update_case_unauthorized( - server_fixture: Server, job_fixture: Job, account_fixture: Account + server_fixture: Server, job_on_server: Job, account_fixture: Account ) -> None: """Test case update without API key.""" test_case = Case( id=f"test-case-unauth-{uuid4()}", - job_id=job_fixture.id, + job_id=job_on_server.id, account=account_fixture, status=EntityStatus.AWAITING, name="Test Case", @@ -165,9 +245,223 @@ def test_update_case_unauthorized( } response = client.post( - f"/supervaizer/jobs/{job_fixture.id}/cases/{test_case.id}/update", + f"/supervaizer/jobs/{job_on_server.id}/cases/{test_case.id}/update", json=request_data, ) assert response.status_code == 401 assert "Not authenticated" in response.json()["detail"] + + +def test_human_answer_uses_workbench_style_params_and_strips_casestep_index( + server_fixture: Server, + job_on_server: Job, + account_fixture: Account, + mocker: MockerFixture, +) -> None: + """human_answer is invoked via Agent._execute with fields/context (not answer=).""" + ha = AgentMethod( + name="human_answer", + method="supervaizer.examples.hello_world_agent.human_answer", + params={}, + description="HITL", + is_async=False, + ) + agent = server_fixture.agents[0] + assert agent.methods is not None + agent.methods = agent.methods.model_copy(update={"human_answer": ha}) + + mock_execute = mocker.patch.object( + agent, + "_execute", + return_value=JobResponse( + job_id=job_on_server.id, + status=EntityStatus.IN_PROGRESS, + message="ok", + ), + ) + + test_case = Case( + id=f"test-case-ha-{uuid4()}", + job_id=job_on_server.id, + account=account_fixture, + status=EntityStatus.AWAITING, + name="Test Case", + description="Test", + ) + mocker.patch("supervaizer.account_service.send_event", return_value=None) + + client = TestClient(server_fixture.app) + headers = {"X-API-Key": server_fixture.api_key} + request_data = { + "answer": {"field1": "x", "casestep_index": 2}, + "message": "hi", + } + + response = client.post( + f"/supervaizer/jobs/{job_on_server.id}/cases/{test_case.id}/update", + headers=headers, + json=request_data, + ) + assert response.status_code == 200 + mock_execute.assert_called_once() + _method, params = mock_execute.call_args[0] + assert "human_answer" in _method + # casestep_index is stripped from fields for the hook only; payload keeps full answer + assert params["fields"] == {"field1": "x"} + assert params["context"] == {"job_id": job_on_server.id, "case_id": test_case.id} + assert params["payload"] == request_data["answer"] + assert params["job_id"] == job_on_server.id + assert params["case_id"] == test_case.id + assert params["message"] == "hi" + + +def test_human_answer_only_owning_agent_executed( + account_fixture: Account, + job_fixture: Job, + mocker: MockerFixture, +) -> None: + """Only the job owner's human_answer runs when multiple agents are registered.""" + ha = AgentMethod( + name="human_answer", + method="supervaizer.examples.hello_world_agent.human_answer", + params={}, + description="HITL", + is_async=False, + ) + stub_method = AgentMethod( + name="start", + method="start", + params={}, + description="s", + is_async=False, + ) + methods = AgentMethods( + job_start=stub_method, + job_stop=stub_method, + job_status=stub_method, + chat=None, + custom={"m1": stub_method}, + human_answer=ha, + ) + params_setup = ParametersSetup.from_list( + [Parameter(name="p", value="v", is_environment=True)] + ) + assert params_setup is not None + owner = Agent( + name="owner-agent", + author="a", + developer="d", + version="1", + description="d", + methods=methods, + parameters_setup=params_setup, + ) + other = Agent( + name="other-agent", + author="a", + developer="d", + version="1", + description="d", + methods=methods, + parameters_setup=params_setup, + ) + server = Server( + scheme="http", + host="localhost", + port=8011, + environment="test", + mac_addr="E2-AC-ED-22-BF-B2", + debug=True, + agent_timeout=10, + private_key=rsa.generate_private_key(public_exponent=65537, key_size=2048), + a2a_endpoints=True, + supervisor_account=account_fixture, + agents=[owner, other], + api_key="test-api-key-two", + ) + Jobs().reset() + job_fixture.agent_name = "owner-agent" + Jobs().add_job(job_fixture) + + test_case = Case( + id=f"case-owner-only-{uuid4()}", + job_id=job_fixture.id, + account=account_fixture, + status=EntityStatus.AWAITING, + name="c", + description="c", + ) + mocker.patch("supervaizer.account_service.send_event", return_value=None) + + mock_owner = mocker.patch.object( + owner, + "_execute", + return_value=JobResponse( + job_id=job_fixture.id, + status=EntityStatus.IN_PROGRESS, + message="ok", + ), + ) + mock_other = mocker.patch.object( + other, + "_execute", + return_value=JobResponse( + job_id=job_fixture.id, + status=EntityStatus.IN_PROGRESS, + message="ok", + ), + ) + + client = TestClient(server.app) + headers = {"X-API-Key": server.api_key} + response = client.post( + f"/supervaizer/jobs/{job_fixture.id}/cases/{test_case.id}/update", + headers=headers, + json={"answer": {"a": 1}}, + ) + assert response.status_code == 200 + mock_owner.assert_called_once() + mock_other.assert_not_called() + + +def test_human_answer_skipped_when_job_agent_not_on_server( + server_fixture: Server, + job_on_server: Job, + account_fixture: Account, + mocker: MockerFixture, +) -> None: + """If job.agent_name does not match any server agent, hook is skipped (no crash).""" + job_on_server.agent_name = "ghost-agent-not-on-server" + Jobs().reset() + Jobs().add_job(job_on_server) + + test_case = Case( + id=f"case-ghost-{uuid4()}", + job_id=job_on_server.id, + account=account_fixture, + status=EntityStatus.AWAITING, + name="c", + description="c", + ) + mocker.patch("supervaizer.account_service.send_event", return_value=None) + + spy = mocker.patch.object( + server_fixture.agents[0], + "_execute", + return_value=JobResponse( + job_id=job_on_server.id, + status=EntityStatus.IN_PROGRESS, + message="ok", + ), + ) + + client = TestClient(server_fixture.app) + headers = {"X-API-Key": server_fixture.api_key} + response = client.post( + f"/supervaizer/jobs/{job_on_server.id}/cases/{test_case.id}/update", + headers=headers, + json={"answer": {"x": 1}}, + ) + assert response.status_code == 200 + spy.assert_not_called()