diff --git a/AGENTS.md b/AGENTS.md index 5486e56..2c29705 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Skill Runtime 全生命周期已落地(含依赖管理 Schema v15),Schema - **Workspace**:`%LOCALAPPDATA%\devbase\workspace/` —— 文件系统 = source of truth - `vault/` —— PARA 结构:00-Inbox, 01-Projects, 02-Areas, 03-Resources, 04-Archives, 99-Meta - `assets/` —— 二进制资源 -- **MCP Server**:stdio only,**57 个 tools**(含 5 个 vault tools + 8 个代码分析工具 + 4 个 embedding/搜索工具 + 4 个 Skill Runtime tools + 3 个 Workflow/评分 tools + 1 个报告工具 + 1 个 arXiv 工具 + 2 个 KnownLimit tools + 3 个 Relation tools + 2 个 Agent 状态工具 + 8 个 Agent Context tools + 1 个 streaming index 工具 + 1 个 oplog 工具);配置见 `mcp.json` +- **MCP Server**:stdio only,**58 个 tools**(含 5 个 vault tools + 8 个代码分析工具 + 4 个 embedding/搜索工具 + 4 个 Skill Runtime tools + 3 个 Workflow/评分 tools + 1 个报告工具 + 1 个 arXiv 工具 + 2 个 KnownLimit tools + 3 个 Relation tools + 2 个 Agent 状态工具 + 9 个 Agent Context tools + 1 个 streaming index 工具 + 1 个 oplog 工具);配置见 `mcp.json` - **Kimi CLI 集成**:MCP server 已通过 `kimi mcp add` 注册,端到端验证通过(`kimi --print` 成功调用 `devkit_health`);项目级 skill 位于 `.kimi/skills/devbase-project/SKILL.md` - **统一节点模型**:`core::node::{Node, NodeType, Edge}` —— GitRepo / VaultNote / Asset / ExternalLink - **当前测试**:490+ workspace passed / 0 failed / 4 ignored(主 crate 390 + symbol-links 4 + sync-protocol 12 + core-types 3 + syncthing-client 2 + vault-frontmatter 5 + vault-wikilink 5 + workflow-interpolate 9 + workflow-model 2 + registry-health 3 + registry-metrics 4 + registry-workspace 5 + embedding 5 + skill-runtime-types 7 + skill-runtime-parser 3 + 其他 crates ~30);11/11 passed(integration `tests/cli.rs`) diff --git a/src/commands/workflow.rs b/src/commands/workflow.rs index ee2a8c3..030f70a 100644 --- a/src/commands/workflow.rs +++ b/src/commands/workflow.rs @@ -82,10 +82,12 @@ pub fn run_workflow( )); } } + let active_ctx = crate::registry::agent_context::resolve_active_context(); let exec_id = crate::workflow::create_execution( &conn, &workflow_id, &serde_json::to_string(&input_map)?, + active_ctx.as_deref(), )?; crate::workflow::update_execution( &conn, diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index b49b91e..3ebfbf6 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -108,6 +108,7 @@ pub enum McpToolEnum { SessionActivate(DevkitSessionActivateTool), SessionSearch(DevkitSessionSearchTool), SessionCapture(DevkitSessionCaptureTool), + SessionWorkflows(DevkitSessionWorkflowsTool), WorkflowList(DevkitWorkflowListTool), WorkflowRun(DevkitWorkflowRunTool), WorkflowStatus(DevkitWorkflowStatusTool), @@ -193,6 +194,7 @@ impl McpToolEnum { McpToolEnum::SessionActivate(_) => ToolTier::Beta, McpToolEnum::SessionSearch(_) => ToolTier::Beta, McpToolEnum::SessionCapture(_) => ToolTier::Beta, + McpToolEnum::SessionWorkflows(_) => ToolTier::Beta, McpToolEnum::WorkflowList(_) => ToolTier::Beta, McpToolEnum::WorkflowRun(_) => ToolTier::Beta, McpToolEnum::WorkflowStatus(_) => ToolTier::Beta, @@ -257,6 +259,7 @@ impl McpTool for McpToolEnum { McpToolEnum::SessionActivate(t) => t.name(), McpToolEnum::SessionSearch(t) => t.name(), McpToolEnum::SessionCapture(t) => t.name(), + McpToolEnum::SessionWorkflows(t) => t.name(), McpToolEnum::WorkflowList(t) => t.name(), McpToolEnum::WorkflowRun(t) => t.name(), McpToolEnum::WorkflowStatus(t) => t.name(), @@ -319,6 +322,7 @@ impl McpTool for McpToolEnum { McpToolEnum::SessionActivate(t) => t.schema(), McpToolEnum::SessionSearch(t) => t.schema(), McpToolEnum::SessionCapture(t) => t.schema(), + McpToolEnum::SessionWorkflows(t) => t.schema(), McpToolEnum::WorkflowList(t) => t.schema(), McpToolEnum::WorkflowRun(t) => t.schema(), McpToolEnum::WorkflowStatus(t) => t.schema(), @@ -385,6 +389,7 @@ impl McpTool for McpToolEnum { McpToolEnum::SessionActivate(t) => t.invoke(args, ctx).await, McpToolEnum::SessionSearch(t) => t.invoke(args, ctx).await, McpToolEnum::SessionCapture(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionWorkflows(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowList(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowRun(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowStatus(t) => t.invoke(args, ctx).await, @@ -641,6 +646,7 @@ pub fn build_server_with_tiers(tiers: Option<&HashSet>) -> McpServer { McpToolEnum::SessionActivate(DevkitSessionActivateTool), McpToolEnum::SessionSearch(DevkitSessionSearchTool), McpToolEnum::SessionCapture(DevkitSessionCaptureTool), + McpToolEnum::SessionWorkflows(DevkitSessionWorkflowsTool), McpToolEnum::WorkflowList(DevkitWorkflowListTool), McpToolEnum::WorkflowRun(DevkitWorkflowRunTool), McpToolEnum::WorkflowStatus(DevkitWorkflowStatusTool), diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index e925136..4fda4d9 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -39,7 +39,7 @@ async fn test_tools_list() { let (mut ctx, _tmp) = test_ctx(); let resp = server.handle_request(req, &mut ctx).await.unwrap(); let tools = resp.get("result").unwrap().get("tools").unwrap().as_array().unwrap(); - assert_eq!(tools.len(), 57); + assert_eq!(tools.len(), 58); let names: Vec<&str> = tools.iter().map(|t| t.get("name").unwrap().as_str().unwrap()).collect(); assert!(names.contains(&"devkit_session_save")); assert!(names.contains(&"devkit_session_list")); diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 9f5ccc5..5cb265d 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -60,5 +60,6 @@ mod tests { let _ = super::session::DevkitSessionActivateTool; let _ = super::session::DevkitSessionSearchTool; let _ = super::session::DevkitSessionCaptureTool; + let _ = super::session::DevkitSessionWorkflowsTool; } } diff --git a/src/mcp/tools/session.rs b/src/mcp/tools/session.rs index 3638cb2..c598322 100644 --- a/src/mcp/tools/session.rs +++ b/src/mcp/tools/session.rs @@ -591,6 +591,70 @@ This is a lightweight append-only operation. No validation is performed on conte } } +#[derive(Clone)] +pub struct DevkitSessionWorkflowsTool; + +impl McpTool for DevkitSessionWorkflowsTool { + fn name(&self) -> &'static str { + "devkit_session_workflows" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"List workflow executions associated with an agent session. + +Use this when the user wants to: +- Review what automated workflows were run in a project context +- Audit the execution history of a session +- Check workflow status for a specific project"#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { "type": "string", "description": "Session ID" }, + "limit": { "type": "integer", "description": "Maximum results", "default": 20 } + }, + "required": ["context_id"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let context_id = args.get("context_id").and_then(|v| v.as_str()).unwrap_or(""); + let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(20); + if context_id.is_empty() { + anyhow::bail!("Missing required argument: context_id"); + } + + let conn = ctx.conn()?; + let executions = + crate::workflow::state::list_executions_by_context(&conn, context_id, limit)?; + let results: Vec = executions + .into_iter() + .map(|(id, wf_id, status, current_step, started_at, duration_ms)| { + json!({ + "execution_id": id, + "workflow_id": wf_id, + "status": status, + "current_step": current_step, + "started_at": started_at, + "duration_ms": duration_ms, + }) + }) + .collect(); + + Ok(json!({ + "success": true, + "context_id": context_id, + "count": results.len(), + "executions": results, + })) + } +} + #[cfg(test)] mod tests { use super::*; @@ -606,6 +670,7 @@ mod tests { assert_eq!(DevkitSessionActivateTool.name(), "devkit_session_activate"); assert_eq!(DevkitSessionSearchTool.name(), "devkit_session_search"); assert_eq!(DevkitSessionCaptureTool.name(), "devkit_session_capture"); + assert_eq!(DevkitSessionWorkflowsTool.name(), "devkit_session_workflows"); } #[test] diff --git a/src/registry/agent_context.rs b/src/registry/agent_context.rs index 2a716df..9978c9a 100644 --- a/src/registry/agent_context.rs +++ b/src/registry/agent_context.rs @@ -361,6 +361,22 @@ fn log_op( ); } +/// Resolve the active agent context ID from environment or workspace state file. +pub fn resolve_active_context() -> Option { + if let Ok(ctx) = std::env::var("DEVBASE_ACTIVE_CONTEXT") + && !ctx.is_empty() + { + return Some(ctx); + } + let state_file = crate::registry::WorkspaceRegistry::workspace_dir() + .ok()? + .join(".active_context"); + std::fs::read_to_string(state_file) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/registry/migrate.rs b/src/registry/migrate.rs index f22b5d6..8dc5b8b 100644 --- a/src/registry/migrate.rs +++ b/src/registry/migrate.rs @@ -4,7 +4,7 @@ use super::*; use crate::storage::StorageBackend; use std::path::PathBuf; -pub const CURRENT_SCHEMA_VERSION: i32 = 32; +pub const CURRENT_SCHEMA_VERSION: i32 = 33; impl WorkspaceRegistry { pub fn db_path() -> anyhow::Result { diff --git a/src/registry/migrations/mod.rs b/src/registry/migrations/mod.rs index 450949a..903d985 100644 --- a/src/registry/migrations/mod.rs +++ b/src/registry/migrations/mod.rs @@ -34,6 +34,7 @@ pub mod v29_compensation_log; pub mod v30_code_symbol_attributes; pub mod v31_agent_contexts; pub mod v32_context_links; +pub mod v33_workflow_context; pub fn run_all(conn: &mut Connection) -> anyhow::Result<()> { let user_version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; @@ -134,6 +135,9 @@ pub fn run_all(conn: &mut Connection) -> anyhow::Result<()> { if user_version < 32 { v32_context_links::run(conn)?; } + if user_version < 33 { + v33_workflow_context::run(conn)?; + } Ok(()) } diff --git a/src/registry/migrations/v33_workflow_context.rs b/src/registry/migrations/v33_workflow_context.rs new file mode 100644 index 0000000..3f4f8d5 --- /dev/null +++ b/src/registry/migrations/v33_workflow_context.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 juice094 +use rusqlite::Connection; + +pub fn run(conn: &Connection) -> anyhow::Result<()> { + let cols: Vec = { + let mut stmt = conn.prepare("PRAGMA table_info(workflow_executions)")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; + rows.filter_map(Result::ok).collect() + }; + if !cols.iter().any(|c| c == "context_id") { + conn.execute("ALTER TABLE workflow_executions ADD COLUMN context_id TEXT", [])?; + } + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_workflow_execs_context ON workflow_executions(context_id)", + [], + )?; + conn.execute("PRAGMA user_version = 33", [])?; + Ok(()) +} diff --git a/src/registry/test_helpers.rs b/src/registry/test_helpers.rs index b20d239..9a68d4f 100644 --- a/src/registry/test_helpers.rs +++ b/src/registry/test_helpers.rs @@ -275,7 +275,8 @@ CREATE TABLE IF NOT EXISTS workflow_executions ( current_step TEXT, started_at TEXT NOT NULL, finished_at TEXT, - duration_ms INTEGER + duration_ms INTEGER, + context_id TEXT ); -- v18: Known Limits (L3 risk layer) diff --git a/src/skill_runtime/executor.rs b/src/skill_runtime/executor.rs index b20af52..47af436 100644 --- a/src/skill_runtime/executor.rs +++ b/src/skill_runtime/executor.rs @@ -66,7 +66,7 @@ pub fn run_skill( cmd.env("DEVBASE_HOME", devbase_home()?); // P2-B: Inject active session context memories if available - if let Some(ctx_id) = resolve_active_context() { + if let Some(ctx_id) = crate::registry::agent_context::resolve_active_context() { if let Ok(memories) = crate::registry::agent_context::list_memories(conn, &ctx_id) && !memories.is_empty() { @@ -254,22 +254,6 @@ pub(crate) fn check_hard_vetoes_for_skill( )) } -/// Resolve the active agent context ID from environment or state file. -fn resolve_active_context() -> Option { - if let Ok(ctx) = std::env::var("DEVBASE_ACTIVE_CONTEXT") - && !ctx.is_empty() - { - return Some(ctx); - } - let state_file = crate::registry::WorkspaceRegistry::workspace_dir() - .ok()? - .join(".active_context"); - std::fs::read_to_string(state_file) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - fn resolve_interpreter(path: &std::path::Path) -> (Option, String) { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let path_str = path.to_string_lossy().to_string(); diff --git a/src/workflow/mod.rs b/src/workflow/mod.rs index be45150..862f32b 100644 --- a/src/workflow/mod.rs +++ b/src/workflow/mod.rs @@ -84,7 +84,9 @@ impl crate::clients::WorkflowClient for AppContext { }; let inputs_json = inputs.to_string(); - let exec_id = state::create_execution(&conn, workflow_id, &inputs_json)?; + let active_ctx = crate::registry::agent_context::resolve_active_context(); + let exec_id = + state::create_execution(&conn, workflow_id, &inputs_json, active_ctx.as_deref())?; state::update_execution(&conn, exec_id, &model::ExecutionStatus::Running, None, None)?; let pool = self.pool(); diff --git a/src/workflow/state.rs b/src/workflow/state.rs index 7c8b150..e50bb26 100644 --- a/src/workflow/state.rs +++ b/src/workflow/state.rs @@ -61,16 +61,44 @@ pub fn create_execution( conn: &Connection, workflow_id: &str, inputs_json: &str, + context_id: Option<&str>, ) -> anyhow::Result { let now = chrono::Utc::now().to_rfc3339(); conn.execute( - "INSERT INTO workflow_executions (workflow_id, inputs_json, status, current_step, started_at) - VALUES (?1, ?2, 'Pending', NULL, ?3)", - params![workflow_id, inputs_json, now], + "INSERT INTO workflow_executions (workflow_id, inputs_json, status, current_step, started_at, context_id) + VALUES (?1, ?2, 'Pending', NULL, ?3, ?4)", + params![workflow_id, inputs_json, now, context_id], )?; Ok(conn.last_insert_rowid()) } +/// List workflow executions bound to a session context. +#[allow(clippy::type_complexity)] +pub fn list_executions_by_context( + conn: &Connection, + context_id: &str, + limit: i64, +) -> anyhow::Result, String, Option)>> { + let mut stmt = conn.prepare( + "SELECT id, workflow_id, status, current_step, started_at, duration_ms + FROM workflow_executions + WHERE context_id = ?1 + ORDER BY started_at DESC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![context_id, limit], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + )) + })?; + rows.collect::, _>>().map_err(Into::into) +} + pub fn update_execution( conn: &Connection, exec_id: i64, @@ -162,7 +190,7 @@ mod tests { let conn = WorkspaceRegistry::init_in_memory().unwrap(); let wf = dummy_wf(); save_workflow(&conn, &wf).unwrap(); - let exec_id = create_execution(&conn, "test-wf", r#"{"repo_path":"/tmp"}"#).unwrap(); + let exec_id = create_execution(&conn, "test-wf", r#"{"repo_path":"/tmp"}"#, None).unwrap(); assert!(exec_id > 0); update_execution(&conn, exec_id, &ExecutionStatus::Running, Some("step1"), None).unwrap(); let exec = get_execution(&conn, exec_id).unwrap().unwrap(); @@ -204,7 +232,7 @@ mod tests { validate_workflow(&wf).unwrap(); save_workflow(&conn, &wf).unwrap(); - let exec_id = create_execution(&conn, "e2e-wf", "{}").unwrap(); + let exec_id = create_execution(&conn, "e2e-wf", "{}", None).unwrap(); update_execution(&conn, exec_id, &ExecutionStatus::Running, None, None).unwrap(); // Execution should fail because skill does not exist