diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 283f3506..cf884429 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -2,7 +2,16 @@ //! (`modelcontextprotocol/rust-sdk`, published to crates.io — a normal //! dependency, not ported code; no /NOTICE entry needed). +mod artifact; +mod asset; +mod claim; +mod comment; +mod flare_git; +mod handoff; mod item; +mod memory_tool; +mod review; +mod types; use crate::optimize; use crate::progress::{PROGRESS_SENDER, ProgressSender}; @@ -24,421 +33,7 @@ use rmcp::{ use rusqlite::OptionalExtension; use serde::Deserialize; -#[derive(Debug, Deserialize, schemars::JsonSchema)] -struct GetRoutingSuggestionRequest { - #[schemars(description = "The user's prompt to analyze")] - prompt: String, -} - -#[derive(Debug, Deserialize, schemars::JsonSchema)] -struct CheckSessionHealthRequest { - #[schemars(description = "The session ID to check")] - session_id: String, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct SkillRequest { - #[schemars(description = "Action: search|load")] - action: String, - #[schemars(description = "What you need to do; keyword-style works best (search)")] - #[serde(default)] - query: Option, - #[schemars(description = "Skill name; qualify as 'source:name' if ambiguous (load)")] - #[serde(default)] - name: Option, - #[schemars(description = "Max results (default 5) (search)")] - #[serde(default)] - limit: Option, - #[schemars( - description = "'all' = every word must match (default); 'any' = broader recall for retries (search)" - )] - #[serde(default)] - mode: Option, - #[schemars(description = "true = load the original even when a compressed copy exists (load)")] - #[serde(default)] - original: bool, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct ToolRequest { - #[schemars(description = "Action: search|execute")] - action: String, - #[schemars(description = "What tool you need; keyword-style works best (search)")] - #[serde(default)] - query: Option, - #[schemars(description = "Max results (default 5) (search)")] - #[serde(default)] - limit: Option, - #[schemars( - description = "'all' = every word must match (default); 'any' = broader recall for retries (search)" - )] - #[serde(default)] - mode: Option, - #[schemars(description = "Server name from the search action (execute)")] - #[serde(default)] - server: Option, - #[schemars(description = "Tool name from the search action (execute)")] - #[serde(default)] - tool: Option, - // A bare `serde_json::Value` here made schemars emit a typeless schema - // (Value can be anything), so callers had no signal to send a nested - // JSON object rather than a stringified one — execute couldn't actually - // be invoked with arguments. `Map` renders as `{"type": ["object", - // "null"]}`, a real hint. - #[schemars(description = "Arguments object matching the tool's input_schema (execute)")] - #[serde(default)] - args: Option>, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct ClaimRequest { - #[schemars(description = "Action: acquire|done|heartbeat|list|release")] - action: String, - #[schemars(description = "Target to claim, e.g. \"issue#42\" or \"pr#7\"")] - #[serde(default)] - target: Option, - #[schemars(description = "Repo key owner/name (default: normalized origin remote)")] - #[serde(default)] - repo: Option, - #[schemars(description = "Include stale and done claims (default false) (list)")] - #[serde(default)] - all: bool, - #[schemars(description = "List across every repo in the ledger (default false) (list)")] - #[serde(default)] - all_repos: bool, -} - -#[derive(Debug, Deserialize, schemars::JsonSchema)] -struct ChannelSendRequest { - #[schemars(description = "Platform to send to: telegram, slack, or discord")] - platform: String, - #[schemars(description = "Recipient id: Telegram chat_id, or Slack/Discord channel id")] - target: String, - #[schemars(description = "The message text to send")] - message: String, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct ReviewRequest { - #[schemars(description = "Action: clear|consensus|list|record|scores|submit")] - action: String, - #[schemars( - description = "Findings, each {file, line, message, severity?, category?} (submit)" - )] - #[serde(default)] - findings: Option>, - #[schemars(description = "Review round id (default: current branch)")] - #[serde(default)] - pr: Option, - #[schemars(description = "Finder name (default: detected agent) (submit)")] - #[serde(default)] - agent: Option, - #[schemars(description = "Diff base ref (default: master) (consensus, record)")] - #[serde(default)] - base: Option, - #[schemars(description = "Diff head ref (default: HEAD) (consensus, record)")] - #[serde(default)] - head: Option, - #[schemars(description = "Repo key owner/name (default: origin remote)")] - #[serde(default)] - repo: Option, - #[schemars(description = "Aggregate across every repo (default false) (scores)")] - #[serde(default)] - all_repos: bool, -} - -/// A handoff assigns an item to another agent and attaches the work product -/// to it as an asset. Unlike a bare item update, `recipient` is a required -/// field, not `Option` — the schema itself makes an unaddressed handoff -/// unrepresentable, so an intended handoff can't silently land with no -/// assignee. Re-attaching under the same `item_id` (or the same generated -/// filename on a freshly created item) becomes the next asset version, not -/// a duplicate. -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct HandoffRequest { - #[schemars( - description = "Agent/runtime this handoff is addressed to — becomes the item's assignee_agent. Required." - )] - recipient: String, - #[schemars( - description = "Short name/brief for the handoff — the item's name when creating one" - )] - name: String, - #[schemars( - description = "The work product being handed off (diff, review, document, ...). Prepend the brief so the recipient knows the ask. Attached to the item as an asset." - )] - content: String, - #[schemars( - description = "html | markdown | mermaid | diagram | text (default: markdown) — picks the attached asset's extension/mime type" - )] - #[serde(default)] - r#type: Option, - #[schemars( - description = "Existing item ID to assign and attach to, instead of creating a new item" - )] - #[serde(default)] - item_id: Option, - #[schemars( - description = "Handoff thread to continue; omit to start a new one. Stored in the new item's metadata, or the attached asset's metadata when item_id is given." - )] - #[serde(default)] - thread_id: Option, - #[schemars( - description = "Id this replies to (when answering an inbox item) — stored in the attached asset's metadata for provenance" - )] - #[serde(default)] - reply_to: Option, - #[schemars( - description = "One-line description; used as the new item's description when creating one" - )] - #[serde(default)] - description: Option, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct ArtifactRequest { - #[schemars(description = "Action: delete|diff|get|list|publish|search")] - action: String, - #[schemars(description = "Artifact id")] - #[serde(default)] - id: Option, - #[schemars(description = "Display name of the artifact (publish)")] - #[serde(default)] - name: Option, - #[schemars( - description = "html | markdown | mermaid | diagram | text (default: text) (publish)" - )] - #[serde(default)] - r#type: Option, - #[schemars( - description = "Full artifact content (HTML document, markdown source, plain text, ...) (publish)" - )] - #[serde(default)] - content: Option, - #[schemars(description = "Session ID for grouping artifacts (optional)")] - #[serde(default)] - session_id: Option, - #[schemars( - description = "Existing artifact id to update in place — keeps the same URL and live-reloads open viewers (publish)" - )] - #[serde(default)] - update_id: Option, - #[schemars( - description = "Short label for this version, shown in history (e.g. \"draft\", \"final\") (publish)" - )] - #[serde(default)] - label: Option, - #[schemars(description = "One-line description shown in the gallery (publish)")] - #[serde(default)] - description: Option, - #[schemars(description = "One or two emoji used as the page icon (publish)")] - #[serde(default)] - favicon: Option, - #[schemars( - description = "Optimistic-concurrency guard: update only applies if the artifact's current version equals this; otherwise a version-conflict error is returned (publish)" - )] - #[serde(default)] - base_version: Option, - #[schemars( - description = "Handoff envelope: agent/runtime this artifact is addressed to — for WORK PRODUCTS only; facts and decisions belong in memory (memory_remember), not artifacts (publish)" - )] - #[serde(default)] - recipient: Option, - #[schemars( - description = "Handoff envelope: thread this belongs to; replies reuse the sender's thread_id (publish)" - )] - #[serde(default)] - thread_id: Option, - #[schemars(description = "Handoff envelope: artifact id this replies to (publish)")] - #[serde(default)] - reply_to: Option, - #[schemars(description = "Older version number to diff from (diff)")] - #[serde(default)] - from_version: Option, - #[schemars(description = "Newer version number (omit for latest) (diff)")] - #[serde(default)] - to_version: Option, - #[schemars( - description = "Case-insensitive text to find in names, descriptions, or content (search)" - )] - #[serde(default)] - query: Option, - #[schemars(description = "Specific version to fetch (omit for latest) (get)")] - #[serde(default)] - version: Option, - #[schemars( - description = "Inbox filter: only artifacts addressed to this agent/runtime (list)" - )] - #[serde(default)] - inbox_recipient: Option, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct OptimizeRequest { - #[schemars(description = "Action: retrieve | list")] - action: String, - #[serde(default)] - #[schemars(description = "Registered compression id (required for retrieve)")] - id: Option, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct MemoryRequest { - #[schemars(description = "Action: compact|context|curate|handoff|recall|relate|remember")] - action: String, - #[schemars(description = "Title of the observation (remember)")] - #[serde(default)] - title: Option, - #[schemars(description = "Content body of the observation (remember, curate)")] - #[serde(default)] - content: Option, - #[schemars( - description = "Type: decision|bugfix|discovery|pattern|learning|manual (remember, recall)" - )] - #[serde(default)] - r#type: Option, - #[schemars(description = "Session ID to associate with")] - #[serde(default)] - session_id: Option, - #[schemars(description = "Project name")] - #[serde(default)] - project: Option, - #[schemars(description = "Stable topic key for upsert dedup (remember)")] - #[serde(default)] - topic_key: Option, - #[schemars(description = "Scope: project (default) or personal (remember)")] - #[serde(default)] - scope: Option, - #[schemars(description = "Search query (FTS5 BM25); omit for recent listing (recall)")] - #[serde(default)] - query: Option, - #[schemars(description = "Direct lookup by ID (recall)")] - #[serde(default)] - id: Option, - #[schemars(description = "Max results (default 10, max 50) (recall)")] - #[serde(default)] - limit: Option, - #[schemars(description = "Session summary (handoff)")] - #[serde(default)] - summary: Option, - #[schemars(description = "Findings array [{file, line?, summary}] (handoff)")] - #[serde(default)] - findings: Option>, - #[schemars(description = "Decisions array [{summary, rationale?}] (handoff)")] - #[serde(default)] - decisions: Option>, - #[schemars(description = "Files touched array [{path, modified?, tokens}] (handoff)")] - #[serde(default)] - files_touched: Option>, - #[schemars(description = "Evidence array [{kind, action, detail}] (handoff)")] - #[serde(default)] - evidence: Option>, - #[schemars(description = "Source observation ID (relate)")] - #[serde(default)] - source_id: Option, - #[schemars(description = "Target observation ID (relate)")] - #[serde(default)] - target_id: Option, - #[schemars( - description = "Relation: related|compatible|scoped|conflicts_with|supersedes|not_conflict (relate)" - )] - #[serde(default)] - relation: Option, - #[schemars(description = "Reason for the relation (relate)")] - #[serde(default)] - reason: Option, - #[schemars(description = "Confidence score 0.0..1.0 (relate)")] - #[serde(default)] - confidence: Option, - #[schemars(description = "Pin status (curate pin/unpin actions)")] - #[serde(default)] - pinned: Option, - #[schemars(description = "Sub-action for curate: update|delete|pin|unpin")] - #[serde(default)] - curate_action: Option, - #[schemars(description = "Target fraction of lines to keep (0.0-1.0, compact)")] - #[serde(default)] - compression_ratio: Option, - #[schemars(description = "Keep N most recent messages verbatim (compact)")] - #[serde(default)] - preserve_recent: Option, - #[schemars(description = "Scorer backend: fts5 (compact)")] - #[serde(default)] - scorer: Option, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct GitHubRequest { - #[schemars( - description = "Action: pr_create|pr_list|pr_get|pr_merge|pr_comment|pr_request_review|issue_create|issue_list|issue_get|issue_comment|issue_close|issue_label|release_list|release_get|release_latest|release_create|run_list|run_get|run_rerun|workflow_dispatch" - )] - action: String, - #[schemars(description = "owner/repo (default: resolved from the current repo's origin)")] - #[serde(default)] - repo: Option, - #[schemars(description = "PR number (pr_get, pr_merge, pr_comment, pr_request_review)")] - #[serde(default)] - number: Option, - #[schemars(description = "PR title (pr_create)")] - #[serde(default)] - title: Option, - #[schemars(description = "Head branch (pr_create)")] - #[serde(default)] - head: Option, - #[schemars(description = "Base branch (pr_create)")] - #[serde(default)] - base: Option, - #[schemars(description = "Body / comment text (pr_create, pr_comment)")] - #[serde(default)] - body: Option, - #[schemars(description = "State filter for pr_list: open|closed|all (default open)")] - #[serde(default)] - state: Option, - #[schemars(description = "Merge method for pr_merge: merge|squash|rebase (default merge)")] - #[serde(default)] - merge_method: Option, - #[schemars(description = "Reviewer logins (pr_request_review)")] - #[serde(default)] - reviewers: Option>, - #[schemars(description = "Labels (issue_create, issue_label)")] - #[serde(default)] - labels: Option>, - #[schemars(description = "Assignee logins (issue_create)")] - #[serde(default)] - assignees: Option>, - #[schemars(description = "Release id (release_get)")] - #[serde(default)] - release_id: Option, - #[schemars(description = "Git tag (release_create)")] - #[serde(default)] - tag: Option, - #[schemars(description = "Release name (release_create)")] - #[serde(default)] - name: Option, - #[schemars(description = "Mark release as draft (release_create, default false)")] - #[serde(default)] - draft: Option, - #[schemars(description = "Mark release as prerelease (release_create, default false)")] - #[serde(default)] - prerelease: Option, - #[schemars(description = "Workflow run id (run_get, run_rerun)")] - #[serde(default)] - run_id: Option, - #[schemars(description = "Branch filter for run_list")] - #[serde(default)] - branch: Option, - #[schemars(description = "Workflow file name or id (workflow_dispatch)")] - #[serde(default)] - workflow: Option, - #[schemars( - description = "Git ref to dispatch against (workflow_dispatch, default: repo default branch)" - )] - #[serde(default)] - git_ref: Option, - #[schemars(description = "JSON inputs object for workflow_dispatch")] - #[serde(default)] - inputs: Option, -} +use types::*; #[derive(Default)] pub struct AgentflareMcp { @@ -497,442 +92,6 @@ pub struct AgentflareMcp { worktree_repo_root_override: Option, } -/// All local artifact backends (flared, another session, or our own -/// owned server) bind loopback-only — never advertise anything else. -const LOCAL_HOST: &str = "127.0.0.1"; -/// flared's default HTTP port; its artifact routes live under /artifacts. -const FLARED_DEFAULT_PORT: u16 = 35273; -const FLARED_ARTIFACTS_PATH: &str = "/artifacts/"; - -/// flared's HTTP port: honor a `port` override in its config.toml when -/// readable (a `--port` CLI override is invisible here and lands on the -/// fixed-port fallback chain); default otherwise. -fn flared_port() -> u16 { - dirs::config_dir() - .map(|dir| dir.join("flared").join("config.toml")) - .and_then(|path| std::fs::read_to_string(path).ok()) - .and_then(|text| parse_flared_port(&text)) - .unwrap_or(FLARED_DEFAULT_PORT) -} - -/// Extract the top-level `port` key from flared's config.toml text — a -/// minimal scan that avoids a toml dependency for one key. Absent or -/// malformed values -> None. -fn parse_flared_port(text: &str) -> Option { - for line in text.lines() { - let line = line.trim(); - if line.starts_with('[') { - // TOML top-level keys end at the first table header - return None; - } - if let Some(rest) = line.strip_prefix("port") - && let Some(value) = rest.trim_start().strip_prefix('=') - { - return value - .trim() - .split(|c: char| c == '#' || c.is_whitespace()) - .next() - .and_then(|v| v.parse().ok()); - } - } - None -} - -/// How artifact pages reach the browser for this process. -enum ArtifactBackend { - /// This process owns the listener. - Owned(agentflare_artifacts::ArtifactServer), - /// Another process serves the shared store: flared under /artifacts on - /// its fixed port, or an earlier session's root-mounted server. - External { port: u16, path: &'static str }, -} - -impl ArtifactBackend { - /// Base URL artifact links hang off (no trailing slash). - fn base_url(&self) -> String { - match self { - ArtifactBackend::Owned(server) => server.base_url(), - ArtifactBackend::External { port, path } => { - format!("http://{LOCAL_HOST}:{port}{}", path.trim_end_matches('/')) - } - } - } -} - -// --- agentflare-backend MCP tools ----------------------------------------- -// -// Workspace is fully hidden: exactly one per system, auto-created lazily on -// first use. Project is Vercel-style auto-linked: -// `.agentflare/project.json` at the repo root maps this checkout to -// a project, created on first use and re-linked (never duplicated — see -// `resolve_project`) if the link file goes missing. Neither workspace_id nor -// project_id is ever an MCP-exposed parameter; every tool resolves -// both from cwd/git context. - -/// The `.agentflare/project.json` link file's shape. -#[derive(Debug, serde::Serialize, serde::Deserialize)] -struct ProjectLink { - workspace_id: String, - project_id: String, - identifier: String, -} - -/// Default 4h — item claims are plausibly longer-running than -/// `src/claims.rs`'s 30-min GitHub-issue-claim default, hence a separate env -/// var rather than sharing `AGENTFLARE_CLAIM_TTL_SECS`. -fn backend_claim_ttl_secs() -> i64 { - std::env::var("AGENTFLARE_BACKEND_CLAIM_TTL_SECS") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(14400) as i64 -} - -/// NotFound/Duplicate/InvalidTransition are caller-fixable → invalid_params; -/// a raw database error is ours to fix → internal_error. Same split as -/// `skill_load`'s NotFound/Ambiguous handling above. -fn map_backend_err(e: agentflare_backend::Error) -> ErrorData { - match e { - agentflare_backend::Error::NotFound(msg) - | agentflare_backend::Error::Duplicate(msg) - | agentflare_backend::Error::InvalidTransition(msg) - | agentflare_backend::Error::Validation(msg) => ErrorData::invalid_params(msg, None), - agentflare_backend::Error::Database(e) => ErrorData::internal_error(e.to_string(), None), - } -} - -/// Maps a `GitHubError` to MCP `ErrorData`: client/auth mistakes become -/// `invalid_params`, transport/parse failures become `internal_error`. -fn to_mcp_error(err: crate::github::GitHubError) -> ErrorData { - let msg = err.to_string(); - if crate::github::mcp::is_client_error(&err) { - ErrorData::invalid_params(msg, None) - } else { - ErrorData::internal_error(msg, None) - } -} - -/// Converts the unified dispatch-layer error type once, at whichever `?` -/// first needs an `ErrorData` — lets internal helpers (`with_fresh_registry`, -/// `claim_db`, `resolve_workspace_id`, ...) chain heterogeneous fallible -/// steps with `?` instead of mapping each one to `ErrorData` individually. -impl From for ErrorData { - fn from(e: crate::errors::AgentflareError) -> Self { - match e { - crate::errors::AgentflareError::Backend(e) => map_backend_err(e), - other => ErrorData::internal_error(other.to_string(), None), - } - } -} - -/// 24 random bytes, hex-encoded — used as a webhook's HMAC signing secret -/// when the caller doesn't supply one. -fn generate_webhook_secret() -> String { - use rand::Rng; - let bytes: [u8; 24] = rand::thread_rng().r#gen(); - hex::encode(bytes) -} - -fn base64_encode(bytes: &[u8]) -> String { - use base64::engine::general_purpose; - general_purpose::STANDARD.encode(bytes) -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct ItemRequest { - #[schemars( - description = "Action: create|get|list|search|update|update_state|delete|claim|heartbeat|release|done|cancel|add_label|remove_label|groom|standup|health" - )] - action: String, - #[schemars( - description = "Item ID (required for get, update, update_state, delete, claim, heartbeat, release, done, add_label, remove_label)" - )] - #[serde(default)] - id: Option, - #[schemars(description = "Item name/title (required for create)")] - #[serde(default)] - name: Option, - #[schemars( - description = "State ID (create, update_state); omit to use the project's default (Backlog) state" - )] - #[serde(default)] - state_id: Option, - #[schemars(description = "Markdown description body (create, update)")] - #[serde(default)] - description: Option, - #[schemars(description = "Priority: none|low|medium|high|urgent (create, update)")] - #[serde(default)] - priority: Option, - #[schemars(description = "Parent item ID, for sub-items (create)")] - #[serde(default)] - parent_id: Option, - #[schemars( - description = "Agent ID to assign (create, update), or to filter by (list — matches items assigned to this agent plus unassigned ones, sorted open+assigned-to-you first)" - )] - #[serde(default)] - assignee_agent: Option, - #[schemars( - description = "Domain-specific fields as a JSON object (create, update). Set {\"size\": \"S\"|\"M\"|\"L\"} so `groom` can score effort instead of reporting the item unestimated." - )] - #[serde(default)] - metadata: Option, - #[schemars(description = "Label IDs to attach on creation (create)")] - #[serde(default)] - label_ids: Option>, - #[schemars(description = "Item IDs this item depends on (create)")] - #[serde(default)] - dependency_ids: Option>, - #[schemars(description = "Label ID (add_label, remove_label)")] - #[serde(default)] - label_id: Option, - #[schemars( - description = "Filter by state group (list); one of backlog|unstarted|started|completed|cancelled|triage, or a comma-separated list (e.g. \"backlog,unstarted,started\") to match any" - )] - #[serde(default)] - state_group: Option, - #[schemars( - description = "Max items to return (list: omit for no limit; search: omit for 20, capped at 1000; groom: omit for 15, capped at 200)" - )] - #[serde(default)] - limit: Option, - #[schemars(description = "Items to skip before applying limit (list); default 0")] - #[serde(default)] - offset: Option, - #[schemars(description = "FTS5 search query (search)")] - #[serde(default)] - query: Option, - #[schemars( - description = "Days since updated_at before an item counts as stale/stuck (groom: default 14; standup/health: default 7)" - )] - #[serde(default)] - staleness_days: Option, - #[schemars( - description = "Now-bucket size (groom only) — when set, additionally buckets the shortlist into now/next/later/needs_estimation for sprint planning" - )] - #[serde(default)] - capacity: Option, - #[schemars( - description = "Hours back a completed item counts as \"done\" (standup); default 24" - )] - #[serde(default)] - cutoff_hours: Option, - #[schemars(description = "Trailing weekly windows for velocity (health); default 4, max 52")] - #[serde(default)] - window_weeks: Option, -} - -/// Lean per-item projection for `item(list)` — the raw 19-field `Item` (full -/// description/metadata/timestamps) is what `get` returns; `list` only needs -/// enough to triage, and resolves the opaque `state_id` into a readable name. -#[derive(Debug, serde::Serialize)] -struct ItemSummary { - id: String, - name: String, - state: String, - state_group: String, - priority: String, - assignee_agent: Option, - parent_id: Option, - sequence_id: i64, - updated_at: i64, -} - -/// One shortlisted item plus the decision-support signals `groom` computes -/// server-side (staleness, blocking, fan-in, near-duplicates) so the caller -/// doesn't have to re-derive them by eyeballing timestamps and free text. -#[derive(Debug, serde::Serialize)] -struct GroomItem { - id: String, - sequence_id: i64, - name: String, - description: String, - state: String, - state_group: String, - priority: String, - assignee_agent: Option, - updated_at: i64, - stale: bool, - unassigned: bool, - /// Parsed from `metadata.size` ("S"|"M"|"L"); `None` when absent — see `unestimated`. - size: Option, - /// True when `metadata.size` is missing — add a size label to enable real RICE scoring. - unestimated: bool, - /// IDs this item depends on that are still open (not completed/cancelled). - blocked_by: Vec, - /// How many other items declare a dependency on this one. - depended_on_by_count: i64, - /// Other shortlisted items with a near-identical name (token-Jaccard ≥ 0.5). - possible_duplicates: Vec, -} - -/// One-call groom result: priority+staleness-ranked shortlist with all the -/// flags a human/agent needs to make pull-next decisions, computed in Rust -/// instead of costing N `get` round trips + manual LLM staleness/dup checks. -#[derive(Debug, serde::Serialize)] -struct GroomResponse { - staleness_days: i64, - stale_count: usize, - unassigned_count: usize, - unestimated_count: usize, - items: Vec, - /// Top unassigned, not-stale, unblocked items from the shortlist. - pull_next: Vec, - /// Only populated when the `capacity` request field is set. - #[serde(skip_serializing_if = "Option::is_none")] - now: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - next: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - later: Option>, - /// Unestimated items — excluded from now/next/later, can't be planned yet. - #[serde(skip_serializing_if = "Option::is_none")] - needs_estimation: Option>, -} - -/// Lean per-item row for `standup` — no description, matches `ItemSummary`'s -/// thin-projection philosophy since standup doesn't need item bodies. -#[derive(Debug, Clone, serde::Serialize)] -struct StandupItem { - id: String, - sequence_id: i64, - name: String, - priority: String, - assignee_agent: Option, - updated_at: i64, -} - -#[derive(Debug, serde::Serialize)] -struct StandupGroup { - /// The literal string "unassigned" when `assignee_agent` is null. - assignee: String, - items: Vec, -} - -#[derive(Debug, serde::Serialize)] -struct StandupResponse { - cutoff_hours: i64, - stuck_days: i64, - done: Vec, - done_count: usize, - in_progress: Vec, - in_progress_count: usize, - stuck: Vec, - stuck_count: usize, -} - -#[derive(Debug, serde::Serialize)] -struct VelocityWeek { - week_start: i64, - week_end: i64, - completed_count: usize, -} - -#[derive(Debug, serde::Serialize)] -struct HealthResponse { - window_weeks: i64, - /// Oldest → newest. - velocity: Vec, - /// "up" | "down" | "flat" — last window vs. the one before it. - velocity_trend: String, - wip_count: usize, - wip: Vec, - stuck_days: i64, - stuck_count: usize, - stuck: Vec, - /// Empty today — agentflare has no persisted handoff log distinct from - /// item state, so this can't be computed yet (see `bottleneck_note`). - bottlenecks: Vec, - bottleneck_note: String, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct CommentRequest { - #[schemars(description = "Action: create|edit|delete|list")] - action: String, - #[schemars(description = "Item ID to comment on (required for create, list)")] - #[serde(default)] - item_id: Option, - #[schemars(description = "Comment ID (required for edit, delete)")] - #[serde(default)] - id: Option, - #[schemars(description = "Comment body text (required for create, edit)")] - #[serde(default)] - body: Option, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct LabelRequest { - #[schemars(description = "Action: create|list|update|delete")] - action: String, - #[schemars(description = "Label ID (required for update, delete)")] - #[serde(default)] - id: Option, - #[schemars(description = "Label name (required for create; optional for update)")] - #[serde(default)] - name: Option, - #[schemars(description = "Hex color, e.g. #F59E0B (create, update)")] - #[serde(default)] - color: Option, - #[schemars(description = "Parent label ID for nesting/grouping (create)")] - #[serde(default)] - parent_id: Option, - #[schemars(description = "Sort order for manual ordering (create, update)")] - #[serde(default)] - sort_order: Option, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct WebhookRequest { - #[schemars(description = "Action: create|list|delete")] - action: String, - #[schemars(description = "Webhook ID (required for delete)")] - #[serde(default)] - id: Option, - #[schemars(description = "HTTPS/HTTP URL to deliver events to (required for create)")] - #[serde(default)] - url: Option, - #[schemars(description = "HMAC signing secret; auto-generated if omitted (create)")] - #[serde(default)] - secret: Option, - #[schemars(description = "Fire on item create/update/delete (create)")] - #[serde(default)] - on_item: Option, - #[schemars(description = "Fire on state changes (create)")] - #[serde(default)] - on_state: Option, - #[schemars(description = "Fire on project changes (create)")] - #[serde(default)] - on_project: Option, -} - -#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] -struct ProjectRequest { - #[schemars(description = "Action: info")] - action: String, -} - -#[derive(Debug, Deserialize, schemars::JsonSchema)] -struct AssetRequest { - #[schemars(description = "Action: attach|get|list|delete")] - action: String, - #[schemars(description = "Asset ID (required for get, delete)")] - #[serde(default)] - id: Option, - #[schemars(description = "Item ID to attach to (xor project_id)")] - #[serde(default)] - item_id: Option, - #[schemars(description = "Project ID to attach to (xor item_id)")] - #[serde(default)] - project_id: Option, - #[schemars( - description = "Filename (required for attach) — must exist in ~/.agentflare/staging/" - )] - #[serde(default)] - filename: Option, - #[schemars(description = "JSON metadata (optional, attach only)")] - #[serde(default)] - metadata: Option, -} - #[tool_router] impl AgentflareMcp { #[tool( @@ -1247,291 +406,13 @@ impl AgentflareMcp { description = "Artifact operations — publish, list, get, diff, search, or delete. Single consolidated tool with `action` field (delete|diff|get|list|publish|search)." )] fn artifact(&self, Parameters(req): Parameters) -> Result { - match req.action.as_str() { - "publish" => { - let name = req - .name - .ok_or_else(|| ErrorData::invalid_params("name is required", None))?; - if name.trim().is_empty() { - return Err(ErrorData::invalid_params("name is required", None)); - } - let content = req - .content - .ok_or_else(|| ErrorData::invalid_params("content is required", None))?; - if content.is_empty() { - return Err(ErrorData::invalid_params("content is required", None)); - } - let (store, base) = self.ensure_artifact_server()?; - let req2 = agentflare_artifacts::PublishRequest { - name, - artifact_type: agentflare_artifacts::ArtifactType::from( - req.r#type.as_deref().unwrap_or("text"), - ), - content, - session_id: req.session_id.unwrap_or_default(), - update_id: req.update_id, - label: req.label, - description: req.description, - favicon: req.favicon, - base_version: req.base_version, - sender: self.agent.clone(), - recipient: req.recipient, - thread_id: req.thread_id, - reply_to: req.reply_to, - git: Self::git_provenance(), - }; - let resp = store.publish(&req2).map_err(Self::artifact_error)?; - Ok(serde_json::to_string_pretty(&serde_json::json!({ "id": resp.id, "version": resp.version, "url": format!("{base}/{}", resp.id), "index": format!("{base}/") })).unwrap_or_default()) - } - "list" => { - let (store, base) = self.ensure_artifact_server()?; - let summaries = store - .list(req.session_id.as_deref()) - .map_err(Self::artifact_error)?; - let items: Vec = summaries - .iter() - .filter(|s| { - req.inbox_recipient - .as_deref() - .is_none_or(|r| s.recipient.as_deref() == Some(r)) - && req - .thread_id - .as_deref() - .is_none_or(|t| s.thread_id.as_deref() == Some(t)) - }) - .map(|s| { - let mut v = serde_json::to_value(s).unwrap_or_default(); - if let Some(obj) = v.as_object_mut() { - obj.insert("url".into(), serde_json::json!(format!("{base}/{}", s.id))); - } - v - }) - .collect(); - Ok(serde_json::to_string_pretty(&items).unwrap_or_default()) - } - "get" => { - let id = req - .id - .ok_or_else(|| ErrorData::invalid_params("id is required", None))?; - let (store, _) = self.ensure_artifact_server()?; - let artifact = match req.version { - Some(n) => store.get_version(&id, n), - None => store.get(&id), - } - .map_err(Self::artifact_error)?; - Ok(serde_json::to_string_pretty(&artifact).unwrap_or_default()) - } - "diff" => { - let id = req - .id - .ok_or_else(|| ErrorData::invalid_params("id is required", None))?; - let from_version = req - .from_version - .ok_or_else(|| ErrorData::invalid_params("from_version is required", None))?; - let (store, _) = self.ensure_artifact_server()?; - let to = match req.to_version { - Some(v) => v, - None => store.get(&id).map_err(Self::artifact_error)?.version, - }; - let diff = store - .diff(&id, from_version, to) - .map_err(Self::artifact_error)?; - Ok(serde_json::to_string_pretty(&diff).unwrap_or_default()) - } - "search" => { - let query = req - .query - .ok_or_else(|| ErrorData::invalid_params("query is required", None))?; - if query.trim().is_empty() { - return Err(ErrorData::invalid_params("query is required", None)); - } - let (store, base) = self.ensure_artifact_server()?; - let needle = query.to_lowercase(); - let mut hits = Vec::new(); - for summary in store - .list(req.session_id.as_deref()) - .map_err(Self::artifact_error)? - { - let name_hit = summary.name.to_lowercase().contains(&needle); - let desc_hit = summary - .description - .as_deref() - .is_some_and(|d| d.to_lowercase().contains(&needle)); - let content = store - .get(&summary.id) - .map(|a| a.content) - .unwrap_or_default(); - let content_pos = content.to_lowercase().find(&needle); - if !(name_hit || desc_hit || content_pos.is_some()) { - continue; - } - let snippet = content_pos.map(|pos| { - let mut start = pos.saturating_sub(40); - while !content.is_char_boundary(start) { - start -= 1; - } - let mut end = (pos + needle.len() + 40).min(content.len()); - while !content.is_char_boundary(end) { - end += 1; - } - content[start..end].to_string() - }); - let mut v = serde_json::to_value(&summary).unwrap_or_default(); - if let Some(obj) = v.as_object_mut() { - obj.insert( - "url".into(), - serde_json::json!(format!("{base}/{}", summary.id)), - ); - if let Some(snippet) = snippet { - obj.insert("snippet".into(), serde_json::json!(snippet)); - } - } - hits.push(v); - } - Ok(serde_json::to_string_pretty(&hits).unwrap_or_default()) - } - "delete" => { - let id = req - .id - .ok_or_else(|| ErrorData::invalid_params("id is required", None))?; - let (store, _) = self.ensure_artifact_server()?; - store.delete(&id).map_err(Self::artifact_error)?; - Ok(serde_json::json!({"deleted": id}).to_string()) - } - other => Err(ErrorData::invalid_params( - format!("unknown action: {other}"), - None, - )), - } + self.artifact_impl(req) } #[tool( description = "Hand a work product to another agent: assigns/creates an item for the recipient (in the repo's linked project) and attaches the content to it as an asset. Re-attaching under the same item_id creates the next asset version, not a duplicate. Sender is this runtime's own identity." )] - fn handoff( - &self, - Parameters(HandoffRequest { - recipient, - name, - content, - r#type, - item_id, - thread_id, - reply_to, - description, - }): Parameters, - ) -> Result { - if recipient.trim().is_empty() { - return Err(ErrorData::invalid_params( - "recipient is required for a handoff — without it the item lands with no assignee", - None, - )); - } - if name.trim().is_empty() { - return Err(ErrorData::invalid_params("name is required", None)); - } - if content.is_empty() { - return Err(ErrorData::invalid_params("content is required", None)); - } - let recipient = recipient.trim().to_string(); - let name = name.trim().to_string(); - let ext = match r#type.as_deref() { - Some("html") => "html", - Some("mermaid") | Some("diagram") => "mmd", - Some("text") => "txt", - _ => "md", - }; - - self.with_backend_db(|conn| { - let project = self.resolve_project(conn)?; - let ws_id = Self::resolve_workspace_id(conn)?; - - let item = match &item_id { - Some(id) => { - let input = agentflare_backend::item::UpdateItem { - assignee_agent: Some(recipient.clone()), - ..Default::default() - }; - agentflare_backend::item::update(conn, id, input).map_err(map_backend_err)? - } - None => { - let state_id = agentflare_backend::state::list_by_project(conn, &project.id) - .map_err(map_backend_err)? - .into_iter() - .find(|s| s.is_default) - .ok_or_else(|| { - ErrorData::internal_error("project has no default state", None) - })? - .id; - let metadata = thread_id - .as_ref() - .map(|t| serde_json::json!({ "thread": t }).to_string()); - let input = agentflare_backend::item::CreateItem { - project_id: project.id.clone(), - state_id, - name: name.clone(), - description: description.clone().or_else(|| Some(content.clone())), - priority: None, - parent_id: None, - assignee_agent: Some(recipient.clone()), - sort_order: None, - external_source: None, - external_id: None, - metadata, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }; - agentflare_backend::item::create(conn, input).map_err(map_backend_err)? - } - }; - - let bytes = content.as_bytes(); - let hash = Self::content_hash(bytes); - // Keyed on item.id, not name — name is the per-call brief and - // can legitimately differ between messages on the same item - // (e.g. a reply's brief vs. the original ask); keying on it - // would silently reset versioning to 1 instead of continuing - // the chain. - let safe_stem = Self::slugify(&item.id); - let filename = format!("{safe_stem}.{ext}"); - let full_storage = format!("{ws_id}/assets/{safe_stem}-{hash}.{ext}"); - let base_path = crate::paths::home().join(".agentflare"); - let target = base_path.join(&full_storage); - if !target.exists() { - agentflare_backend::asset::write_file(&base_path, &full_storage, bytes) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - } - let mut meta = serde_json::json!({ "sender": self.agent, "recipient": recipient }); - if let Some(t) = &thread_id { - meta["thread_id"] = serde_json::json!(t); - } - if let Some(r) = &reply_to { - meta["reply_to"] = serde_json::json!(r); - } - let asset = agentflare_backend::asset::create( - conn, - agentflare_backend::asset::CreateAsset { - workspace_id: Some(ws_id), - entity_type: "item_attachment".into(), - entity_id: item.id.clone(), - filename, - size: bytes.len() as i64, - mime_type: Some(Self::infer_mime_type(ext)), - metadata: Some(meta.to_string()), - storage_path: Some(full_storage), - }, - ) - .map_err(map_backend_err)?; - - let result = serde_json::json!({ - "item_id": item.id, - "item_sequence_id": item.sequence_id, - "asset_id": asset.id, - "asset_version": asset.version, - "recipient": recipient, - }); - Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) - })? + fn handoff(&self, Parameters(req): Parameters) -> Result { + self.handoff_impl(req) } /// Runs `git` in the current cwd; None on any failure (not a repo, git @@ -1875,94 +756,7 @@ impl AgentflareMcp { description = "Manage work claims — acquire, heartbeat, release, done, or list. Single consolidated tool with `action` field (acquire|done|heartbeat|list|release)." )] fn claim(&self, Parameters(req): Parameters) -> Result { - match req.action.as_str() { - "acquire" => { - let target = req - .target - .ok_or_else(|| ErrorData::invalid_params("target is required", None))?; - let repo_opt = req.repo; - let repo_overridden = repo_opt.as_ref().is_some_and(|r| !r.is_empty()); - let (conn, repo) = Self::claim_ctx(&target, repo_opt)?; - let owner = crate::claims::owner_id(); - let commit = if repo_overridden { - None - } else { - Self::git_provenance().and_then(|g| g.commit) - }; - let outcome = crate::claims::acquire( - &conn, - &repo, - &target, - &owner, - commit.as_deref(), - crate::claims::now(), - crate::claims::ttl_secs(), - ) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok(match outcome { - crate::claims::Acquire::Acquired => serde_json::json!({ "status": "acquired", "repo": repo, "target": target, "owner": owner }), - crate::claims::Acquire::Held { owner: holder, age_secs } => serde_json::json!({ "status": "held", "repo": repo, "target": target, "owner": holder, "age_secs": age_secs }), - }.to_string()) - } - "heartbeat" => { - let target = req - .target - .ok_or_else(|| ErrorData::invalid_params("target is required", None))?; - let (conn, repo) = Self::claim_ctx(&target, req.repo)?; - let owner = crate::claims::owner_id(); - let ok = - crate::claims::heartbeat(&conn, &repo, &target, &owner, crate::claims::now()) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok( - serde_json::json!({ "refreshed": ok, "repo": repo, "target": target }) - .to_string(), - ) - } - "release" => { - let target = req - .target - .ok_or_else(|| ErrorData::invalid_params("target is required", None))?; - let (conn, repo) = Self::claim_ctx(&target, req.repo)?; - let owner = crate::claims::owner_id(); - let ok = crate::claims::release(&conn, &repo, &target, &owner) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok( - serde_json::json!({ "released": ok, "repo": repo, "target": target }) - .to_string(), - ) - } - "done" => { - let target = req - .target - .ok_or_else(|| ErrorData::invalid_params("target is required", None))?; - let (conn, repo) = Self::claim_ctx(&target, req.repo)?; - let owner = crate::claims::owner_id(); - let ok = crate::claims::done(&conn, &repo, &target, &owner, crate::claims::now()) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok(serde_json::json!({ "done": ok, "repo": repo, "target": target }).to_string()) - } - "list" => { - let conn = Self::claim_db()?; - let scope = if req.all_repos { - None - } else { - Some(crate::claims::resolve_repo(req.repo).ok_or_else(|| ErrorData::invalid_params("could not determine repo — run in a git repo or pass repo=owner/name (or all_repos=true)", None))?) - }; - let claims = crate::claims::list( - &conn, - scope.as_deref(), - req.all, - crate::claims::now(), - crate::claims::ttl_secs(), - ) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok(serde_json::to_string_pretty(&claims).unwrap_or_default()) - } - other => Err(ErrorData::invalid_params( - format!("unknown action: {other}"), - None, - )), - } + self.claim_impl(req) } /// Opens the ledger db. fn claim_db() -> crate::errors::Result { @@ -1991,108 +785,7 @@ impl AgentflareMcp { description = "Review operations — submit findings, run consensus, list/clear/record rounds, check scores. Single consolidated tool with `action` field (clear|consensus|list|record|scores|submit)." )] fn review(&self, Parameters(req): Parameters) -> Result { - match req.action.as_str() { - "submit" => { - let findings = req - .findings - .ok_or_else(|| ErrorData::invalid_params("findings is required", None))?; - let conn = Self::claim_db()?; - let repo = Self::resolve_repo_or_err(req.repo)?; - let pr = Self::resolve_round(req.pr)?; - // SECURITY / step-3 classification (#75): the finder `agent` - // stays caller-settable BY DESIGN. Unlike artifact authorship, - // review findings live in a local, per-repo, single-user DB, and - // a `/code-review` orchestrator legitimately submits on behalf - // of many finder sub-agents — consensus counts DISTINCT finder - // names, so collapsing them to one server identity would break - // it. No cross-principal trust boundary exists here; the - // server-derived `submitter_name` is the fallback when unset. - let agent = req - .agent - .filter(|s| !s.is_empty()) - .unwrap_or_else(crate::review::submitter_name); - let parsed: Vec = findings - .into_iter() - .map(serde_json::from_value) - .collect::>() - .map_err(|e| { - ErrorData::invalid_params(format!("invalid finding: {e}"), None) - })?; - let n = - crate::review::submit(&conn, &repo, &pr, &agent, &parsed, crate::claims::now()) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok( - serde_json::json!({ "submitted": n, "repo": repo, "pr": pr, "agent": agent }) - .to_string(), - ) - } - "consensus" => { - let conn = Self::claim_db()?; - let repo = Self::resolve_repo_or_err(req.repo)?; - let pr = Self::resolve_round(req.pr)?; - let findings = crate::review::load(&conn, &repo, &pr) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let diff = crate::review::compute_diff(req.base.as_deref(), req.head.as_deref()) - .map_err(|e| ErrorData::invalid_params(e, None))?; - let changed = crate::review::changed_lines(&diff); - let result = crate::review::consensus(&findings, &changed); - Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) - } - "list" => { - let conn = Self::claim_db()?; - let repo = Self::resolve_repo_or_err(req.repo)?; - let pr = Self::resolve_round(req.pr)?; - let findings = crate::review::load(&conn, &repo, &pr) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let rows: Vec = findings.iter().map(|sf| serde_json::json!({ "agent": sf.agent, "file": sf.finding.file, "line": sf.finding.line, "message": sf.finding.message, "severity": sf.finding.severity })).collect(); - Ok(serde_json::to_string_pretty(&rows).unwrap_or_default()) - } - "clear" => { - let conn = Self::claim_db()?; - let repo = Self::resolve_repo_or_err(req.repo)?; - let pr = Self::resolve_round(req.pr)?; - crate::review::clear(&conn, &repo, &pr) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok(serde_json::json!({"cleared": true}).to_string()) - } - "record" => { - let conn = Self::claim_db()?; - let repo = Self::resolve_repo_or_err(req.repo)?; - let pr = Self::resolve_round(req.pr)?; - let findings = crate::review::load(&conn, &repo, &pr) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let diff = crate::review::compute_diff(req.base.as_deref(), req.head.as_deref()) - .map_err(|e| ErrorData::invalid_params(e, None))?; - let changed = crate::review::changed_lines(&diff); - let n = crate::review::record_round( - &conn, - &repo, - &pr, - &findings, - &changed, - crate::claims::now(), - ) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok(serde_json::json!({ "recorded_agents": n, "repo": repo, "pr": pr }).to_string()) - } - "scores" => { - let conn = Self::claim_db()?; - let repo = req.repo; - let all_repos = req.all_repos; - let scope = if all_repos { - None - } else { - Some(Self::resolve_repo_or_err(repo)?) - }; - let scores = crate::review::scores(&conn, scope.as_deref()) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok(serde_json::to_string_pretty(&scores).unwrap_or_default()) - } - other => Err(ErrorData::invalid_params( - format!("unknown action: {other}"), - None, - )), - } + self.review_impl(req) } fn resolve_repo_or_err(repo: Option) -> Result { crate::claims::resolve_repo(repo).ok_or_else(|| { @@ -2309,123 +1002,7 @@ impl AgentflareMcp { description = "Memory operations — compact, context, curate, handoff, recall, relate, or remember observations. Single consolidated tool with `action` field (compact|context|curate|handoff|recall|relate|remember)." )] fn memory(&self, Parameters(req): Parameters) -> Result { - match req.action.as_str() { - "remember" => { - let title = req - .title - .ok_or_else(|| ErrorData::invalid_params("title is required", None))?; - let content = req - .content - .ok_or_else(|| ErrorData::invalid_params("content is required", None))?; - let r#type = req - .r#type - .ok_or_else(|| ErrorData::invalid_params("type is required", None))?; - let input = crate::memory::mcp::RememberInput { - title, - content, - r#type, - session_id: req.session_id, - project: req.project, - topic_key: req.topic_key, - scope: req.scope, - }; - crate::memory::mcp::handle_remember(input) - .map_err(|e| ErrorData::internal_error(e, None)) - } - "recall" => { - let input = crate::memory::mcp::RecallInput { - query: req.query, - id: req.id, - r#type: req.r#type, - project: req.project, - limit: req.limit, - }; - crate::memory::mcp::handle_recall(input) - .map_err(|e| ErrorData::internal_error(e, None)) - } - "context" => { - let input = crate::memory::mcp::ContextInput { - session_id: req.session_id, - project: req.project, - }; - crate::memory::mcp::handle_context(input) - .map_err(|e| ErrorData::internal_error(e, None)) - } - "handoff" => { - let session_id = req - .session_id - .ok_or_else(|| ErrorData::invalid_params("session_id is required", None))?; - let summary = req - .summary - .ok_or_else(|| ErrorData::invalid_params("summary is required", None))?; - let input = crate::memory::mcp::HandoffInput { - session_id, - summary, - findings: req.findings, - decisions: req.decisions, - files_touched: req.files_touched, - evidence: req.evidence, - }; - crate::memory::mcp::handle_handoff(input) - .map_err(|e| ErrorData::internal_error(e, None)) - } - "compact" => { - let input = crate::memory::mcp::CompactInput { - lines: req.content.unwrap_or_default(), - query: req.query, - compression_ratio: req.compression_ratio, - preserve_recent: req.preserve_recent, - scorer: req.scorer, - }; - crate::memory::mcp::handle_compact(input) - .map_err(|e| ErrorData::internal_error(e, None)) - } - "relate" => { - let source_id = req - .source_id - .ok_or_else(|| ErrorData::invalid_params("source_id is required", None))?; - let target_id = req - .target_id - .ok_or_else(|| ErrorData::invalid_params("target_id is required", None))?; - let relation = req - .relation - .ok_or_else(|| ErrorData::invalid_params("relation is required", None))?; - let input = crate::memory::mcp::RelateInput { - source_id, - target_id, - relation, - reason: req.reason, - confidence: req.confidence, - }; - crate::memory::mcp::handle_relate(input) - .map_err(|e| ErrorData::internal_error(e, None)) - } - "curate" => { - let id = req - .id - .ok_or_else(|| ErrorData::invalid_params("id is required", None))?; - let curate_action = req.curate_action.ok_or_else(|| { - ErrorData::invalid_params( - "curate_action is required (update|delete|pin|unpin)", - None, - ) - })?; - let input = crate::memory::mcp::CurateInput { - action: curate_action, - id, - title: req.title, - content: req.content, - r#type: req.r#type, - pinned: req.pinned, - }; - crate::memory::mcp::handle_curate(input) - .map_err(|e| ErrorData::internal_error(e, None)) - } - other => Err(ErrorData::invalid_params( - format!("unknown action: {other}"), - None, - )), - } + self.memory_impl(req) } /// Rejects PR titles that don't start with a conventional-commit type, @@ -2452,255 +1029,7 @@ impl AgentflareMcp { description = "GitHub repo management via the flare_git module. Single action-dispatch tool: action=pr_create|pr_list|pr_get|pr_merge|pr_comment|pr_request_review|issue_create|issue_list|issue_get|issue_comment|issue_close|issue_label|release_list|release_get|release_latest|release_create|run_list|run_get|run_rerun|workflow_dispatch. Uses gh/GITHUB_TOKEN credentials; repo defaults to the current repo's origin." )] fn flare_git(&self, Parameters(req): Parameters) -> Result { - use crate::github::{Client, RepoId, actions, issues, pulls, releases}; - - let repo = match &req.repo { - Some(r) => RepoId::parse(r) - .ok_or_else(|| ErrorData::invalid_params(format!("bad repo: {r}"), None))?, - None => RepoId::resolve_from_remote(&std::env::current_dir().unwrap_or_default()) - .ok_or_else(|| { - ErrorData::invalid_params( - "no repo given and could not resolve origin remote".to_string(), - None, - ) - })?, - }; - let client = Client::new().map_err(to_mcp_error)?; - - let out = match req.action.as_str() { - "pr_create" => { - let title = req - .title - .as_deref() - .ok_or_else(|| ErrorData::invalid_params("title is required", None))?; - Self::validate_conventional_pr_title(title) - .map_err(|e| ErrorData::invalid_params(e, None))?; - let head = req - .head - .as_deref() - .ok_or_else(|| ErrorData::invalid_params("head is required", None))?; - let base = req - .base - .as_deref() - .ok_or_else(|| ErrorData::invalid_params("base is required", None))?; - let pr = pulls::create(&client, &repo, title, head, base, req.body.as_deref()) - .map_err(to_mcp_error)?; - format!("Opened PR #{}: {}", pr.number, pr.html_url) - } - "pr_list" => { - let state = req.state.as_deref().unwrap_or("open"); - let prs = pulls::list(&client, &repo, state).map_err(to_mcp_error)?; - serde_json::to_string(&prs.iter().map(|p| &p.html_url).collect::>()) - .unwrap_or_default() - } - "pr_get" => { - let n = req - .number - .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; - let pr = pulls::get(&client, &repo, n).map_err(to_mcp_error)?; - format!( - "PR #{} [{}] {}: {}", - pr.number, pr.state, pr.title, pr.html_url - ) - } - "pr_merge" => { - let n = req - .number - .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; - let method = req.merge_method.as_deref().unwrap_or("merge"); - pulls::merge(&client, &repo, n, method).map_err(to_mcp_error)?; - format!("Merged PR #{n} ({method})") - } - "pr_comment" => { - let n = req - .number - .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; - let body = req - .body - .as_deref() - .ok_or_else(|| ErrorData::invalid_params("body is required", None))?; - pulls::comment(&client, &repo, n, body).map_err(to_mcp_error)?; - format!("Commented on PR #{n}") - } - "pr_request_review" => { - let n = req - .number - .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; - let reviewers = req.reviewers.clone().unwrap_or_default(); - pulls::request_review(&client, &repo, n, &reviewers).map_err(to_mcp_error)?; - format!("Requested review on PR #{n}") - } - "issue_create" => { - let title = req - .title - .as_deref() - .ok_or_else(|| ErrorData::invalid_params("title is required", None))?; - let labels = req.labels.clone().unwrap_or_default(); - let assignees = req.assignees.clone().unwrap_or_default(); - let issue = issues::create( - &client, - &repo, - title, - req.body.as_deref(), - &labels, - &assignees, - ) - .map_err(to_mcp_error)?; - format!("Opened issue #{}: {}", issue.number, issue.html_url) - } - "issue_list" => { - let state = req.state.as_deref().unwrap_or("open"); - let items = issues::list(&client, &repo, state).map_err(to_mcp_error)?; - serde_json::to_string(&items.iter().map(|i| &i.html_url).collect::>()) - .unwrap_or_default() - } - "issue_get" => { - let n = req - .number - .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; - let issue = issues::get(&client, &repo, n).map_err(to_mcp_error)?; - format!( - "Issue #{} [{}] {}: {}", - issue.number, issue.state, issue.title, issue.html_url - ) - } - "issue_comment" => { - let n = req - .number - .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; - let body = req - .body - .as_deref() - .ok_or_else(|| ErrorData::invalid_params("body is required", None))?; - issues::comment(&client, &repo, n, body).map_err(to_mcp_error)?; - format!("Commented on issue #{n}") - } - "issue_close" => { - let n = req - .number - .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; - let issue = issues::close(&client, &repo, n).map_err(to_mcp_error)?; - format!("Closed issue #{} [{}]", issue.number, issue.state) - } - "issue_label" => { - let n = req - .number - .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; - let labels = req.labels.clone().unwrap_or_default(); - issues::add_labels(&client, &repo, n, &labels).map_err(to_mcp_error)?; - format!("Added {} label(s) to issue #{n}", labels.len()) - } - "release_list" => { - let rels = releases::list(&client, &repo).map_err(to_mcp_error)?; - serde_json::to_string(&rels.iter().map(|r| &r.tag_name).collect::>()) - .unwrap_or_default() - } - "release_get" => { - let id = req - .release_id - .ok_or_else(|| ErrorData::invalid_params("release_id is required", None))?; - let rel = releases::get(&client, &repo, id).map_err(to_mcp_error)?; - format!( - "Release {} [{}]: {}", - rel.tag_name, - if rel.prerelease { "pre" } else { "stable" }, - rel.html_url - ) - } - "release_latest" => { - let rel = releases::latest(&client, &repo).map_err(to_mcp_error)?; - format!("Latest: {} — {}", rel.tag_name, rel.html_url) - } - "release_create" => { - let tag = req - .tag - .as_deref() - .ok_or_else(|| ErrorData::invalid_params("tag is required", None))?; - let rel = releases::create( - &client, - &repo, - tag, - req.name.as_deref(), - req.body.as_deref(), - req.draft.unwrap_or(false), - req.prerelease.unwrap_or(false), - ) - .map_err(to_mcp_error)?; - format!("Created release {}: {}", rel.tag_name, rel.html_url) - } - "run_list" => { - let runs = actions::list_runs(&client, &repo, req.branch.as_deref()) - .map_err(to_mcp_error)?; - let summary: Vec = runs - .iter() - .map(|r| { - format!( - "{} {} {}", - r.id, - r.status, - r.conclusion.as_deref().unwrap_or("-") - ) - }) - .collect(); - serde_json::to_string(&summary).unwrap_or_default() - } - "run_get" => { - let id = req - .run_id - .ok_or_else(|| ErrorData::invalid_params("run_id is required", None))?; - let run = actions::get_run(&client, &repo, id).map_err(to_mcp_error)?; - format!( - "Run {} [{}/{}]: {}", - run.id, - run.status, - run.conclusion.as_deref().unwrap_or("-"), - run.html_url - ) - } - "run_rerun" => { - let id = req - .run_id - .ok_or_else(|| ErrorData::invalid_params("run_id is required", None))?; - actions::rerun(&client, &repo, id).map_err(to_mcp_error)?; - format!("Re-queued run {id}") - } - "workflow_dispatch" => { - let wf = req - .workflow - .as_deref() - .ok_or_else(|| ErrorData::invalid_params("workflow is required", None))?; - if req.inputs.as_ref().is_some_and(|v| !v.is_object()) { - return Err(ErrorData::invalid_params( - "inputs must be a JSON object", - None, - )); - } - let git_ref = match req.git_ref.as_deref() { - Some(r) => r.to_string(), - None => { - if req.repo.is_some() { - return Err(ErrorData::invalid_params( - "git_ref is required when repo is overridden (cannot infer the target repo default branch)", - None, - )); - } - crate::git::resolve_default_branch( - &std::env::current_dir().unwrap_or_default(), - ) - } - }; - actions::dispatch(&client, &repo, wf, &git_ref, req.inputs.as_ref()) - .map_err(to_mcp_error)?; - format!("Dispatched {wf} on {git_ref}") - } - other => { - return Err(ErrorData::invalid_params( - format!("unknown action: {other}"), - None, - )); - } - }; - Ok(out) + self.flare_git_impl(req) } #[tool( description = "Optimize layer — reversible-compression retrieval (CCR). action=retrieve returns the original for a registered id; action=list enumerates live entries." @@ -2779,162 +1108,7 @@ impl AgentflareMcp { description = "Create, edit, delete, or list threaded comments on an item. Single consolidated tool with `action` field (create|edit|delete|list). Only the author of a comment may edit/delete it, only the latest comment on an item is editable/deletable, and edit/delete are blocked while another agent holds an active claim on the item." )] fn comment(&self, Parameters(req): Parameters) -> Result { - match req.action.as_str() { - "create" => { - let item_id = req.item_id.ok_or_else(|| { - ErrorData::invalid_params("item_id is required for create", None) - })?; - let body = req.body.ok_or_else(|| { - ErrorData::invalid_params("body is required for create", None) - })?; - if item_id.trim().is_empty() || body.trim().is_empty() { - return Err(ErrorData::invalid_params( - "item_id and body are required", - None, - )); - } - let author = crate::claims::owner_id(); - self.with_backend_db(|conn| { - let comment = - agentflare_backend::comment::create(conn, &item_id, &author, &body) - .map_err(map_backend_err)?; - Ok(serde_json::to_string_pretty(&comment).unwrap_or_default()) - })? - } - "edit" => { - let comment_id = req - .id - .ok_or_else(|| ErrorData::invalid_params("id is required for edit", None))?; - let body = req - .body - .ok_or_else(|| ErrorData::invalid_params("body is required for edit", None))?; - if comment_id.trim().is_empty() || body.trim().is_empty() { - return Err(ErrorData::invalid_params("id and body are required", None)); - } - let owner = crate::claims::owner_id(); - let now = crate::claims::now(); - let ttl = backend_claim_ttl_secs(); - self.with_backend_db(|conn| { - // The author/latest/claim checks and the write must be one - // transaction — otherwise a comment landing between the - // is_latest check and the write (routine under concurrent - // multi-agent access) can silently violate the - // "only the latest comment is editable" invariant. - let tx = conn - .unchecked_transaction() - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let comment = agentflare_backend::comment::get(&tx, &comment_id) - .map_err(map_backend_err)?; - if crate::claims::agent_of(&comment.author_agent) - != crate::claims::agent_of(&owner) - { - return Err(ErrorData::invalid_params( - "can only edit your own comments", - None, - )); - } - if !agentflare_backend::comment::is_latest(&tx, &comment) - .map_err(map_backend_err)? - { - return Err(ErrorData::invalid_params( - "comment is not the latest on this item — cannot edit", - None, - )); - } - if agentflare_backend::claim::has_active_claim_by_other( - &tx, - &comment.item_id, - &owner, - now, - ttl, - ) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))? - { - return Err(ErrorData::invalid_params( - "another agent has started work on this item — cannot edit", - None, - )); - } - let updated = agentflare_backend::comment::update(&tx, &comment_id, &body) - .map_err(map_backend_err)?; - tx.commit() - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok(serde_json::to_string_pretty(&updated).unwrap_or_default()) - })? - } - "delete" => { - let comment_id = req - .id - .ok_or_else(|| ErrorData::invalid_params("id is required for delete", None))?; - if comment_id.trim().is_empty() { - return Err(ErrorData::invalid_params("id is required", None)); - } - let owner = crate::claims::owner_id(); - let now = crate::claims::now(); - let ttl = backend_claim_ttl_secs(); - self.with_backend_db(|conn| { - // See "edit" above: checks + write must be one transaction - // to close the same TOCTOU window. - let tx = conn - .unchecked_transaction() - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let comment = agentflare_backend::comment::get(&tx, &comment_id) - .map_err(map_backend_err)?; - if crate::claims::agent_of(&comment.author_agent) - != crate::claims::agent_of(&owner) - { - return Err(ErrorData::invalid_params( - "can only delete your own comments", - None, - )); - } - if !agentflare_backend::comment::is_latest(&tx, &comment) - .map_err(map_backend_err)? - { - return Err(ErrorData::invalid_params( - "comment is not the latest on this item — cannot delete", - None, - )); - } - if agentflare_backend::claim::has_active_claim_by_other( - &tx, - &comment.item_id, - &owner, - now, - ttl, - ) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))? - { - return Err(ErrorData::invalid_params( - "another agent has started work on this item — cannot delete", - None, - )); - } - agentflare_backend::comment::delete(&tx, &comment_id) - .map_err(map_backend_err)?; - tx.commit() - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - Ok(serde_json::json!({"deleted": true, "id": comment_id}).to_string()) - })? - } - "list" => { - let item_id = req.item_id.ok_or_else(|| { - ErrorData::invalid_params("item_id is required for list", None) - })?; - if item_id.trim().is_empty() { - return Err(ErrorData::invalid_params("item_id is required", None)); - } - self.with_backend_db(|conn| { - let comments = agentflare_backend::comment::list_by_item(conn, &item_id) - .map_err(map_backend_err)?; - Ok(serde_json::to_string_pretty(&comments).unwrap_or_default()) - })? - } - other => Err(ErrorData::invalid_params( - format!("unknown comment action: '{other}' — expected create|edit|delete|list"), - None, - )), - } + self.comment_impl(req) } /// Verify a label belongs to the repo's resolved project before mutating it by @@ -3116,240 +1290,8 @@ impl AgentflareMcp { #[tool( description = "Attach, get, list, or delete file assets on items/projects. Attach requires the file to exist in ~/.agentflare/staging/ first." )] - fn asset( - &self, - Parameters(AssetRequest { - action, - id, - item_id, - project_id, - filename, - metadata, - }): Parameters, - ) -> Result { - match action.as_str() { - "attach" => { - let has_item = item_id.is_some(); - let has_project = project_id.is_some(); - if has_item == has_project { - return Err(ErrorData::invalid_params( - "exactly one of item_id or project_id is required for attach", - None, - )); - } - let fn_val = filename.ok_or_else(|| { - ErrorData::invalid_params("filename is required for attach", None) - })?; - // path traversal guard: reject filename with .. or absolute components - let staged_rel = std::path::Path::new(&fn_val); - if staged_rel - .components() - .any(|c| !matches!(c, std::path::Component::Normal(_))) - { - return Err(ErrorData::invalid_params( - format!( - "filename '{fn_val}' contains path separators or parent-refs — not allowed" - ), - None, - )); - } - let staging_dir = crate::paths::home().join(".agentflare").join("staging"); - let staged = staging_dir.join(&fn_val); - if !staged.exists() { - return Err(ErrorData::invalid_params( - format!( - "file not found at staging path: {} — write the file there before calling attach", - staged.display() - ), - None, - )); - } - let size = std::fs::metadata(&staged) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))? - .len(); - let max_attach = Self::asset_max_attach_bytes(); - if size > max_attach { - return Err(ErrorData::invalid_params( - format!( - "file is {} bytes, exceeds the {} byte attach limit", - size, max_attach - ), - None, - )); - } - let bytes = std::fs::read(&staged) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let hash = Self::content_hash(&bytes); - let meta = metadata.unwrap_or_else(|| "{}".to_string()); - self.with_backend_db(|conn| { - let ws_id = Self::resolve_workspace_id(conn)?; - let (entity_type, entity_id) = if has_item { - agentflare_backend::item::get(conn, item_id.as_ref().unwrap()) - .map_err(map_backend_err)?; - ("item_attachment", item_id.as_ref().unwrap().clone()) - } else { - agentflare_backend::project::get(conn, project_id.as_ref().unwrap()) - .map_err(map_backend_err)?; - ("project_attachment", project_id.as_ref().unwrap().clone()) - }; - let ext = std::path::Path::new(&fn_val) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); - let mime = Self::infer_mime_type(ext); - let stem = std::path::Path::new(&fn_val) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(&fn_val); - let safe_stem: String = { - let s: String = stem - .chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') - .collect(); - if s.is_empty() { "file".to_string() } else { s } - }; - let full_storage = if ext.is_empty() { - format!("{}/assets/{}-{}", ws_id, safe_stem, hash) - } else { - format!("{}/assets/{}-{}.{}", ws_id, safe_stem, hash, ext) - }; - let base_path = crate::paths::home().join(".agentflare"); - // only write if file doesn't already exist (same content already stored) - let target = base_path.join(&full_storage); - if !target.exists() { - agentflare_backend::asset::write_file(&base_path, &full_storage, &bytes) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - } - let asset = agentflare_backend::asset::create( - conn, - agentflare_backend::asset::CreateAsset { - workspace_id: Some(ws_id.clone()), - entity_type: entity_type.into(), - entity_id, - filename: fn_val.clone(), - size: size as i64, - mime_type: Some(mime), - metadata: Some(meta), - storage_path: Some(full_storage), - }, - ) - .map_err(map_backend_err)?; - // remove staging file only after the DB insert succeeds - let _ = std::fs::remove_file(&staged); - Ok( - serde_json::to_string_pretty(&Self::strip_storage_path(&asset)) - .unwrap_or_default(), - ) - })? - } - "get" => { - let id = - id.ok_or_else(|| ErrorData::invalid_params("id is required for get", None))?; - self.with_backend_db(|conn| { - let asset = agentflare_backend::asset::get(conn, &id) - .map_err(map_backend_err)?; - let base_path = crate::paths::home().join(".agentflare"); - let max_inline = Self::asset_max_inline_bytes(); - let meta = Self::strip_storage_path(&asset); - let size = asset.size as u64; - if size <= max_inline { - match agentflare_backend::asset::read_file(&base_path, &asset.storage_path) { - Ok(bytes) => { - // Textual MIME + valid UTF-8 => return readable text so - // callers don't decode every text asset; everything else - // (binary MIME, or invalid UTF-8) => Base64. - let (content, encoding) = match std::str::from_utf8(&bytes) { - Ok(text) if Self::mime_is_textual(asset.mime_type.as_deref()) => (text.to_string(), "utf8"), - _ => (base64_encode(&bytes), "base64"), - }; - let result = serde_json::json!({ - "asset": meta, - "content": content, - "encoding": encoding, - }); - Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) - } - Err(e) => { - let result = serde_json::json!({ - "asset": meta, - "content": null, - "content_omitted_reason": format!("could not read file: {}", e), - }); - Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) - } - } - } else { - let result = serde_json::json!({ - "asset": meta, - "content": null, - "content_omitted_reason": format!("file is {} bytes, exceeds the {} byte inline limit", size, max_inline), - }); - Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) - } - })? - } - "list" => self.with_backend_db(|conn| { - let ws_id = Self::resolve_workspace_id(conn)?; - let assets: Vec = match (item_id, project_id) { - (Some(iid), None) => { - agentflare_backend::asset::list_by_entity(conn, "item_attachment", &iid) - .map_err(map_backend_err)? - } - (None, Some(pid)) => { - agentflare_backend::asset::list_by_entity(conn, "project_attachment", &pid) - .map_err(map_backend_err)? - } - (Some(_), Some(_)) => { - return Err(ErrorData::invalid_params( - "only one of item_id or project_id allowed for list, not both", - None, - )); - } - (None, None) => { - let mut assets: Vec = Vec::new(); - for a in agentflare_backend::asset::list_by_workspace(conn, &ws_id) - .map_err(map_backend_err)? - { - assets.push(Self::strip_storage_path(&a)); - } - return Ok(serde_json::to_string_pretty(&assets).unwrap_or_default()); - } - }; - let mut stripped: Vec = Vec::new(); - for a in assets { - stripped.push(Self::strip_storage_path(&a)); - } - Ok(serde_json::to_string_pretty(&stripped).unwrap_or_default()) - })?, - "delete" => { - let id = - id.ok_or_else(|| ErrorData::invalid_params("id is required for delete", None))?; - self.with_backend_db(|conn| { - let asset = agentflare_backend::asset::get(conn, &id) - .map_err(map_backend_err)?; - // soft-delete the row - agentflare_backend::asset::delete(conn, &id) - .map_err(map_backend_err)?; - // only unlink from disk if no other live row references the same storage_path - let remaining: i64 = conn - .query_row( - "SELECT count(*) FROM assets WHERE storage_path = ?1 AND deleted_at IS NULL", - rusqlite::params![&asset.storage_path], - |r| r.get(0), - ) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - if remaining == 0 { - let base_path = crate::paths::home().join(".agentflare"); - let _ = agentflare_backend::asset::delete_file(&base_path, &asset.storage_path); - } - Ok(serde_json::json!({"deleted": true, "id": id}).to_string()) - })? - } - other => Err(ErrorData::invalid_params( - format!("unknown action '{other}'; expected attach|get|list|delete"), - None, - )), - } + fn asset(&self, Parameters(req): Parameters) -> Result { + self.asset_impl(req) } } @@ -3576,3587 +1518,4 @@ pub async fn run() -> Result<(), Box> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_flared_port_reads_top_level_key_only() { - assert_eq!(parse_flared_port("port = 4444\n"), Some(4444)); - assert_eq!( - parse_flared_port("# comment\nport=9999 # inline\n"), - Some(9999) - ); - // tables end the top-level scan; a port inside one is not flared's - assert_eq!(parse_flared_port("[[registries]]\nport = 1\n"), None); - // prefix collisions and malformed values are not overrides - assert_eq!( - parse_flared_port("portable = 1\nlight_interval_secs = 60\n"), - None - ); - assert_eq!(parse_flared_port("port = not-a-number\n"), None); - assert_eq!(parse_flared_port(""), None); - } - - #[test] - fn validate_conventional_pr_title_accepts_known_types_rejects_others() { - for good in [ - "feat: add thing", - "fix(scope): bug", - "chore!: breaking rename", - "docs: update readme", - ] { - assert!( - AgentflareMcp::validate_conventional_pr_title(good).is_ok(), - "expected {good:?} to pass" - ); - } - for bad in [ - "Add thing", - "Relicense repo from MIT to Apache-2.0", - "Feat: wrong case", - "unknown: not a real type", - ] { - assert!( - AgentflareMcp::validate_conventional_pr_title(bad).is_err(), - "expected {bad:?} to fail" - ); - } - } - - #[test] - fn get_info_reports_agentflare_identity() { - let s = AgentflareMcp::default(); - let info = s.get_info(); - assert_eq!(info.server_info.name, env!("CARGO_PKG_NAME")); - assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION")); - } - - #[test] - fn routing_suggestion_returns_null_for_non_locate() { - let s = AgentflareMcp::default(); - let result = s.get_routing_suggestion(Parameters(GetRoutingSuggestionRequest { - prompt: "refactor the payment module".to_string(), - })); - assert!(result.contains("null")); - } - - #[test] - fn routing_suggestion_returns_nudge_for_find() { - let s = AgentflareMcp::default(); - let result = s.get_routing_suggestion(Parameters(GetRoutingSuggestionRequest { - prompt: "find the auth handler".to_string(), - })); - assert!(result.contains("cheap-model")); - } - - #[test] - fn check_session_health_unknown_returns_status() { - let s = AgentflareMcp::default(); - let result = s - .check_session_health(Parameters(CheckSessionHealthRequest { - session_id: "nonexistent-session-id".to_string(), - })) - .unwrap(); - assert!(result.contains("unknown")); - } - - #[test] - fn check_session_health_rejects_empty_session_id() { - let s = AgentflareMcp::default(); - let err = s - .check_session_health(Parameters(CheckSessionHealthRequest { - session_id: String::new(), - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn optimize_tool_retrieve_returns_registered_original() { - crate::paths::test_support::with_temp_home(|| { - let backup = crate::state::state_dir().join("o.md"); - std::fs::create_dir_all(backup.parent().unwrap()).unwrap(); - std::fs::write(&backup, "ORIG").unwrap(); - let e = crate::optimize::retrieve::register( - crate::optimize::retrieve::EntryKind::FileBackup { - backup_path: backup, - }, - 4, - 1, - 1, - ); - - let s = AgentflareMcp::default(); - let out = s - .optimize(Parameters(OptimizeRequest { - action: "retrieve".into(), - id: Some(e.id), - })) - .unwrap(); - assert_eq!(out, "ORIG"); - }); - } - - // NOTE: `list_resources`/`read_resource` on `ServerHandler` take a - // `RequestContext`, which embeds a `Peer` whose - // constructor is `pub(crate)` inside rmcp (and requires the `client` - // feature this crate doesn't enable) — there is no supported way to - // build one from outside the rmcp crate. The URI-dispatch logic is - // therefore extracted into `list_resources_sync`/`read_resource_sync` - // (plain sync methods with identical bodies to the trait methods) so it - // can be unit-tested directly; the trait methods are thin async shells - // over them. - // - // `agentflare://sessions` is deliberately NOT covered here: it reads - // mutable on-disk runtime state via `optimize::load_runtime()`, whose - // path (`crate::state::state_dir()/runtime-state.json`) is not - // injectable, so exercising it deterministically would mean reading (or - // mutating) the real shared user state file. - - #[test] - fn list_resources_returns_sessions_and_nudges() { - let s = AgentflareMcp::default(); - let result = s.list_resources_sync(); - let uris: Vec<&str> = result.resources.iter().map(|r| r.uri.as_str()).collect(); - assert_eq!(uris, vec!["agentflare://sessions", "agentflare://nudges"]); - } - - #[test] - fn read_resource_nudges_returns_nudges_json() { - let s = AgentflareMcp::default(); - let result = s.read_resource_sync("agentflare://nudges").unwrap(); - assert_eq!(result.contents.len(), 1); - let ResourceContents::TextResourceContents { text, uri, .. } = &result.contents[0] else { - panic!("expected text resource contents"); - }; - assert_eq!(uri, "agentflare://nudges"); - assert!(text.contains("session_hygiene")); - } - - #[test] - fn read_resource_unknown_uri_returns_resource_not_found() { - let s = AgentflareMcp::default(); - let err = s.read_resource_sync("agentflare://bogus").unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::RESOURCE_NOT_FOUND); - } - - #[tokio::test] - async fn skill_search_empty_query_is_invalid_params() { - let s = AgentflareMcp::default(); - let err = s - .skill(Parameters(SkillRequest { - action: "search".into(), - query: Some("".into()), - ..Default::default() - })) - .await - .unwrap_err(); - assert!(err.to_string().contains("query")); - } - - #[tokio::test] - async fn skill_load_unknown_name_reports_not_found_with_search_hint() { - // Isolated DB path so the test never opens/refreshes the shared skills.db. - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - skills_db_override: Some(tmp.path().join("skills.db")), - ..Default::default() - }; - let out = s - .skill(Parameters(SkillRequest { - action: "load".into(), - name: Some("definitely-not-a-skill-xyz".into()), - original: false, - ..Default::default() - })) - .await - .unwrap_err(); - assert!(out.to_string().contains("skill_search")); - } - - #[tokio::test] - async fn skill_search_mode_rejects_unknown_value() { - let s = AgentflareMcp::default(); - let err = s - .skill(Parameters(SkillRequest { - action: "search".into(), - query: Some("anything".into()), - mode: Some("fuzzy".into()), - ..Default::default() - })) - .await - .unwrap_err(); - assert!(err.to_string().contains("mode")); - } - - #[tokio::test] - async fn tool_search_empty_query_is_invalid_params() { - // Isolated DB path so the test never opens/refreshes the shared gateway.db. - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - gateway_db_override: Some(tmp.path().join("gateway.db")), - ..Default::default() - }; - let err = s - .tool(Parameters(ToolRequest { - action: "search".into(), - query: Some("".into()), - ..Default::default() - })) - .await - .unwrap_err(); - assert!(err.to_string().contains("query is required")); - } - - #[tokio::test] - async fn tool_search_mode_rejects_unknown_value() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - gateway_db_override: Some(tmp.path().join("gateway.db")), - ..Default::default() - }; - let err = s - .tool(Parameters(ToolRequest { - action: "search".into(), - query: Some("x".into()), - mode: Some("bogus".into()), - ..Default::default() - })) - .await - .unwrap_err(); - assert!(err.to_string().contains("mode must be")); - } - - #[tokio::test] - async fn tool_execute_requires_server_and_tool() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - gateway_db_override: Some(tmp.path().join("gateway.db")), - ..Default::default() - }; - let err = s - .tool(Parameters(ToolRequest { - action: "execute".into(), - server: Some("".into()), - tool: Some("x".into()), - args: Some(serde_json::Map::new()), - ..Default::default() - })) - .await - .unwrap_err(); - assert!(err.to_string().contains("required")); - } - - #[tokio::test] - async fn tool_execute_unknown_server_is_invalid_params() { - // Isolated DB path, no servers configured — `Registry::execute` is - // guaranteed to hit `GatewayError::ServerNotFound`, which must map to - // `invalid_params` (a caller-fixable mistake), not `internal_error`. - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - gateway_db_override: Some(tmp.path().join("gateway.db")), - ..Default::default() - }; - let err = s - .tool(Parameters(ToolRequest { - action: "execute".into(), - server: Some("definitely-not-a-configured-server".into()), - tool: Some("x".into()), - args: Some(serde_json::Map::new()), - ..Default::default() - })) - .await - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - assert!(err.to_string().contains("not found")); - } - - #[test] - fn tool_execute_args_schema_is_object_or_null() { - let schema = schemars::schema_for!(ToolRequest); - let schema_json = serde_json::to_value(&schema).unwrap(); - let args_schema = schema_json - .get("properties") - .and_then(|p| p.get("args")) - .expect("args schema present"); - let rendered = args_schema.to_string(); - assert!(rendered.contains("\"object\""), "{rendered}"); - assert!(rendered.contains("\"null\""), "{rendered}"); - } - - /// Minimal HTTP GET against a `http://127.0.0.1:/` URL, - /// returning the full response (status line + headers + body). - fn http_get(url: &str) -> String { - use std::io::{Read, Write}; - let rest = url.strip_prefix("http://").expect("http url"); - let (host_port, path) = rest.split_once('/').unwrap_or((rest, "")); - let mut stream = std::net::TcpStream::connect(host_port) - .unwrap_or_else(|_| panic!("connect to {host_port}")); - stream - .set_read_timeout(Some(std::time::Duration::from_secs(2))) - .unwrap(); - write!(stream, "GET /{path} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n").unwrap(); - stream.flush().unwrap(); - let mut full = String::new(); - let _ = stream.read_to_string(&mut full); - full - } - - #[test] - fn artifact_publish_serves_content_at_returned_url() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - let out = s - .artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("hello".into()), - r#type: None, - content: Some("artifact-body-marker".into()), - session_id: None, - update_id: None, - ..Default::default() - })) - .unwrap(); - let v: serde_json::Value = serde_json::from_str(&out).unwrap(); - let url = v["url"].as_str().expect("url in response"); - assert!(url.starts_with("http://127.0.0.1:"), "local url: {url}"); - assert!(!v["id"].as_str().unwrap_or_default().is_empty()); - - let resp = http_get(url); - assert!(resp.contains("200"), "serves published artifact: {resp}"); - assert!( - resp.contains("artifact-body-marker"), - "body present: {resp}" - ); - } - - #[test] - fn artifact_publish_update_id_keeps_same_id() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - let first: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("doc".into()), - r#type: Some("markdown".into()), - content: Some("v1".into()), - session_id: Some("ses-1".into()), - update_id: None, - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let id = first["id"].as_str().unwrap().to_string(); - - let second: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("doc".into()), - r#type: Some("markdown".into()), - content: Some("v2".into()), - session_id: Some("ses-1".into()), - update_id: Some(id.clone()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(second["id"].as_str().unwrap(), id); - assert_eq!(second["url"], first["url"]); - } - - #[test] - fn artifact_list_get_delete_roundtrip() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - let publish = |name: &str, session: &str| -> serde_json::Value { - serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some(name.into()), - r#type: None, - content: Some(format!("content-of-{name}")), - session_id: Some(session.into()), - update_id: None, - description: Some(format!("desc-{name}")), - ..Default::default() - })) - .unwrap(), - ) - .unwrap() - }; - let a = publish("alpha", "ses-1"); - let _b = publish("beta", "ses-2"); - - let all: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "list".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(all.as_array().unwrap().len(), 2); - - let one: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "list".into(), - session_id: Some("ses-1".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(one.as_array().unwrap().len(), 1); - assert_eq!(one[0]["name"], "alpha"); - assert_eq!(one[0]["description"], "desc-alpha"); - - let id = a["id"].as_str().unwrap().to_string(); - let got: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "get".into(), - id: Some(id.clone()), - version: None, - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(got["content"], "content-of-alpha"); - - let del: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "delete".into(), - id: Some(id.clone()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(del["deleted"], id); - - let err = s - .artifact(Parameters(ArtifactRequest { - action: "get".into(), - id: Some(id), - version: None, - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn artifact_publish_version_and_conflict() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - let first: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("doc".into()), - r#type: None, - content: Some("v1".into()), - session_id: None, - update_id: None, - label: Some("draft".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(first["version"], 1); - let id = first["id"].as_str().unwrap().to_string(); - - // stale base_version maps to invalid_params, not internal_error - let update = |base: Option, content: &str| { - s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("doc".into()), - r#type: None, - content: Some(content.into()), - session_id: None, - update_id: Some(id.clone()), - base_version: base, - ..Default::default() - })) - }; - let second: serde_json::Value = - serde_json::from_str(&update(Some(1), "v2").unwrap()).unwrap(); - assert_eq!(second["version"], 2); - - let err = update(Some(1), "v3-stale").unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - assert!(err.to_string().contains("conflict"), "{err}"); - } - - #[test] - fn artifact_list_filters_by_recipient_and_thread() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - let publish = - |name: &str, recipient: Option<&str>, thread: Option<&str>| -> serde_json::Value { - serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some(name.into()), - content: Some(format!("content {name}")), - recipient: recipient.map(Into::into), - thread_id: thread.map(Into::into), - ..Default::default() - })) - .unwrap(), - ) - .unwrap() - }; - publish("packet", Some("codex"), Some("t1")); - publish("reply", Some("claude-code"), Some("t1")); - publish("other", None, None); - - let inbox: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "list".into(), - inbox_recipient: Some("codex".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(inbox.as_array().unwrap().len(), 1); - assert_eq!(inbox[0]["name"], "packet"); - - let thread: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "list".into(), - thread_id: Some("t1".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(thread.as_array().unwrap().len(), 2); - } - - fn handoff_harness() -> (tempfile::TempDir, AgentflareMcp) { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - backend_db_override: Some(tmp.path().join("backend.db")), - backend_project_link_override: Some(tmp.path().join("project.json")), - agent: Some("claude-code".into()), - ..Default::default() - }; - (tmp, s) - } - - fn item_assets(s: &AgentflareMcp, item_id: &str) -> serde_json::Value { - serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "list".into(), - id: None, - item_id: Some(item_id.to_string()), - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap() - } - - #[test] - fn handoff_tool_requires_recipient_and_assigns_item() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = handoff_harness(); - - // A blank recipient is rejected — the whole reason this tool exists. - let err = s - .handoff(Parameters(HandoffRequest { - recipient: " ".into(), - name: "orphan".into(), - content: "for someone".into(), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - - // A real handoff creates an item assigned to the recipient and - // attaches the content to it as an asset. - let result: serde_json::Value = serde_json::from_str( - &s.handoff(Parameters(HandoffRequest { - recipient: "opencode".into(), - name: "review-packet".into(), - content: "please review".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let item_id = result["item_id"].as_str().unwrap().to_string(); - assert_eq!(result["recipient"], "opencode"); - assert_eq!(result["asset_version"], 1); - - let item: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "get".into(), - id: Some(item_id.clone()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(item["name"], "review-packet"); - assert_eq!(item["assignee_agent"], "opencode"); - - let assets = item_assets(&s, &item_id); - assert_eq!(assets.as_array().unwrap().len(), 1); - assert_eq!(assets[0]["filename"], format!("{item_id}.md")); - }); - } - - #[test] - fn handoff_trims_whitespace_padded_recipient() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = handoff_harness(); - - // A whitespace-padded recipient passes the emptiness check but must - // still be stored trimmed, or exact-match assignee lookups miss it. - let result: serde_json::Value = serde_json::from_str( - &s.handoff(Parameters(HandoffRequest { - recipient: " opencode ".into(), - name: "review-packet".into(), - content: "please review".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(result["recipient"], "opencode"); - - let item_id = result["item_id"].as_str().unwrap().to_string(); - let item: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "get".into(), - id: Some(item_id), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(item["assignee_agent"], "opencode"); - }); - } - - #[test] - fn handoff_with_item_id_assigns_existing_item_and_versions_the_asset() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = handoff_harness(); - let created: serde_json::Value = serde_json::from_str( - &s.item(Parameters(empty_item_create("Existing task"))) - .unwrap(), - ) - .unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - let first: serde_json::Value = serde_json::from_str( - &s.handoff(Parameters(HandoffRequest { - recipient: "opencode".into(), - name: "Existing task".into(), - content: "v1 content".into(), - item_id: Some(item_id.clone()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(first["item_id"], item_id); - assert_eq!(first["asset_version"], 1); - - // A different brief/name on the reply must not reset the version - // chain — it's keyed on item_id, not name. - let second: serde_json::Value = serde_json::from_str( - &s.handoff(Parameters(HandoffRequest { - recipient: "opencode".into(), - name: "Addressed feedback".into(), - content: "v2 content".into(), - item_id: Some(item_id.clone()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(second["asset_version"], 2); - - // no duplicate item was created - let item: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "get".into(), - id: Some(item_id.clone()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(item["assignee_agent"], "opencode"); - assert_eq!(item_assets(&s, &item_id).as_array().unwrap().len(), 2); - }); - } - - #[test] - fn artifact_diff_tool_returns_unified_diff() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - let first: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("doc".into()), - content: Some("alpha\nbeta\n".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let id = first["id"].as_str().unwrap().to_string(); - s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("doc".into()), - content: Some("alpha\ngamma\n".into()), - update_id: Some(id.clone()), - ..Default::default() - })) - .unwrap(); - - // to_version omitted = latest - let diff = s - .artifact(Parameters(ArtifactRequest { - action: "diff".into(), - id: Some(id), - from_version: Some(1), - to_version: None, - ..Default::default() - })) - .unwrap(); - assert!(diff.contains("-beta"), "{diff}"); - assert!(diff.contains("+gamma"), "{diff}"); - } - - #[test] - fn artifact_search_matches_name_description_and_content() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("alpha".into()), - content: Some("there is a hidden NEEDLE in here".into()), - ..Default::default() - })) - .unwrap(); - s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("beta".into()), - content: Some("nothing to see".into()), - ..Default::default() - })) - .unwrap(); - - let hits: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "search".into(), - query: Some("needle".into()), - session_id: None, - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(hits.as_array().unwrap().len(), 1); - assert_eq!(hits[0]["name"], "alpha"); - assert!( - hits[0]["snippet"] - .as_str() - .unwrap() - .to_lowercase() - .contains("needle"), - "{hits}" - ); - - let by_name: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "search".into(), - query: Some("beta".into()), - session_id: None, - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(by_name.as_array().unwrap().len(), 1); - } - - #[test] - fn artifact_publish_captures_git_provenance_in_repo() { - // Tests run with cwd inside this git repo, so capture must succeed. - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - let out: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some("prov".into()), - content: Some("x".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let got: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "get".into(), - id: Some(out["id"].as_str().unwrap().into()), - version: None, - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let commit = got["git"]["commit"].as_str().expect("git commit captured"); - assert!(commit.len() >= 7, "{got}"); - } - - #[test] - fn artifact_publish_defaults_sender_to_agent_identity() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - agent: Some("opencode".into()), - ..Default::default() - }; - let sender_of = |req: ArtifactRequest| -> serde_json::Value { - let out: serde_json::Value = - serde_json::from_str(&s.artifact(Parameters(req)).unwrap()).unwrap(); - let got: serde_json::Value = serde_json::from_str( - &s.artifact(Parameters(ArtifactRequest { - action: "get".into(), - id: Some(out["id"].as_str().unwrap().into()), - version: None, - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - got["sender"].clone() - }; - - let defaulted = sender_of(ArtifactRequest { - action: "publish".into(), - name: Some("defaulted".into()), - content: Some("x".into()), - ..Default::default() - }); - assert_eq!(defaulted, "opencode"); - - // ArtifactRequest has no `sender` field (removed in #75): authorship is - // always the server-derived identity, so a caller cannot attribute a - // published artifact to another agent. The spoof is unrepresentable at - // the type level — stronger than a runtime "override ignored" check. - } - - #[test] - fn identity_prefers_explicit_override_then_detection() { - // Explicit override beats detection… - assert_eq!( - AgentflareMcp::identity(Some("opencode".into())).as_deref(), - Some("opencode") - ); - // …empty counts as unset, and without an override identity falls - // back to detecting the host that launched us (None outside agents). - assert_eq!( - AgentflareMcp::identity(Some(String::new())), - agent_detector::agent_name() - ); - assert_eq!(AgentflareMcp::identity(None), agent_detector::agent_name()); - } - - #[test] - fn artifact_publish_rejects_empty_name_and_content() { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - artifacts_dir_override: Some(tmp.path().to_path_buf()), - ..Default::default() - }; - for (name, content) in [("", "x"), ("x", "")] { - let err = s - .artifact(Parameters(ArtifactRequest { - action: "publish".into(), - name: Some(name.into()), - r#type: None, - content: Some(content.into()), - session_id: None, - update_id: None, - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - } - - /// Guards against the exact bug Phase 2's spec was written to avoid: a - /// second, untagged `impl AgentflareMcp` block would compile fine and - /// its `#[tool]` methods would still be directly callable (which is why - /// unit tests calling them would pass either way) but be invisible to - /// every real MCP client. Not fully sufficient on its own (see the - /// spec) but catches the single-router invariant cheaply. - #[test] - fn exactly_one_tool_router_block_exists() { - // Matches the attribute directly annotating `impl AgentflareMcp {`, - // not every prose mention of it (e.g. the placement-rule doc comment - // on the memory tools, or this test's own description). - let marker = ["#[", "tool_router", "]\nimpl AgentflareMcp {"].concat(); - let src = include_str!("mcp_server.rs"); - assert_eq!( - src.matches(&marker).count(), - 1, - "all #[tool] methods must live in the one tool-router-tagged impl block" - ); - } - - fn harness() -> (tempfile::TempDir, AgentflareMcp) { - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - backend_db_override: Some(tmp.path().join("backend.db")), - backend_project_link_override: Some(tmp.path().join("project.json")), - ..Default::default() - }; - (tmp, s) - } - - fn backend_conn(tmp: &tempfile::TempDir) -> rusqlite::Connection { - agentflare_backend::db::open_db(&tmp.path().join("backend.db")).unwrap() - } - - fn empty_item_create(name: &str) -> ItemRequest { - ItemRequest { - action: "create".into(), - name: Some(name.to_string()), - ..Default::default() - } - } - - #[test] - fn item_create_auto_provisions_workspace_and_project() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test Item"))).unwrap()) - .unwrap(); - assert_eq!(created["name"], "Test Item"); - assert_eq!(created["sequence_id"], 1); - assert!(created["project_id"].as_str().is_some()); - } - - #[test] - fn item_create_rejects_empty_name() { - let (_tmp, s) = harness(); - let err = s.item(Parameters(empty_item_create(""))).unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn item_update_state_sets_timestamps_via_mcp() { - let (tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - let project_id = created["project_id"].as_str().unwrap().to_string(); - - let started_state_id = { - let conn = backend_conn(&tmp); - agentflare_backend::state::list_by_project(&conn, &project_id) - .unwrap() - .into_iter() - .find(|st| st.group_name == "started") - .unwrap() - .id - }; - - let updated: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(item_id), - state_id: Some(started_state_id), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert!(updated["started_at"].is_number()); - assert!(updated["completed_at"].is_null()); - } - - #[test] - fn item_cancel_moves_to_cancelled_state() { - let (tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - let project_id = created["project_id"].as_str().unwrap().to_string(); - - let cancelled: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "cancel".into(), - id: Some(item_id), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let state_id = cancelled["state_id"].as_str().unwrap().to_string(); - - let conn = backend_conn(&tmp); - let group = agentflare_backend::state::list_by_project(&conn, &project_id) - .unwrap() - .into_iter() - .find(|st| st.id == state_id) - .unwrap() - .group_name; - assert_eq!(group, "cancelled"); - } - - #[test] - fn item_cancel_releases_the_callers_own_claim() { - // `claim` always resolves a worktree_repo_root and may run real `git - // worktree` commands against it — every test that calls `claim` must - // override this to an isolated throwaway repo, never the repo - // `cargo test` itself is running in. Same scaffolding as - // `item_claim_response_includes_worktree_path`. - let tmp = tempfile::tempdir().unwrap(); - let repo_dir = tempfile::tempdir().unwrap(); - let repo_root = repo_dir.path().to_path_buf(); - let run_git = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .current_dir(&repo_root) - .output() - .unwrap() - }; - run_git(&["init", "-b", "master"]); - run_git(&["config", "user.email", "test@test.com"]); - run_git(&["config", "user.name", "Test"]); - run_git(&["commit", "--allow-empty", "-m", "initial"]); - - let s = AgentflareMcp { - backend_db_override: Some(tmp.path().join("backend.db")), - backend_project_link_override: Some(tmp.path().join("project.json")), - worktree_repo_root_override: Some(repo_root), - ..Default::default() - }; - - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - s.item(Parameters(ItemRequest { - action: "claim".into(), - id: Some(item_id.clone()), - ..Default::default() - })) - .unwrap(); - - s.item(Parameters(ItemRequest { - action: "cancel".into(), - id: Some(item_id.clone()), - ..Default::default() - })) - .unwrap(); - - // The claim must be released — re-claiming should succeed - // immediately instead of coming back "held". - let reclaimed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "claim".into(), - id: Some(item_id), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(reclaimed["status"], "acquired"); - } - - #[test] - fn item_list_rejects_negative_limit_and_offset() { - let (_tmp, s) = harness(); - let err = s - .item(Parameters(ItemRequest { - action: "list".into(), - limit: Some(-1), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - - let err = s - .item(Parameters(ItemRequest { - action: "list".into(), - offset: Some(-1), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn item_list_filters_by_assignee_or_unassigned_and_sorts_open_first() { - let (tmp, s) = harness(); - let mine_open: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Mine open"))).unwrap()) - .unwrap(); - let project_id = mine_open["project_id"].as_str().unwrap().to_string(); - s.item(Parameters(ItemRequest { - action: "update".into(), - id: Some(mine_open["id"].as_str().unwrap().to_string()), - assignee_agent: Some("me".into()), - ..Default::default() - })) - .unwrap(); - - serde_json::from_str::( - &s.item(Parameters(empty_item_create("Unassigned"))).unwrap(), - ) - .unwrap(); - - let others: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Others"))).unwrap()) - .unwrap(); - s.item(Parameters(ItemRequest { - action: "update".into(), - id: Some(others["id"].as_str().unwrap().to_string()), - assignee_agent: Some("someone-else".into()), - ..Default::default() - })) - .unwrap(); - - let mine_done: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Mine done"))).unwrap()) - .unwrap(); - s.item(Parameters(ItemRequest { - action: "update".into(), - id: Some(mine_done["id"].as_str().unwrap().to_string()), - assignee_agent: Some("me".into()), - ..Default::default() - })) - .unwrap(); - let done_state_id = { - let conn = backend_conn(&tmp); - agentflare_backend::state::list_by_project(&conn, &project_id) - .unwrap() - .into_iter() - .find(|st| st.group_name == "completed") - .unwrap() - .id - }; - s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(mine_done["id"].as_str().unwrap().to_string()), - state_id: Some(done_state_id), - ..Default::default() - })) - .unwrap(); - - let listed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "list".into(), - assignee_agent: Some("me".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let names: Vec<&str> = listed - .as_array() - .unwrap() - .iter() - .map(|i| i["name"].as_str().unwrap()) - .collect(); - assert_eq!(names, vec!["Mine open", "Unassigned", "Mine done"]); - } - - #[test] - fn item_list_defaults_assignee_filter_to_server_identity() { - // #75: a bare `item(list)` (no assignee_agent) must default to the - // server-derived identity — mine + unassigned — not dump every item. - let tmp = tempfile::tempdir().unwrap(); - let s = AgentflareMcp { - backend_db_override: Some(tmp.path().join("backend.db")), - backend_project_link_override: Some(tmp.path().join("project.json")), - agent: Some("me".into()), - ..Default::default() - }; - - let mine: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Mine"))).unwrap()).unwrap(); - s.item(Parameters(ItemRequest { - action: "update".into(), - id: Some(mine["id"].as_str().unwrap().to_string()), - assignee_agent: Some("me".into()), - ..Default::default() - })) - .unwrap(); - - serde_json::from_str::( - &s.item(Parameters(empty_item_create("Unassigned"))).unwrap(), - ) - .unwrap(); - - let others: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Others"))).unwrap()) - .unwrap(); - s.item(Parameters(ItemRequest { - action: "update".into(), - id: Some(others["id"].as_str().unwrap().to_string()), - assignee_agent: Some("someone-else".into()), - ..Default::default() - })) - .unwrap(); - - // Bare list: no assignee_agent → defaults to "me" (mine + unassigned). - let defaulted: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "list".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let mut names: Vec<&str> = defaulted - .as_array() - .unwrap() - .iter() - .map(|i| i["name"].as_str().unwrap()) - .collect(); - names.sort_unstable(); - assert_eq!(names, vec!["Mine", "Unassigned"]); - - // An explicit assignee_agent is still honored (view a teammate's queue). - let explicit: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "list".into(), - assignee_agent: Some("someone-else".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let mut names2: Vec<&str> = explicit - .as_array() - .unwrap() - .iter() - .map(|i| i["name"].as_str().unwrap()) - .collect(); - names2.sort_unstable(); - assert_eq!(names2, vec!["Others", "Unassigned"]); - } - - #[test] - fn item_list_state_group_filter_accepts_comma_separated_groups() { - let (tmp, s) = harness(); - let open_item: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Open"))).unwrap()).unwrap(); - let project_id = open_item["project_id"].as_str().unwrap().to_string(); - let done_item: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Done"))).unwrap()).unwrap(); - let cancelled_item: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Cancelled"))).unwrap()) - .unwrap(); - - let conn = backend_conn(&tmp); - let states = agentflare_backend::state::list_by_project(&conn, &project_id).unwrap(); - let done_state_id = states - .iter() - .find(|st| st.group_name == "completed") - .unwrap() - .id - .clone(); - let cancelled_state_id = states - .iter() - .find(|st| st.group_name == "cancelled") - .unwrap() - .id - .clone(); - drop(conn); - - s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(done_item["id"].as_str().unwrap().to_string()), - state_id: Some(done_state_id), - ..Default::default() - })) - .unwrap(); - s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(cancelled_item["id"].as_str().unwrap().to_string()), - state_id: Some(cancelled_state_id), - ..Default::default() - })) - .unwrap(); - - let listed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "list".into(), - state_group: Some("backlog,completed".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let names: Vec<&str> = listed - .as_array() - .unwrap() - .iter() - .map(|i| i["name"].as_str().unwrap()) - .collect(); - assert_eq!(names, vec!["Open", "Done"]); - } - - #[test] - fn item_groom_flags_unassigned_and_computes_pull_next() { - let (_tmp, s) = harness(); - let foo: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Foo"))).unwrap()).unwrap(); - s.item(Parameters(ItemRequest { - action: "create".into(), - name: Some("Bar".into()), - assignee_agent: Some("someone".into()), - ..Default::default() - })) - .unwrap(); - - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let items = groomed["items"].as_array().unwrap(); - let foo_entry = items - .iter() - .find(|i| i["name"] == "Foo") - .expect("Foo present"); - assert_eq!(foo_entry["unassigned"], true); - assert_eq!(foo_entry["stale"], false); - let bar_entry = items - .iter() - .find(|i| i["name"] == "Bar") - .expect("Bar present"); - assert_eq!(bar_entry["unassigned"], false); - - let pull_next: Vec<&str> = groomed["pull_next"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert!(pull_next.contains(&foo["id"].as_str().unwrap())); - assert_eq!(groomed["unassigned_count"], 1); - } - - /// Regression (CodeRabbit): a completed dependency must never read back - /// as an open blocker just because it fell outside the shortlist's - /// default state_group filter (completed items aren't in - /// "backlog,unstarted", so the naive shortlist-scoped lookup used to - /// return "" for its state and treat that as "still open"). - #[test] - fn item_groom_does_not_block_on_a_completed_dependency_outside_the_shortlist() { - let (_tmp, s) = harness(); - let dep: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Dep"))).unwrap()).unwrap(); - let project_id = dep["project_id"].as_str().unwrap().to_string(); - let blocked: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "create".into(), - name: Some("Blocked".into()), - dependency_ids: Some(vec![dep["id"].as_str().unwrap().to_string()]), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let conn = backend_conn(&_tmp); - let completed_state = agentflare_backend::state::list_by_project(&conn, &project_id) - .unwrap() - .into_iter() - .find(|st| st.group_name == "completed") - .unwrap() - .id; - drop(conn); - s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(dep["id"].as_str().unwrap().to_string()), - state_id: Some(completed_state), - ..Default::default() - })) - .unwrap(); - - // Default state_group is "backlog,unstarted" — Dep (now completed) - // falls outside the shortlist entirely. - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let items = groomed["items"].as_array().unwrap(); - assert!( - !items.iter().any(|i| i["id"] == dep["id"]), - "completed Dep should not be in the default shortlist" - ); - let blocked_entry = items.iter().find(|i| i["id"] == blocked["id"]).unwrap(); - assert_eq!( - blocked_entry["blocked_by"].as_array().unwrap().len(), - 0, - "a completed dependency must not block, even when it's outside the shortlist" - ); - } - - /// Regression (CodeRabbit): fan-in must count dependents project-wide, - /// not just other items that happen to share the same shortlist. - #[test] - fn item_groom_fanin_counts_dependents_outside_the_shortlist() { - let (_tmp, s) = harness(); - let target: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Target"))).unwrap()) - .unwrap(); - let project_id = target["project_id"].as_str().unwrap().to_string(); - let dependent: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "create".into(), - name: Some("Dependent".into()), - dependency_ids: Some(vec![target["id"].as_str().unwrap().to_string()]), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let conn = backend_conn(&_tmp); - let completed_state = agentflare_backend::state::list_by_project(&conn, &project_id) - .unwrap() - .into_iter() - .find(|st| st.group_name == "completed") - .unwrap() - .id; - drop(conn); - // Move the dependent out of the default shortlist filter — Target's - // fan-in must still count it. - s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(dependent["id"].as_str().unwrap().to_string()), - state_id: Some(completed_state), - ..Default::default() - })) - .unwrap(); - - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let items = groomed["items"].as_array().unwrap(); - assert!(!items.iter().any(|i| i["id"] == dependent["id"])); - let target_entry = items.iter().find(|i| i["id"] == target["id"]).unwrap(); - assert_eq!(target_entry["depended_on_by_count"], 1); - } - - #[test] - fn item_groom_flags_blocked_by_open_dependency() { - let (_tmp, s) = harness(); - let dep: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Dep"))).unwrap()).unwrap(); - let dep_id = dep["id"].as_str().unwrap().to_string(); - let blocked: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "create".into(), - name: Some("Blocked".into()), - dependency_ids: Some(vec![dep_id.clone()]), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let items = groomed["items"].as_array().unwrap(); - let blocked_entry = items - .iter() - .find(|i| i["id"] == blocked["id"]) - .expect("Blocked present"); - let blocked_by: Vec<&str> = blocked_entry["blocked_by"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert_eq!(blocked_by, vec![dep_id.as_str()]); - - let dep_entry = items.iter().find(|i| i["id"] == dep["id"]).unwrap(); - assert_eq!(dep_entry["depended_on_by_count"], 1); - - let pull_next: Vec<&str> = groomed["pull_next"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert!(!pull_next.contains(&blocked["id"].as_str().unwrap())); - } - - #[test] - fn item_groom_detects_near_duplicate_names() { - let (_tmp, s) = harness(); - let a: serde_json::Value = serde_json::from_str( - &s.item(Parameters(empty_item_create( - "FIX-08 backlog low unassigned stale", - ))) - .unwrap(), - ) - .unwrap(); - let b: serde_json::Value = serde_json::from_str( - &s.item(Parameters(empty_item_create( - "FIX-09 backlog low unassigned stale duplicateish", - ))) - .unwrap(), - ) - .unwrap(); - - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let items = groomed["items"].as_array().unwrap(); - let a_entry = items.iter().find(|i| i["id"] == a["id"]).unwrap(); - let dups: Vec<&str> = a_entry["possible_duplicates"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert!(dups.contains(&b["id"].as_str().unwrap())); - } - - #[test] - fn item_update_sets_metadata() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Sized"))).unwrap()).unwrap(); - let updated: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "update".into(), - id: Some(created["id"].as_str().unwrap().to_string()), - metadata: Some(serde_json::json!({"size": "M"})), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!( - updated["metadata"], - serde_json::json!({"size": "M"}).to_string() - ); - } - - #[test] - fn item_groom_reads_size_and_flags_unestimated() { - let (_tmp, s) = harness(); - let sized: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "create".into(), - name: Some("Sized".into()), - metadata: Some(serde_json::json!({"size": "L"})), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let bare: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Bare"))).unwrap()).unwrap(); - - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let items = groomed["items"].as_array().unwrap(); - let sized_entry = items.iter().find(|i| i["id"] == sized["id"]).unwrap(); - assert_eq!(sized_entry["size"], "L"); - assert_eq!(sized_entry["unestimated"], false); - let bare_entry = items.iter().find(|i| i["id"] == bare["id"]).unwrap(); - assert_eq!(bare_entry["size"], serde_json::Value::Null); - assert_eq!(bare_entry["unestimated"], true); - assert_eq!(groomed["unestimated_count"], 1); - } - - /// Regression: some callers double-encode an object-typed `metadata` param - /// as a JSON string containing JSON — reproduced live via item(create) - /// with metadata={"size":"S"}, which stored `"{\"size\": \"S\"}"` (a - /// string) rather than the object itself. `groom` must still read `size` - /// through that extra layer instead of silently reporting `unestimated`. - #[test] - fn item_groom_reads_size_through_double_encoded_metadata() { - let (_tmp, s) = harness(); - let double_encoded: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "create".into(), - name: Some("Double-encoded".into()), - metadata: Some(serde_json::Value::String( - serde_json::json!({"size": "M"}).to_string(), - )), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let entry = groomed["items"] - .as_array() - .unwrap() - .iter() - .find(|i| i["id"] == double_encoded["id"]) - .unwrap(); - assert_eq!(entry["size"], "M"); - assert_eq!(entry["unestimated"], false); - } - - #[test] - fn item_groom_capacity_buckets_now_next_later_and_needs_estimation() { - let (_tmp, s) = harness(); - let sized = |name: &str, size: &str| ItemRequest { - action: "create".into(), - name: Some(name.into()), - metadata: Some(serde_json::json!({"size": size})), - ..Default::default() - }; - let ready_a: serde_json::Value = - serde_json::from_str(&s.item(Parameters(sized("Ready A", "S"))).unwrap()).unwrap(); - let ready_b: serde_json::Value = - serde_json::from_str(&s.item(Parameters(sized("Ready B", "S"))).unwrap()).unwrap(); - let dep: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Dep"))).unwrap()).unwrap(); - let blocked: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - dependency_ids: Some(vec![dep["id"].as_str().unwrap().to_string()]), - ..sized("Blocked", "M") - })) - .unwrap(), - ) - .unwrap(); - let unestimated: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Unsized"))).unwrap()) - .unwrap(); - - // No capacity: buckets omitted entirely (backward compatible). - let unbucketed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert!(unbucketed.get("now").is_none()); - - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - capacity: Some(1), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let ids = |key: &str| -> Vec { - groomed[key] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap().to_string()) - .collect() - }; - let now = ids("now"); - let next = ids("next"); - assert_eq!(now.len(), 1, "capacity=1 caps now to 1 ready item"); - assert!( - now.contains(&ready_a["id"].as_str().unwrap().to_string()) - || now.contains(&ready_b["id"].as_str().unwrap().to_string()) - ); - // Whichever ready item didn't make `now` spills into `next`. - assert_eq!(now.len() + next.len(), 2); - assert_eq!(ids("later"), vec![blocked["id"].as_str().unwrap()]); - // "Dep" has no size either — unestimated, same as the dedicated "Unsized" item. - let mut needs_est = ids("needs_estimation"); - needs_est.sort_unstable(); - let mut expected = vec![ - dep["id"].as_str().unwrap().to_string(), - unestimated["id"].as_str().unwrap().to_string(), - ]; - expected.sort_unstable(); - assert_eq!(needs_est, expected); - } - - /// Regression (CodeRabbit): standup's "done" filter and health's - /// velocity bucketing must key off `completed_at`, not `updated_at` — - /// editing an already-completed item (e.g. fixing a typo) bumps - /// `updated_at` without re-completing it, and must not make old work - /// spuriously reappear as "just done" or shift which week it counts in. - #[test] - fn item_standup_and_health_use_completed_at_not_updated_at() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Old work"))).unwrap()) - .unwrap(); - let project_id = created["project_id"].as_str().unwrap().to_string(); - let id = created["id"].as_str().unwrap().to_string(); - let conn = backend_conn(&_tmp); - let completed_state = agentflare_backend::state::list_by_project(&conn, &project_id) - .unwrap() - .into_iter() - .find(|st| st.group_name == "completed") - .unwrap() - .id; - drop(conn); - s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(id.clone()), - state_id: Some(completed_state), - ..Default::default() - })) - .unwrap(); - - // Simulate: completed long ago, then edited just now (updated_at - // recent, completed_at old) — direct SQL, no clock control in tests. - let old_ts = 1_700_000_000_i64; // long before "now" in this fixture era - let conn = backend_conn(&_tmp); - conn.execute( - "UPDATE items SET completed_at = ?1 WHERE id = ?2", - rusqlite::params![old_ts, id], - ) - .unwrap(); - drop(conn); - s.item(Parameters(ItemRequest { - action: "update".into(), - id: Some(id.clone()), - description: Some("fixed a typo".into()), - ..Default::default() - })) - .unwrap(); - - let standup: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "standup".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert!( - !standup["done"] - .as_array() - .unwrap() - .iter() - .any(|i| i["id"] == id), - "editing an old completed item must not resurrect it in 'done'" - ); - - let health: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "health".into(), - window_weeks: Some(1), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!( - health["velocity"][0]["completed_count"], 0, - "an old completion must not count in this week's velocity just because it was edited" - ); - } - - #[test] - fn item_standup_buckets_done_in_progress_grouped_and_stuck() { - let (_tmp, s) = harness(); - let project_id: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("bootstrap"))).unwrap()) - .unwrap(); - let project_id = project_id["project_id"].as_str().unwrap().to_string(); - let conn = backend_conn(&_tmp); - let states = agentflare_backend::state::list_by_project(&conn, &project_id).unwrap(); - let started_state = states - .iter() - .find(|st| st.group_name == "started") - .unwrap() - .id - .clone(); - let completed_state = states - .iter() - .find(|st| st.group_name == "completed") - .unwrap() - .id - .clone(); - drop(conn); - - let move_to = |name: &str, assignee: Option<&str>, state_id: &str| -> serde_json::Value { - let created: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "create".into(), - name: Some(name.into()), - assignee_agent: assignee.map(String::from), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(created["id"].as_str().unwrap().to_string()), - state_id: Some(state_id.to_string()), - ..Default::default() - })) - .unwrap(); - created - }; - - let wip_alice = move_to("WIP Alice", Some("alice"), &started_state); - let _wip_bob = move_to("WIP Bob", Some("bob"), &started_state); - let _wip_unassigned = move_to("WIP Unassigned", None, &started_state); - let done_item = move_to("Done item", Some("alice"), &completed_state); - - let standup: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "standup".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - assert_eq!(standup["done_count"], 1); - assert_eq!(standup["done"][0]["id"], done_item["id"]); - assert_eq!(standup["in_progress_count"], 3); - let groups: Vec<&str> = standup["in_progress"] - .as_array() - .unwrap() - .iter() - .map(|g| g["assignee"].as_str().unwrap()) - .collect(); - assert_eq!(groups, vec!["alice", "bob", "unassigned"]); - let alice_group = standup["in_progress"] - .as_array() - .unwrap() - .iter() - .find(|g| g["assignee"] == "alice") - .unwrap(); - assert_eq!(alice_group["items"][0]["id"], wip_alice["id"]); - // Nothing is 7+ days old in a freshly-created fixture. - assert_eq!(standup["stuck_count"], 0); - } - - #[test] - fn item_health_reports_velocity_wip_and_bottleneck_placeholder() { - let (_tmp, s) = harness(); - let project_id: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("bootstrap"))).unwrap()) - .unwrap(); - let project_id = project_id["project_id"].as_str().unwrap().to_string(); - let conn = backend_conn(&_tmp); - let states = agentflare_backend::state::list_by_project(&conn, &project_id).unwrap(); - let started_state = states - .iter() - .find(|st| st.group_name == "started") - .unwrap() - .id - .clone(); - let completed_state = states - .iter() - .find(|st| st.group_name == "completed") - .unwrap() - .id - .clone(); - drop(conn); - - let move_to = |name: &str, state_id: &str| { - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create(name))).unwrap()) - .unwrap(); - s.item(Parameters(ItemRequest { - action: "update_state".into(), - id: Some(created["id"].as_str().unwrap().to_string()), - state_id: Some(state_id.to_string()), - ..Default::default() - })) - .unwrap(); - }; - move_to("Done 1", &completed_state); - move_to("Done 2", &completed_state); - move_to("WIP", &started_state); - - let health: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "health".into(), - window_weeks: Some(2), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - - let velocity = health["velocity"].as_array().unwrap(); - assert_eq!(velocity.len(), 2, "oldest -> newest, 2 requested windows"); - assert_eq!( - velocity[1]["completed_count"], 2, - "current week has both Done items" - ); - assert_eq!(velocity[0]["completed_count"], 0, "prior week is empty"); - assert_eq!(health["velocity_trend"], "up"); - assert_eq!(health["wip_count"], 1); - assert_eq!(health["stuck_count"], 0); - assert_eq!(health["bottlenecks"].as_array().unwrap().len(), 0); - assert!( - health["bottleneck_note"] - .as_str() - .unwrap() - .contains("no handoff history") - ); - } - - /// Regression (CodeRabbit): an absurd `window_weeks` must be clamped, - /// not used to size a `Vec` directly — otherwise a caller - /// passing e.g. `i64::MAX` drives a near-infinite allocation while the - /// backend DB lock is held. - #[test] - fn item_health_clamps_window_weeks_to_a_sane_maximum() { - let (_tmp, s) = harness(); - let health: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "health".into(), - window_weeks: Some(i64::MAX), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(health["window_weeks"], 52); - assert_eq!(health["velocity"].as_array().unwrap().len(), 52); - } - - /// Regression (CodeRabbit): an absurd groom `limit` must be clamped — - /// bounds the O(n^2) duplicate-detection pass and the SQLite `IN (...)` - /// parameter list built from the shortlist. - #[test] - fn item_groom_clamps_limit_to_a_sane_maximum() { - let (_tmp, s) = harness(); - let groomed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "groom".into(), - limit: Some(i64::MAX), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert!(groomed["items"].as_array().unwrap().len() <= 200); - } - - /// Real measured comparison, not an estimate: one `groom` call vs. the - /// `list` + N×`get` path it replaces, against a backlog-sized dataset (60 - /// items — close to this project's real ~40-item backlog) with dependency - /// edges so `groom`'s blocked/fan-in computation does real work too. Not a - /// hard perf gate (`#[ignore]`, run explicitly) — timing assertions in CI - /// are flaky; this is for a human to re-run and read the numbers. - #[test] - #[ignore = "manual benchmark — run with: cargo test item_groom_benchmark -- --ignored --nocapture"] - fn item_groom_benchmark() { - let (_tmp, s) = harness(); - let mut ids: Vec = Vec::with_capacity(60); - for n in 0..60 { - let priority = ["urgent", "high", "medium", "low", "none"][n % 5]; - let created: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "create".into(), - name: Some(format!("Benchmark item {n}")), - description: Some( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(20), - ), - priority: Some(priority.into()), - dependency_ids: if n > 0 && n % 7 == 0 { - Some(vec![ids[n - 1].clone()]) - } else { - None - }, - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - ids.push(created["id"].as_str().unwrap().to_string()); - } - - let groom_start = std::time::Instant::now(); - let groomed = s - .item(Parameters(ItemRequest { - action: "groom".into(), - ..Default::default() - })) - .unwrap(); - let groom_elapsed = groom_start.elapsed(); - - let old_start = std::time::Instant::now(); - let listed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "list".into(), - state_group: Some("backlog,unstarted".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let shortlist_ids: Vec = listed - .as_array() - .unwrap() - .iter() - .take(15) - .map(|i| i["id"].as_str().unwrap().to_string()) - .collect(); - for id in &shortlist_ids { - s.item(Parameters(ItemRequest { - action: "get".into(), - id: Some(id.clone()), - ..Default::default() - })) - .unwrap(); - } - let old_elapsed = old_start.elapsed(); - - println!( - "groom (1 call): {groom_elapsed:?} | list+{}xget (old path): {old_elapsed:?} | speedup: {:.1}x", - shortlist_ids.len(), - old_elapsed.as_secs_f64() / groom_elapsed.as_secs_f64().max(1e-9) - ); - assert!(groomed.contains("pull_next")); - } - - #[test] - fn item_list_respects_limit_and_offset() { - let (_tmp, s) = harness(); - for name in ["A", "B", "C"] { - s.item(Parameters(empty_item_create(name))).unwrap(); - } - let listed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "list".into(), - limit: Some(1), - offset: Some(1), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let names: Vec<&str> = listed - .as_array() - .unwrap() - .iter() - .map(|i| i["name"].as_str().unwrap()) - .collect(); - assert_eq!(names, vec!["B"]); - } - - #[test] - fn item_list_returns_lean_projection_with_readable_state() { - let (_tmp, s) = harness(); - s.item(Parameters(empty_item_create("Test"))).unwrap(); - let listed: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "list".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let first = &listed.as_array().unwrap()[0]; - assert_eq!(first["state"], "Backlog"); - assert_eq!(first["state_group"], "backlog"); - assert!(first.get("description").is_none()); - assert!(first.get("metadata").is_none()); - } - - #[test] - fn resolve_workspace_id_creates_once_and_reuses() { - let (tmp, _s) = harness(); - let conn = backend_conn(&tmp); - let id1 = AgentflareMcp::resolve_workspace_id(&conn).unwrap(); - let id2 = AgentflareMcp::resolve_workspace_id(&conn).unwrap(); - assert_eq!(id1, id2); - } - - /// If `.agentflare/project.json` is deleted (wiped worktree, `rm -rf`, - /// etc.) while the project it pointed to still exists, resolving again - /// must reconnect to that same project — not silently fork a duplicate, - /// which would strand the original project's items. - #[test] - fn resolve_project_relinks_to_existing_project_when_link_file_is_deleted() { - let (tmp, s) = harness(); - let conn = backend_conn(&tmp); - let first = s.resolve_project(&conn).unwrap(); - - std::fs::remove_file(s.project_link_path()).unwrap(); - - let second = s.resolve_project(&conn).unwrap(); - assert_eq!( - first.id, second.id, - "must reconnect to the same project, not fork a duplicate" - ); - let all = - agentflare_backend::project::list_by_workspace(&conn, &first.workspace_id).unwrap(); - assert_eq!( - all.len(), - 1, - "no duplicate project should have been created: {all:?}" - ); - } - - /// Two different repos can easily share a directory basename (or, for - /// non-git dirs, no distinguishing info at all beyond the name). They - /// must never be conflated into one project just because they'd derive - /// the same display identifier — each gets its own project, with the - /// second disambiguated by a suffix. - #[test] - fn resolve_project_does_not_conflate_different_repos_with_the_same_derived_name() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("backend.db"); - let s1 = AgentflareMcp { - backend_db_override: Some(db_path.clone()), - backend_project_link_override: Some(tmp.path().join("link1.json")), - backend_repo_key_override: Some("path:/repo/one".to_string()), - ..Default::default() - }; - let s2 = AgentflareMcp { - backend_db_override: Some(db_path.clone()), - backend_project_link_override: Some(tmp.path().join("link2.json")), - backend_repo_key_override: Some("path:/repo/two".to_string()), - ..Default::default() - }; - let conn = agentflare_backend::db::open_db(&db_path).unwrap(); - let p1 = s1.resolve_project(&conn).unwrap(); - let p2 = s2.resolve_project(&conn).unwrap(); - assert_ne!( - p1.id, p2.id, - "different repos must never share a project even with the same derived name" - ); - assert_ne!( - p1.identifier, p2.identifier, - "the second project must get a disambiguating suffix" - ); - - // Each keeps resolving to its own project on repeat calls. - assert_eq!(s1.resolve_project(&conn).unwrap().id, p1.id); - assert_eq!(s2.resolve_project(&conn).unwrap().id, p2.id); - } - - /// Non-git projects need the same "root is stable no matter which - /// subdirectory you're in" guarantee git repos get for free from `git - /// rev-parse --show-toplevel` — otherwise the same project would split - /// across multiple `.agentflare/project.json` files depending on which - /// subdirectory a tool happened to be called from. - #[test] - fn find_root_from_walks_up_to_the_nearest_marker() { - // Bounding "home" at the tempdir's own parent contains the walk - // entirely within this test's constructed tree — passing some - // unrelated path here would NOT do that: the walk follows the real - // filesystem's `.parent()` chain regardless, so it would keep - // climbing past `root` into real ancestor directories (which may - // have their own real markers, e.g. this machine's actual - // `~/.agentflare`) until it happened to reach that unrelated path, - // which — not being a real ancestor — it never would, walking all - // the way to the filesystem root instead. - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let home = root.parent().unwrap(); - std::fs::write(root.join("package.json"), "{}").unwrap(); - let deep = root.join("src").join("nested").join("deep"); - std::fs::create_dir_all(&deep).unwrap(); - - assert_eq!(AgentflareMcp::find_root_from(&deep, home), root); - assert_eq!(AgentflareMcp::find_root_from(root, home), root); - } - - #[test] - fn find_root_from_prefers_an_existing_agentflare_link_over_other_markers() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let home = root.parent().unwrap(); - // A nested directory with its own marker (e.g. a sub-package) must - // not shadow an ancestor's existing project link — the - // .agentflare pass runs before the ROOT_MARKERS pass for - // exactly this reason. - std::fs::create_dir_all(root.join(".agentflare")).unwrap(); - let sub = root.join("packages").join("sub"); - std::fs::create_dir_all(&sub).unwrap(); - std::fs::write(sub.join("package.json"), "{}").unwrap(); - - assert_eq!(AgentflareMcp::find_root_from(&sub, home), root); - assert_eq!(AgentflareMcp::find_root_from(root, home), root); - } - - /// The boundary itself: a directory that IS `home` must never be - /// treated as a project root, even if it happens to contain a marker — - /// this is what keeps the global `~/.agentflare` data dir from ever - /// being mistaken for a per-repo link. - #[test] - fn find_root_from_never_resolves_to_home_itself() { - let home = tempfile::tempdir().unwrap(); - // Stands in for the real global data dir at ~/.agentflare. - std::fs::create_dir_all(home.path().join(".agentflare")).unwrap(); - let start = home.path().join("some_project"); - std::fs::create_dir_all(&start).unwrap(); - - // `start` itself has no marker, and home — one level up — does. If - // the walk checked markers at `home`, this would return `home`. It - // must instead stop short of ever inspecting `home` and fall back - // to `start`. - assert_eq!(AgentflareMcp::find_root_from(&start, home.path()), start); - } - - // No test for the "nothing found anywhere above" fallback: `find_root_from` - // walks all the way to the filesystem root, so a tempdir-based test would - // depend on what markers happen to exist above the OS temp directory on - // whatever machine runs this — not a property this test can control. The - // fallback itself is a single trivial `None => return start`. - - #[test] - fn asset_attach_get_list_delete_round_trip() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - - let item: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("asset-test"))).unwrap()) - .unwrap(); - let item_id = item["id"].as_str().unwrap().to_string(); - - let content = b"hello asset test"; - std::fs::write(staging.join("test.txt"), content).unwrap(); - - let attached: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some(item_id.clone()), - project_id: None, - filename: Some("test.txt".into()), - metadata: Some(r#"{"source":"test"}"#.into()), - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(attached["filename"], "test.txt"); - let asset_id = attached["id"].as_str().unwrap().to_string(); - - let got: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "get".into(), - id: Some(asset_id.clone()), - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(got["asset"]["filename"], "test.txt"); - assert!(got["content"].as_str().is_some()); - - let list: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "list".into(), - id: None, - item_id: Some(item_id.clone()), - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(list.as_array().unwrap().len(), 1); - assert_eq!(list[0]["id"], asset_id); - - let del: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "delete".into(), - id: Some(asset_id.clone()), - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(del["deleted"], true); - - let after: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "list".into(), - id: None, - item_id: Some(item_id), - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert!(after.as_array().unwrap().is_empty()); - }); - } - - #[test] - fn asset_attach_rejects_path_traversal() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let err = s - .asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some("item-1".into()), - project_id: None, - filename: Some("../etc/hosts".into()), - metadata: None, - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - }); - } - - #[test] - fn asset_attach_rejects_missing_filename() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let err = s - .asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some("item-1".into()), - project_id: None, - filename: None, - metadata: None, - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - }); - } - - #[test] - fn asset_attach_rejects_both_item_and_project() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let err = s - .asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some("item-1".into()), - project_id: Some("proj-1".into()), - filename: Some("anything.txt".into()), - metadata: None, - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - }); - } - - #[test] - fn asset_get_rejects_missing_id() { - let (_tmp, s) = harness(); - let err = s - .asset(Parameters(AssetRequest { - action: "get".into(), - id: None, - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn asset_shared_storage_delete_safety() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - - let item1: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("shared-1"))).unwrap()) - .unwrap(); - let item2: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("shared-2"))).unwrap()) - .unwrap(); - let id1 = item1["id"].as_str().unwrap().to_string(); - let id2 = item2["id"].as_str().unwrap().to_string(); - - let content = b"same content for shared delete test"; - std::fs::write(staging.join("shared.txt"), content).unwrap(); - let asset1: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some(id1.clone()), - project_id: None, - filename: Some("shared.txt".into()), - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - let a1_id = asset1["id"].as_str().unwrap().to_string(); - - // re-stage the same content for item2 - std::fs::write(staging.join("shared.txt"), content).unwrap(); - let asset2: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some(id2.clone()), - project_id: None, - filename: Some("shared.txt".into()), - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - let a2_id = asset2["id"].as_str().unwrap().to_string(); - - // delete first — second should still be readable - let del1: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "delete".into(), - id: Some(a1_id.clone()), - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(del1["deleted"], true); - - let got2_raw = s - .asset(Parameters(AssetRequest { - action: "get".into(), - id: Some(a2_id.clone()), - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(); - let got2: serde_json::Value = serde_json::from_str(&got2_raw).unwrap(); - assert!( - got2["content"].as_str().is_some(), - "item2 must still be readable after item1 deletion: {got2_raw}" - ); - - // delete second — now file should be gone - let del2: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "delete".into(), - id: Some(a2_id.clone()), - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(del2["deleted"], true); - }); - } - - #[test] - fn asset_content_dedup() { - crate::paths::test_support::with_temp_home(|| { - let (tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - - let item_a: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("dedup-a"))).unwrap()) - .unwrap(); - let item_b: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("dedup-b"))).unwrap()) - .unwrap(); - let id_a = item_a["id"].as_str().unwrap().to_string(); - let id_b = item_b["id"].as_str().unwrap().to_string(); - - let content = b"dedup me please"; - std::fs::write(staging.join("dedup.txt"), content).unwrap(); - s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some(id_a.clone()), - project_id: None, - filename: Some("dedup.txt".into()), - metadata: None, - })) - .unwrap(); - - std::fs::write(staging.join("dedup.txt"), content).unwrap(); - s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some(id_b.clone()), - project_id: None, - filename: Some("dedup.txt".into()), - metadata: None, - })) - .unwrap(); - - // two rows, one file on disk: count unique storage_path values - let conn = backend_conn(&tmp); - let unique_paths: i64 = conn - .query_row( - "SELECT count(DISTINCT storage_path) FROM assets WHERE deleted_at IS NULL", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(unique_paths, 1, "same content must share one storage_path"); - - let total_rows: i64 = conn - .query_row( - "SELECT count(*) FROM assets WHERE deleted_at IS NULL", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(total_rows, 2, "two rows despite one file on disk"); - }); - } - - #[test] - fn asset_attach_to_project() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - - let project: serde_json::Value = serde_json::from_str( - &s.project(Parameters(ProjectRequest { - action: "info".into(), - })) - .unwrap(), - ) - .unwrap(); - let project_id = project["id"].as_str().unwrap().to_string(); - - std::fs::write(staging.join("project-file.txt"), b"project attachment").unwrap(); - let attached: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: None, - project_id: Some(project_id.clone()), - filename: Some("project-file.txt".into()), - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(attached["filename"], "project-file.txt"); - - let list: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "list".into(), - id: None, - item_id: None, - project_id: Some(project_id), - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(list.as_array().unwrap().len(), 1); - }); - } - - #[test] - fn asset_attach_rejects_neither_item_nor_project() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let err = s - .asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: None, - project_id: None, - filename: Some("anything.txt".into()), - metadata: None, - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - }); - } - - #[test] - fn asset_attach_rejects_nonexistent_item() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - std::fs::write(staging.join("f.txt"), b"data").unwrap(); - let err = s - .asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some("nonexistent-item".into()), - project_id: None, - filename: Some("f.txt".into()), - metadata: None, - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - }); - } - - #[test] - fn asset_attach_rejects_missing_staging_file() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let err = s - .asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some("item-1".into()), - project_id: None, - filename: Some("does-not-exist.txt".into()), - metadata: None, - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - }); - } - - #[test] - fn asset_attach_rejects_oversized_file() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - // write a file just past the default 5 MB limit - let big = vec![0u8; 5 * 1024 * 1024 + 1]; - std::fs::write(staging.join("big.bin"), &big).unwrap(); - let err = s - .asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some("item-1".into()), - project_id: None, - filename: Some("big.bin".into()), - metadata: None, - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - }); - } - - #[test] - fn asset_get_over_max_inline_omits_content() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - - let item: serde_json::Value = serde_json::from_str( - &s.item(Parameters(empty_item_create("big-inline-test"))) - .unwrap(), - ) - .unwrap(); - let item_id = item["id"].as_str().unwrap().to_string(); - - // write a small file, but set a tiny inline cap for this test - std::fs::write(staging.join("small.txt"), b"hello inline cap").unwrap(); - let attached: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some(item_id.clone()), - project_id: None, - filename: Some("small.txt".into()), - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - let asset_id = attached["id"].as_str().unwrap().to_string(); - - // override inline limit to 1 byte so our file exceeds it - // SAFETY: with_temp_home holds GLOBAL_STATE_LOCK so no concurrent env mutation. - let saved = std::env::var("AGENTFLARE_BACKEND_ASSET_MAX_INLINE_BYTES").ok(); - unsafe { std::env::set_var("AGENTFLARE_BACKEND_ASSET_MAX_INLINE_BYTES", "1") }; - let got: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "get".into(), - id: Some(asset_id), - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - assert!(got["content"].is_null()); - assert!(got["content_omitted_reason"].as_str().is_some()); - // restore to avoid leaking to sibling tests - match saved { - Some(v) => unsafe { - std::env::set_var("AGENTFLARE_BACKEND_ASSET_MAX_INLINE_BYTES", v) - }, - None => unsafe { - std::env::remove_var("AGENTFLARE_BACKEND_ASSET_MAX_INLINE_BYTES") - }, - } - }); - } - - #[test] - fn asset_get_returns_text_content_as_utf8_not_base64() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - - let item: serde_json::Value = serde_json::from_str( - &s.item(Parameters(empty_item_create("utf8-content-test"))) - .unwrap(), - ) - .unwrap(); - let item_id = item["id"].as_str().unwrap().to_string(); - - let body = "# Handoff\n\nImplement the fix \u{2192} land a PR. \u{2713}"; - std::fs::write(staging.join("note.md"), body.as_bytes()).unwrap(); - let attached: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some(item_id.clone()), - project_id: None, - filename: Some("note.md".into()), - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - let asset_id = attached["id"].as_str().unwrap().to_string(); - - let got: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "get".into(), - id: Some(asset_id), - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - - // Text assets must come back as readable UTF-8, not base64. - assert_eq!(got["encoding"].as_str(), Some("utf8")); - assert_eq!(got["content"].as_str(), Some(body)); - }); - } - - #[test] - fn asset_get_returns_base64_for_binary_with_valid_utf8_bytes() { - crate::paths::test_support::with_temp_home(|| { - let (_tmp, s) = harness(); - let home = crate::paths::home(); - let staging = home.join(".agentflare").join("staging"); - std::fs::create_dir_all(&staging).unwrap(); - - let item: serde_json::Value = serde_json::from_str( - &s.item(Parameters(empty_item_create("binary-utf8-test"))) - .unwrap(), - ) - .unwrap(); - let item_id = item["id"].as_str().unwrap().to_string(); - - // Bytes 0x00,0x01,0x02,0x03 are valid UTF-8, but this is an - // octet-stream (.bin) asset: it must come back Base64, not "utf8". - let raw = [0u8, 1, 2, 3]; - assert!( - std::str::from_utf8(&raw).is_ok(), - "precondition: valid UTF-8" - ); - std::fs::write(staging.join("blob.bin"), raw).unwrap(); - let attached: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "attach".into(), - id: None, - item_id: Some(item_id.clone()), - project_id: None, - filename: Some("blob.bin".into()), - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - let asset_id = attached["id"].as_str().unwrap().to_string(); - - let got: serde_json::Value = serde_json::from_str( - &s.asset(Parameters(AssetRequest { - action: "get".into(), - id: Some(asset_id), - item_id: None, - project_id: None, - filename: None, - metadata: None, - })) - .unwrap(), - ) - .unwrap(); - - // Binary MIME => Base64 regardless of UTF-8 validity. - assert_eq!(got["encoding"].as_str(), Some("base64")); - // Content must be the Base64 of the raw bytes, not merely labeled so. - assert_eq!(got["content"].as_str(), Some("AAECAw==")); - }); - } - - #[test] - fn item_comment_create_and_list_roundtrip() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - let comment: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "create".into(), - item_id: Some(item_id.clone()), - body: Some("Hello, world!".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(comment["body"], "Hello, world!"); - assert!(comment["author_agent"].as_str().unwrap().contains(':')); - - let comments: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "list".into(), - item_id: Some(item_id), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let arr = comments.as_array().unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["body"], "Hello, world!"); - } - - #[test] - fn item_comment_rejects_empty_body() { - let (_tmp, s) = harness(); - let err = s - .comment(Parameters(CommentRequest { - action: "create".into(), - item_id: Some("item-1".into()), - body: Some("".into()), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn item_comment_edit_succeeds_when_latest_and_own_and_unclaimed_by_other() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - let comment: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "create".into(), - item_id: Some(item_id.clone()), - body: Some("original".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let comment_id = comment["id"].as_str().unwrap().to_string(); - - let updated: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "edit".into(), - id: Some(comment_id.clone()), - body: Some("edited".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(updated["body"], "edited"); - } - - #[test] - fn item_comment_edit_rejected_when_comment_not_found() { - let (_tmp, s) = harness(); - let err = s - .comment(Parameters(CommentRequest { - action: "edit".into(), - id: Some("nonexistent".into()), - body: Some("edited".into()), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn item_comment_edit_rejected_when_different_agent() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - let comment_id = s - .with_backend_db(|conn| { - agentflare_backend::comment::create(conn, &item_id, "someone-else:1", "not mine") - .unwrap() - .id - }) - .unwrap(); - - let err = s - .comment(Parameters(CommentRequest { - action: "edit".into(), - id: Some(comment_id), - body: Some("edited".into()), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - assert!(err.message.contains("own comments")); - } - - #[test] - fn item_comment_edit_succeeds_across_sessions_of_same_agent() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - // Same agent, different session instance — e.g. a prior CLI - // invocation, or an MCP server process that has since restarted. - let agent = crate::claims::agent_of(&crate::claims::owner_id()).to_string(); - let earlier_session_author = format!("{agent}:some-earlier-session"); - - let comment_id = s - .with_backend_db(|conn| { - agentflare_backend::comment::create( - conn, - &item_id, - &earlier_session_author, - "mine, from an earlier session", - ) - .unwrap() - .id - }) - .unwrap(); - - let updated: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "edit".into(), - id: Some(comment_id), - body: Some("edited".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(updated["body"], "edited"); - } - - #[test] - fn item_comment_edit_uses_id_tiebreak_when_timestamps_collide() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - let first: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "create".into(), - item_id: Some(item_id.clone()), - body: Some("first".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let first_id = first["id"].as_str().unwrap().to_string(); - - let second: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "create".into(), - item_id: Some(item_id), - body: Some("second".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let second_id = second["id"].as_str().unwrap().to_string(); - - // Force both comments onto the same second-resolution timestamp, as - // happens routinely under real multi-agent traffic. Only the comment - // with the higher (later) UUIDv7 id should still count as latest. - s.with_backend_db(|conn| { - conn.execute( - "UPDATE item_comments SET created_at = 1000, updated_at = 1000", - [], - ) - .unwrap(); - }) - .unwrap(); - - let err = s - .comment(Parameters(CommentRequest { - action: "edit".into(), - id: Some(first_id), - body: Some("edited".into()), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - - let updated: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "edit".into(), - id: Some(second_id), - body: Some("edited".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(updated["body"], "edited"); - } - - #[test] - fn item_comment_delete_succeeds_when_latest_and_own() { - let (_tmp, s) = harness(); - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - let comment: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "create".into(), - item_id: Some(item_id), - body: Some("delete-me".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let comment_id = comment["id"].as_str().unwrap().to_string(); - - let result: serde_json::Value = serde_json::from_str( - &s.comment(Parameters(CommentRequest { - action: "delete".into(), - id: Some(comment_id), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(result["deleted"], true); - } - - #[test] - fn item_claim_response_includes_worktree_path() { - let tmp = tempfile::tempdir().unwrap(); - // Isolated temp repo — this test must never run real `git - // worktree`/branch operations against the actual repository running - // the test suite. - let repo_dir = tempfile::tempdir().unwrap(); - let repo_root = repo_dir.path().to_path_buf(); - let run_git = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .current_dir(&repo_root) - .output() - .unwrap() - }; - run_git(&["init", "-b", "master"]); - run_git(&["config", "user.email", "test@test.com"]); - run_git(&["config", "user.name", "Test"]); - run_git(&["commit", "--allow-empty", "-m", "initial"]); - - let s = AgentflareMcp { - backend_db_override: Some(tmp.path().join("backend.db")), - backend_project_link_override: Some(tmp.path().join("project.json")), - worktree_repo_root_override: Some(repo_root), - ..Default::default() - }; - - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - let result: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "claim".into(), - id: Some(item_id), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(result["status"], "acquired"); - assert!(result.get("worktree_path").is_some()); - let path = result["worktree_path"].as_str().unwrap(); - assert!(std::path::Path::new(path).exists()); - // `next` is now a protocol-level decoration injected by - // `call_tool`, not in the direct method output. - assert!(result.get("next").is_none()); - } - - #[test] - fn item_done_without_new_commits_omits_pr_fields() { - let tmp = tempfile::tempdir().unwrap(); - let repo_dir = tempfile::tempdir().unwrap(); - let repo_root = repo_dir.path().to_path_buf(); - let run_git = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .current_dir(&repo_root) - .output() - .unwrap() - }; - run_git(&["init", "-b", "master"]); - run_git(&["config", "user.email", "test@test.com"]); - run_git(&["config", "user.name", "Test"]); - run_git(&["commit", "--allow-empty", "-m", "initial"]); - - let s = AgentflareMcp { - backend_db_override: Some(tmp.path().join("backend.db")), - backend_project_link_override: Some(tmp.path().join("project.json")), - worktree_repo_root_override: Some(repo_root), - ..Default::default() - }; - - let created: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = created["id"].as_str().unwrap().to_string(); - - s.item(Parameters(ItemRequest { - action: "claim".into(), - id: Some(item_id.clone()), - ..Default::default() - })) - .unwrap(); - - // No commits were made in the claimed worktree, so `done` has - // nothing to push/PR — must not attempt a real push (no remote - // configured on this throwaway repo). - let result: serde_json::Value = serde_json::from_str( - &s.item(Parameters(ItemRequest { - action: "done".into(), - id: Some(item_id), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(result["done"], true); - assert!(result.get("pr_url").is_none()); - assert!(result.get("next").is_none()); - } - - #[test] - fn item_rejects_unknown_action() { - let (_tmp, s) = harness(); - let err = s - .item(Parameters(ItemRequest { - action: "nonexistent".into(), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn label_rejects_unknown_action() { - let (_tmp, s) = harness(); - let err = s - .label(Parameters(LabelRequest { - action: "nonexistent".into(), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn label_create_list_update_delete_via_mcp() { - let (_tmp, s) = harness(); - // create - let created: serde_json::Value = serde_json::from_str( - &s.label(Parameters(LabelRequest { - action: "create".into(), - name: Some("bug".into()), - color: Some("#EF4444".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - let id = created["id"].as_str().unwrap().to_string(); - assert_eq!(created["name"], "bug"); - assert_eq!(created["color"], "#EF4444"); - - // list shows it - let listed: serde_json::Value = serde_json::from_str( - &s.label(Parameters(LabelRequest { - action: "list".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(listed.as_array().unwrap().len(), 1); - assert_eq!(listed[0]["id"], id); - - // update renames + recolors - let updated: serde_json::Value = serde_json::from_str( - &s.label(Parameters(LabelRequest { - action: "update".into(), - id: Some(id.clone()), - name: Some("defect".into()), - color: Some("#F59E0B".into()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(updated["name"], "defect"); - assert_eq!(updated["color"], "#F59E0B"); - - // delete - let deleted: serde_json::Value = serde_json::from_str( - &s.label(Parameters(LabelRequest { - action: "delete".into(), - id: Some(id.clone()), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert_eq!(deleted["deleted"], true); - - // list is now empty - let after: serde_json::Value = serde_json::from_str( - &s.label(Parameters(LabelRequest { - action: "list".into(), - ..Default::default() - })) - .unwrap(), - ) - .unwrap(); - assert!(after.as_array().unwrap().is_empty()); - } - - #[test] - fn label_update_requires_id() { - let (_tmp, s) = harness(); - let err = s - .label(Parameters(LabelRequest { - action: "update".into(), - name: Some("x".into()), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn item_add_label_rejects_foreign_project_label_via_mcp() { - let (tmp, s) = harness(); - // Auto-provisions this repo's workspace + project. - let item: serde_json::Value = - serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); - let item_id = item["id"].as_str().unwrap().to_string(); - - // A label belonging to a completely separate workspace/project. - let foreign_label_id = { - let conn = backend_conn(&tmp); - let ws = agentflare_backend::workspace::create( - &conn, - agentflare_backend::workspace::CreateWorkspace { - name: "Other".into(), - slug: "other".into(), - owner_agent: None, - item_label: None, - }, - ) - .unwrap(); - let proj = agentflare_backend::project::create( - &conn, - agentflare_backend::project::CreateProject { - workspace_id: ws.id.clone(), - name: "Other".into(), - identifier: "OTH".into(), - external_source: None, - external_id: None, - }, - ) - .unwrap(); - agentflare_backend::label::create( - &conn, - agentflare_backend::label::CreateLabel { - project_id: Some(proj.id), - workspace_id: ws.id, - name: "bug".into(), - color: None, - parent_id: None, - sort_order: None, - external_source: None, - external_id: None, - }, - ) - .unwrap() - .id - }; - - let err = s - .item(Parameters(ItemRequest { - action: "add_label".into(), - id: Some(item_id), - label_id: Some(foreign_label_id), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn label_update_and_delete_reject_foreign_project_label() { - let (tmp, s) = harness(); - - // A label in a separate workspace/project, not the repo's resolved project. - let foreign_label_id = { - let conn = backend_conn(&tmp); - let ws = agentflare_backend::workspace::create( - &conn, - agentflare_backend::workspace::CreateWorkspace { - name: "Other".into(), - slug: "other".into(), - owner_agent: None, - item_label: None, - }, - ) - .unwrap(); - let proj = agentflare_backend::project::create( - &conn, - agentflare_backend::project::CreateProject { - workspace_id: ws.id.clone(), - name: "Other".into(), - identifier: "OTH".into(), - external_source: None, - external_id: None, - }, - ) - .unwrap(); - agentflare_backend::label::create( - &conn, - agentflare_backend::label::CreateLabel { - project_id: Some(proj.id), - workspace_id: ws.id, - name: "bug".into(), - color: None, - parent_id: None, - sort_order: None, - external_source: None, - external_id: None, - }, - ) - .unwrap() - .id - }; - - let upd = s - .label(Parameters(LabelRequest { - action: "update".into(), - id: Some(foreign_label_id.clone()), - name: Some("hijacked".into()), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(upd.code, rmcp::model::ErrorCode::INVALID_PARAMS); - - let del = s - .label(Parameters(LabelRequest { - action: "delete".into(), - id: Some(foreign_label_id.clone()), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(del.code, rmcp::model::ErrorCode::INVALID_PARAMS); - - // The foreign label must survive both rejected attempts unchanged. - let conn = backend_conn(&tmp); - let survivor = agentflare_backend::label::get(&conn, &foreign_label_id).unwrap(); - assert_eq!(survivor.name, "bug"); - } - - #[test] - fn webhook_rejects_unknown_action() { - let (_tmp, s) = harness(); - let err = s - .webhook(Parameters(WebhookRequest { - action: "nonexistent".into(), - ..Default::default() - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn project_rejects_unknown_action() { - let (_tmp, s) = harness(); - let err = s - .project(Parameters(ProjectRequest { - action: "nonexistent".into(), - })) - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn next_hint_claim_with_worktree_path() { - let json = serde_json::json!({"status": "acquired", "worktree_path": "/tmp/wt"}); - let hint = next_hint("item", &json).unwrap(); - assert!(hint.contains("worktree_path"), "{}", hint); - } - - #[test] - fn next_hint_done_with_pr_url() { - let json = serde_json::json!({"done": true, "pr_url": "https://github.com/x/pull/1"}); - let hint = next_hint("item", &json).unwrap(); - assert!(hint.contains("review/merge"), "{}", hint); - } - - #[test] - fn next_hint_handoff_always_returns_hint() { - let json = serde_json::json!({"item_id": "abc", "recipient": "x"}); - let hint = next_hint("handoff", &json).unwrap(); - assert!(hint.contains("inbox"), "{}", hint); - } - - #[test] - fn next_hint_unknown_tool_returns_none() { - let json = serde_json::json!({"result": "ok"}); - assert!(next_hint("asset", &json).is_none()); - } - - #[test] - fn next_hint_item_without_trigger_fields_returns_none() { - let json = serde_json::json!({"done": true}); - assert!(next_hint("item", &json).is_none()); - } - - #[test] - fn next_hint_non_object_input_returns_none() { - assert!(next_hint("item", &serde_json::Value::String("text".into())).is_none()); - } -} +mod tests; diff --git a/src/mcp_server/artifact.rs b/src/mcp_server/artifact.rs new file mode 100644 index 00000000..1e89a703 --- /dev/null +++ b/src/mcp_server/artifact.rs @@ -0,0 +1,164 @@ +//! `artifact` MCP tool handler body -- split out of mcp_server.rs (item #168). + +use super::*; + +impl AgentflareMcp { + pub fn artifact_impl(&self, req: ArtifactRequest) -> Result { + match req.action.as_str() { + "publish" => { + let name = req + .name + .ok_or_else(|| ErrorData::invalid_params("name is required", None))?; + if name.trim().is_empty() { + return Err(ErrorData::invalid_params("name is required", None)); + } + let content = req + .content + .ok_or_else(|| ErrorData::invalid_params("content is required", None))?; + if content.is_empty() { + return Err(ErrorData::invalid_params("content is required", None)); + } + let (store, base) = self.ensure_artifact_server()?; + let req2 = agentflare_artifacts::PublishRequest { + name, + artifact_type: agentflare_artifacts::ArtifactType::from( + req.r#type.as_deref().unwrap_or("text"), + ), + content, + session_id: req.session_id.unwrap_or_default(), + update_id: req.update_id, + label: req.label, + description: req.description, + favicon: req.favicon, + base_version: req.base_version, + sender: self.agent.clone(), + recipient: req.recipient, + thread_id: req.thread_id, + reply_to: req.reply_to, + git: Self::git_provenance(), + }; + let resp = store.publish(&req2).map_err(Self::artifact_error)?; + Ok(serde_json::to_string_pretty(&serde_json::json!({ "id": resp.id, "version": resp.version, "url": format!("{base}/{}", resp.id), "index": format!("{base}/") })).unwrap_or_default()) + } + "list" => { + let (store, base) = self.ensure_artifact_server()?; + let summaries = store + .list(req.session_id.as_deref()) + .map_err(Self::artifact_error)?; + let items: Vec = summaries + .iter() + .filter(|s| { + req.inbox_recipient + .as_deref() + .is_none_or(|r| s.recipient.as_deref() == Some(r)) + && req + .thread_id + .as_deref() + .is_none_or(|t| s.thread_id.as_deref() == Some(t)) + }) + .map(|s| { + let mut v = serde_json::to_value(s).unwrap_or_default(); + if let Some(obj) = v.as_object_mut() { + obj.insert("url".into(), serde_json::json!(format!("{base}/{}", s.id))); + } + v + }) + .collect(); + Ok(serde_json::to_string_pretty(&items).unwrap_or_default()) + } + "get" => { + let id = req + .id + .ok_or_else(|| ErrorData::invalid_params("id is required", None))?; + let (store, _) = self.ensure_artifact_server()?; + let artifact = match req.version { + Some(n) => store.get_version(&id, n), + None => store.get(&id), + } + .map_err(Self::artifact_error)?; + Ok(serde_json::to_string_pretty(&artifact).unwrap_or_default()) + } + "diff" => { + let id = req + .id + .ok_or_else(|| ErrorData::invalid_params("id is required", None))?; + let from_version = req + .from_version + .ok_or_else(|| ErrorData::invalid_params("from_version is required", None))?; + let (store, _) = self.ensure_artifact_server()?; + let to = match req.to_version { + Some(v) => v, + None => store.get(&id).map_err(Self::artifact_error)?.version, + }; + let diff = store + .diff(&id, from_version, to) + .map_err(Self::artifact_error)?; + Ok(serde_json::to_string_pretty(&diff).unwrap_or_default()) + } + "search" => { + let query = req + .query + .ok_or_else(|| ErrorData::invalid_params("query is required", None))?; + if query.trim().is_empty() { + return Err(ErrorData::invalid_params("query is required", None)); + } + let (store, base) = self.ensure_artifact_server()?; + let needle = query.to_lowercase(); + let mut hits = Vec::new(); + for summary in store + .list(req.session_id.as_deref()) + .map_err(Self::artifact_error)? + { + let name_hit = summary.name.to_lowercase().contains(&needle); + let desc_hit = summary + .description + .as_deref() + .is_some_and(|d| d.to_lowercase().contains(&needle)); + let content = store + .get(&summary.id) + .map(|a| a.content) + .unwrap_or_default(); + let content_pos = content.to_lowercase().find(&needle); + if !(name_hit || desc_hit || content_pos.is_some()) { + continue; + } + let snippet = content_pos.map(|pos| { + let mut start = pos.saturating_sub(40); + while !content.is_char_boundary(start) { + start -= 1; + } + let mut end = (pos + needle.len() + 40).min(content.len()); + while !content.is_char_boundary(end) { + end += 1; + } + content[start..end].to_string() + }); + let mut v = serde_json::to_value(&summary).unwrap_or_default(); + if let Some(obj) = v.as_object_mut() { + obj.insert( + "url".into(), + serde_json::json!(format!("{base}/{}", summary.id)), + ); + if let Some(snippet) = snippet { + obj.insert("snippet".into(), serde_json::json!(snippet)); + } + } + hits.push(v); + } + Ok(serde_json::to_string_pretty(&hits).unwrap_or_default()) + } + "delete" => { + let id = req + .id + .ok_or_else(|| ErrorData::invalid_params("id is required", None))?; + let (store, _) = self.ensure_artifact_server()?; + store.delete(&id).map_err(Self::artifact_error)?; + Ok(serde_json::json!({"deleted": id}).to_string()) + } + other => Err(ErrorData::invalid_params( + format!("unknown action: {other}"), + None, + )), + } + } +} diff --git a/src/mcp_server/asset.rs b/src/mcp_server/asset.rs new file mode 100644 index 00000000..af196d47 --- /dev/null +++ b/src/mcp_server/asset.rs @@ -0,0 +1,241 @@ +//! `asset` MCP tool handler body -- split out of mcp_server.rs (item #168). + +use super::*; + +impl AgentflareMcp { + pub fn asset_impl( + &self, + AssetRequest { + action, + id, + item_id, + project_id, + filename, + metadata, + }: AssetRequest, + ) -> Result { + match action.as_str() { + "attach" => { + let has_item = item_id.is_some(); + let has_project = project_id.is_some(); + if has_item == has_project { + return Err(ErrorData::invalid_params( + "exactly one of item_id or project_id is required for attach", + None, + )); + } + let fn_val = filename.ok_or_else(|| { + ErrorData::invalid_params("filename is required for attach", None) + })?; + // path traversal guard: reject filename with .. or absolute components + let staged_rel = std::path::Path::new(&fn_val); + if staged_rel + .components() + .any(|c| !matches!(c, std::path::Component::Normal(_))) + { + return Err(ErrorData::invalid_params( + format!( + "filename '{fn_val}' contains path separators or parent-refs — not allowed" + ), + None, + )); + } + let staging_dir = crate::paths::home().join(".agentflare").join("staging"); + let staged = staging_dir.join(&fn_val); + if !staged.exists() { + return Err(ErrorData::invalid_params( + format!( + "file not found at staging path: {} — write the file there before calling attach", + staged.display() + ), + None, + )); + } + let size = std::fs::metadata(&staged) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + .len(); + let max_attach = Self::asset_max_attach_bytes(); + if size > max_attach { + return Err(ErrorData::invalid_params( + format!( + "file is {} bytes, exceeds the {} byte attach limit", + size, max_attach + ), + None, + )); + } + let bytes = std::fs::read(&staged) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let hash = Self::content_hash(&bytes); + let meta = metadata.unwrap_or_else(|| "{}".to_string()); + self.with_backend_db(|conn| { + let ws_id = Self::resolve_workspace_id(conn)?; + let (entity_type, entity_id) = if has_item { + agentflare_backend::item::get(conn, item_id.as_ref().unwrap()) + .map_err(map_backend_err)?; + ("item_attachment", item_id.as_ref().unwrap().clone()) + } else { + agentflare_backend::project::get(conn, project_id.as_ref().unwrap()) + .map_err(map_backend_err)?; + ("project_attachment", project_id.as_ref().unwrap().clone()) + }; + let ext = std::path::Path::new(&fn_val) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let mime = Self::infer_mime_type(ext); + let stem = std::path::Path::new(&fn_val) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&fn_val); + let safe_stem: String = { + let s: String = stem + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + if s.is_empty() { "file".to_string() } else { s } + }; + let full_storage = if ext.is_empty() { + format!("{}/assets/{}-{}", ws_id, safe_stem, hash) + } else { + format!("{}/assets/{}-{}.{}", ws_id, safe_stem, hash, ext) + }; + let base_path = crate::paths::home().join(".agentflare"); + // only write if file doesn't already exist (same content already stored) + let target = base_path.join(&full_storage); + if !target.exists() { + agentflare_backend::asset::write_file(&base_path, &full_storage, &bytes) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + } + let asset = agentflare_backend::asset::create( + conn, + agentflare_backend::asset::CreateAsset { + workspace_id: Some(ws_id.clone()), + entity_type: entity_type.into(), + entity_id, + filename: fn_val.clone(), + size: size as i64, + mime_type: Some(mime), + metadata: Some(meta), + storage_path: Some(full_storage), + }, + ) + .map_err(map_backend_err)?; + // remove staging file only after the DB insert succeeds + let _ = std::fs::remove_file(&staged); + Ok( + serde_json::to_string_pretty(&Self::strip_storage_path(&asset)) + .unwrap_or_default(), + ) + })? + } + "get" => { + let id = + id.ok_or_else(|| ErrorData::invalid_params("id is required for get", None))?; + self.with_backend_db(|conn| { + let asset = agentflare_backend::asset::get(conn, &id) + .map_err(map_backend_err)?; + let base_path = crate::paths::home().join(".agentflare"); + let max_inline = Self::asset_max_inline_bytes(); + let meta = Self::strip_storage_path(&asset); + let size = asset.size as u64; + if size <= max_inline { + match agentflare_backend::asset::read_file(&base_path, &asset.storage_path) { + Ok(bytes) => { + // Textual MIME + valid UTF-8 => return readable text so + // callers don't decode every text asset; everything else + // (binary MIME, or invalid UTF-8) => Base64. + let (content, encoding) = match std::str::from_utf8(&bytes) { + Ok(text) if Self::mime_is_textual(asset.mime_type.as_deref()) => (text.to_string(), "utf8"), + _ => (base64_encode(&bytes), "base64"), + }; + let result = serde_json::json!({ + "asset": meta, + "content": content, + "encoding": encoding, + }); + Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) + } + Err(e) => { + let result = serde_json::json!({ + "asset": meta, + "content": null, + "content_omitted_reason": format!("could not read file: {}", e), + }); + Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) + } + } + } else { + let result = serde_json::json!({ + "asset": meta, + "content": null, + "content_omitted_reason": format!("file is {} bytes, exceeds the {} byte inline limit", size, max_inline), + }); + Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) + } + })? + } + "list" => self.with_backend_db(|conn| { + let ws_id = Self::resolve_workspace_id(conn)?; + let assets: Vec = match (item_id, project_id) { + (Some(iid), None) => { + agentflare_backend::asset::list_by_entity(conn, "item_attachment", &iid) + .map_err(map_backend_err)? + } + (None, Some(pid)) => { + agentflare_backend::asset::list_by_entity(conn, "project_attachment", &pid) + .map_err(map_backend_err)? + } + (Some(_), Some(_)) => { + return Err(ErrorData::invalid_params( + "only one of item_id or project_id allowed for list, not both", + None, + )); + } + (None, None) => { + let mut assets: Vec = Vec::new(); + for a in agentflare_backend::asset::list_by_workspace(conn, &ws_id) + .map_err(map_backend_err)? + { + assets.push(Self::strip_storage_path(&a)); + } + return Ok(serde_json::to_string_pretty(&assets).unwrap_or_default()); + } + }; + let mut stripped: Vec = Vec::new(); + for a in assets { + stripped.push(Self::strip_storage_path(&a)); + } + Ok(serde_json::to_string_pretty(&stripped).unwrap_or_default()) + })?, + "delete" => { + let id = + id.ok_or_else(|| ErrorData::invalid_params("id is required for delete", None))?; + self.with_backend_db(|conn| { + let asset = agentflare_backend::asset::get(conn, &id) + .map_err(map_backend_err)?; + // soft-delete the row + agentflare_backend::asset::delete(conn, &id) + .map_err(map_backend_err)?; + // only unlink from disk if no other live row references the same storage_path + let remaining: i64 = conn + .query_row( + "SELECT count(*) FROM assets WHERE storage_path = ?1 AND deleted_at IS NULL", + rusqlite::params![&asset.storage_path], + |r| r.get(0), + ) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + if remaining == 0 { + let base_path = crate::paths::home().join(".agentflare"); + let _ = agentflare_backend::asset::delete_file(&base_path, &asset.storage_path); + } + Ok(serde_json::json!({"deleted": true, "id": id}).to_string()) + })? + } + other => Err(ErrorData::invalid_params( + format!("unknown action '{other}'; expected attach|get|list|delete"), + None, + )), + } + } +} diff --git a/src/mcp_server/claim.rs b/src/mcp_server/claim.rs new file mode 100644 index 00000000..148655ba --- /dev/null +++ b/src/mcp_server/claim.rs @@ -0,0 +1,96 @@ +//! `claim` MCP tool handler body -- split out of mcp_server.rs (item #168). + +use super::*; + +impl AgentflareMcp { + pub fn claim_impl(&self, req: ClaimRequest) -> Result { + match req.action.as_str() { + "acquire" => { + let target = req + .target + .ok_or_else(|| ErrorData::invalid_params("target is required", None))?; + let repo_opt = req.repo; + let repo_overridden = repo_opt.as_ref().is_some_and(|r| !r.is_empty()); + let (conn, repo) = Self::claim_ctx(&target, repo_opt)?; + let owner = crate::claims::owner_id(); + let commit = if repo_overridden { + None + } else { + Self::git_provenance().and_then(|g| g.commit) + }; + let outcome = crate::claims::acquire( + &conn, + &repo, + &target, + &owner, + commit.as_deref(), + crate::claims::now(), + crate::claims::ttl_secs(), + ) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(match outcome { + crate::claims::Acquire::Acquired => serde_json::json!({ "status": "acquired", "repo": repo, "target": target, "owner": owner }), + crate::claims::Acquire::Held { owner: holder, age_secs } => serde_json::json!({ "status": "held", "repo": repo, "target": target, "owner": holder, "age_secs": age_secs }), + }.to_string()) + } + "heartbeat" => { + let target = req + .target + .ok_or_else(|| ErrorData::invalid_params("target is required", None))?; + let (conn, repo) = Self::claim_ctx(&target, req.repo)?; + let owner = crate::claims::owner_id(); + let ok = + crate::claims::heartbeat(&conn, &repo, &target, &owner, crate::claims::now()) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok( + serde_json::json!({ "refreshed": ok, "repo": repo, "target": target }) + .to_string(), + ) + } + "release" => { + let target = req + .target + .ok_or_else(|| ErrorData::invalid_params("target is required", None))?; + let (conn, repo) = Self::claim_ctx(&target, req.repo)?; + let owner = crate::claims::owner_id(); + let ok = crate::claims::release(&conn, &repo, &target, &owner) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok( + serde_json::json!({ "released": ok, "repo": repo, "target": target }) + .to_string(), + ) + } + "done" => { + let target = req + .target + .ok_or_else(|| ErrorData::invalid_params("target is required", None))?; + let (conn, repo) = Self::claim_ctx(&target, req.repo)?; + let owner = crate::claims::owner_id(); + let ok = crate::claims::done(&conn, &repo, &target, &owner, crate::claims::now()) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(serde_json::json!({ "done": ok, "repo": repo, "target": target }).to_string()) + } + "list" => { + let conn = Self::claim_db()?; + let scope = if req.all_repos { + None + } else { + Some(crate::claims::resolve_repo(req.repo).ok_or_else(|| ErrorData::invalid_params("could not determine repo — run in a git repo or pass repo=owner/name (or all_repos=true)", None))?) + }; + let claims = crate::claims::list( + &conn, + scope.as_deref(), + req.all, + crate::claims::now(), + crate::claims::ttl_secs(), + ) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(serde_json::to_string_pretty(&claims).unwrap_or_default()) + } + other => Err(ErrorData::invalid_params( + format!("unknown action: {other}"), + None, + )), + } + } +} diff --git a/src/mcp_server/comment.rs b/src/mcp_server/comment.rs new file mode 100644 index 00000000..c18cf4d1 --- /dev/null +++ b/src/mcp_server/comment.rs @@ -0,0 +1,164 @@ +//! `comment` MCP tool handler body -- split out of mcp_server.rs (item #168). + +use super::*; + +impl AgentflareMcp { + pub fn comment_impl(&self, req: CommentRequest) -> Result { + match req.action.as_str() { + "create" => { + let item_id = req.item_id.ok_or_else(|| { + ErrorData::invalid_params("item_id is required for create", None) + })?; + let body = req.body.ok_or_else(|| { + ErrorData::invalid_params("body is required for create", None) + })?; + if item_id.trim().is_empty() || body.trim().is_empty() { + return Err(ErrorData::invalid_params( + "item_id and body are required", + None, + )); + } + let author = crate::claims::owner_id(); + self.with_backend_db(|conn| { + let comment = + agentflare_backend::comment::create(conn, &item_id, &author, &body) + .map_err(map_backend_err)?; + Ok(serde_json::to_string_pretty(&comment).unwrap_or_default()) + })? + } + "edit" => { + let comment_id = req + .id + .ok_or_else(|| ErrorData::invalid_params("id is required for edit", None))?; + let body = req + .body + .ok_or_else(|| ErrorData::invalid_params("body is required for edit", None))?; + if comment_id.trim().is_empty() || body.trim().is_empty() { + return Err(ErrorData::invalid_params("id and body are required", None)); + } + let owner = crate::claims::owner_id(); + let now = crate::claims::now(); + let ttl = backend_claim_ttl_secs(); + self.with_backend_db(|conn| { + // The author/latest/claim checks and the write must be one + // transaction — otherwise a comment landing between the + // is_latest check and the write (routine under concurrent + // multi-agent access) can silently violate the + // "only the latest comment is editable" invariant. + let tx = conn + .unchecked_transaction() + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let comment = agentflare_backend::comment::get(&tx, &comment_id) + .map_err(map_backend_err)?; + if crate::claims::agent_of(&comment.author_agent) + != crate::claims::agent_of(&owner) + { + return Err(ErrorData::invalid_params( + "can only edit your own comments", + None, + )); + } + if !agentflare_backend::comment::is_latest(&tx, &comment) + .map_err(map_backend_err)? + { + return Err(ErrorData::invalid_params( + "comment is not the latest on this item — cannot edit", + None, + )); + } + if agentflare_backend::claim::has_active_claim_by_other( + &tx, + &comment.item_id, + &owner, + now, + ttl, + ) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + { + return Err(ErrorData::invalid_params( + "another agent has started work on this item — cannot edit", + None, + )); + } + let updated = agentflare_backend::comment::update(&tx, &comment_id, &body) + .map_err(map_backend_err)?; + tx.commit() + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(serde_json::to_string_pretty(&updated).unwrap_or_default()) + })? + } + "delete" => { + let comment_id = req + .id + .ok_or_else(|| ErrorData::invalid_params("id is required for delete", None))?; + if comment_id.trim().is_empty() { + return Err(ErrorData::invalid_params("id is required", None)); + } + let owner = crate::claims::owner_id(); + let now = crate::claims::now(); + let ttl = backend_claim_ttl_secs(); + self.with_backend_db(|conn| { + // See "edit" above: checks + write must be one transaction + // to close the same TOCTOU window. + let tx = conn + .unchecked_transaction() + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let comment = agentflare_backend::comment::get(&tx, &comment_id) + .map_err(map_backend_err)?; + if crate::claims::agent_of(&comment.author_agent) + != crate::claims::agent_of(&owner) + { + return Err(ErrorData::invalid_params( + "can only delete your own comments", + None, + )); + } + if !agentflare_backend::comment::is_latest(&tx, &comment) + .map_err(map_backend_err)? + { + return Err(ErrorData::invalid_params( + "comment is not the latest on this item — cannot delete", + None, + )); + } + if agentflare_backend::claim::has_active_claim_by_other( + &tx, + &comment.item_id, + &owner, + now, + ttl, + ) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + { + return Err(ErrorData::invalid_params( + "another agent has started work on this item — cannot delete", + None, + )); + } + agentflare_backend::comment::delete(&tx, &comment_id) + .map_err(map_backend_err)?; + tx.commit() + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(serde_json::json!({"deleted": true, "id": comment_id}).to_string()) + })? + } + "list" => { + let item_id = req.item_id.ok_or_else(|| { + ErrorData::invalid_params("item_id is required for list", None) + })?; + if item_id.trim().is_empty() { + return Err(ErrorData::invalid_params("item_id is required", None)); + } + self.with_backend_db(|conn| { + let comments = agentflare_backend::comment::list_by_item(conn, &item_id) + .map_err(map_backend_err)?; + Ok(serde_json::to_string_pretty(&comments).unwrap_or_default()) + })? + } + other => Err(ErrorData::invalid_params( + format!("unknown comment action: '{other}' — expected create|edit|delete|list"), + None, + )), + } + } +} diff --git a/src/mcp_server/flare_git.rs b/src/mcp_server/flare_git.rs new file mode 100644 index 00000000..319491ff --- /dev/null +++ b/src/mcp_server/flare_git.rs @@ -0,0 +1,257 @@ +//! `flare_git` MCP tool handler body -- split out of mcp_server.rs (item #168). + +use super::*; + +impl AgentflareMcp { + pub fn flare_git_impl(&self, req: GitHubRequest) -> Result { + use crate::github::{Client, RepoId, actions, issues, pulls, releases}; + + let repo = match &req.repo { + Some(r) => RepoId::parse(r) + .ok_or_else(|| ErrorData::invalid_params(format!("bad repo: {r}"), None))?, + None => RepoId::resolve_from_remote(&std::env::current_dir().unwrap_or_default()) + .ok_or_else(|| { + ErrorData::invalid_params( + "no repo given and could not resolve origin remote".to_string(), + None, + ) + })?, + }; + let client = Client::new().map_err(to_mcp_error)?; + + let out = match req.action.as_str() { + "pr_create" => { + let title = req + .title + .as_deref() + .ok_or_else(|| ErrorData::invalid_params("title is required", None))?; + Self::validate_conventional_pr_title(title) + .map_err(|e| ErrorData::invalid_params(e, None))?; + let head = req + .head + .as_deref() + .ok_or_else(|| ErrorData::invalid_params("head is required", None))?; + let base = req + .base + .as_deref() + .ok_or_else(|| ErrorData::invalid_params("base is required", None))?; + let pr = pulls::create(&client, &repo, title, head, base, req.body.as_deref()) + .map_err(to_mcp_error)?; + format!("Opened PR #{}: {}", pr.number, pr.html_url) + } + "pr_list" => { + let state = req.state.as_deref().unwrap_or("open"); + let prs = pulls::list(&client, &repo, state).map_err(to_mcp_error)?; + serde_json::to_string(&prs.iter().map(|p| &p.html_url).collect::>()) + .unwrap_or_default() + } + "pr_get" => { + let n = req + .number + .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; + let pr = pulls::get(&client, &repo, n).map_err(to_mcp_error)?; + format!( + "PR #{} [{}] {}: {}", + pr.number, pr.state, pr.title, pr.html_url + ) + } + "pr_merge" => { + let n = req + .number + .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; + let method = req.merge_method.as_deref().unwrap_or("merge"); + pulls::merge(&client, &repo, n, method).map_err(to_mcp_error)?; + format!("Merged PR #{n} ({method})") + } + "pr_comment" => { + let n = req + .number + .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; + let body = req + .body + .as_deref() + .ok_or_else(|| ErrorData::invalid_params("body is required", None))?; + pulls::comment(&client, &repo, n, body).map_err(to_mcp_error)?; + format!("Commented on PR #{n}") + } + "pr_request_review" => { + let n = req + .number + .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; + let reviewers = req.reviewers.clone().unwrap_or_default(); + pulls::request_review(&client, &repo, n, &reviewers).map_err(to_mcp_error)?; + format!("Requested review on PR #{n}") + } + "issue_create" => { + let title = req + .title + .as_deref() + .ok_or_else(|| ErrorData::invalid_params("title is required", None))?; + let labels = req.labels.clone().unwrap_or_default(); + let assignees = req.assignees.clone().unwrap_or_default(); + let issue = issues::create( + &client, + &repo, + title, + req.body.as_deref(), + &labels, + &assignees, + ) + .map_err(to_mcp_error)?; + format!("Opened issue #{}: {}", issue.number, issue.html_url) + } + "issue_list" => { + let state = req.state.as_deref().unwrap_or("open"); + let items = issues::list(&client, &repo, state).map_err(to_mcp_error)?; + serde_json::to_string(&items.iter().map(|i| &i.html_url).collect::>()) + .unwrap_or_default() + } + "issue_get" => { + let n = req + .number + .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; + let issue = issues::get(&client, &repo, n).map_err(to_mcp_error)?; + format!( + "Issue #{} [{}] {}: {}", + issue.number, issue.state, issue.title, issue.html_url + ) + } + "issue_comment" => { + let n = req + .number + .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; + let body = req + .body + .as_deref() + .ok_or_else(|| ErrorData::invalid_params("body is required", None))?; + issues::comment(&client, &repo, n, body).map_err(to_mcp_error)?; + format!("Commented on issue #{n}") + } + "issue_close" => { + let n = req + .number + .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; + let issue = issues::close(&client, &repo, n).map_err(to_mcp_error)?; + format!("Closed issue #{} [{}]", issue.number, issue.state) + } + "issue_label" => { + let n = req + .number + .ok_or_else(|| ErrorData::invalid_params("number is required", None))?; + let labels = req.labels.clone().unwrap_or_default(); + issues::add_labels(&client, &repo, n, &labels).map_err(to_mcp_error)?; + format!("Added {} label(s) to issue #{n}", labels.len()) + } + "release_list" => { + let rels = releases::list(&client, &repo).map_err(to_mcp_error)?; + serde_json::to_string(&rels.iter().map(|r| &r.tag_name).collect::>()) + .unwrap_or_default() + } + "release_get" => { + let id = req + .release_id + .ok_or_else(|| ErrorData::invalid_params("release_id is required", None))?; + let rel = releases::get(&client, &repo, id).map_err(to_mcp_error)?; + format!( + "Release {} [{}]: {}", + rel.tag_name, + if rel.prerelease { "pre" } else { "stable" }, + rel.html_url + ) + } + "release_latest" => { + let rel = releases::latest(&client, &repo).map_err(to_mcp_error)?; + format!("Latest: {} — {}", rel.tag_name, rel.html_url) + } + "release_create" => { + let tag = req + .tag + .as_deref() + .ok_or_else(|| ErrorData::invalid_params("tag is required", None))?; + let rel = releases::create( + &client, + &repo, + tag, + req.name.as_deref(), + req.body.as_deref(), + req.draft.unwrap_or(false), + req.prerelease.unwrap_or(false), + ) + .map_err(to_mcp_error)?; + format!("Created release {}: {}", rel.tag_name, rel.html_url) + } + "run_list" => { + let runs = actions::list_runs(&client, &repo, req.branch.as_deref()) + .map_err(to_mcp_error)?; + let summary: Vec = runs + .iter() + .map(|r| { + format!( + "{} {} {}", + r.id, + r.status, + r.conclusion.as_deref().unwrap_or("-") + ) + }) + .collect(); + serde_json::to_string(&summary).unwrap_or_default() + } + "run_get" => { + let id = req + .run_id + .ok_or_else(|| ErrorData::invalid_params("run_id is required", None))?; + let run = actions::get_run(&client, &repo, id).map_err(to_mcp_error)?; + format!( + "Run {} [{}/{}]: {}", + run.id, + run.status, + run.conclusion.as_deref().unwrap_or("-"), + run.html_url + ) + } + "run_rerun" => { + let id = req + .run_id + .ok_or_else(|| ErrorData::invalid_params("run_id is required", None))?; + actions::rerun(&client, &repo, id).map_err(to_mcp_error)?; + format!("Re-queued run {id}") + } + "workflow_dispatch" => { + let wf = req + .workflow + .as_deref() + .ok_or_else(|| ErrorData::invalid_params("workflow is required", None))?; + if req.inputs.as_ref().is_some_and(|v| !v.is_object()) { + return Err(ErrorData::invalid_params( + "inputs must be a JSON object", + None, + )); + } + let git_ref = match req.git_ref.as_deref() { + Some(r) => r.to_string(), + None => { + if req.repo.is_some() { + return Err(ErrorData::invalid_params( + "git_ref is required when repo is overridden (cannot infer the target repo default branch)", + None, + )); + } + crate::git::resolve_default_branch( + &std::env::current_dir().unwrap_or_default(), + ) + } + }; + actions::dispatch(&client, &repo, wf, &git_ref, req.inputs.as_ref()) + .map_err(to_mcp_error)?; + format!("Dispatched {wf} on {git_ref}") + } + other => { + return Err(ErrorData::invalid_params( + format!("unknown action: {other}"), + None, + )); + } + }; + Ok(out) + } +} diff --git a/src/mcp_server/handoff.rs b/src/mcp_server/handoff.rs new file mode 100644 index 00000000..c3118370 --- /dev/null +++ b/src/mcp_server/handoff.rs @@ -0,0 +1,132 @@ +//! `handoff` MCP tool handler body -- split out of mcp_server.rs (item #168). + +use super::*; + +impl AgentflareMcp { + pub fn handoff_impl( + &self, + HandoffRequest { + recipient, + name, + content, + r#type, + item_id, + thread_id, + reply_to, + description, + }: HandoffRequest, + ) -> Result { + if recipient.trim().is_empty() { + return Err(ErrorData::invalid_params( + "recipient is required for a handoff — without it the item lands with no assignee", + None, + )); + } + if name.trim().is_empty() { + return Err(ErrorData::invalid_params("name is required", None)); + } + if content.is_empty() { + return Err(ErrorData::invalid_params("content is required", None)); + } + let recipient = recipient.trim().to_string(); + let name = name.trim().to_string(); + let ext = match r#type.as_deref() { + Some("html") => "html", + Some("mermaid") | Some("diagram") => "mmd", + Some("text") => "txt", + _ => "md", + }; + + self.with_backend_db(|conn| { + let project = self.resolve_project(conn)?; + let ws_id = Self::resolve_workspace_id(conn)?; + + let item = match &item_id { + Some(id) => { + let input = agentflare_backend::item::UpdateItem { + assignee_agent: Some(recipient.clone()), + ..Default::default() + }; + agentflare_backend::item::update(conn, id, input).map_err(map_backend_err)? + } + None => { + let state_id = agentflare_backend::state::list_by_project(conn, &project.id) + .map_err(map_backend_err)? + .into_iter() + .find(|s| s.is_default) + .ok_or_else(|| { + ErrorData::internal_error("project has no default state", None) + })? + .id; + let metadata = thread_id + .as_ref() + .map(|t| serde_json::json!({ "thread": t }).to_string()); + let input = agentflare_backend::item::CreateItem { + project_id: project.id.clone(), + state_id, + name: name.clone(), + description: description.clone().or_else(|| Some(content.clone())), + priority: None, + parent_id: None, + assignee_agent: Some(recipient.clone()), + sort_order: None, + external_source: None, + external_id: None, + metadata, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }; + agentflare_backend::item::create(conn, input).map_err(map_backend_err)? + } + }; + + let bytes = content.as_bytes(); + let hash = Self::content_hash(bytes); + // Keyed on item.id, not name — name is the per-call brief and + // can legitimately differ between messages on the same item + // (e.g. a reply's brief vs. the original ask); keying on it + // would silently reset versioning to 1 instead of continuing + // the chain. + let safe_stem = Self::slugify(&item.id); + let filename = format!("{safe_stem}.{ext}"); + let full_storage = format!("{ws_id}/assets/{safe_stem}-{hash}.{ext}"); + let base_path = crate::paths::home().join(".agentflare"); + let target = base_path.join(&full_storage); + if !target.exists() { + agentflare_backend::asset::write_file(&base_path, &full_storage, bytes) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + } + let mut meta = serde_json::json!({ "sender": self.agent, "recipient": recipient }); + if let Some(t) = &thread_id { + meta["thread_id"] = serde_json::json!(t); + } + if let Some(r) = &reply_to { + meta["reply_to"] = serde_json::json!(r); + } + let asset = agentflare_backend::asset::create( + conn, + agentflare_backend::asset::CreateAsset { + workspace_id: Some(ws_id), + entity_type: "item_attachment".into(), + entity_id: item.id.clone(), + filename, + size: bytes.len() as i64, + mime_type: Some(Self::infer_mime_type(ext)), + metadata: Some(meta.to_string()), + storage_path: Some(full_storage), + }, + ) + .map_err(map_backend_err)?; + + let result = serde_json::json!({ + "item_id": item.id, + "item_sequence_id": item.sequence_id, + "asset_id": asset.id, + "asset_version": asset.version, + "recipient": recipient, + }); + Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) + })? + } +} diff --git a/src/mcp_server/memory_tool.rs b/src/mcp_server/memory_tool.rs new file mode 100644 index 00000000..045b1f3b --- /dev/null +++ b/src/mcp_server/memory_tool.rs @@ -0,0 +1,125 @@ +//! `memory` MCP tool handler body -- split out of mcp_server.rs (item #168). + +use super::*; + +impl AgentflareMcp { + pub fn memory_impl(&self, req: MemoryRequest) -> Result { + match req.action.as_str() { + "remember" => { + let title = req + .title + .ok_or_else(|| ErrorData::invalid_params("title is required", None))?; + let content = req + .content + .ok_or_else(|| ErrorData::invalid_params("content is required", None))?; + let r#type = req + .r#type + .ok_or_else(|| ErrorData::invalid_params("type is required", None))?; + let input = crate::memory::mcp::RememberInput { + title, + content, + r#type, + session_id: req.session_id, + project: req.project, + topic_key: req.topic_key, + scope: req.scope, + }; + crate::memory::mcp::handle_remember(input) + .map_err(|e| ErrorData::internal_error(e, None)) + } + "recall" => { + let input = crate::memory::mcp::RecallInput { + query: req.query, + id: req.id, + r#type: req.r#type, + project: req.project, + limit: req.limit, + }; + crate::memory::mcp::handle_recall(input) + .map_err(|e| ErrorData::internal_error(e, None)) + } + "context" => { + let input = crate::memory::mcp::ContextInput { + session_id: req.session_id, + project: req.project, + }; + crate::memory::mcp::handle_context(input) + .map_err(|e| ErrorData::internal_error(e, None)) + } + "handoff" => { + let session_id = req + .session_id + .ok_or_else(|| ErrorData::invalid_params("session_id is required", None))?; + let summary = req + .summary + .ok_or_else(|| ErrorData::invalid_params("summary is required", None))?; + let input = crate::memory::mcp::HandoffInput { + session_id, + summary, + findings: req.findings, + decisions: req.decisions, + files_touched: req.files_touched, + evidence: req.evidence, + }; + crate::memory::mcp::handle_handoff(input) + .map_err(|e| ErrorData::internal_error(e, None)) + } + "compact" => { + let input = crate::memory::mcp::CompactInput { + lines: req.content.unwrap_or_default(), + query: req.query, + compression_ratio: req.compression_ratio, + preserve_recent: req.preserve_recent, + scorer: req.scorer, + }; + crate::memory::mcp::handle_compact(input) + .map_err(|e| ErrorData::internal_error(e, None)) + } + "relate" => { + let source_id = req + .source_id + .ok_or_else(|| ErrorData::invalid_params("source_id is required", None))?; + let target_id = req + .target_id + .ok_or_else(|| ErrorData::invalid_params("target_id is required", None))?; + let relation = req + .relation + .ok_or_else(|| ErrorData::invalid_params("relation is required", None))?; + let input = crate::memory::mcp::RelateInput { + source_id, + target_id, + relation, + reason: req.reason, + confidence: req.confidence, + }; + crate::memory::mcp::handle_relate(input) + .map_err(|e| ErrorData::internal_error(e, None)) + } + "curate" => { + let id = req + .id + .ok_or_else(|| ErrorData::invalid_params("id is required", None))?; + let curate_action = req.curate_action.ok_or_else(|| { + ErrorData::invalid_params( + "curate_action is required (update|delete|pin|unpin)", + None, + ) + })?; + let input = crate::memory::mcp::CurateInput { + action: curate_action, + id, + title: req.title, + content: req.content, + r#type: req.r#type, + pinned: req.pinned, + }; + crate::memory::mcp::handle_curate(input) + .map_err(|e| ErrorData::internal_error(e, None)) + } + other => Err(ErrorData::invalid_params( + format!("unknown action: {other}"), + None, + )), + } + } +} diff --git a/src/mcp_server/review.rs b/src/mcp_server/review.rs new file mode 100644 index 00000000..2c39d7d7 --- /dev/null +++ b/src/mcp_server/review.rs @@ -0,0 +1,110 @@ +//! `review` MCP tool handler body -- split out of mcp_server.rs (item #168). + +use super::*; + +impl AgentflareMcp { + pub fn review_impl(&self, req: ReviewRequest) -> Result { + match req.action.as_str() { + "submit" => { + let findings = req + .findings + .ok_or_else(|| ErrorData::invalid_params("findings is required", None))?; + let conn = Self::claim_db()?; + let repo = Self::resolve_repo_or_err(req.repo)?; + let pr = Self::resolve_round(req.pr)?; + // SECURITY / step-3 classification (#75): the finder `agent` + // stays caller-settable BY DESIGN. Unlike artifact authorship, + // review findings live in a local, per-repo, single-user DB, and + // a `/code-review` orchestrator legitimately submits on behalf + // of many finder sub-agents — consensus counts DISTINCT finder + // names, so collapsing them to one server identity would break + // it. No cross-principal trust boundary exists here; the + // server-derived `submitter_name` is the fallback when unset. + let agent = req + .agent + .filter(|s| !s.is_empty()) + .unwrap_or_else(crate::review::submitter_name); + let parsed: Vec = findings + .into_iter() + .map(serde_json::from_value) + .collect::>() + .map_err(|e| { + ErrorData::invalid_params(format!("invalid finding: {e}"), None) + })?; + let n = + crate::review::submit(&conn, &repo, &pr, &agent, &parsed, crate::claims::now()) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok( + serde_json::json!({ "submitted": n, "repo": repo, "pr": pr, "agent": agent }) + .to_string(), + ) + } + "consensus" => { + let conn = Self::claim_db()?; + let repo = Self::resolve_repo_or_err(req.repo)?; + let pr = Self::resolve_round(req.pr)?; + let findings = crate::review::load(&conn, &repo, &pr) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let diff = crate::review::compute_diff(req.base.as_deref(), req.head.as_deref()) + .map_err(|e| ErrorData::invalid_params(e, None))?; + let changed = crate::review::changed_lines(&diff); + let result = crate::review::consensus(&findings, &changed); + Ok(serde_json::to_string_pretty(&result).unwrap_or_default()) + } + "list" => { + let conn = Self::claim_db()?; + let repo = Self::resolve_repo_or_err(req.repo)?; + let pr = Self::resolve_round(req.pr)?; + let findings = crate::review::load(&conn, &repo, &pr) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let rows: Vec = findings.iter().map(|sf| serde_json::json!({ "agent": sf.agent, "file": sf.finding.file, "line": sf.finding.line, "message": sf.finding.message, "severity": sf.finding.severity })).collect(); + Ok(serde_json::to_string_pretty(&rows).unwrap_or_default()) + } + "clear" => { + let conn = Self::claim_db()?; + let repo = Self::resolve_repo_or_err(req.repo)?; + let pr = Self::resolve_round(req.pr)?; + crate::review::clear(&conn, &repo, &pr) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(serde_json::json!({"cleared": true}).to_string()) + } + "record" => { + let conn = Self::claim_db()?; + let repo = Self::resolve_repo_or_err(req.repo)?; + let pr = Self::resolve_round(req.pr)?; + let findings = crate::review::load(&conn, &repo, &pr) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let diff = crate::review::compute_diff(req.base.as_deref(), req.head.as_deref()) + .map_err(|e| ErrorData::invalid_params(e, None))?; + let changed = crate::review::changed_lines(&diff); + let n = crate::review::record_round( + &conn, + &repo, + &pr, + &findings, + &changed, + crate::claims::now(), + ) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(serde_json::json!({ "recorded_agents": n, "repo": repo, "pr": pr }).to_string()) + } + "scores" => { + let conn = Self::claim_db()?; + let repo = req.repo; + let all_repos = req.all_repos; + let scope = if all_repos { + None + } else { + Some(Self::resolve_repo_or_err(repo)?) + }; + let scores = crate::review::scores(&conn, scope.as_deref()) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(serde_json::to_string_pretty(&scores).unwrap_or_default()) + } + other => Err(ErrorData::invalid_params( + format!("unknown action: {other}"), + None, + )), + } + } +} diff --git a/src/mcp_server/tests/action_tests.rs b/src/mcp_server/tests/action_tests.rs new file mode 100644 index 00000000..abd26055 --- /dev/null +++ b/src/mcp_server/tests/action_tests.rs @@ -0,0 +1,655 @@ +use super::*; + +#[test] +fn item_comment_create_and_list_roundtrip() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + let comment: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "create".into(), + item_id: Some(item_id.clone()), + body: Some("Hello, world!".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(comment["body"], "Hello, world!"); + assert!(comment["author_agent"].as_str().unwrap().contains(':')); + + let comments: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "list".into(), + item_id: Some(item_id), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let arr = comments.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["body"], "Hello, world!"); +} + +#[test] +fn item_comment_rejects_empty_body() { + let (_tmp, s) = harness(); + let err = s + .comment(Parameters(CommentRequest { + action: "create".into(), + item_id: Some("item-1".into()), + body: Some("".into()), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn item_comment_edit_succeeds_when_latest_and_own_and_unclaimed_by_other() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + let comment: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "create".into(), + item_id: Some(item_id.clone()), + body: Some("original".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let comment_id = comment["id"].as_str().unwrap().to_string(); + + let updated: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "edit".into(), + id: Some(comment_id.clone()), + body: Some("edited".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(updated["body"], "edited"); +} + +#[test] +fn item_comment_edit_rejected_when_comment_not_found() { + let (_tmp, s) = harness(); + let err = s + .comment(Parameters(CommentRequest { + action: "edit".into(), + id: Some("nonexistent".into()), + body: Some("edited".into()), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn item_comment_edit_rejected_when_different_agent() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + let comment_id = s + .with_backend_db(|conn| { + agentflare_backend::comment::create(conn, &item_id, "someone-else:1", "not mine") + .unwrap() + .id + }) + .unwrap(); + + let err = s + .comment(Parameters(CommentRequest { + action: "edit".into(), + id: Some(comment_id), + body: Some("edited".into()), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + assert!(err.message.contains("own comments")); +} + +#[test] +fn item_comment_edit_succeeds_across_sessions_of_same_agent() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + // Same agent, different session instance — e.g. a prior CLI + // invocation, or an MCP server process that has since restarted. + let agent = crate::claims::agent_of(&crate::claims::owner_id()).to_string(); + let earlier_session_author = format!("{agent}:some-earlier-session"); + + let comment_id = s + .with_backend_db(|conn| { + agentflare_backend::comment::create( + conn, + &item_id, + &earlier_session_author, + "mine, from an earlier session", + ) + .unwrap() + .id + }) + .unwrap(); + + let updated: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "edit".into(), + id: Some(comment_id), + body: Some("edited".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(updated["body"], "edited"); +} + +#[test] +fn item_comment_edit_uses_id_tiebreak_when_timestamps_collide() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + let first: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "create".into(), + item_id: Some(item_id.clone()), + body: Some("first".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let first_id = first["id"].as_str().unwrap().to_string(); + + let second: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "create".into(), + item_id: Some(item_id), + body: Some("second".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let second_id = second["id"].as_str().unwrap().to_string(); + + // Force both comments onto the same second-resolution timestamp, as + // happens routinely under real multi-agent traffic. Only the comment + // with the higher (later) UUIDv7 id should still count as latest. + s.with_backend_db(|conn| { + conn.execute( + "UPDATE item_comments SET created_at = 1000, updated_at = 1000", + [], + ) + .unwrap(); + }) + .unwrap(); + + let err = s + .comment(Parameters(CommentRequest { + action: "edit".into(), + id: Some(first_id), + body: Some("edited".into()), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + + let updated: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "edit".into(), + id: Some(second_id), + body: Some("edited".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(updated["body"], "edited"); +} + +#[test] +fn item_comment_delete_succeeds_when_latest_and_own() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + let comment: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "create".into(), + item_id: Some(item_id), + body: Some("delete-me".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let comment_id = comment["id"].as_str().unwrap().to_string(); + + let result: serde_json::Value = serde_json::from_str( + &s.comment(Parameters(CommentRequest { + action: "delete".into(), + id: Some(comment_id), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["deleted"], true); +} + +#[test] +fn item_claim_response_includes_worktree_path() { + let tmp = tempfile::tempdir().unwrap(); + // Isolated temp repo — this test must never run real `git + // worktree`/branch operations against the actual repository running + // the test suite. + let repo_dir = tempfile::tempdir().unwrap(); + let repo_root = repo_dir.path().to_path_buf(); + let run_git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .current_dir(&repo_root) + .output() + .unwrap() + }; + run_git(&["init", "-b", "master"]); + run_git(&["config", "user.email", "test@test.com"]); + run_git(&["config", "user.name", "Test"]); + run_git(&["commit", "--allow-empty", "-m", "initial"]); + + let s = AgentflareMcp { + backend_db_override: Some(tmp.path().join("backend.db")), + backend_project_link_override: Some(tmp.path().join("project.json")), + worktree_repo_root_override: Some(repo_root), + ..Default::default() + }; + + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + let result: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "claim".into(), + id: Some(item_id), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["status"], "acquired"); + assert!(result.get("worktree_path").is_some()); + let path = result["worktree_path"].as_str().unwrap(); + assert!(std::path::Path::new(path).exists()); + // `next` is now a protocol-level decoration injected by + // `call_tool`, not in the direct method output. + assert!(result.get("next").is_none()); +} + +#[test] +fn item_done_without_new_commits_omits_pr_fields() { + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let repo_root = repo_dir.path().to_path_buf(); + let run_git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .current_dir(&repo_root) + .output() + .unwrap() + }; + run_git(&["init", "-b", "master"]); + run_git(&["config", "user.email", "test@test.com"]); + run_git(&["config", "user.name", "Test"]); + run_git(&["commit", "--allow-empty", "-m", "initial"]); + + let s = AgentflareMcp { + backend_db_override: Some(tmp.path().join("backend.db")), + backend_project_link_override: Some(tmp.path().join("project.json")), + worktree_repo_root_override: Some(repo_root), + ..Default::default() + }; + + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + s.item(Parameters(ItemRequest { + action: "claim".into(), + id: Some(item_id.clone()), + ..Default::default() + })) + .unwrap(); + + // No commits were made in the claimed worktree, so `done` has + // nothing to push/PR — must not attempt a real push (no remote + // configured on this throwaway repo). + let result: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "done".into(), + id: Some(item_id), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["done"], true); + assert!(result.get("pr_url").is_none()); + assert!(result.get("next").is_none()); +} + +#[test] +fn item_rejects_unknown_action() { + let (_tmp, s) = harness(); + let err = s + .item(Parameters(ItemRequest { + action: "nonexistent".into(), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn label_rejects_unknown_action() { + let (_tmp, s) = harness(); + let err = s + .label(Parameters(LabelRequest { + action: "nonexistent".into(), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn label_create_list_update_delete_via_mcp() { + let (_tmp, s) = harness(); + // create + let created: serde_json::Value = serde_json::from_str( + &s.label(Parameters(LabelRequest { + action: "create".into(), + name: Some("bug".into()), + color: Some("#EF4444".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["name"], "bug"); + assert_eq!(created["color"], "#EF4444"); + + // list shows it + let listed: serde_json::Value = serde_json::from_str( + &s.label(Parameters(LabelRequest { + action: "list".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(listed.as_array().unwrap().len(), 1); + assert_eq!(listed[0]["id"], id); + + // update renames + recolors + let updated: serde_json::Value = serde_json::from_str( + &s.label(Parameters(LabelRequest { + action: "update".into(), + id: Some(id.clone()), + name: Some("defect".into()), + color: Some("#F59E0B".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(updated["name"], "defect"); + assert_eq!(updated["color"], "#F59E0B"); + + // delete + let deleted: serde_json::Value = serde_json::from_str( + &s.label(Parameters(LabelRequest { + action: "delete".into(), + id: Some(id.clone()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(deleted["deleted"], true); + + // list is now empty + let after: serde_json::Value = serde_json::from_str( + &s.label(Parameters(LabelRequest { + action: "list".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert!(after.as_array().unwrap().is_empty()); +} + +#[test] +fn label_update_requires_id() { + let (_tmp, s) = harness(); + let err = s + .label(Parameters(LabelRequest { + action: "update".into(), + name: Some("x".into()), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn item_add_label_rejects_foreign_project_label_via_mcp() { + let (tmp, s) = harness(); + // Auto-provisions this repo's workspace + project. + let item: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = item["id"].as_str().unwrap().to_string(); + + // A label belonging to a completely separate workspace/project. + let foreign_label_id = { + let conn = backend_conn(&tmp); + let ws = agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Other".into(), + slug: "other".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let proj = agentflare_backend::project::create( + &conn, + agentflare_backend::project::CreateProject { + workspace_id: ws.id.clone(), + name: "Other".into(), + identifier: "OTH".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + agentflare_backend::label::create( + &conn, + agentflare_backend::label::CreateLabel { + project_id: Some(proj.id), + workspace_id: ws.id, + name: "bug".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap() + .id + }; + + let err = s + .item(Parameters(ItemRequest { + action: "add_label".into(), + id: Some(item_id), + label_id: Some(foreign_label_id), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn label_update_and_delete_reject_foreign_project_label() { + let (tmp, s) = harness(); + + // A label in a separate workspace/project, not the repo's resolved project. + let foreign_label_id = { + let conn = backend_conn(&tmp); + let ws = agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Other".into(), + slug: "other".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let proj = agentflare_backend::project::create( + &conn, + agentflare_backend::project::CreateProject { + workspace_id: ws.id.clone(), + name: "Other".into(), + identifier: "OTH".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + agentflare_backend::label::create( + &conn, + agentflare_backend::label::CreateLabel { + project_id: Some(proj.id), + workspace_id: ws.id, + name: "bug".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap() + .id + }; + + let upd = s + .label(Parameters(LabelRequest { + action: "update".into(), + id: Some(foreign_label_id.clone()), + name: Some("hijacked".into()), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(upd.code, rmcp::model::ErrorCode::INVALID_PARAMS); + + let del = s + .label(Parameters(LabelRequest { + action: "delete".into(), + id: Some(foreign_label_id.clone()), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(del.code, rmcp::model::ErrorCode::INVALID_PARAMS); + + // The foreign label must survive both rejected attempts unchanged. + let conn = backend_conn(&tmp); + let survivor = agentflare_backend::label::get(&conn, &foreign_label_id).unwrap(); + assert_eq!(survivor.name, "bug"); +} + +#[test] +fn webhook_rejects_unknown_action() { + let (_tmp, s) = harness(); + let err = s + .webhook(Parameters(WebhookRequest { + action: "nonexistent".into(), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn project_rejects_unknown_action() { + let (_tmp, s) = harness(); + let err = s + .project(Parameters(ProjectRequest { + action: "nonexistent".into(), + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn next_hint_claim_with_worktree_path() { + let json = serde_json::json!({"status": "acquired", "worktree_path": "/tmp/wt"}); + let hint = next_hint("item", &json).unwrap(); + assert!(hint.contains("worktree_path"), "{}", hint); +} + +#[test] +fn next_hint_done_with_pr_url() { + let json = serde_json::json!({"done": true, "pr_url": "https://github.com/x/pull/1"}); + let hint = next_hint("item", &json).unwrap(); + assert!(hint.contains("review/merge"), "{}", hint); +} + +#[test] +fn next_hint_handoff_always_returns_hint() { + let json = serde_json::json!({"item_id": "abc", "recipient": "x"}); + let hint = next_hint("handoff", &json).unwrap(); + assert!(hint.contains("inbox"), "{}", hint); +} + +#[test] +fn next_hint_unknown_tool_returns_none() { + let json = serde_json::json!({"result": "ok"}); + assert!(next_hint("asset", &json).is_none()); +} + +#[test] +fn next_hint_item_without_trigger_fields_returns_none() { + let json = serde_json::json!({"done": true}); + assert!(next_hint("item", &json).is_none()); +} + +#[test] +fn next_hint_non_object_input_returns_none() { + assert!(next_hint("item", &serde_json::Value::String("text".into())).is_none()); +} diff --git a/src/mcp_server/tests/artifact_tests.rs b/src/mcp_server/tests/artifact_tests.rs new file mode 100644 index 00000000..c1350508 --- /dev/null +++ b/src/mcp_server/tests/artifact_tests.rs @@ -0,0 +1,638 @@ +use super::*; + +/// Minimal HTTP GET against a `http://127.0.0.1:/` URL, +/// returning the full response (status line + headers + body). +fn http_get(url: &str) -> String { + use std::io::{Read, Write}; + let rest = url.strip_prefix("http://").expect("http url"); + let (host_port, path) = rest.split_once('/').unwrap_or((rest, "")); + let mut stream = std::net::TcpStream::connect(host_port) + .unwrap_or_else(|_| panic!("connect to {host_port}")); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + write!(stream, "GET /{path} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n").unwrap(); + stream.flush().unwrap(); + let mut full = String::new(); + let _ = stream.read_to_string(&mut full); + full +} + +#[test] +fn artifact_publish_serves_content_at_returned_url() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let out = s + .artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("hello".into()), + r#type: None, + content: Some("artifact-body-marker".into()), + session_id: None, + update_id: None, + ..Default::default() + })) + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let url = v["url"].as_str().expect("url in response"); + assert!(url.starts_with("http://127.0.0.1:"), "local url: {url}"); + assert!(!v["id"].as_str().unwrap_or_default().is_empty()); + + let resp = http_get(url); + assert!(resp.contains("200"), "serves published artifact: {resp}"); + assert!( + resp.contains("artifact-body-marker"), + "body present: {resp}" + ); +} + +#[test] +fn artifact_publish_update_id_keeps_same_id() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let first: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("doc".into()), + r#type: Some("markdown".into()), + content: Some("v1".into()), + session_id: Some("ses-1".into()), + update_id: None, + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let id = first["id"].as_str().unwrap().to_string(); + + let second: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("doc".into()), + r#type: Some("markdown".into()), + content: Some("v2".into()), + session_id: Some("ses-1".into()), + update_id: Some(id.clone()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(second["id"].as_str().unwrap(), id); + assert_eq!(second["url"], first["url"]); +} + +#[test] +fn artifact_list_get_delete_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let publish = |name: &str, session: &str| -> serde_json::Value { + serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some(name.into()), + r#type: None, + content: Some(format!("content-of-{name}")), + session_id: Some(session.into()), + update_id: None, + description: Some(format!("desc-{name}")), + ..Default::default() + })) + .unwrap(), + ) + .unwrap() + }; + let a = publish("alpha", "ses-1"); + let _b = publish("beta", "ses-2"); + + let all: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "list".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(all.as_array().unwrap().len(), 2); + + let one: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "list".into(), + session_id: Some("ses-1".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(one.as_array().unwrap().len(), 1); + assert_eq!(one[0]["name"], "alpha"); + assert_eq!(one[0]["description"], "desc-alpha"); + + let id = a["id"].as_str().unwrap().to_string(); + let got: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "get".into(), + id: Some(id.clone()), + version: None, + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(got["content"], "content-of-alpha"); + + let del: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "delete".into(), + id: Some(id.clone()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(del["deleted"], id); + + let err = s + .artifact(Parameters(ArtifactRequest { + action: "get".into(), + id: Some(id), + version: None, + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn artifact_publish_version_and_conflict() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let first: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("doc".into()), + r#type: None, + content: Some("v1".into()), + session_id: None, + update_id: None, + label: Some("draft".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(first["version"], 1); + let id = first["id"].as_str().unwrap().to_string(); + + // stale base_version maps to invalid_params, not internal_error + let update = |base: Option, content: &str| { + s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("doc".into()), + r#type: None, + content: Some(content.into()), + session_id: None, + update_id: Some(id.clone()), + base_version: base, + ..Default::default() + })) + }; + let second: serde_json::Value = serde_json::from_str(&update(Some(1), "v2").unwrap()).unwrap(); + assert_eq!(second["version"], 2); + + let err = update(Some(1), "v3-stale").unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + assert!(err.to_string().contains("conflict"), "{err}"); +} + +#[test] +fn artifact_list_filters_by_recipient_and_thread() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let publish = + |name: &str, recipient: Option<&str>, thread: Option<&str>| -> serde_json::Value { + serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some(name.into()), + content: Some(format!("content {name}")), + recipient: recipient.map(Into::into), + thread_id: thread.map(Into::into), + ..Default::default() + })) + .unwrap(), + ) + .unwrap() + }; + publish("packet", Some("codex"), Some("t1")); + publish("reply", Some("claude-code"), Some("t1")); + publish("other", None, None); + + let inbox: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "list".into(), + inbox_recipient: Some("codex".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(inbox.as_array().unwrap().len(), 1); + assert_eq!(inbox[0]["name"], "packet"); + + let thread: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "list".into(), + thread_id: Some("t1".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(thread.as_array().unwrap().len(), 2); +} + +fn handoff_harness() -> (tempfile::TempDir, AgentflareMcp) { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + backend_db_override: Some(tmp.path().join("backend.db")), + backend_project_link_override: Some(tmp.path().join("project.json")), + agent: Some("claude-code".into()), + ..Default::default() + }; + (tmp, s) +} + +fn item_assets(s: &AgentflareMcp, item_id: &str) -> serde_json::Value { + serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "list".into(), + id: None, + item_id: Some(item_id.to_string()), + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap() +} + +#[test] +fn handoff_tool_requires_recipient_and_assigns_item() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = handoff_harness(); + + // A blank recipient is rejected — the whole reason this tool exists. + let err = s + .handoff(Parameters(HandoffRequest { + recipient: " ".into(), + name: "orphan".into(), + content: "for someone".into(), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + + // A real handoff creates an item assigned to the recipient and + // attaches the content to it as an asset. + let result: serde_json::Value = serde_json::from_str( + &s.handoff(Parameters(HandoffRequest { + recipient: "opencode".into(), + name: "review-packet".into(), + content: "please review".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let item_id = result["item_id"].as_str().unwrap().to_string(); + assert_eq!(result["recipient"], "opencode"); + assert_eq!(result["asset_version"], 1); + + let item: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "get".into(), + id: Some(item_id.clone()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(item["name"], "review-packet"); + assert_eq!(item["assignee_agent"], "opencode"); + + let assets = item_assets(&s, &item_id); + assert_eq!(assets.as_array().unwrap().len(), 1); + assert_eq!(assets[0]["filename"], format!("{item_id}.md")); + }); +} + +#[test] +fn handoff_trims_whitespace_padded_recipient() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = handoff_harness(); + + // A whitespace-padded recipient passes the emptiness check but must + // still be stored trimmed, or exact-match assignee lookups miss it. + let result: serde_json::Value = serde_json::from_str( + &s.handoff(Parameters(HandoffRequest { + recipient: " opencode ".into(), + name: "review-packet".into(), + content: "please review".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["recipient"], "opencode"); + + let item_id = result["item_id"].as_str().unwrap().to_string(); + let item: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "get".into(), + id: Some(item_id), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(item["assignee_agent"], "opencode"); + }); +} + +#[test] +fn handoff_with_item_id_assigns_existing_item_and_versions_the_asset() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = handoff_harness(); + let created: serde_json::Value = serde_json::from_str( + &s.item(Parameters(empty_item_create("Existing task"))) + .unwrap(), + ) + .unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + let first: serde_json::Value = serde_json::from_str( + &s.handoff(Parameters(HandoffRequest { + recipient: "opencode".into(), + name: "Existing task".into(), + content: "v1 content".into(), + item_id: Some(item_id.clone()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(first["item_id"], item_id); + assert_eq!(first["asset_version"], 1); + + // A different brief/name on the reply must not reset the version + // chain — it's keyed on item_id, not name. + let second: serde_json::Value = serde_json::from_str( + &s.handoff(Parameters(HandoffRequest { + recipient: "opencode".into(), + name: "Addressed feedback".into(), + content: "v2 content".into(), + item_id: Some(item_id.clone()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(second["asset_version"], 2); + + // no duplicate item was created + let item: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "get".into(), + id: Some(item_id.clone()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(item["assignee_agent"], "opencode"); + assert_eq!(item_assets(&s, &item_id).as_array().unwrap().len(), 2); + }); +} + +#[test] +fn artifact_diff_tool_returns_unified_diff() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let first: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("doc".into()), + content: Some("alpha\nbeta\n".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let id = first["id"].as_str().unwrap().to_string(); + s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("doc".into()), + content: Some("alpha\ngamma\n".into()), + update_id: Some(id.clone()), + ..Default::default() + })) + .unwrap(); + + // to_version omitted = latest + let diff = s + .artifact(Parameters(ArtifactRequest { + action: "diff".into(), + id: Some(id), + from_version: Some(1), + to_version: None, + ..Default::default() + })) + .unwrap(); + assert!(diff.contains("-beta"), "{diff}"); + assert!(diff.contains("+gamma"), "{diff}"); +} + +#[test] +fn artifact_search_matches_name_description_and_content() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("alpha".into()), + content: Some("there is a hidden NEEDLE in here".into()), + ..Default::default() + })) + .unwrap(); + s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("beta".into()), + content: Some("nothing to see".into()), + ..Default::default() + })) + .unwrap(); + + let hits: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "search".into(), + query: Some("needle".into()), + session_id: None, + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(hits.as_array().unwrap().len(), 1); + assert_eq!(hits[0]["name"], "alpha"); + assert!( + hits[0]["snippet"] + .as_str() + .unwrap() + .to_lowercase() + .contains("needle"), + "{hits}" + ); + + let by_name: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "search".into(), + query: Some("beta".into()), + session_id: None, + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(by_name.as_array().unwrap().len(), 1); +} + +#[test] +fn artifact_publish_captures_git_provenance_in_repo() { + // Tests run with cwd inside this git repo, so capture must succeed. + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let out: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some("prov".into()), + content: Some("x".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let got: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "get".into(), + id: Some(out["id"].as_str().unwrap().into()), + version: None, + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let commit = got["git"]["commit"].as_str().expect("git commit captured"); + assert!(commit.len() >= 7, "{got}"); +} + +#[test] +fn artifact_publish_defaults_sender_to_agent_identity() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + agent: Some("opencode".into()), + ..Default::default() + }; + let sender_of = |req: ArtifactRequest| -> serde_json::Value { + let out: serde_json::Value = + serde_json::from_str(&s.artifact(Parameters(req)).unwrap()).unwrap(); + let got: serde_json::Value = serde_json::from_str( + &s.artifact(Parameters(ArtifactRequest { + action: "get".into(), + id: Some(out["id"].as_str().unwrap().into()), + version: None, + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + got["sender"].clone() + }; + + let defaulted = sender_of(ArtifactRequest { + action: "publish".into(), + name: Some("defaulted".into()), + content: Some("x".into()), + ..Default::default() + }); + assert_eq!(defaulted, "opencode"); + + // ArtifactRequest has no `sender` field (removed in #75): authorship is + // always the server-derived identity, so a caller cannot attribute a + // published artifact to another agent. The spoof is unrepresentable at + // the type level — stronger than a runtime "override ignored" check. +} + +#[test] +fn identity_prefers_explicit_override_then_detection() { + // Explicit override beats detection… + assert_eq!( + AgentflareMcp::identity(Some("opencode".into())).as_deref(), + Some("opencode") + ); + // …empty counts as unset, and without an override identity falls + // back to detecting the host that launched us (None outside agents). + assert_eq!( + AgentflareMcp::identity(Some(String::new())), + agent_detector::agent_name() + ); + assert_eq!(AgentflareMcp::identity(None), agent_detector::agent_name()); +} + +#[test] +fn artifact_publish_rejects_empty_name_and_content() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + artifacts_dir_override: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + for (name, content) in [("", "x"), ("x", "")] { + let err = s + .artifact(Parameters(ArtifactRequest { + action: "publish".into(), + name: Some(name.into()), + r#type: None, + content: Some(content.into()), + session_id: None, + update_id: None, + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + } +} diff --git a/src/mcp_server/tests/asset_tests.rs b/src/mcp_server/tests/asset_tests.rs new file mode 100644 index 00000000..1e18e811 --- /dev/null +++ b/src/mcp_server/tests/asset_tests.rs @@ -0,0 +1,615 @@ +use super::*; + +#[test] +fn asset_attach_get_list_delete_round_trip() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + + let item: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("asset-test"))).unwrap()) + .unwrap(); + let item_id = item["id"].as_str().unwrap().to_string(); + + let content = b"hello asset test"; + std::fs::write(staging.join("test.txt"), content).unwrap(); + + let attached: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some(item_id.clone()), + project_id: None, + filename: Some("test.txt".into()), + metadata: Some(r#"{"source":"test"}"#.into()), + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(attached["filename"], "test.txt"); + let asset_id = attached["id"].as_str().unwrap().to_string(); + + let got: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "get".into(), + id: Some(asset_id.clone()), + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(got["asset"]["filename"], "test.txt"); + assert!(got["content"].as_str().is_some()); + + let list: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "list".into(), + id: None, + item_id: Some(item_id.clone()), + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(list.as_array().unwrap().len(), 1); + assert_eq!(list[0]["id"], asset_id); + + let del: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "delete".into(), + id: Some(asset_id.clone()), + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(del["deleted"], true); + + let after: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "list".into(), + id: None, + item_id: Some(item_id), + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert!(after.as_array().unwrap().is_empty()); + }); +} + +#[test] +fn asset_attach_rejects_path_traversal() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let err = s + .asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some("item-1".into()), + project_id: None, + filename: Some("../etc/hosts".into()), + metadata: None, + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + }); +} + +#[test] +fn asset_attach_rejects_missing_filename() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let err = s + .asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some("item-1".into()), + project_id: None, + filename: None, + metadata: None, + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + }); +} + +#[test] +fn asset_attach_rejects_both_item_and_project() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let err = s + .asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some("item-1".into()), + project_id: Some("proj-1".into()), + filename: Some("anything.txt".into()), + metadata: None, + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + }); +} + +#[test] +fn asset_get_rejects_missing_id() { + let (_tmp, s) = harness(); + let err = s + .asset(Parameters(AssetRequest { + action: "get".into(), + id: None, + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn asset_shared_storage_delete_safety() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + + let item1: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("shared-1"))).unwrap()) + .unwrap(); + let item2: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("shared-2"))).unwrap()) + .unwrap(); + let id1 = item1["id"].as_str().unwrap().to_string(); + let id2 = item2["id"].as_str().unwrap().to_string(); + + let content = b"same content for shared delete test"; + std::fs::write(staging.join("shared.txt"), content).unwrap(); + let asset1: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some(id1.clone()), + project_id: None, + filename: Some("shared.txt".into()), + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + let a1_id = asset1["id"].as_str().unwrap().to_string(); + + // re-stage the same content for item2 + std::fs::write(staging.join("shared.txt"), content).unwrap(); + let asset2: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some(id2.clone()), + project_id: None, + filename: Some("shared.txt".into()), + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + let a2_id = asset2["id"].as_str().unwrap().to_string(); + + // delete first — second should still be readable + let del1: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "delete".into(), + id: Some(a1_id.clone()), + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(del1["deleted"], true); + + let got2_raw = s + .asset(Parameters(AssetRequest { + action: "get".into(), + id: Some(a2_id.clone()), + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(); + let got2: serde_json::Value = serde_json::from_str(&got2_raw).unwrap(); + assert!( + got2["content"].as_str().is_some(), + "item2 must still be readable after item1 deletion: {got2_raw}" + ); + + // delete second — now file should be gone + let del2: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "delete".into(), + id: Some(a2_id.clone()), + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(del2["deleted"], true); + }); +} + +#[test] +fn asset_content_dedup() { + crate::paths::test_support::with_temp_home(|| { + let (tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + + let item_a: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("dedup-a"))).unwrap()) + .unwrap(); + let item_b: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("dedup-b"))).unwrap()) + .unwrap(); + let id_a = item_a["id"].as_str().unwrap().to_string(); + let id_b = item_b["id"].as_str().unwrap().to_string(); + + let content = b"dedup me please"; + std::fs::write(staging.join("dedup.txt"), content).unwrap(); + s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some(id_a.clone()), + project_id: None, + filename: Some("dedup.txt".into()), + metadata: None, + })) + .unwrap(); + + std::fs::write(staging.join("dedup.txt"), content).unwrap(); + s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some(id_b.clone()), + project_id: None, + filename: Some("dedup.txt".into()), + metadata: None, + })) + .unwrap(); + + // two rows, one file on disk: count unique storage_path values + let conn = backend_conn(&tmp); + let unique_paths: i64 = conn + .query_row( + "SELECT count(DISTINCT storage_path) FROM assets WHERE deleted_at IS NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(unique_paths, 1, "same content must share one storage_path"); + + let total_rows: i64 = conn + .query_row( + "SELECT count(*) FROM assets WHERE deleted_at IS NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(total_rows, 2, "two rows despite one file on disk"); + }); +} + +#[test] +fn asset_attach_to_project() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + + let project: serde_json::Value = serde_json::from_str( + &s.project(Parameters(ProjectRequest { + action: "info".into(), + })) + .unwrap(), + ) + .unwrap(); + let project_id = project["id"].as_str().unwrap().to_string(); + + std::fs::write(staging.join("project-file.txt"), b"project attachment").unwrap(); + let attached: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: None, + project_id: Some(project_id.clone()), + filename: Some("project-file.txt".into()), + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(attached["filename"], "project-file.txt"); + + let list: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "list".into(), + id: None, + item_id: None, + project_id: Some(project_id), + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(list.as_array().unwrap().len(), 1); + }); +} + +#[test] +fn asset_attach_rejects_neither_item_nor_project() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let err = s + .asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: None, + project_id: None, + filename: Some("anything.txt".into()), + metadata: None, + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + }); +} + +#[test] +fn asset_attach_rejects_nonexistent_item() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + std::fs::write(staging.join("f.txt"), b"data").unwrap(); + let err = s + .asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some("nonexistent-item".into()), + project_id: None, + filename: Some("f.txt".into()), + metadata: None, + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + }); +} + +#[test] +fn asset_attach_rejects_missing_staging_file() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let err = s + .asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some("item-1".into()), + project_id: None, + filename: Some("does-not-exist.txt".into()), + metadata: None, + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + }); +} + +#[test] +fn asset_attach_rejects_oversized_file() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + // write a file just past the default 5 MB limit + let big = vec![0u8; 5 * 1024 * 1024 + 1]; + std::fs::write(staging.join("big.bin"), &big).unwrap(); + let err = s + .asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some("item-1".into()), + project_id: None, + filename: Some("big.bin".into()), + metadata: None, + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + }); +} + +#[test] +fn asset_get_over_max_inline_omits_content() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + + let item: serde_json::Value = serde_json::from_str( + &s.item(Parameters(empty_item_create("big-inline-test"))) + .unwrap(), + ) + .unwrap(); + let item_id = item["id"].as_str().unwrap().to_string(); + + // write a small file, but set a tiny inline cap for this test + std::fs::write(staging.join("small.txt"), b"hello inline cap").unwrap(); + let attached: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some(item_id.clone()), + project_id: None, + filename: Some("small.txt".into()), + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + let asset_id = attached["id"].as_str().unwrap().to_string(); + + // override inline limit to 1 byte so our file exceeds it + // SAFETY: with_temp_home holds GLOBAL_STATE_LOCK so no concurrent env mutation. + let saved = std::env::var("AGENTFLARE_BACKEND_ASSET_MAX_INLINE_BYTES").ok(); + unsafe { std::env::set_var("AGENTFLARE_BACKEND_ASSET_MAX_INLINE_BYTES", "1") }; + let got: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "get".into(), + id: Some(asset_id), + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + assert!(got["content"].is_null()); + assert!(got["content_omitted_reason"].as_str().is_some()); + // restore to avoid leaking to sibling tests + match saved { + Some(v) => unsafe { std::env::set_var("AGENTFLARE_BACKEND_ASSET_MAX_INLINE_BYTES", v) }, + None => unsafe { std::env::remove_var("AGENTFLARE_BACKEND_ASSET_MAX_INLINE_BYTES") }, + } + }); +} + +#[test] +fn asset_get_returns_text_content_as_utf8_not_base64() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + + let item: serde_json::Value = serde_json::from_str( + &s.item(Parameters(empty_item_create("utf8-content-test"))) + .unwrap(), + ) + .unwrap(); + let item_id = item["id"].as_str().unwrap().to_string(); + + let body = "# Handoff\n\nImplement the fix \u{2192} land a PR. \u{2713}"; + std::fs::write(staging.join("note.md"), body.as_bytes()).unwrap(); + let attached: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some(item_id.clone()), + project_id: None, + filename: Some("note.md".into()), + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + let asset_id = attached["id"].as_str().unwrap().to_string(); + + let got: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "get".into(), + id: Some(asset_id), + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + + // Text assets must come back as readable UTF-8, not base64. + assert_eq!(got["encoding"].as_str(), Some("utf8")); + assert_eq!(got["content"].as_str(), Some(body)); + }); +} + +#[test] +fn asset_get_returns_base64_for_binary_with_valid_utf8_bytes() { + crate::paths::test_support::with_temp_home(|| { + let (_tmp, s) = harness(); + let home = crate::paths::home(); + let staging = home.join(".agentflare").join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + + let item: serde_json::Value = serde_json::from_str( + &s.item(Parameters(empty_item_create("binary-utf8-test"))) + .unwrap(), + ) + .unwrap(); + let item_id = item["id"].as_str().unwrap().to_string(); + + // Bytes 0x00,0x01,0x02,0x03 are valid UTF-8, but this is an + // octet-stream (.bin) asset: it must come back Base64, not "utf8". + let raw = [0u8, 1, 2, 3]; + assert!( + std::str::from_utf8(&raw).is_ok(), + "precondition: valid UTF-8" + ); + std::fs::write(staging.join("blob.bin"), raw).unwrap(); + let attached: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "attach".into(), + id: None, + item_id: Some(item_id.clone()), + project_id: None, + filename: Some("blob.bin".into()), + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + let asset_id = attached["id"].as_str().unwrap().to_string(); + + let got: serde_json::Value = serde_json::from_str( + &s.asset(Parameters(AssetRequest { + action: "get".into(), + id: Some(asset_id), + item_id: None, + project_id: None, + filename: None, + metadata: None, + })) + .unwrap(), + ) + .unwrap(); + + // Binary MIME => Base64 regardless of UTF-8 validity. + assert_eq!(got["encoding"].as_str(), Some("base64")); + // Content must be the Base64 of the raw bytes, not merely labeled so. + assert_eq!(got["content"].as_str(), Some("AAECAw==")); + }); +} diff --git a/src/mcp_server/tests/item_tests.rs b/src/mcp_server/tests/item_tests.rs new file mode 100644 index 00000000..5166dcd0 --- /dev/null +++ b/src/mcp_server/tests/item_tests.rs @@ -0,0 +1,1312 @@ +use super::*; + +#[test] +fn item_create_auto_provisions_workspace_and_project() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test Item"))).unwrap()).unwrap(); + assert_eq!(created["name"], "Test Item"); + assert_eq!(created["sequence_id"], 1); + assert!(created["project_id"].as_str().is_some()); +} + +#[test] +fn item_create_rejects_empty_name() { + let (_tmp, s) = harness(); + let err = s.item(Parameters(empty_item_create(""))).unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn item_update_state_sets_timestamps_via_mcp() { + let (tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + let project_id = created["project_id"].as_str().unwrap().to_string(); + + let started_state_id = { + let conn = backend_conn(&tmp); + agentflare_backend::state::list_by_project(&conn, &project_id) + .unwrap() + .into_iter() + .find(|st| st.group_name == "started") + .unwrap() + .id + }; + + let updated: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(item_id), + state_id: Some(started_state_id), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert!(updated["started_at"].is_number()); + assert!(updated["completed_at"].is_null()); +} + +#[test] +fn item_cancel_moves_to_cancelled_state() { + let (tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + let project_id = created["project_id"].as_str().unwrap().to_string(); + + let cancelled: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "cancel".into(), + id: Some(item_id), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let state_id = cancelled["state_id"].as_str().unwrap().to_string(); + + let conn = backend_conn(&tmp); + let group = agentflare_backend::state::list_by_project(&conn, &project_id) + .unwrap() + .into_iter() + .find(|st| st.id == state_id) + .unwrap() + .group_name; + assert_eq!(group, "cancelled"); +} + +#[test] +fn item_cancel_releases_the_callers_own_claim() { + // `claim` always resolves a worktree_repo_root and may run real `git + // worktree` commands against it — every test that calls `claim` must + // override this to an isolated throwaway repo, never the repo + // `cargo test` itself is running in. Same scaffolding as + // `item_claim_response_includes_worktree_path`. + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let repo_root = repo_dir.path().to_path_buf(); + let run_git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .current_dir(&repo_root) + .output() + .unwrap() + }; + run_git(&["init", "-b", "master"]); + run_git(&["config", "user.email", "test@test.com"]); + run_git(&["config", "user.name", "Test"]); + run_git(&["commit", "--allow-empty", "-m", "initial"]); + + let s = AgentflareMcp { + backend_db_override: Some(tmp.path().join("backend.db")), + backend_project_link_override: Some(tmp.path().join("project.json")), + worktree_repo_root_override: Some(repo_root), + ..Default::default() + }; + + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Test"))).unwrap()).unwrap(); + let item_id = created["id"].as_str().unwrap().to_string(); + + s.item(Parameters(ItemRequest { + action: "claim".into(), + id: Some(item_id.clone()), + ..Default::default() + })) + .unwrap(); + + s.item(Parameters(ItemRequest { + action: "cancel".into(), + id: Some(item_id.clone()), + ..Default::default() + })) + .unwrap(); + + // The claim must be released — re-claiming should succeed + // immediately instead of coming back "held". + let reclaimed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "claim".into(), + id: Some(item_id), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(reclaimed["status"], "acquired"); +} + +#[test] +fn item_list_rejects_negative_limit_and_offset() { + let (_tmp, s) = harness(); + let err = s + .item(Parameters(ItemRequest { + action: "list".into(), + limit: Some(-1), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + + let err = s + .item(Parameters(ItemRequest { + action: "list".into(), + offset: Some(-1), + ..Default::default() + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn item_list_filters_by_assignee_or_unassigned_and_sorts_open_first() { + let (tmp, s) = harness(); + let mine_open: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Mine open"))).unwrap()).unwrap(); + let project_id = mine_open["project_id"].as_str().unwrap().to_string(); + s.item(Parameters(ItemRequest { + action: "update".into(), + id: Some(mine_open["id"].as_str().unwrap().to_string()), + assignee_agent: Some("me".into()), + ..Default::default() + })) + .unwrap(); + + serde_json::from_str::( + &s.item(Parameters(empty_item_create("Unassigned"))).unwrap(), + ) + .unwrap(); + + let others: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Others"))).unwrap()).unwrap(); + s.item(Parameters(ItemRequest { + action: "update".into(), + id: Some(others["id"].as_str().unwrap().to_string()), + assignee_agent: Some("someone-else".into()), + ..Default::default() + })) + .unwrap(); + + let mine_done: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Mine done"))).unwrap()).unwrap(); + s.item(Parameters(ItemRequest { + action: "update".into(), + id: Some(mine_done["id"].as_str().unwrap().to_string()), + assignee_agent: Some("me".into()), + ..Default::default() + })) + .unwrap(); + let done_state_id = { + let conn = backend_conn(&tmp); + agentflare_backend::state::list_by_project(&conn, &project_id) + .unwrap() + .into_iter() + .find(|st| st.group_name == "completed") + .unwrap() + .id + }; + s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(mine_done["id"].as_str().unwrap().to_string()), + state_id: Some(done_state_id), + ..Default::default() + })) + .unwrap(); + + let listed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "list".into(), + assignee_agent: Some("me".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let names: Vec<&str> = listed + .as_array() + .unwrap() + .iter() + .map(|i| i["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["Mine open", "Unassigned", "Mine done"]); +} + +#[test] +fn item_list_defaults_assignee_filter_to_server_identity() { + // #75: a bare `item(list)` (no assignee_agent) must default to the + // server-derived identity — mine + unassigned — not dump every item. + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + backend_db_override: Some(tmp.path().join("backend.db")), + backend_project_link_override: Some(tmp.path().join("project.json")), + agent: Some("me".into()), + ..Default::default() + }; + + let mine: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Mine"))).unwrap()).unwrap(); + s.item(Parameters(ItemRequest { + action: "update".into(), + id: Some(mine["id"].as_str().unwrap().to_string()), + assignee_agent: Some("me".into()), + ..Default::default() + })) + .unwrap(); + + serde_json::from_str::( + &s.item(Parameters(empty_item_create("Unassigned"))).unwrap(), + ) + .unwrap(); + + let others: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Others"))).unwrap()).unwrap(); + s.item(Parameters(ItemRequest { + action: "update".into(), + id: Some(others["id"].as_str().unwrap().to_string()), + assignee_agent: Some("someone-else".into()), + ..Default::default() + })) + .unwrap(); + + // Bare list: no assignee_agent → defaults to "me" (mine + unassigned). + let defaulted: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "list".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let mut names: Vec<&str> = defaulted + .as_array() + .unwrap() + .iter() + .map(|i| i["name"].as_str().unwrap()) + .collect(); + names.sort_unstable(); + assert_eq!(names, vec!["Mine", "Unassigned"]); + + // An explicit assignee_agent is still honored (view a teammate's queue). + let explicit: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "list".into(), + assignee_agent: Some("someone-else".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let mut names2: Vec<&str> = explicit + .as_array() + .unwrap() + .iter() + .map(|i| i["name"].as_str().unwrap()) + .collect(); + names2.sort_unstable(); + assert_eq!(names2, vec!["Others", "Unassigned"]); +} + +#[test] +fn item_list_state_group_filter_accepts_comma_separated_groups() { + let (tmp, s) = harness(); + let open_item: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Open"))).unwrap()).unwrap(); + let project_id = open_item["project_id"].as_str().unwrap().to_string(); + let done_item: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Done"))).unwrap()).unwrap(); + let cancelled_item: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Cancelled"))).unwrap()).unwrap(); + + let conn = backend_conn(&tmp); + let states = agentflare_backend::state::list_by_project(&conn, &project_id).unwrap(); + let done_state_id = states + .iter() + .find(|st| st.group_name == "completed") + .unwrap() + .id + .clone(); + let cancelled_state_id = states + .iter() + .find(|st| st.group_name == "cancelled") + .unwrap() + .id + .clone(); + drop(conn); + + s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(done_item["id"].as_str().unwrap().to_string()), + state_id: Some(done_state_id), + ..Default::default() + })) + .unwrap(); + s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(cancelled_item["id"].as_str().unwrap().to_string()), + state_id: Some(cancelled_state_id), + ..Default::default() + })) + .unwrap(); + + let listed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "list".into(), + state_group: Some("backlog,completed".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let names: Vec<&str> = listed + .as_array() + .unwrap() + .iter() + .map(|i| i["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["Open", "Done"]); +} + +#[test] +fn item_groom_flags_unassigned_and_computes_pull_next() { + let (_tmp, s) = harness(); + let foo: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Foo"))).unwrap()).unwrap(); + s.item(Parameters(ItemRequest { + action: "create".into(), + name: Some("Bar".into()), + assignee_agent: Some("someone".into()), + ..Default::default() + })) + .unwrap(); + + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let items = groomed["items"].as_array().unwrap(); + let foo_entry = items + .iter() + .find(|i| i["name"] == "Foo") + .expect("Foo present"); + assert_eq!(foo_entry["unassigned"], true); + assert_eq!(foo_entry["stale"], false); + let bar_entry = items + .iter() + .find(|i| i["name"] == "Bar") + .expect("Bar present"); + assert_eq!(bar_entry["unassigned"], false); + + let pull_next: Vec<&str> = groomed["pull_next"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(pull_next.contains(&foo["id"].as_str().unwrap())); + assert_eq!(groomed["unassigned_count"], 1); +} + +/// Regression (CodeRabbit): a completed dependency must never read back +/// as an open blocker just because it fell outside the shortlist's +/// default state_group filter (completed items aren't in +/// "backlog,unstarted", so the naive shortlist-scoped lookup used to +/// return "" for its state and treat that as "still open"). +#[test] +fn item_groom_does_not_block_on_a_completed_dependency_outside_the_shortlist() { + let (_tmp, s) = harness(); + let dep: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Dep"))).unwrap()).unwrap(); + let project_id = dep["project_id"].as_str().unwrap().to_string(); + let blocked: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "create".into(), + name: Some("Blocked".into()), + dependency_ids: Some(vec![dep["id"].as_str().unwrap().to_string()]), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let conn = backend_conn(&_tmp); + let completed_state = agentflare_backend::state::list_by_project(&conn, &project_id) + .unwrap() + .into_iter() + .find(|st| st.group_name == "completed") + .unwrap() + .id; + drop(conn); + s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(dep["id"].as_str().unwrap().to_string()), + state_id: Some(completed_state), + ..Default::default() + })) + .unwrap(); + + // Default state_group is "backlog,unstarted" — Dep (now completed) + // falls outside the shortlist entirely. + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let items = groomed["items"].as_array().unwrap(); + assert!( + !items.iter().any(|i| i["id"] == dep["id"]), + "completed Dep should not be in the default shortlist" + ); + let blocked_entry = items.iter().find(|i| i["id"] == blocked["id"]).unwrap(); + assert_eq!( + blocked_entry["blocked_by"].as_array().unwrap().len(), + 0, + "a completed dependency must not block, even when it's outside the shortlist" + ); +} + +/// Regression (CodeRabbit): fan-in must count dependents project-wide, +/// not just other items that happen to share the same shortlist. +#[test] +fn item_groom_fanin_counts_dependents_outside_the_shortlist() { + let (_tmp, s) = harness(); + let target: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Target"))).unwrap()).unwrap(); + let project_id = target["project_id"].as_str().unwrap().to_string(); + let dependent: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "create".into(), + name: Some("Dependent".into()), + dependency_ids: Some(vec![target["id"].as_str().unwrap().to_string()]), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let conn = backend_conn(&_tmp); + let completed_state = agentflare_backend::state::list_by_project(&conn, &project_id) + .unwrap() + .into_iter() + .find(|st| st.group_name == "completed") + .unwrap() + .id; + drop(conn); + // Move the dependent out of the default shortlist filter — Target's + // fan-in must still count it. + s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(dependent["id"].as_str().unwrap().to_string()), + state_id: Some(completed_state), + ..Default::default() + })) + .unwrap(); + + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let items = groomed["items"].as_array().unwrap(); + assert!(!items.iter().any(|i| i["id"] == dependent["id"])); + let target_entry = items.iter().find(|i| i["id"] == target["id"]).unwrap(); + assert_eq!(target_entry["depended_on_by_count"], 1); +} + +#[test] +fn item_groom_flags_blocked_by_open_dependency() { + let (_tmp, s) = harness(); + let dep: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Dep"))).unwrap()).unwrap(); + let dep_id = dep["id"].as_str().unwrap().to_string(); + let blocked: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "create".into(), + name: Some("Blocked".into()), + dependency_ids: Some(vec![dep_id.clone()]), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let items = groomed["items"].as_array().unwrap(); + let blocked_entry = items + .iter() + .find(|i| i["id"] == blocked["id"]) + .expect("Blocked present"); + let blocked_by: Vec<&str> = blocked_entry["blocked_by"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(blocked_by, vec![dep_id.as_str()]); + + let dep_entry = items.iter().find(|i| i["id"] == dep["id"]).unwrap(); + assert_eq!(dep_entry["depended_on_by_count"], 1); + + let pull_next: Vec<&str> = groomed["pull_next"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(!pull_next.contains(&blocked["id"].as_str().unwrap())); +} + +#[test] +fn item_groom_detects_near_duplicate_names() { + let (_tmp, s) = harness(); + let a: serde_json::Value = serde_json::from_str( + &s.item(Parameters(empty_item_create( + "FIX-08 backlog low unassigned stale", + ))) + .unwrap(), + ) + .unwrap(); + let b: serde_json::Value = serde_json::from_str( + &s.item(Parameters(empty_item_create( + "FIX-09 backlog low unassigned stale duplicateish", + ))) + .unwrap(), + ) + .unwrap(); + + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let items = groomed["items"].as_array().unwrap(); + let a_entry = items.iter().find(|i| i["id"] == a["id"]).unwrap(); + let dups: Vec<&str> = a_entry["possible_duplicates"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(dups.contains(&b["id"].as_str().unwrap())); +} + +#[test] +fn item_update_sets_metadata() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Sized"))).unwrap()).unwrap(); + let updated: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "update".into(), + id: Some(created["id"].as_str().unwrap().to_string()), + metadata: Some(serde_json::json!({"size": "M"})), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!( + updated["metadata"], + serde_json::json!({"size": "M"}).to_string() + ); +} + +#[test] +fn item_groom_reads_size_and_flags_unestimated() { + let (_tmp, s) = harness(); + let sized: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "create".into(), + name: Some("Sized".into()), + metadata: Some(serde_json::json!({"size": "L"})), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let bare: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Bare"))).unwrap()).unwrap(); + + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let items = groomed["items"].as_array().unwrap(); + let sized_entry = items.iter().find(|i| i["id"] == sized["id"]).unwrap(); + assert_eq!(sized_entry["size"], "L"); + assert_eq!(sized_entry["unestimated"], false); + let bare_entry = items.iter().find(|i| i["id"] == bare["id"]).unwrap(); + assert_eq!(bare_entry["size"], serde_json::Value::Null); + assert_eq!(bare_entry["unestimated"], true); + assert_eq!(groomed["unestimated_count"], 1); +} + +/// Regression: some callers double-encode an object-typed `metadata` param +/// as a JSON string containing JSON — reproduced live via item(create) +/// with metadata={"size":"S"}, which stored `"{\"size\": \"S\"}"` (a +/// string) rather than the object itself. `groom` must still read `size` +/// through that extra layer instead of silently reporting `unestimated`. +#[test] +fn item_groom_reads_size_through_double_encoded_metadata() { + let (_tmp, s) = harness(); + let double_encoded: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "create".into(), + name: Some("Double-encoded".into()), + metadata: Some(serde_json::Value::String( + serde_json::json!({"size": "M"}).to_string(), + )), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let entry = groomed["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["id"] == double_encoded["id"]) + .unwrap(); + assert_eq!(entry["size"], "M"); + assert_eq!(entry["unestimated"], false); +} + +#[test] +fn item_groom_capacity_buckets_now_next_later_and_needs_estimation() { + let (_tmp, s) = harness(); + let sized = |name: &str, size: &str| ItemRequest { + action: "create".into(), + name: Some(name.into()), + metadata: Some(serde_json::json!({"size": size})), + ..Default::default() + }; + let ready_a: serde_json::Value = + serde_json::from_str(&s.item(Parameters(sized("Ready A", "S"))).unwrap()).unwrap(); + let ready_b: serde_json::Value = + serde_json::from_str(&s.item(Parameters(sized("Ready B", "S"))).unwrap()).unwrap(); + let dep: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Dep"))).unwrap()).unwrap(); + let blocked: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + dependency_ids: Some(vec![dep["id"].as_str().unwrap().to_string()]), + ..sized("Blocked", "M") + })) + .unwrap(), + ) + .unwrap(); + let unestimated: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Unsized"))).unwrap()).unwrap(); + + // No capacity: buckets omitted entirely (backward compatible). + let unbucketed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert!(unbucketed.get("now").is_none()); + + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + capacity: Some(1), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let ids = |key: &str| -> Vec { + groomed[key] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect() + }; + let now = ids("now"); + let next = ids("next"); + assert_eq!(now.len(), 1, "capacity=1 caps now to 1 ready item"); + assert!( + now.contains(&ready_a["id"].as_str().unwrap().to_string()) + || now.contains(&ready_b["id"].as_str().unwrap().to_string()) + ); + // Whichever ready item didn't make `now` spills into `next`. + assert_eq!(now.len() + next.len(), 2); + assert_eq!(ids("later"), vec![blocked["id"].as_str().unwrap()]); + // "Dep" has no size either — unestimated, same as the dedicated "Unsized" item. + let mut needs_est = ids("needs_estimation"); + needs_est.sort_unstable(); + let mut expected = vec![ + dep["id"].as_str().unwrap().to_string(), + unestimated["id"].as_str().unwrap().to_string(), + ]; + expected.sort_unstable(); + assert_eq!(needs_est, expected); +} + +/// Regression (CodeRabbit): standup's "done" filter and health's +/// velocity bucketing must key off `completed_at`, not `updated_at` — +/// editing an already-completed item (e.g. fixing a typo) bumps +/// `updated_at` without re-completing it, and must not make old work +/// spuriously reappear as "just done" or shift which week it counts in. +#[test] +fn item_standup_and_health_use_completed_at_not_updated_at() { + let (_tmp, s) = harness(); + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("Old work"))).unwrap()).unwrap(); + let project_id = created["project_id"].as_str().unwrap().to_string(); + let id = created["id"].as_str().unwrap().to_string(); + let conn = backend_conn(&_tmp); + let completed_state = agentflare_backend::state::list_by_project(&conn, &project_id) + .unwrap() + .into_iter() + .find(|st| st.group_name == "completed") + .unwrap() + .id; + drop(conn); + s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(id.clone()), + state_id: Some(completed_state), + ..Default::default() + })) + .unwrap(); + + // Simulate: completed long ago, then edited just now (updated_at + // recent, completed_at old) — direct SQL, no clock control in tests. + let old_ts = 1_700_000_000_i64; // long before "now" in this fixture era + let conn = backend_conn(&_tmp); + conn.execute( + "UPDATE items SET completed_at = ?1 WHERE id = ?2", + rusqlite::params![old_ts, id], + ) + .unwrap(); + drop(conn); + s.item(Parameters(ItemRequest { + action: "update".into(), + id: Some(id.clone()), + description: Some("fixed a typo".into()), + ..Default::default() + })) + .unwrap(); + + let standup: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "standup".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert!( + !standup["done"] + .as_array() + .unwrap() + .iter() + .any(|i| i["id"] == id), + "editing an old completed item must not resurrect it in 'done'" + ); + + let health: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "health".into(), + window_weeks: Some(1), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!( + health["velocity"][0]["completed_count"], 0, + "an old completion must not count in this week's velocity just because it was edited" + ); +} + +#[test] +fn item_standup_buckets_done_in_progress_grouped_and_stuck() { + let (_tmp, s) = harness(); + let project_id: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("bootstrap"))).unwrap()).unwrap(); + let project_id = project_id["project_id"].as_str().unwrap().to_string(); + let conn = backend_conn(&_tmp); + let states = agentflare_backend::state::list_by_project(&conn, &project_id).unwrap(); + let started_state = states + .iter() + .find(|st| st.group_name == "started") + .unwrap() + .id + .clone(); + let completed_state = states + .iter() + .find(|st| st.group_name == "completed") + .unwrap() + .id + .clone(); + drop(conn); + + let move_to = |name: &str, assignee: Option<&str>, state_id: &str| -> serde_json::Value { + let created: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "create".into(), + name: Some(name.into()), + assignee_agent: assignee.map(String::from), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(created["id"].as_str().unwrap().to_string()), + state_id: Some(state_id.to_string()), + ..Default::default() + })) + .unwrap(); + created + }; + + let wip_alice = move_to("WIP Alice", Some("alice"), &started_state); + let _wip_bob = move_to("WIP Bob", Some("bob"), &started_state); + let _wip_unassigned = move_to("WIP Unassigned", None, &started_state); + let done_item = move_to("Done item", Some("alice"), &completed_state); + + let standup: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "standup".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + assert_eq!(standup["done_count"], 1); + assert_eq!(standup["done"][0]["id"], done_item["id"]); + assert_eq!(standup["in_progress_count"], 3); + let groups: Vec<&str> = standup["in_progress"] + .as_array() + .unwrap() + .iter() + .map(|g| g["assignee"].as_str().unwrap()) + .collect(); + assert_eq!(groups, vec!["alice", "bob", "unassigned"]); + let alice_group = standup["in_progress"] + .as_array() + .unwrap() + .iter() + .find(|g| g["assignee"] == "alice") + .unwrap(); + assert_eq!(alice_group["items"][0]["id"], wip_alice["id"]); + // Nothing is 7+ days old in a freshly-created fixture. + assert_eq!(standup["stuck_count"], 0); +} + +#[test] +fn item_health_reports_velocity_wip_and_bottleneck_placeholder() { + let (_tmp, s) = harness(); + let project_id: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create("bootstrap"))).unwrap()).unwrap(); + let project_id = project_id["project_id"].as_str().unwrap().to_string(); + let conn = backend_conn(&_tmp); + let states = agentflare_backend::state::list_by_project(&conn, &project_id).unwrap(); + let started_state = states + .iter() + .find(|st| st.group_name == "started") + .unwrap() + .id + .clone(); + let completed_state = states + .iter() + .find(|st| st.group_name == "completed") + .unwrap() + .id + .clone(); + drop(conn); + + let move_to = |name: &str, state_id: &str| { + let created: serde_json::Value = + serde_json::from_str(&s.item(Parameters(empty_item_create(name))).unwrap()).unwrap(); + s.item(Parameters(ItemRequest { + action: "update_state".into(), + id: Some(created["id"].as_str().unwrap().to_string()), + state_id: Some(state_id.to_string()), + ..Default::default() + })) + .unwrap(); + }; + move_to("Done 1", &completed_state); + move_to("Done 2", &completed_state); + move_to("WIP", &started_state); + + let health: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "health".into(), + window_weeks: Some(2), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + + let velocity = health["velocity"].as_array().unwrap(); + assert_eq!(velocity.len(), 2, "oldest -> newest, 2 requested windows"); + assert_eq!( + velocity[1]["completed_count"], 2, + "current week has both Done items" + ); + assert_eq!(velocity[0]["completed_count"], 0, "prior week is empty"); + assert_eq!(health["velocity_trend"], "up"); + assert_eq!(health["wip_count"], 1); + assert_eq!(health["stuck_count"], 0); + assert_eq!(health["bottlenecks"].as_array().unwrap().len(), 0); + assert!( + health["bottleneck_note"] + .as_str() + .unwrap() + .contains("no handoff history") + ); +} + +/// Regression (CodeRabbit): an absurd `window_weeks` must be clamped, +/// not used to size a `Vec` directly — otherwise a caller +/// passing e.g. `i64::MAX` drives a near-infinite allocation while the +/// backend DB lock is held. +#[test] +fn item_health_clamps_window_weeks_to_a_sane_maximum() { + let (_tmp, s) = harness(); + let health: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "health".into(), + window_weeks: Some(i64::MAX), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(health["window_weeks"], 52); + assert_eq!(health["velocity"].as_array().unwrap().len(), 52); +} + +/// Regression (CodeRabbit): an absurd groom `limit` must be clamped — +/// bounds the O(n^2) duplicate-detection pass and the SQLite `IN (...)` +/// parameter list built from the shortlist. +#[test] +fn item_groom_clamps_limit_to_a_sane_maximum() { + let (_tmp, s) = harness(); + let groomed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "groom".into(), + limit: Some(i64::MAX), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + assert!(groomed["items"].as_array().unwrap().len() <= 200); +} + +/// Real measured comparison, not an estimate: one `groom` call vs. the +/// `list` + N×`get` path it replaces, against a backlog-sized dataset (60 +/// items — close to this project's real ~40-item backlog) with dependency +/// edges so `groom`'s blocked/fan-in computation does real work too. Not a +/// hard perf gate (`#[ignore]`, run explicitly) — timing assertions in CI +/// are flaky; this is for a human to re-run and read the numbers. +#[test] +#[ignore = "manual benchmark — run with: cargo test item_groom_benchmark -- --ignored --nocapture"] +fn item_groom_benchmark() { + let (_tmp, s) = harness(); + let mut ids: Vec = Vec::with_capacity(60); + for n in 0..60 { + let priority = ["urgent", "high", "medium", "low", "none"][n % 5]; + let created: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "create".into(), + name: Some(format!("Benchmark item {n}")), + description: Some( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(20), + ), + priority: Some(priority.into()), + dependency_ids: if n > 0 && n % 7 == 0 { + Some(vec![ids[n - 1].clone()]) + } else { + None + }, + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + ids.push(created["id"].as_str().unwrap().to_string()); + } + + let groom_start = std::time::Instant::now(); + let groomed = s + .item(Parameters(ItemRequest { + action: "groom".into(), + ..Default::default() + })) + .unwrap(); + let groom_elapsed = groom_start.elapsed(); + + let old_start = std::time::Instant::now(); + let listed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "list".into(), + state_group: Some("backlog,unstarted".into()), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let shortlist_ids: Vec = listed + .as_array() + .unwrap() + .iter() + .take(15) + .map(|i| i["id"].as_str().unwrap().to_string()) + .collect(); + for id in &shortlist_ids { + s.item(Parameters(ItemRequest { + action: "get".into(), + id: Some(id.clone()), + ..Default::default() + })) + .unwrap(); + } + let old_elapsed = old_start.elapsed(); + + println!( + "groom (1 call): {groom_elapsed:?} | list+{}xget (old path): {old_elapsed:?} | speedup: {:.1}x", + shortlist_ids.len(), + old_elapsed.as_secs_f64() / groom_elapsed.as_secs_f64().max(1e-9) + ); + assert!(groomed.contains("pull_next")); +} + +#[test] +fn item_list_respects_limit_and_offset() { + let (_tmp, s) = harness(); + for name in ["A", "B", "C"] { + s.item(Parameters(empty_item_create(name))).unwrap(); + } + let listed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "list".into(), + limit: Some(1), + offset: Some(1), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let names: Vec<&str> = listed + .as_array() + .unwrap() + .iter() + .map(|i| i["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["B"]); +} + +#[test] +fn item_list_returns_lean_projection_with_readable_state() { + let (_tmp, s) = harness(); + s.item(Parameters(empty_item_create("Test"))).unwrap(); + let listed: serde_json::Value = serde_json::from_str( + &s.item(Parameters(ItemRequest { + action: "list".into(), + ..Default::default() + })) + .unwrap(), + ) + .unwrap(); + let first = &listed.as_array().unwrap()[0]; + assert_eq!(first["state"], "Backlog"); + assert_eq!(first["state_group"], "backlog"); + assert!(first.get("description").is_none()); + assert!(first.get("metadata").is_none()); +} + +#[test] +fn resolve_workspace_id_creates_once_and_reuses() { + let (tmp, _s) = harness(); + let conn = backend_conn(&tmp); + let id1 = AgentflareMcp::resolve_workspace_id(&conn).unwrap(); + let id2 = AgentflareMcp::resolve_workspace_id(&conn).unwrap(); + assert_eq!(id1, id2); +} + +/// If `.agentflare/project.json` is deleted (wiped worktree, `rm -rf`, +/// etc.) while the project it pointed to still exists, resolving again +/// must reconnect to that same project — not silently fork a duplicate, +/// which would strand the original project's items. +#[test] +fn resolve_project_relinks_to_existing_project_when_link_file_is_deleted() { + let (tmp, s) = harness(); + let conn = backend_conn(&tmp); + let first = s.resolve_project(&conn).unwrap(); + + std::fs::remove_file(s.project_link_path()).unwrap(); + + let second = s.resolve_project(&conn).unwrap(); + assert_eq!( + first.id, second.id, + "must reconnect to the same project, not fork a duplicate" + ); + let all = agentflare_backend::project::list_by_workspace(&conn, &first.workspace_id).unwrap(); + assert_eq!( + all.len(), + 1, + "no duplicate project should have been created: {all:?}" + ); +} + +/// Two different repos can easily share a directory basename (or, for +/// non-git dirs, no distinguishing info at all beyond the name). They +/// must never be conflated into one project just because they'd derive +/// the same display identifier — each gets its own project, with the +/// second disambiguated by a suffix. +#[test] +fn resolve_project_does_not_conflate_different_repos_with_the_same_derived_name() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("backend.db"); + let s1 = AgentflareMcp { + backend_db_override: Some(db_path.clone()), + backend_project_link_override: Some(tmp.path().join("link1.json")), + backend_repo_key_override: Some("path:/repo/one".to_string()), + ..Default::default() + }; + let s2 = AgentflareMcp { + backend_db_override: Some(db_path.clone()), + backend_project_link_override: Some(tmp.path().join("link2.json")), + backend_repo_key_override: Some("path:/repo/two".to_string()), + ..Default::default() + }; + let conn = agentflare_backend::db::open_db(&db_path).unwrap(); + let p1 = s1.resolve_project(&conn).unwrap(); + let p2 = s2.resolve_project(&conn).unwrap(); + assert_ne!( + p1.id, p2.id, + "different repos must never share a project even with the same derived name" + ); + assert_ne!( + p1.identifier, p2.identifier, + "the second project must get a disambiguating suffix" + ); + + // Each keeps resolving to its own project on repeat calls. + assert_eq!(s1.resolve_project(&conn).unwrap().id, p1.id); + assert_eq!(s2.resolve_project(&conn).unwrap().id, p2.id); +} + +/// Non-git projects need the same "root is stable no matter which +/// subdirectory you're in" guarantee git repos get for free from `git +/// rev-parse --show-toplevel` — otherwise the same project would split +/// across multiple `.agentflare/project.json` files depending on which +/// subdirectory a tool happened to be called from. +#[test] +fn find_root_from_walks_up_to_the_nearest_marker() { + // Bounding "home" at the tempdir's own parent contains the walk + // entirely within this test's constructed tree — passing some + // unrelated path here would NOT do that: the walk follows the real + // filesystem's `.parent()` chain regardless, so it would keep + // climbing past `root` into real ancestor directories (which may + // have their own real markers, e.g. this machine's actual + // `~/.agentflare`) until it happened to reach that unrelated path, + // which — not being a real ancestor — it never would, walking all + // the way to the filesystem root instead. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let home = root.parent().unwrap(); + std::fs::write(root.join("package.json"), "{}").unwrap(); + let deep = root.join("src").join("nested").join("deep"); + std::fs::create_dir_all(&deep).unwrap(); + + assert_eq!(AgentflareMcp::find_root_from(&deep, home), root); + assert_eq!(AgentflareMcp::find_root_from(root, home), root); +} + +#[test] +fn find_root_from_prefers_an_existing_agentflare_link_over_other_markers() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let home = root.parent().unwrap(); + // A nested directory with its own marker (e.g. a sub-package) must + // not shadow an ancestor's existing project link — the + // .agentflare pass runs before the ROOT_MARKERS pass for + // exactly this reason. + std::fs::create_dir_all(root.join(".agentflare")).unwrap(); + let sub = root.join("packages").join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("package.json"), "{}").unwrap(); + + assert_eq!(AgentflareMcp::find_root_from(&sub, home), root); + assert_eq!(AgentflareMcp::find_root_from(root, home), root); +} + +/// The boundary itself: a directory that IS `home` must never be +/// treated as a project root, even if it happens to contain a marker — +/// this is what keeps the global `~/.agentflare` data dir from ever +/// being mistaken for a per-repo link. +#[test] +fn find_root_from_never_resolves_to_home_itself() { + let home = tempfile::tempdir().unwrap(); + // Stands in for the real global data dir at ~/.agentflare. + std::fs::create_dir_all(home.path().join(".agentflare")).unwrap(); + let start = home.path().join("some_project"); + std::fs::create_dir_all(&start).unwrap(); + + // `start` itself has no marker, and home — one level up — does. If + // the walk checked markers at `home`, this would return `home`. It + // must instead stop short of ever inspecting `home` and fall back + // to `start`. + assert_eq!(AgentflareMcp::find_root_from(&start, home.path()), start); +} + +// No test for the "nothing found anywhere above" fallback: `find_root_from` +// walks all the way to the filesystem root, so a tempdir-based test would +// depend on what markers happen to exist above the OS temp directory on +// whatever machine runs this — not a property this test can control. The +// fallback itself is a single trivial `None => return start`. diff --git a/src/mcp_server/tests/mod.rs b/src/mcp_server/tests/mod.rs new file mode 100644 index 00000000..8b49ea79 --- /dev/null +++ b/src/mcp_server/tests/mod.rs @@ -0,0 +1,353 @@ +use super::*; + +#[test] +fn parse_flared_port_reads_top_level_key_only() { + assert_eq!(parse_flared_port("port = 4444\n"), Some(4444)); + assert_eq!( + parse_flared_port("# comment\nport=9999 # inline\n"), + Some(9999) + ); + // tables end the top-level scan; a port inside one is not flared's + assert_eq!(parse_flared_port("[[registries]]\nport = 1\n"), None); + // prefix collisions and malformed values are not overrides + assert_eq!( + parse_flared_port("portable = 1\nlight_interval_secs = 60\n"), + None + ); + assert_eq!(parse_flared_port("port = not-a-number\n"), None); + assert_eq!(parse_flared_port(""), None); +} + +#[test] +fn validate_conventional_pr_title_accepts_known_types_rejects_others() { + for good in [ + "feat: add thing", + "fix(scope): bug", + "chore!: breaking rename", + "docs: update readme", + ] { + assert!( + AgentflareMcp::validate_conventional_pr_title(good).is_ok(), + "expected {good:?} to pass" + ); + } + for bad in [ + "Add thing", + "Relicense repo from MIT to Apache-2.0", + "Feat: wrong case", + "unknown: not a real type", + ] { + assert!( + AgentflareMcp::validate_conventional_pr_title(bad).is_err(), + "expected {bad:?} to fail" + ); + } +} + +#[test] +fn get_info_reports_agentflare_identity() { + let s = AgentflareMcp::default(); + let info = s.get_info(); + assert_eq!(info.server_info.name, env!("CARGO_PKG_NAME")); + assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION")); +} + +#[test] +fn routing_suggestion_returns_null_for_non_locate() { + let s = AgentflareMcp::default(); + let result = s.get_routing_suggestion(Parameters(GetRoutingSuggestionRequest { + prompt: "refactor the payment module".to_string(), + })); + assert!(result.contains("null")); +} + +#[test] +fn routing_suggestion_returns_nudge_for_find() { + let s = AgentflareMcp::default(); + let result = s.get_routing_suggestion(Parameters(GetRoutingSuggestionRequest { + prompt: "find the auth handler".to_string(), + })); + assert!(result.contains("cheap-model")); +} + +#[test] +fn check_session_health_unknown_returns_status() { + let s = AgentflareMcp::default(); + let result = s + .check_session_health(Parameters(CheckSessionHealthRequest { + session_id: "nonexistent-session-id".to_string(), + })) + .unwrap(); + assert!(result.contains("unknown")); +} + +#[test] +fn check_session_health_rejects_empty_session_id() { + let s = AgentflareMcp::default(); + let err = s + .check_session_health(Parameters(CheckSessionHealthRequest { + session_id: String::new(), + })) + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); +} + +#[test] +fn optimize_tool_retrieve_returns_registered_original() { + crate::paths::test_support::with_temp_home(|| { + let backup = crate::state::state_dir().join("o.md"); + std::fs::create_dir_all(backup.parent().unwrap()).unwrap(); + std::fs::write(&backup, "ORIG").unwrap(); + let e = crate::optimize::retrieve::register( + crate::optimize::retrieve::EntryKind::FileBackup { + backup_path: backup, + }, + 4, + 1, + 1, + ); + + let s = AgentflareMcp::default(); + let out = s + .optimize(Parameters(OptimizeRequest { + action: "retrieve".into(), + id: Some(e.id), + })) + .unwrap(); + assert_eq!(out, "ORIG"); + }); +} + +// NOTE: `list_resources`/`read_resource` on `ServerHandler` take a +// `RequestContext`, which embeds a `Peer` whose +// constructor is `pub(crate)` inside rmcp (and requires the `client` +// feature this crate doesn't enable) — there is no supported way to +// build one from outside the rmcp crate. The URI-dispatch logic is +// therefore extracted into `list_resources_sync`/`read_resource_sync` +// (plain sync methods with identical bodies to the trait methods) so it +// can be unit-tested directly; the trait methods are thin async shells +// over them. +// +// `agentflare://sessions` is deliberately NOT covered here: it reads +// mutable on-disk runtime state via `optimize::load_runtime()`, whose +// path (`crate::state::state_dir()/runtime-state.json`) is not +// injectable, so exercising it deterministically would mean reading (or +// mutating) the real shared user state file. + +#[test] +fn list_resources_returns_sessions_and_nudges() { + let s = AgentflareMcp::default(); + let result = s.list_resources_sync(); + let uris: Vec<&str> = result.resources.iter().map(|r| r.uri.as_str()).collect(); + assert_eq!(uris, vec!["agentflare://sessions", "agentflare://nudges"]); +} + +#[test] +fn read_resource_nudges_returns_nudges_json() { + let s = AgentflareMcp::default(); + let result = s.read_resource_sync("agentflare://nudges").unwrap(); + assert_eq!(result.contents.len(), 1); + let ResourceContents::TextResourceContents { text, uri, .. } = &result.contents[0] else { + panic!("expected text resource contents"); + }; + assert_eq!(uri, "agentflare://nudges"); + assert!(text.contains("session_hygiene")); +} + +#[test] +fn read_resource_unknown_uri_returns_resource_not_found() { + let s = AgentflareMcp::default(); + let err = s.read_resource_sync("agentflare://bogus").unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::RESOURCE_NOT_FOUND); +} + +#[tokio::test] +async fn skill_search_empty_query_is_invalid_params() { + let s = AgentflareMcp::default(); + let err = s + .skill(Parameters(SkillRequest { + action: "search".into(), + query: Some("".into()), + ..Default::default() + })) + .await + .unwrap_err(); + assert!(err.to_string().contains("query")); +} + +#[tokio::test] +async fn skill_load_unknown_name_reports_not_found_with_search_hint() { + // Isolated DB path so the test never opens/refreshes the shared skills.db. + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + skills_db_override: Some(tmp.path().join("skills.db")), + ..Default::default() + }; + let out = s + .skill(Parameters(SkillRequest { + action: "load".into(), + name: Some("definitely-not-a-skill-xyz".into()), + original: false, + ..Default::default() + })) + .await + .unwrap_err(); + assert!(out.to_string().contains("skill_search")); +} + +#[tokio::test] +async fn skill_search_mode_rejects_unknown_value() { + let s = AgentflareMcp::default(); + let err = s + .skill(Parameters(SkillRequest { + action: "search".into(), + query: Some("anything".into()), + mode: Some("fuzzy".into()), + ..Default::default() + })) + .await + .unwrap_err(); + assert!(err.to_string().contains("mode")); +} + +#[tokio::test] +async fn tool_search_empty_query_is_invalid_params() { + // Isolated DB path so the test never opens/refreshes the shared gateway.db. + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + gateway_db_override: Some(tmp.path().join("gateway.db")), + ..Default::default() + }; + let err = s + .tool(Parameters(ToolRequest { + action: "search".into(), + query: Some("".into()), + ..Default::default() + })) + .await + .unwrap_err(); + assert!(err.to_string().contains("query is required")); +} + +#[tokio::test] +async fn tool_search_mode_rejects_unknown_value() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + gateway_db_override: Some(tmp.path().join("gateway.db")), + ..Default::default() + }; + let err = s + .tool(Parameters(ToolRequest { + action: "search".into(), + query: Some("x".into()), + mode: Some("bogus".into()), + ..Default::default() + })) + .await + .unwrap_err(); + assert!(err.to_string().contains("mode must be")); +} + +#[tokio::test] +async fn tool_execute_requires_server_and_tool() { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + gateway_db_override: Some(tmp.path().join("gateway.db")), + ..Default::default() + }; + let err = s + .tool(Parameters(ToolRequest { + action: "execute".into(), + server: Some("".into()), + tool: Some("x".into()), + args: Some(serde_json::Map::new()), + ..Default::default() + })) + .await + .unwrap_err(); + assert!(err.to_string().contains("required")); +} + +#[tokio::test] +async fn tool_execute_unknown_server_is_invalid_params() { + // Isolated DB path, no servers configured — `Registry::execute` is + // guaranteed to hit `GatewayError::ServerNotFound`, which must map to + // `invalid_params` (a caller-fixable mistake), not `internal_error`. + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + gateway_db_override: Some(tmp.path().join("gateway.db")), + ..Default::default() + }; + let err = s + .tool(Parameters(ToolRequest { + action: "execute".into(), + server: Some("definitely-not-a-configured-server".into()), + tool: Some("x".into()), + args: Some(serde_json::Map::new()), + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); + assert!(err.to_string().contains("not found")); +} + +#[test] +fn tool_execute_args_schema_is_object_or_null() { + let schema = schemars::schema_for!(ToolRequest); + let schema_json = serde_json::to_value(&schema).unwrap(); + let args_schema = schema_json + .get("properties") + .and_then(|p| p.get("args")) + .expect("args schema present"); + let rendered = args_schema.to_string(); + assert!(rendered.contains("\"object\""), "{rendered}"); + assert!(rendered.contains("\"null\""), "{rendered}"); +} + +/// Guards against the exact bug Phase 2's spec was written to avoid: a +/// second, untagged `impl AgentflareMcp` block would compile fine and +/// its `#[tool]` methods would still be directly callable (which is why +/// unit tests calling them would pass either way) but be invisible to +/// every real MCP client. Not fully sufficient on its own (see the +/// spec) but catches the single-router invariant cheaply. +#[test] +fn exactly_one_tool_router_block_exists() { + // Matches the attribute directly annotating `impl AgentflareMcp {`, + // not every prose mention of it (e.g. the placement-rule doc comment + // on the memory tools, or this test's own description). + let marker = ["#[", "tool_router", "]\nimpl AgentflareMcp {"].concat(); + let src = include_str!("../../mcp_server.rs"); + assert_eq!( + src.matches(&marker).count(), + 1, + "all #[tool] methods must live in the one tool-router-tagged impl block" + ); +} + +fn harness() -> (tempfile::TempDir, AgentflareMcp) { + let tmp = tempfile::tempdir().unwrap(); + let s = AgentflareMcp { + backend_db_override: Some(tmp.path().join("backend.db")), + backend_project_link_override: Some(tmp.path().join("project.json")), + ..Default::default() + }; + (tmp, s) +} + +fn backend_conn(tmp: &tempfile::TempDir) -> rusqlite::Connection { + agentflare_backend::db::open_db(&tmp.path().join("backend.db")).unwrap() +} + +fn empty_item_create(name: &str) -> ItemRequest { + ItemRequest { + action: "create".into(), + name: Some(name.to_string()), + ..Default::default() + } +} + +mod action_tests; +mod artifact_tests; +mod asset_tests; +mod item_tests; diff --git a/src/mcp_server/types.rs b/src/mcp_server/types.rs new file mode 100644 index 00000000..d65232f0 --- /dev/null +++ b/src/mcp_server/types.rs @@ -0,0 +1,856 @@ +//! Request/response types and small shared helpers used across mcp_server.rs's +//! MCP tool handlers -- split out to shrink mcp_server.rs (see item #168). + +use super::*; + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct GetRoutingSuggestionRequest { + #[schemars(description = "The user's prompt to analyze")] + pub(crate) prompt: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct CheckSessionHealthRequest { + #[schemars(description = "The session ID to check")] + pub(crate) session_id: String, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct SkillRequest { + #[schemars(description = "Action: search|load")] + pub(crate) action: String, + #[schemars(description = "What you need to do; keyword-style works best (search)")] + #[serde(default)] + pub(crate) query: Option, + #[schemars(description = "Skill name; qualify as 'source:name' if ambiguous (load)")] + #[serde(default)] + pub(crate) name: Option, + #[schemars(description = "Max results (default 5) (search)")] + #[serde(default)] + pub(crate) limit: Option, + #[schemars( + description = "'all' = every word must match (default); 'any' = broader recall for retries (search)" + )] + #[serde(default)] + pub(crate) mode: Option, + #[schemars(description = "true = load the original even when a compressed copy exists (load)")] + #[serde(default)] + pub(crate) original: bool, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct ToolRequest { + #[schemars(description = "Action: search|execute")] + pub(crate) action: String, + #[schemars(description = "What tool you need; keyword-style works best (search)")] + #[serde(default)] + pub(crate) query: Option, + #[schemars(description = "Max results (default 5) (search)")] + #[serde(default)] + pub(crate) limit: Option, + #[schemars( + description = "'all' = every word must match (default); 'any' = broader recall for retries (search)" + )] + #[serde(default)] + pub(crate) mode: Option, + #[schemars(description = "Server name from the search action (execute)")] + #[serde(default)] + pub(crate) server: Option, + #[schemars(description = "Tool name from the search action (execute)")] + #[serde(default)] + pub(crate) tool: Option, + // A bare `serde_json::Value` here made schemars emit a typeless schema + // (Value can be anything), so callers had no signal to send a nested + // JSON object rather than a stringified one — execute couldn't actually + // be invoked with arguments. `Map` renders as `{"type": ["object", + // "null"]}`, a real hint. + #[schemars(description = "Arguments object matching the tool's input_schema (execute)")] + #[serde(default)] + pub(crate) args: Option>, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct ClaimRequest { + #[schemars(description = "Action: acquire|done|heartbeat|list|release")] + pub(crate) action: String, + #[schemars(description = "Target to claim, e.g. \"issue#42\" or \"pr#7\"")] + #[serde(default)] + pub(crate) target: Option, + #[schemars(description = "Repo key owner/name (default: normalized origin remote)")] + #[serde(default)] + pub(crate) repo: Option, + #[schemars(description = "Include stale and done claims (default false) (list)")] + #[serde(default)] + pub(crate) all: bool, + #[schemars(description = "List across every repo in the ledger (default false) (list)")] + #[serde(default)] + pub(crate) all_repos: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct ChannelSendRequest { + #[schemars(description = "Platform to send to: telegram, slack, or discord")] + pub(crate) platform: String, + #[schemars(description = "Recipient id: Telegram chat_id, or Slack/Discord channel id")] + pub(crate) target: String, + #[schemars(description = "The message text to send")] + pub(crate) message: String, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct ReviewRequest { + #[schemars(description = "Action: clear|consensus|list|record|scores|submit")] + pub(crate) action: String, + #[schemars( + description = "Findings, each {file, line, message, severity?, category?} (submit)" + )] + #[serde(default)] + pub(crate) findings: Option>, + #[schemars(description = "Review round id (default: current branch)")] + #[serde(default)] + pub(crate) pr: Option, + #[schemars(description = "Finder name (default: detected agent) (submit)")] + #[serde(default)] + pub(crate) agent: Option, + #[schemars(description = "Diff base ref (default: master) (consensus, record)")] + #[serde(default)] + pub(crate) base: Option, + #[schemars(description = "Diff head ref (default: HEAD) (consensus, record)")] + #[serde(default)] + pub(crate) head: Option, + #[schemars(description = "Repo key owner/name (default: origin remote)")] + #[serde(default)] + pub(crate) repo: Option, + #[schemars(description = "Aggregate across every repo (default false) (scores)")] + #[serde(default)] + pub(crate) all_repos: bool, +} + +/// A handoff assigns an item to another agent and attaches the work product +/// to it as an asset. Unlike a bare item update, `recipient` is a required +/// field, not `Option` — the schema itself makes an unaddressed handoff +/// unrepresentable, so an intended handoff can't silently land with no +/// assignee. Re-attaching under the same `item_id` (or the same generated +/// filename on a freshly created item) becomes the next asset version, not +/// a duplicate. +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct HandoffRequest { + #[schemars( + description = "Agent/runtime this handoff is addressed to — becomes the item's assignee_agent. Required." + )] + pub(crate) recipient: String, + #[schemars( + description = "Short name/brief for the handoff — the item's name when creating one" + )] + pub(crate) name: String, + #[schemars( + description = "The work product being handed off (diff, review, document, ...). Prepend the brief so the recipient knows the ask. Attached to the item as an asset." + )] + pub(crate) content: String, + #[schemars( + description = "html | markdown | mermaid | diagram | text (default: markdown) — picks the attached asset's extension/mime type" + )] + #[serde(default)] + pub(crate) r#type: Option, + #[schemars( + description = "Existing item ID to assign and attach to, instead of creating a new item" + )] + #[serde(default)] + pub(crate) item_id: Option, + #[schemars( + description = "Handoff thread to continue; omit to start a new one. Stored in the new item's metadata, or the attached asset's metadata when item_id is given." + )] + #[serde(default)] + pub(crate) thread_id: Option, + #[schemars( + description = "Id this replies to (when answering an inbox item) — stored in the attached asset's metadata for provenance" + )] + #[serde(default)] + pub(crate) reply_to: Option, + #[schemars( + description = "One-line description; used as the new item's description when creating one" + )] + #[serde(default)] + pub(crate) description: Option, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct ArtifactRequest { + #[schemars(description = "Action: delete|diff|get|list|publish|search")] + pub(crate) action: String, + #[schemars(description = "Artifact id")] + #[serde(default)] + pub(crate) id: Option, + #[schemars(description = "Display name of the artifact (publish)")] + #[serde(default)] + pub(crate) name: Option, + #[schemars( + description = "html | markdown | mermaid | diagram | text (default: text) (publish)" + )] + #[serde(default)] + pub(crate) r#type: Option, + #[schemars( + description = "Full artifact content (HTML document, markdown source, plain text, ...) (publish)" + )] + #[serde(default)] + pub(crate) content: Option, + #[schemars(description = "Session ID for grouping artifacts (optional)")] + #[serde(default)] + pub(crate) session_id: Option, + #[schemars( + description = "Existing artifact id to update in place — keeps the same URL and live-reloads open viewers (publish)" + )] + #[serde(default)] + pub(crate) update_id: Option, + #[schemars( + description = "Short label for this version, shown in history (e.g. \"draft\", \"final\") (publish)" + )] + #[serde(default)] + pub(crate) label: Option, + #[schemars(description = "One-line description shown in the gallery (publish)")] + #[serde(default)] + pub(crate) description: Option, + #[schemars(description = "One or two emoji used as the page icon (publish)")] + #[serde(default)] + pub(crate) favicon: Option, + #[schemars( + description = "Optimistic-concurrency guard: update only applies if the artifact's current version equals this; otherwise a version-conflict error is returned (publish)" + )] + #[serde(default)] + pub(crate) base_version: Option, + #[schemars( + description = "Handoff envelope: agent/runtime this artifact is addressed to — for WORK PRODUCTS only; facts and decisions belong in memory (memory_remember), not artifacts (publish)" + )] + #[serde(default)] + pub(crate) recipient: Option, + #[schemars( + description = "Handoff envelope: thread this belongs to; replies reuse the sender's thread_id (publish)" + )] + #[serde(default)] + pub(crate) thread_id: Option, + #[schemars(description = "Handoff envelope: artifact id this replies to (publish)")] + #[serde(default)] + pub(crate) reply_to: Option, + #[schemars(description = "Older version number to diff from (diff)")] + #[serde(default)] + pub(crate) from_version: Option, + #[schemars(description = "Newer version number (omit for latest) (diff)")] + #[serde(default)] + pub(crate) to_version: Option, + #[schemars( + description = "Case-insensitive text to find in names, descriptions, or content (search)" + )] + #[serde(default)] + pub(crate) query: Option, + #[schemars(description = "Specific version to fetch (omit for latest) (get)")] + #[serde(default)] + pub(crate) version: Option, + #[schemars( + description = "Inbox filter: only artifacts addressed to this agent/runtime (list)" + )] + #[serde(default)] + pub(crate) inbox_recipient: Option, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct OptimizeRequest { + #[schemars(description = "Action: retrieve | list")] + pub(crate) action: String, + #[serde(default)] + #[schemars(description = "Registered compression id (required for retrieve)")] + pub(crate) id: Option, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct MemoryRequest { + #[schemars(description = "Action: compact|context|curate|handoff|recall|relate|remember")] + pub(crate) action: String, + #[schemars(description = "Title of the observation (remember)")] + #[serde(default)] + pub(crate) title: Option, + #[schemars(description = "Content body of the observation (remember, curate)")] + #[serde(default)] + pub(crate) content: Option, + #[schemars( + description = "Type: decision|bugfix|discovery|pattern|learning|manual (remember, recall)" + )] + #[serde(default)] + pub(crate) r#type: Option, + #[schemars(description = "Session ID to associate with")] + #[serde(default)] + pub(crate) session_id: Option, + #[schemars(description = "Project name")] + #[serde(default)] + pub(crate) project: Option, + #[schemars(description = "Stable topic key for upsert dedup (remember)")] + #[serde(default)] + pub(crate) topic_key: Option, + #[schemars(description = "Scope: project (default) or personal (remember)")] + #[serde(default)] + pub(crate) scope: Option, + #[schemars(description = "Search query (FTS5 BM25); omit for recent listing (recall)")] + #[serde(default)] + pub(crate) query: Option, + #[schemars(description = "Direct lookup by ID (recall)")] + #[serde(default)] + pub(crate) id: Option, + #[schemars(description = "Max results (default 10, max 50) (recall)")] + #[serde(default)] + pub(crate) limit: Option, + #[schemars(description = "Session summary (handoff)")] + #[serde(default)] + pub(crate) summary: Option, + #[schemars(description = "Findings array [{file, line?, summary}] (handoff)")] + #[serde(default)] + pub(crate) findings: Option>, + #[schemars(description = "Decisions array [{summary, rationale?}] (handoff)")] + #[serde(default)] + pub(crate) decisions: Option>, + #[schemars(description = "Files touched array [{path, modified?, tokens}] (handoff)")] + #[serde(default)] + pub(crate) files_touched: Option>, + #[schemars(description = "Evidence array [{kind, action, detail}] (handoff)")] + #[serde(default)] + pub(crate) evidence: Option>, + #[schemars(description = "Source observation ID (relate)")] + #[serde(default)] + pub(crate) source_id: Option, + #[schemars(description = "Target observation ID (relate)")] + #[serde(default)] + pub(crate) target_id: Option, + #[schemars( + description = "Relation: related|compatible|scoped|conflicts_with|supersedes|not_conflict (relate)" + )] + #[serde(default)] + pub(crate) relation: Option, + #[schemars(description = "Reason for the relation (relate)")] + #[serde(default)] + pub(crate) reason: Option, + #[schemars(description = "Confidence score 0.0..1.0 (relate)")] + #[serde(default)] + pub(crate) confidence: Option, + #[schemars(description = "Pin status (curate pin/unpin actions)")] + #[serde(default)] + pub(crate) pinned: Option, + #[schemars(description = "Sub-action for curate: update|delete|pin|unpin")] + #[serde(default)] + pub(crate) curate_action: Option, + #[schemars(description = "Target fraction of lines to keep (0.0-1.0, compact)")] + #[serde(default)] + pub(crate) compression_ratio: Option, + #[schemars(description = "Keep N most recent messages verbatim (compact)")] + #[serde(default)] + pub(crate) preserve_recent: Option, + #[schemars(description = "Scorer backend: fts5 (compact)")] + #[serde(default)] + pub(crate) scorer: Option, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct GitHubRequest { + #[schemars( + description = "Action: pr_create|pr_list|pr_get|pr_merge|pr_comment|pr_request_review|issue_create|issue_list|issue_get|issue_comment|issue_close|issue_label|release_list|release_get|release_latest|release_create|run_list|run_get|run_rerun|workflow_dispatch" + )] + pub(crate) action: String, + #[schemars(description = "owner/repo (default: resolved from the current repo's origin)")] + #[serde(default)] + pub(crate) repo: Option, + #[schemars(description = "PR number (pr_get, pr_merge, pr_comment, pr_request_review)")] + #[serde(default)] + pub(crate) number: Option, + #[schemars(description = "PR title (pr_create)")] + #[serde(default)] + pub(crate) title: Option, + #[schemars(description = "Head branch (pr_create)")] + #[serde(default)] + pub(crate) head: Option, + #[schemars(description = "Base branch (pr_create)")] + #[serde(default)] + pub(crate) base: Option, + #[schemars(description = "Body / comment text (pr_create, pr_comment)")] + #[serde(default)] + pub(crate) body: Option, + #[schemars(description = "State filter for pr_list: open|closed|all (default open)")] + #[serde(default)] + pub(crate) state: Option, + #[schemars(description = "Merge method for pr_merge: merge|squash|rebase (default merge)")] + #[serde(default)] + pub(crate) merge_method: Option, + #[schemars(description = "Reviewer logins (pr_request_review)")] + #[serde(default)] + pub(crate) reviewers: Option>, + #[schemars(description = "Labels (issue_create, issue_label)")] + #[serde(default)] + pub(crate) labels: Option>, + #[schemars(description = "Assignee logins (issue_create)")] + #[serde(default)] + pub(crate) assignees: Option>, + #[schemars(description = "Release id (release_get)")] + #[serde(default)] + pub(crate) release_id: Option, + #[schemars(description = "Git tag (release_create)")] + #[serde(default)] + pub(crate) tag: Option, + #[schemars(description = "Release name (release_create)")] + #[serde(default)] + pub(crate) name: Option, + #[schemars(description = "Mark release as draft (release_create, default false)")] + #[serde(default)] + pub(crate) draft: Option, + #[schemars(description = "Mark release as prerelease (release_create, default false)")] + #[serde(default)] + pub(crate) prerelease: Option, + #[schemars(description = "Workflow run id (run_get, run_rerun)")] + #[serde(default)] + pub(crate) run_id: Option, + #[schemars(description = "Branch filter for run_list")] + #[serde(default)] + pub(crate) branch: Option, + #[schemars(description = "Workflow file name or id (workflow_dispatch)")] + #[serde(default)] + pub(crate) workflow: Option, + #[schemars( + description = "Git ref to dispatch against (workflow_dispatch, default: repo default branch)" + )] + #[serde(default)] + pub(crate) git_ref: Option, + #[schemars(description = "JSON inputs object for workflow_dispatch")] + #[serde(default)] + pub(crate) inputs: Option, +} + +/// All local artifact backends (flared, another session, or our own +/// owned server) bind loopback-only — never advertise anything else. +pub(crate) const LOCAL_HOST: &str = "127.0.0.1"; +/// flared's default HTTP port; its artifact routes live under /artifacts. +pub(crate) const FLARED_DEFAULT_PORT: u16 = 35273; +pub(crate) const FLARED_ARTIFACTS_PATH: &str = "/artifacts/"; + +/// flared's HTTP port: honor a `port` override in its config.toml when +/// readable (a `--port` CLI override is invisible here and lands on the +/// fixed-port fallback chain); default otherwise. +pub(crate) fn flared_port() -> u16 { + dirs::config_dir() + .map(|dir| dir.join("flared").join("config.toml")) + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| parse_flared_port(&text)) + .unwrap_or(FLARED_DEFAULT_PORT) +} + +/// Extract the top-level `port` key from flared's config.toml text — a +/// minimal scan that avoids a toml dependency for one key. Absent or +/// malformed values -> None. +pub(crate) fn parse_flared_port(text: &str) -> Option { + for line in text.lines() { + let line = line.trim(); + if line.starts_with('[') { + // TOML top-level keys end at the first table header + return None; + } + if let Some(rest) = line.strip_prefix("port") + && let Some(value) = rest.trim_start().strip_prefix('=') + { + return value + .trim() + .split(|c: char| c == '#' || c.is_whitespace()) + .next() + .and_then(|v| v.parse().ok()); + } + } + None +} + +/// How artifact pages reach the browser for this process. +pub(crate) enum ArtifactBackend { + /// This process owns the listener. + Owned(agentflare_artifacts::ArtifactServer), + /// Another process serves the shared store: flared under /artifacts on + /// its fixed port, or an earlier session's root-mounted server. + External { port: u16, path: &'static str }, +} + +impl ArtifactBackend { + /// Base URL artifact links hang off (no trailing slash). + pub(crate) fn base_url(&self) -> String { + match self { + ArtifactBackend::Owned(server) => server.base_url(), + ArtifactBackend::External { port, path } => { + format!("http://{LOCAL_HOST}:{port}{}", path.trim_end_matches('/')) + } + } + } +} + +// --- agentflare-backend MCP tools ----------------------------------------- +// +// Workspace is fully hidden: exactly one per system, auto-created lazily on +// first use. Project is Vercel-style auto-linked: +// `.agentflare/project.json` at the repo root maps this checkout to +// a project, created on first use and re-linked (never duplicated — see +// `resolve_project`) if the link file goes missing. Neither workspace_id nor +// project_id is ever an MCP-exposed parameter; every tool resolves +// both from cwd/git context. + +/// The `.agentflare/project.json` link file's shape. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub(crate) struct ProjectLink { + pub(crate) workspace_id: String, + pub(crate) project_id: String, + pub(crate) identifier: String, +} + +/// Default 4h — item claims are plausibly longer-running than +/// `src/claims.rs`'s 30-min GitHub-issue-claim default, hence a separate env +/// var rather than sharing `AGENTFLARE_CLAIM_TTL_SECS`. +pub(crate) fn backend_claim_ttl_secs() -> i64 { + std::env::var("AGENTFLARE_BACKEND_CLAIM_TTL_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(14400) as i64 +} + +/// NotFound/Duplicate/InvalidTransition are caller-fixable → invalid_params; +/// a raw database error is ours to fix → internal_error. Same split as +/// `skill_load`'s NotFound/Ambiguous handling above. +pub(crate) fn map_backend_err(e: agentflare_backend::Error) -> ErrorData { + match e { + agentflare_backend::Error::NotFound(msg) + | agentflare_backend::Error::Duplicate(msg) + | agentflare_backend::Error::InvalidTransition(msg) + | agentflare_backend::Error::Validation(msg) => ErrorData::invalid_params(msg, None), + agentflare_backend::Error::Database(e) => ErrorData::internal_error(e.to_string(), None), + } +} + +/// Maps a `GitHubError` to MCP `ErrorData`: client/auth mistakes become +/// `invalid_params`, transport/parse failures become `internal_error`. +pub(crate) fn to_mcp_error(err: crate::github::GitHubError) -> ErrorData { + let msg = err.to_string(); + if crate::github::mcp::is_client_error(&err) { + ErrorData::invalid_params(msg, None) + } else { + ErrorData::internal_error(msg, None) + } +} + +/// Converts the unified dispatch-layer error type once, at whichever `?` +/// first needs an `ErrorData` — lets internal helpers (`with_fresh_registry`, +/// `claim_db`, `resolve_workspace_id`, ...) chain heterogeneous fallible +/// steps with `?` instead of mapping each one to `ErrorData` individually. +impl From for ErrorData { + fn from(e: crate::errors::AgentflareError) -> Self { + match e { + crate::errors::AgentflareError::Backend(e) => map_backend_err(e), + other => ErrorData::internal_error(other.to_string(), None), + } + } +} + +/// 24 random bytes, hex-encoded — used as a webhook's HMAC signing secret +/// when the caller doesn't supply one. +pub(crate) fn generate_webhook_secret() -> String { + use rand::Rng; + let bytes: [u8; 24] = rand::thread_rng().r#gen(); + hex::encode(bytes) +} + +pub(crate) fn base64_encode(bytes: &[u8]) -> String { + use base64::engine::general_purpose; + general_purpose::STANDARD.encode(bytes) +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct ItemRequest { + #[schemars( + description = "Action: create|get|list|search|update|update_state|delete|claim|heartbeat|release|done|cancel|add_label|remove_label|groom|standup|health" + )] + pub(crate) action: String, + #[schemars( + description = "Item ID (required for get, update, update_state, delete, claim, heartbeat, release, done, add_label, remove_label)" + )] + #[serde(default)] + pub(crate) id: Option, + #[schemars(description = "Item name/title (required for create)")] + #[serde(default)] + pub(crate) name: Option, + #[schemars( + description = "State ID (create, update_state); omit to use the project's default (Backlog) state" + )] + #[serde(default)] + pub(crate) state_id: Option, + #[schemars(description = "Markdown description body (create, update)")] + #[serde(default)] + pub(crate) description: Option, + #[schemars(description = "Priority: none|low|medium|high|urgent (create, update)")] + #[serde(default)] + pub(crate) priority: Option, + #[schemars(description = "Parent item ID, for sub-items (create)")] + #[serde(default)] + pub(crate) parent_id: Option, + #[schemars( + description = "Agent ID to assign (create, update), or to filter by (list — matches items assigned to this agent plus unassigned ones, sorted open+assigned-to-you first)" + )] + #[serde(default)] + pub(crate) assignee_agent: Option, + #[schemars( + description = "Domain-specific fields as a JSON object (create, update). Set {\"size\": \"S\"|\"M\"|\"L\"} so `groom` can score effort instead of reporting the item unestimated." + )] + #[serde(default)] + pub(crate) metadata: Option, + #[schemars(description = "Label IDs to attach on creation (create)")] + #[serde(default)] + pub(crate) label_ids: Option>, + #[schemars(description = "Item IDs this item depends on (create)")] + #[serde(default)] + pub(crate) dependency_ids: Option>, + #[schemars(description = "Label ID (add_label, remove_label)")] + #[serde(default)] + pub(crate) label_id: Option, + #[schemars( + description = "Filter by state group (list); one of backlog|unstarted|started|completed|cancelled|triage, or a comma-separated list (e.g. \"backlog,unstarted,started\") to match any" + )] + #[serde(default)] + pub(crate) state_group: Option, + #[schemars( + description = "Max items to return (list: omit for no limit; search: omit for 20, capped at 1000; groom: omit for 15, capped at 200)" + )] + #[serde(default)] + pub(crate) limit: Option, + #[schemars(description = "Items to skip before applying limit (list); default 0")] + #[serde(default)] + pub(crate) offset: Option, + #[schemars(description = "FTS5 search query (search)")] + #[serde(default)] + pub(crate) query: Option, + #[schemars( + description = "Days since updated_at before an item counts as stale/stuck (groom: default 14; standup/health: default 7)" + )] + #[serde(default)] + pub(crate) staleness_days: Option, + #[schemars( + description = "Now-bucket size (groom only) — when set, additionally buckets the shortlist into now/next/later/needs_estimation for sprint planning" + )] + #[serde(default)] + pub(crate) capacity: Option, + #[schemars( + description = "Hours back a completed item counts as \"done\" (standup); default 24" + )] + #[serde(default)] + pub(crate) cutoff_hours: Option, + #[schemars(description = "Trailing weekly windows for velocity (health); default 4, max 52")] + #[serde(default)] + pub(crate) window_weeks: Option, +} + +/// Lean per-item projection for `item(list)` — the raw 19-field `Item` (full +/// description/metadata/timestamps) is what `get` returns; `list` only needs +/// enough to triage, and resolves the opaque `state_id` into a readable name. +#[derive(Debug, serde::Serialize)] +pub(crate) struct ItemSummary { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) state: String, + pub(crate) state_group: String, + pub(crate) priority: String, + pub(crate) assignee_agent: Option, + pub(crate) parent_id: Option, + pub(crate) sequence_id: i64, + pub(crate) updated_at: i64, +} + +/// One shortlisted item plus the decision-support signals `groom` computes +/// server-side (staleness, blocking, fan-in, near-duplicates) so the caller +/// doesn't have to re-derive them by eyeballing timestamps and free text. +#[derive(Debug, serde::Serialize)] +pub(crate) struct GroomItem { + pub(crate) id: String, + pub(crate) sequence_id: i64, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) state: String, + pub(crate) state_group: String, + pub(crate) priority: String, + pub(crate) assignee_agent: Option, + pub(crate) updated_at: i64, + pub(crate) stale: bool, + pub(crate) unassigned: bool, + /// Parsed from `metadata.size` ("S"|"M"|"L"); `None` when absent — see `unestimated`. + pub(crate) size: Option, + /// True when `metadata.size` is missing — add a size label to enable real RICE scoring. + pub(crate) unestimated: bool, + /// IDs this item depends on that are still open (not completed/cancelled). + pub(crate) blocked_by: Vec, + /// How many other items declare a dependency on this one. + pub(crate) depended_on_by_count: i64, + /// Other shortlisted items with a near-identical name (token-Jaccard ≥ 0.5). + pub(crate) possible_duplicates: Vec, +} + +/// One-call groom result: priority+staleness-ranked shortlist with all the +/// flags a human/agent needs to make pull-next decisions, computed in Rust +/// instead of costing N `get` round trips + manual LLM staleness/dup checks. +#[derive(Debug, serde::Serialize)] +pub(crate) struct GroomResponse { + pub(crate) staleness_days: i64, + pub(crate) stale_count: usize, + pub(crate) unassigned_count: usize, + pub(crate) unestimated_count: usize, + pub(crate) items: Vec, + /// Top unassigned, not-stale, unblocked items from the shortlist. + pub(crate) pull_next: Vec, + /// Only populated when the `capacity` request field is set. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) now: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) next: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) later: Option>, + /// Unestimated items — excluded from now/next/later, can't be planned yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) needs_estimation: Option>, +} + +/// Lean per-item row for `standup` — no description, matches `ItemSummary`'s +/// thin-projection philosophy since standup doesn't need item bodies. +#[derive(Debug, Clone, serde::Serialize)] +pub(crate) struct StandupItem { + pub(crate) id: String, + pub(crate) sequence_id: i64, + pub(crate) name: String, + pub(crate) priority: String, + pub(crate) assignee_agent: Option, + pub(crate) updated_at: i64, +} + +#[derive(Debug, serde::Serialize)] +pub(crate) struct StandupGroup { + /// The literal string "unassigned" when `assignee_agent` is null. + pub(crate) assignee: String, + pub(crate) items: Vec, +} + +#[derive(Debug, serde::Serialize)] +pub(crate) struct StandupResponse { + pub(crate) cutoff_hours: i64, + pub(crate) stuck_days: i64, + pub(crate) done: Vec, + pub(crate) done_count: usize, + pub(crate) in_progress: Vec, + pub(crate) in_progress_count: usize, + pub(crate) stuck: Vec, + pub(crate) stuck_count: usize, +} + +#[derive(Debug, serde::Serialize)] +pub(crate) struct VelocityWeek { + pub(crate) week_start: i64, + pub(crate) week_end: i64, + pub(crate) completed_count: usize, +} + +#[derive(Debug, serde::Serialize)] +pub(crate) struct HealthResponse { + pub(crate) window_weeks: i64, + /// Oldest → newest. + pub(crate) velocity: Vec, + /// "up" | "down" | "flat" — last window vs. the one before it. + pub(crate) velocity_trend: String, + pub(crate) wip_count: usize, + pub(crate) wip: Vec, + pub(crate) stuck_days: i64, + pub(crate) stuck_count: usize, + pub(crate) stuck: Vec, + /// Empty today — agentflare has no persisted handoff log distinct from + /// item state, so this can't be computed yet (see `bottleneck_note`). + pub(crate) bottlenecks: Vec, + pub(crate) bottleneck_note: String, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct CommentRequest { + #[schemars(description = "Action: create|edit|delete|list")] + pub(crate) action: String, + #[schemars(description = "Item ID to comment on (required for create, list)")] + #[serde(default)] + pub(crate) item_id: Option, + #[schemars(description = "Comment ID (required for edit, delete)")] + #[serde(default)] + pub(crate) id: Option, + #[schemars(description = "Comment body text (required for create, edit)")] + #[serde(default)] + pub(crate) body: Option, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct LabelRequest { + #[schemars(description = "Action: create|list|update|delete")] + pub(crate) action: String, + #[schemars(description = "Label ID (required for update, delete)")] + #[serde(default)] + pub(crate) id: Option, + #[schemars(description = "Label name (required for create; optional for update)")] + #[serde(default)] + pub(crate) name: Option, + #[schemars(description = "Hex color, e.g. #F59E0B (create, update)")] + #[serde(default)] + pub(crate) color: Option, + #[schemars(description = "Parent label ID for nesting/grouping (create)")] + #[serde(default)] + pub(crate) parent_id: Option, + #[schemars(description = "Sort order for manual ordering (create, update)")] + #[serde(default)] + pub(crate) sort_order: Option, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct WebhookRequest { + #[schemars(description = "Action: create|list|delete")] + pub(crate) action: String, + #[schemars(description = "Webhook ID (required for delete)")] + #[serde(default)] + pub(crate) id: Option, + #[schemars(description = "HTTPS/HTTP URL to deliver events to (required for create)")] + #[serde(default)] + pub(crate) url: Option, + #[schemars(description = "HMAC signing secret; auto-generated if omitted (create)")] + #[serde(default)] + pub(crate) secret: Option, + #[schemars(description = "Fire on item create/update/delete (create)")] + #[serde(default)] + pub(crate) on_item: Option, + #[schemars(description = "Fire on state changes (create)")] + #[serde(default)] + pub(crate) on_state: Option, + #[schemars(description = "Fire on project changes (create)")] + #[serde(default)] + pub(crate) on_project: Option, +} + +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct ProjectRequest { + #[schemars(description = "Action: info")] + pub(crate) action: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct AssetRequest { + #[schemars(description = "Action: attach|get|list|delete")] + pub(crate) action: String, + #[schemars(description = "Asset ID (required for get, delete)")] + #[serde(default)] + pub(crate) id: Option, + #[schemars(description = "Item ID to attach to (xor project_id)")] + #[serde(default)] + pub(crate) item_id: Option, + #[schemars(description = "Project ID to attach to (xor item_id)")] + #[serde(default)] + pub(crate) project_id: Option, + #[schemars( + description = "Filename (required for attach) — must exist in ~/.agentflare/staging/" + )] + #[serde(default)] + pub(crate) filename: Option, + #[schemars(description = "JSON metadata (optional, attach only)")] + #[serde(default)] + pub(crate) metadata: Option, +}