From f6a33e5ca9079037341cef092f989d440bbcca4e Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Mon, 13 Apr 2026 14:30:19 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E2=9C=A8=20implement=20human=5Fans?= =?UTF-8?q?wer=20hook=20in=20HITL=20callback;=20handle=20missing=20Cases()?= =?UTF-8?q?=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/gitnexus/gitnexus-cli/SKILL.md | 82 ++++++++++++ .../gitnexus/gitnexus-debugging/SKILL.md | 89 +++++++++++++ .../gitnexus/gitnexus-exploring/SKILL.md | 78 +++++++++++ .../skills/gitnexus/gitnexus-guide/SKILL.md | 64 +++++++++ .../gitnexus-impact-analysis/SKILL.md | 97 ++++++++++++++ .../gitnexus/gitnexus-refactoring/SKILL.md | 121 ++++++++++++++++++ .gitignore | 1 + AGENTS.md | 102 +++++++++++++++ justfile | 3 + pyproject.toml | 2 +- src/supervaizer/__version__.py | 2 +- src/supervaizer/routes.py | 81 ++++++------ 12 files changed, 680 insertions(+), 42 deletions(-) create mode 100644 .claude/skills/gitnexus/gitnexus-cli/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-debugging/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-exploring/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-guide/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md 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/justfile b/justfile index d464ca5..6062bbe 100644 --- a/justfile +++ b/justfile @@ -48,6 +48,9 @@ mypy: env_sync: uv sync +env_upgrade: + uv sync -U + # Sync all dependencies - including dev dependencies env_sync_all: uv sync --all-extras diff --git a/pyproject.toml b/pyproject.toml index 7f81d74..b0067dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ mypy_path = "src" disallow_any_expr = false [tool.bumpversion] -current_version = "0.13.2" +current_version = "0.13.2.dev0" commit = true tag = true tag_name = "v{new_version}" diff --git a/src/supervaizer/__version__.py b/src/supervaizer/__version__.py index c4545c1..37b59fa 100644 --- a/src/supervaizer/__version__.py +++ b/src/supervaizer/__version__.py @@ -5,6 +5,6 @@ # https://mozilla.org/MPL/2.0/. -VERSION = "0.13.2" +VERSION = "0.13.2.dev0" API_VERSION = "v1" TELEMETRY_VERSION = "v1" diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index b21f840..3c15125 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -224,48 +224,49 @@ async def update_case_with_answer( 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: - raise HTTPException( - status_code=http_status.HTTP_404_NOT_FOUND, - detail=f"Job with ID {job_id} not found §SRCU01", - ) - - # Get the case from the Cases registry + # Try in-memory registry (populated on job_start; may be empty on Cloud Run replicas) 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") - raise HTTPException( - status_code=http_status.HTTP_404_NOT_FOUND, - detail=f"Case with ID {case_id} not found for job {job_id} §SRCU02", + if case is not None: + 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", + ) + update = CaseNodeUpdate( + name="Human Input Response", + payload={ + "answer": request.answer, + "message": request.message, + "response_type": "human_input", + }, + is_final=False, ) - # 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", + case.receive_human_input(update) + case_status = case.status.value + else: + log.warning( + f"[Case update] Case {case_id} not in registry for job {job_id} — " + "calling human_answer hook only (stateless replica)" ) - - # Create a case node update with the answer - update = CaseNodeUpdate( - name="Human Input Response", - payload={ - "answer": request.answer, - "message": request.message, - "response_type": "human_input", - }, - is_final=False, - ) - - # 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 + case_status = "unknown" + + # Call the agent's human_answer method if registered + import importlib + for sv_agent in server.agents: + if sv_agent.methods and sv_agent.methods.human_answer: + try: + method_path = sv_agent.methods.human_answer.method + module_name, func_name = method_path.rsplit(".", 1) + module = importlib.import_module(module_name) + func = getattr(module, func_name) + func( + job_id=job_id, + case_id=case_id, + answer=request.answer, + message=request.message, + ) + except Exception as _hook_exc: + log.error(f"[human_answer hook] {sv_agent.name}: {_hook_exc}") log.info( f"[Case update] Job {job_id}, Case {case_id} - Answer processed successfully" @@ -276,7 +277,7 @@ 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]) From 10fd87bb6b35a03718b073b647a4865ca61ef2c1 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Mon, 13 Apr 2026 18:34:19 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat(case):=20add=20upsert/patc?= =?UTF-8?q?h=20behavior=20for=20case=20stepsIntroduce=20an=20"upsert"=20ca?= =?UTF-8?q?pability=20to=20Case=20nodes=20route=20incominganswers=20that?= =?UTF-8?q?=20a=20casestep=5Findex=20to=20patch=20an=20existing=20stepinst?= =?UTF-8?q?ead=20of=20always=20appending=20a=20new=20one.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add up field to CaseUpdate dataclass include it in serialization so can perform update_create on steps. - Implement Case.patch_step(index, updateCaseNode) that marks the update as upsert, sends it to the account, updates the in-memory updates list, and persists the. - Route update requests with answer.casestep_index in routes.py to call case.patch_step(...) and emit an_RECEIVED lifecycle event via PersistentEntityLifecycle. Fall back to receiveuman_input(...) casestep_index is. - Add a unit test ensuring requests with casestep_index the specified stepupsert) of creating a new one. -ump version from0.13.2.dev0 to0.13.2 and commit the version. This enables enriching or completing a previously sent step(e.g. adding interview end time to start step) without creatingduplicate steps in. --- docs/CHANGELOG.md | 8 +++- pyproject.toml | 2 +- src/supervaizer/__version__.py | 2 +- src/supervaizer/case.py | 22 +++++++++ src/supervaizer/routes.py | 9 +++- tests/test_case.py | 49 ++++++++++++++++++++ tests/test_routes_case_update.py | 77 +++++++++++++++++++++++++++----- 7 files changed, 155 insertions(+), 14 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 204e440..f243ec3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,12 @@ 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. + ### 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). @@ -32,7 +38,7 @@ All notable changes to this project will be documented in this file. | ✅ Passed | 466 | | 🤔 Skipped | 0 | | 🔴 Failed | 0 | -| ⏱️ in | 54s | +| ⏱️ in | ~70s | ## v0.13.1 diff --git a/pyproject.toml b/pyproject.toml index b0067dd..7f81d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ mypy_path = "src" disallow_any_expr = false [tool.bumpversion] -current_version = "0.13.2.dev0" +current_version = "0.13.2" commit = true tag = true tag_name = "v{new_version}" diff --git a/src/supervaizer/__version__.py b/src/supervaizer/__version__.py index 37b59fa..c4545c1 100644 --- a/src/supervaizer/__version__.py +++ b/src/supervaizer/__version__.py @@ -5,6 +5,6 @@ # https://mozilla.org/MPL/2.0/. -VERSION = "0.13.2.dev0" +VERSION = "0.13.2" API_VERSION = "v1" TELEMETRY_VERSION = "v1" 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 3c15125..d7524a6 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -241,7 +241,14 @@ async def update_case_with_answer( }, is_final=False, ) - case.receive_human_input(update) + 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 + PersistentEntityLifecycle.handle_event(case, EntityEvents.INPUT_RECEIVED) + else: + case.receive_human_input(update) case_status = case.status.value else: log.warning( diff --git a/tests/test_case.py b/tests/test_case.py index 609e14b..86c3ff7 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -341,3 +341,52 @@ 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..38ea6f0 100644 --- a/tests/test_routes_case_update.py +++ b/tests/test_routes_case_update.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient from pytest_mock import MockerFixture -from supervaizer import Account, Case, Job, Server +from supervaizer import Account, Case, CaseNodeUpdate, Job, Server from supervaizer.lifecycle import EntityStatus @@ -67,8 +67,61 @@ 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_fixture: 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_fixture.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_fixture.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.""" + """When the case is not in the in-memory registry, the route still returns 200. + + Wrong or unknown job_id yields no Case match; the handler forwards to human_answer + hooks only (stateless replica / empty registry) with case_status unknown — not 404. + """ client = TestClient(server_fixture.app) headers = {"X-API-Key": server_fixture.api_key} @@ -82,15 +135,18 @@ def test_update_case_job_not_found(server_fixture: Server) -> None: json=request_data, ) - assert response.status_code == 404 - assert "Job with ID nonexistent-job not found" in response.json()["detail"] + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["case_status"] == "unknown" + assert data["job_id"] == "nonexistent-job" def test_update_case_case_not_found( server_fixture: Server, job_fixture: Job, ) -> None: - """Test case update when case is not found.""" + """When case_id is not registered for the job, registry miss — same as job_not_found path.""" client = TestClient(server_fixture.app) headers = {"X-API-Key": server_fixture.api_key} @@ -104,11 +160,12 @@ def test_update_case_case_not_found( 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 response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["case_status"] == "unknown" + assert data["case_id"] == "nonexistent-case" + assert data["job_id"] == job_fixture.id def test_update_case_not_awaiting_input( From f363422af2a01063224aabfbb43ff1c0827854a6 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Mon, 13 Apr 2026 18:35:25 +0300 Subject: [PATCH 3/4] precommit --- src/supervaizer/routes.py | 6 +++++- tests/test_case.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index d7524a6..6592035 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -246,7 +246,10 @@ async def update_case_with_answer( case.patch_step(int(casestep_index), update) from supervaizer.lifecycle import EntityEvents from supervaizer.storage import PersistentEntityLifecycle - PersistentEntityLifecycle.handle_event(case, EntityEvents.INPUT_RECEIVED) + + PersistentEntityLifecycle.handle_event( + case, EntityEvents.INPUT_RECEIVED + ) else: case.receive_human_input(update) case_status = case.status.value @@ -259,6 +262,7 @@ async def update_case_with_answer( # Call the agent's human_answer method if registered import importlib + for sv_agent in server.agents: if sv_agent.methods and sv_agent.methods.human_answer: try: diff --git a/tests/test_case.py b/tests/test_case.py index 86c3ff7..bf61450 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -377,7 +377,9 @@ def test_case_patch_step_no_index_match_still_sends_but_keeps_registry_unchanged 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) + mock_send = mocker.patch( + "supervaizer.account_service.send_event", return_value=None + ) prior = CaseNodeUpdate(name="Only", payload={}) prior.index = 1 case_fixture.updates = [prior] From 4a1723b7f7586d0e83cc6d3eb84ce4d1e61a1397 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Mon, 13 Apr 2026 19:43:23 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20feat:=20improve=20case=20update?= =?UTF-8?q?=20tests=20and=20job=20registry=20isolation-=20Add=20extensive?= =?UTF-8?q?=20imports=20fixtures=20to=20tests/test=5Froutes=5Fcase=5Fupdat?= =?UTF-8?q?e.py:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -ce pytest fixtures: _jobs_registryolation to reset Jobs() registry tests, and_on_server to register a Job under the server's agent so POST /jobs/{id}/cases/{id}/update resolves. - Import additional domain types (Agent, AgentMethod, AgentMethods, JobResponse,, ParametersSetup and Jobs helper to support and assertions. - Add cryptography and typing imports by fixtures. Update to use job_server fixture: - Replace direct use of job_fixture with job_server in tests that need an in-memory job registration so the route can locate the job. - Adjust expected job_id assertions to match job_on_server.id. - Clarify and tighten test expectations for error cases: - Change test_update_case_job_not_found docstring and assertions to expect a404 job is and assert the error detail includes the id. - Update test_update_case_not_found docstring to reflect404 behavior for unknown case_id under a known. - Minor formatting and comment fixes: - Fix capitalization in header. - Organize into a grouped style for readability. Why: - Ensure tests run deterministically by isolating the global Jobs() registry and by registering authoritative job under the server's agent name. This allows the update route to both success and failure paths correctly and makes assertions reflect the actual behavior (404 for missing resources). --- docs/CHANGELOG.md | 6 +- justfile | 34 ++-- src/supervaizer/routes.py | 206 +++++++++++---------- tests/test_routes_case_update.py | 303 +++++++++++++++++++++++++++---- 4 files changed, 407 insertions(+), 142 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f243ec3..02c86f2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,17 +25,21 @@ All notable changes to this project will be documented in this file. - **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 | ~70s | diff --git a/justfile b/justfile index 6062bbe..e43f711 100644 --- a/justfile +++ b/justfile @@ -45,14 +45,14 @@ mypy: uv run python -m pre_commit run mypy --all-files # Sync dependencies (from pyproject.toml) -env_sync: +install: uv sync -env_upgrade: +upgrade: uv sync -U # Sync all dependencies - including dev dependencies -env_sync_all: +install-all: uv sync --all-extras # build @@ -66,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" @@ -98,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) @@ -106,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) @@ -114,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 @@ -128,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 @@ -151,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/routes.py b/src/supervaizer/routes.py index 6592035..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,66 +214,94 @@ 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]" ) - # Try in-memory registry (populated on job_start; may be empty on Cloud Run replicas) - case = Cases().get_case(case_id, job_id) - if case is not None: - 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", - ) - update = CaseNodeUpdate( - name="Human Input Response", - payload={ - "answer": request.answer, - "message": request.message, - "response_type": "human_input", - }, - is_final=False, + 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 §SRCCWU01", ) - 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 - - PersistentEntityLifecycle.handle_event( - case, EntityEvents.INPUT_RECEIVED - ) - else: - case.receive_human_input(update) - case_status = case.status.value - else: + + # 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 case is None: log.warning( f"[Case update] Case {case_id} not in registry for job {job_id} — " - "calling human_answer hook only (stateless replica)" + "stateless replica, wrong replica, or unknown id; rejecting update" + ) + raise HTTPException( + status_code=http_status.HTTP_404_NOT_FOUND, + detail=f"Case '{case_id}' not found §SRCCWU02", ) - case_status = "unknown" - # Call the agent's human_answer method if registered - import importlib + 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", + ) + update = CaseNodeUpdate( + name="Human Input Response", + payload={ + "answer": request.answer, + "message": request.message, + "response_type": "human_input", + }, + 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 - for sv_agent in server.agents: - if sv_agent.methods and sv_agent.methods.human_answer: + 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: - method_path = sv_agent.methods.human_answer.method - module_name, func_name = method_path.rsplit(".", 1) - module = importlib.import_module(module_name) - func = getattr(module, func_name) - func( - job_id=job_id, - case_id=case_id, - answer=request.answer, - message=request.message, + 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}" ) - except Exception as _hook_exc: - log.error(f"[human_answer hook] {sv_agent.name}: {_hook_exc}") log.info( f"[Case update] Job {job_id}, Case {case_id} - Answer processed successfully" @@ -291,14 +315,14 @@ async def update_case_with_answer( "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") @@ -450,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)], @@ -462,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}" @@ -483,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( @@ -523,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}") @@ -559,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)], @@ -571,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}" @@ -634,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}, }, @@ -646,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}") @@ -687,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}, }, @@ -700,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}") @@ -733,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)], @@ -750,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()) @@ -870,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}") @@ -919,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)], @@ -930,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_routes_case_update.py b/tests/test_routes_case_update.py index 38ea6f0..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, CaseNodeUpdate, 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 @@ -69,14 +102,14 @@ def test_update_case_with_answer_success( def test_update_case_with_casestep_index_patches_step( server_fixture: Server, - job_fixture: Job, + 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_fixture.id, + job_id=job_on_server.id, account=account_fixture, status=EntityStatus.AWAITING, name="Test Case", @@ -99,7 +132,7 @@ def test_update_case_with_casestep_index_patches_step( } 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, ) @@ -117,11 +150,7 @@ def test_update_case_with_casestep_index_patches_step( def test_update_case_job_not_found(server_fixture: Server) -> None: - """When the case is not in the in-memory registry, the route still returns 200. - - Wrong or unknown job_id yields no Case match; the handler forwards to human_answer - hooks only (stateless replica / empty registry) with case_status unknown — not 404. - """ + """Unknown job_id returns 404; human_answer is not dispatched.""" client = TestClient(server_fixture.app) headers = {"X-API-Key": server_fixture.api_key} @@ -135,18 +164,16 @@ def test_update_case_job_not_found(server_fixture: Server) -> None: json=request_data, ) - assert response.status_code == 200 + assert response.status_code == 404 data = response.json() - assert data["status"] == "success" - assert data["case_status"] == "unknown" - assert data["job_id"] == "nonexistent-job" + assert "nonexistent-job" in data["detail"] def test_update_case_case_not_found( server_fixture: Server, - job_fixture: Job, + job_on_server: Job, ) -> None: - """When case_id is not registered for the job, registry miss — same as job_not_found path.""" + """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} @@ -155,29 +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 == 200 - data = response.json() - assert data["status"] == "success" - assert data["case_status"] == "unknown" - assert data["case_id"] == "nonexistent-case" - assert data["job_id"] == job_fixture.id + assert response.status_code == 404 + 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", @@ -192,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, ) @@ -202,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", @@ -222,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()