Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,28 @@
[package]
name = "nomifun-requirement"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-common.workspace = true
nomifun-db.workspace = true
nomifun-api-types.workspace = true
nomifun-file.workspace = true
nomifun-realtime.workspace = true
nomifun-conversation.workspace = true
nomifun-ai-agent.workspace = true
nomifun-terminal.workspace = true
nomifun-auth.workspace = true
axum.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
dashmap.workspace = true
async-trait.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
sqlx = { workspace = true }
reqwest = { workspace = true }
tempfile.workspace = true
@@ -0,0 +1,518 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use nomifun_api_types::{AttachmentDto, NewAttachmentRef};
use nomifun_common::{AppError, generate_prefixed_id, now_ms};
use nomifun_db::IAttachmentRepository;
use nomifun_db::models::AttachmentRow;
use nomifun_file::path_safety::{has_traversal, validate_path};
use tracing::warn;
/// Upload whitelist — images only this iteration, aligned with the frontend
/// `imageExts` (FileService.ts).
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"];
/// Directory (relative to the data dir) where attachment originals live:
/// `attachments/{requirement_id}/{att_id}.{ext}`. The former generic
/// `{kind}/{target_id}` polymorphism collapsed to a single requirement domain.
const ATTACHMENTS_REL_DIR: &str = "attachments";
/// Directory (relative to a session workspace) where AutoWork stages copies
/// for the model to read: `.nomi/requirement-attachments/{req_id}/{file_name}`.
const WORKSPACE_STAGE_REL_DIR: &str = ".nomi/requirement-attachments";
/// An attachment entry rendered into a requirement prompt.
#[derive(Debug, Clone, PartialEq)]
pub struct PromptAttachment {
/// Original display name ("设计稿.png").
pub file_name: String,
/// Path the model should read: workspace-relative (forward slashes) when
/// staged into the session workspace, absolute otherwise. Empty when missing.
pub path: String,
/// The original file vanished from the attachment store — listed so the
/// model knows an image existed but cannot be read.
pub missing: bool,
}
/// Persistent attachment storage under `<data_dir>/attachments/`.
///
/// Files are copied here from the temp upload root at bind time (create/update)
/// so they survive both OS temp cleaning and conversation deletion —
/// requirements deliberately outlive their executing sessions.
pub struct AttachmentStore {
data_dir: PathBuf,
/// Only files inside this root may be bound (`POST /api/fs/upload` lands
/// here). Overridable for tests.
upload_root: PathBuf,
repo: Arc<dyn IAttachmentRepository>,
}
impl AttachmentStore {
pub fn new(data_dir: PathBuf, repo: Arc<dyn IAttachmentRepository>) -> Self {
Self {
data_dir,
upload_root: std::env::temp_dir().join("nomifun"),
repo,
}
}
/// Override the allowed upload source root (tests).
pub fn with_upload_root(mut self, root: PathBuf) -> Self {
self.upload_root = root;
self
}
fn requirement_dir(&self, requirement_id: i64) -> PathBuf {
self.data_dir.join(ATTACHMENTS_REL_DIR).join(requirement_id.to_string())
}
pub fn abs_path(&self, row: &AttachmentRow) -> PathBuf {
self.data_dir.join(&row.rel_path)
}
pub fn to_dto(&self, row: &AttachmentRow) -> AttachmentDto {
AttachmentDto {
id: row.id.clone(),
file_name: row.file_name.clone(),
mime: row.mime.clone(),
size_bytes: row.size_bytes,
created_at: row.created_at,
abs_path: self.abs_path(row).to_string_lossy().to_string(),
}
}
pub async fn list(&self, requirement_id: i64) -> Result<Vec<AttachmentRow>, AppError> {
Ok(self.repo.list_for_requirement(requirement_id).await?)
}
/// Validate + copy `refs` into the persistent store and insert rows.
/// All-or-nothing per call: any failure removes the files and rows created
/// by THIS call before returning the error.
pub async fn ingest(
&self,
requirement_id: i64,
refs: &[NewAttachmentRef],
created_by: Option<&str>,
) -> Result<Vec<AttachmentRow>, AppError> {
if refs.is_empty() {
return Ok(Vec::new());
}
// Pre-validate everything before touching disk or DB.
let mut validated: Vec<(PathBuf, String)> = Vec::with_capacity(refs.len()); // (source, ext)
for r in refs {
let ext = image_ext(&r.file_name).or_else(|| image_ext(&r.source_path)).ok_or_else(|| {
AppError::BadRequest(format!(
"attachment '{}' is not a supported image (allowed: {})",
r.file_name,
IMAGE_EXTENSIONS.join("/")
))
})?;
if has_traversal(&r.source_path) {
return Err(AppError::BadRequest(format!(
"source path '{}' contains invalid traversal patterns",
r.source_path
)));
}
let canonical = validate_path(&r.source_path, &[self.upload_root.as_path()])?;
validated.push((canonical, ext));
}
// Per-requirement display-name dedup: existing rows + this batch.
let mut used_names: Vec<String> = self
.repo
.list_for_requirement(requirement_id)
.await?
.into_iter()
.map(|r| r.file_name)
.collect();
let dir = self.requirement_dir(requirement_id);
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| AppError::Internal(format!("create attachment dir failed: {e}")))?;
let mut inserted: Vec<AttachmentRow> = Vec::with_capacity(refs.len());
let mut copied: Vec<PathBuf> = Vec::with_capacity(refs.len());
for (r, (source, ext)) in refs.iter().zip(validated) {
let result = self
.ingest_one(requirement_id, r, &source, &ext, created_by, &mut used_names, &dir)
.await;
match result {
Ok((row, abs)) => {
copied.push(abs);
inserted.push(row);
}
Err(e) => {
// Roll back THIS call's work: rows then files (best-effort).
for row in &inserted {
if let Err(de) = self.repo.delete(&row.id).await {
warn!(error = %de, id = %row.id, "attachment rollback: row delete failed");
}
}
for p in &copied {
if let Err(fe) = tokio::fs::remove_file(p).await {
warn!(error = %fe, path = %p.display(), "attachment rollback: file delete failed");
}
}
let _ = tokio::fs::remove_dir(&dir).await; // ok if non-empty
return Err(e);
}
}
}
Ok(inserted)
}
#[allow(clippy::too_many_arguments)]
async fn ingest_one(
&self,
requirement_id: i64,
r: &NewAttachmentRef,
source: &Path,
ext: &str,
created_by: Option<&str>,
used_names: &mut Vec<String>,
dir: &Path,
) -> Result<(AttachmentRow, PathBuf), AppError> {
let id = generate_prefixed_id("att");
let disk_name = format!("{id}.{ext}");
let abs = dir.join(&disk_name);
// A missing source is the caller's fault (stale temp ref → BadRequest);
// anything else (disk full, permissions) is a server-side failure.
tokio::fs::copy(source, &abs).await.map_err(|e| {
let msg = format!("cannot copy attachment '{}': {e}", r.file_name);
match e.kind() {
std::io::ErrorKind::NotFound => AppError::BadRequest(msg),
_ => AppError::Internal(msg),
}
})?;
let size_bytes = match tokio::fs::metadata(&abs).await {
Ok(m) => m.len() as i64,
Err(e) => {
warn!(error = %e, path = %abs.display(), "attachment metadata read failed — recording size 0");
0
}
};
let file_name = unique_name(&r.file_name, used_names);
used_names.push(file_name.clone());
let row = AttachmentRow {
id,
requirement_id,
file_name,
rel_path: format!("{ATTACHMENTS_REL_DIR}/{requirement_id}/{disk_name}"),
mime: mime_for_ext(ext).to_string(),
size_bytes,
created_by: created_by.map(|s| s.to_string()),
created_at: now_ms(),
};
if let Err(e) = self.repo.insert(&row).await {
let _ = tokio::fs::remove_file(&abs).await;
return Err(e.into());
}
Ok((row, abs))
}
/// Remove specific attachments (rows + files). Ids that don't exist or
/// belong to a different requirement are skipped — scope guard.
pub async fn remove(&self, requirement_id: i64, ids: &[String]) -> Result<(), AppError> {
for id in ids {
let Some(row) = self.repo.get_by_id(id).await? else { continue };
if row.requirement_id != requirement_id {
warn!(id = %id, "attachment remove skipped: requirement mismatch");
continue;
}
self.repo.delete(id).await?;
let abs = self.abs_path(&row);
if let Err(e) = tokio::fs::remove_file(&abs).await {
warn!(error = %e, path = %abs.display(), "attachment file delete failed (row removed)");
}
}
Ok(())
}
/// Delete every attachment of a requirement (rows + files + dir). File
/// failures are logged, not raised — used from requirement deletion which
/// must not block.
pub async fn delete_all(&self, requirement_id: i64) -> Result<(), AppError> {
let rows = self.repo.list_for_requirement(requirement_id).await?;
for row in &rows {
self.repo.delete(&row.id).await?;
let abs = self.abs_path(row);
if let Err(e) = tokio::fs::remove_file(&abs).await
&& abs.exists()
{
warn!(error = %e, path = %abs.display(), "attachment file delete failed");
}
}
let _ = tokio::fs::remove_dir(self.requirement_dir(requirement_id)).await;
Ok(())
}
/// Build prompt entries for a requirement's attachments, copying each into
/// `{workspace}/.nomi/requirement-attachments/{req_id}/{file_name}` when a
/// workspace is given. Best-effort and infallible: a failed copy falls back
/// to the absolute original path; a vanished original is flagged `missing`.
pub async fn stage_for_prompt(&self, req_id: i64, workspace: Option<&Path>) -> Vec<PromptAttachment> {
let rows = match self.repo.list_for_requirement(req_id).await {
Ok(rows) => rows,
Err(e) => {
warn!(error = %e, req_id, "failed to list attachments for staging");
return Vec::new();
}
};
let mut out = Vec::with_capacity(rows.len());
for row in &rows {
let orig = self.abs_path(row);
if !orig.exists() {
warn!(req_id, file = %row.file_name, "attachment original missing");
out.push(PromptAttachment {
file_name: row.file_name.clone(),
path: String::new(),
missing: true,
});
continue;
}
let mut path = orig.to_string_lossy().to_string();
if let Some(ws) = workspace.filter(|w| !w.as_os_str().is_empty()) {
let stage_root = ws.join(WORKSPACE_STAGE_REL_DIR);
let dest_dir = stage_root.join(req_id.to_string());
let staged: Result<(), std::io::Error> = async {
tokio::fs::create_dir_all(&dest_dir).await?;
// Self-isolate like the knowledge mounts: never pollute the
// workspace's VCS status.
let gitignore = stage_root.join(".gitignore");
if !gitignore.exists() {
let _ = tokio::fs::write(&gitignore, "*\n").await;
}
tokio::fs::copy(&orig, dest_dir.join(&row.file_name)).await?;
Ok(())
}
.await;
match staged {
Ok(()) => {
path = format!("./{WORKSPACE_STAGE_REL_DIR}/{req_id}/{}", row.file_name);
}
Err(e) => {
warn!(error = %e, req_id, file = %row.file_name, "workspace staging failed — falling back to absolute path");
}
}
}
out.push(PromptAttachment {
file_name: row.file_name.clone(),
path,
missing: false,
});
}
out
}
}
/// Lowercased extension when it is in the image whitelist.
fn image_ext(name: &str) -> Option<String> {
let ext = Path::new(name).extension()?.to_str()?.to_ascii_lowercase();
IMAGE_EXTENSIONS.contains(&ext.as_str()).then_some(ext)
}
fn mime_for_ext(ext: &str) -> &'static str {
match ext {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"bmp" => "image/bmp",
"webp" => "image/webp",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
}
}
/// `name(2).ext` display-name dedup within one target (mirrors the upload
/// service's numeric-suffix pattern).
fn unique_name(want: &str, used: &[String]) -> String {
if !used.iter().any(|u| u == want) {
return want.to_string();
}
let (base, ext) = match want.rfind('.') {
Some(i) if i > 0 => (&want[..i], &want[i..]),
_ => (want, ""),
};
for n in 2..1000 {
let candidate = format!("{base}({n}){ext}");
if !used.iter().any(|u| u == &candidate) {
return candidate;
}
}
format!("{base}({}){ext}", generate_prefixed_id("n"))
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_db::{SqliteAttachmentRepository, init_database_memory};
use std::sync::Arc;
async fn store() -> (AttachmentStore, tempfile::TempDir, tempfile::TempDir) {
let db = init_database_memory().await.unwrap();
// attachments.requirement_id FKs requirements(id) (CASCADE) under
// foreign_keys=ON, so seed the parent requirements these tests bind to.
for id in [1i64, 2i64] {
sqlx::query(
"INSERT INTO requirements \
(id, title, content, tag, order_key, sort_seq, status, priority, attempt_count, created_by, extra, created_at, updated_at) \
VALUES (?, 'T', '', 't', '', '', 'pending', 0, 0, 'user', '{}', 0, 0)",
)
.bind(id)
.execute(db.pool())
.await
.unwrap();
}
let repo: Arc<dyn nomifun_db::IAttachmentRepository> =
Arc::new(SqliteAttachmentRepository::new(db.pool().clone()));
Box::leak(Box::new(db));
let data_dir = tempfile::tempdir().unwrap();
let upload_root = tempfile::tempdir().unwrap();
let store = AttachmentStore::new(data_dir.path().to_path_buf(), repo)
.with_upload_root(upload_root.path().to_path_buf());
(store, data_dir, upload_root)
}
fn put_upload(root: &std::path::Path, name: &str, bytes: &[u8]) -> String {
let p = root.join(name);
std::fs::write(&p, bytes).unwrap();
p.to_string_lossy().to_string()
}
#[tokio::test]
async fn ingest_copies_into_data_dir_and_inserts_rows() {
let (store, data_dir, upload_root) = store().await;
let src = put_upload(upload_root.path(), "shot.png", b"png-bytes");
let rows = store
.ingest(1, &[NewAttachmentRef { source_path: src, file_name: "设计稿.png".into() }], Some("user"))
.await
.unwrap();
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert!(row.id.starts_with("att_"));
assert_eq!(row.file_name, "设计稿.png");
assert_eq!(row.mime, "image/png");
assert_eq!(row.size_bytes, 9);
// file landed at data_dir/rel_path with the att id as disk name
let abs = data_dir.path().join(&row.rel_path);
assert!(abs.exists());
assert!(row.rel_path.starts_with("attachments/1/"));
assert!(row.rel_path.ends_with(".png"));
// listed back
assert_eq!(store.list(1).await.unwrap().len(), 1);
}
#[tokio::test]
async fn ingest_rejects_non_image_and_traversal_and_outside_root() {
let (store, _data, upload_root) = store().await;
// non-image extension
let txt = put_upload(upload_root.path(), "a.txt", b"x");
let err = store
.ingest(1, &[NewAttachmentRef { source_path: txt, file_name: "a.txt".into() }], None)
.await
.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::BadRequest(_)));
// traversal in source path
let err = store
.ingest(1, &[NewAttachmentRef { source_path: "../../etc/passwd.png".into(), file_name: "p.png".into() }], None)
.await
.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::BadRequest(_) | nomifun_common::AppError::Forbidden(_)));
// exists but outside upload root
let outside = tempfile::tempdir().unwrap();
let out = put_upload(outside.path(), "b.png", b"x");
let err = store
.ingest(1, &[NewAttachmentRef { source_path: out, file_name: "b.png".into() }], None)
.await
.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::Forbidden(_)));
// nothing was inserted by the failed batches
assert!(store.list(1).await.unwrap().is_empty());
}
#[tokio::test]
async fn ingest_failure_mid_batch_cleans_up_earlier_copies() {
let (store, data_dir, upload_root) = store().await;
let ok = put_upload(upload_root.path(), "ok.png", b"x");
let rows = store
.ingest(
1,
&[
NewAttachmentRef { source_path: ok, file_name: "ok.png".into() },
NewAttachmentRef { source_path: upload_root.path().join("missing.png").to_string_lossy().into(), file_name: "missing.png".into() },
],
None,
)
.await;
assert!(rows.is_err());
assert!(store.list(1).await.unwrap().is_empty(), "no rows survive a failed batch");
let dir = data_dir.path().join("attachments/1");
let leftover = std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0);
assert_eq!(leftover, 0, "copied files from the failed batch are cleaned up");
}
#[tokio::test]
async fn file_name_dedup_within_target() {
let (store, _data, upload_root) = store().await;
let a = put_upload(upload_root.path(), "a.png", b"x");
let b = put_upload(upload_root.path(), "b.png", b"y");
store.ingest(1, &[NewAttachmentRef { source_path: a, file_name: "img.png".into() }], None).await.unwrap();
let rows = store.ingest(1, &[NewAttachmentRef { source_path: b, file_name: "img.png".into() }], None).await.unwrap();
assert_eq!(rows[0].file_name, "img(2).png");
}
#[tokio::test]
async fn remove_and_delete_all_clean_rows_and_files() {
let (store, data_dir, upload_root) = store().await;
let a = put_upload(upload_root.path(), "a.png", b"x");
let b = put_upload(upload_root.path(), "b.png", b"y");
let rows = store
.ingest(
1,
&[
NewAttachmentRef { source_path: a, file_name: "a.png".into() },
NewAttachmentRef { source_path: b, file_name: "b.png".into() },
],
None,
)
.await
.unwrap();
// remove one by id — row + file gone
store.remove(1, &[rows[0].id.clone()]).await.unwrap();
assert_eq!(store.list(1).await.unwrap().len(), 1);
assert!(!data_dir.path().join(&rows[0].rel_path).exists());
// remove with an id belonging to ANOTHER requirement is a no-op (scope guard)
store.remove(2, &[rows[1].id.clone()]).await.unwrap();
assert_eq!(store.list(1).await.unwrap().len(), 1);
// delete_all — everything gone including the dir
store.delete_all(1).await.unwrap();
assert!(store.list(1).await.unwrap().is_empty());
assert!(!data_dir.path().join("attachments/1").exists());
}
#[tokio::test]
async fn stage_for_prompt_copies_into_workspace_and_falls_back() {
let (store, data_dir, upload_root) = store().await;
let a = put_upload(upload_root.path(), "a.png", b"x");
let rows = store
.ingest(1, &[NewAttachmentRef { source_path: a, file_name: "图.png".into() }], None)
.await
.unwrap();
// workspace staging → relative path + copy exists + .gitignore written
let ws = tempfile::tempdir().unwrap();
let staged = store.stage_for_prompt(1, Some(ws.path())).await;
assert_eq!(staged.len(), 1);
assert!(!staged[0].missing);
assert_eq!(staged[0].path, "./.nomi/requirement-attachments/1/图.png");
assert!(ws.path().join(".nomi/requirement-attachments/1/图.png").exists());
assert!(ws.path().join(".nomi/requirement-attachments/.gitignore").exists());
// no workspace → absolute original path
let staged = store.stage_for_prompt(1, None).await;
assert_eq!(staged[0].path, data_dir.path().join(&rows[0].rel_path).to_string_lossy().to_string());
// original deleted → missing flag
std::fs::remove_file(data_dir.path().join(&rows[0].rel_path)).unwrap();
let staged = store.stage_for_prompt(1, Some(ws.path())).await;
assert!(staged[0].missing);
}
}
@@ -0,0 +1,24 @@
use nomifun_api_types::{Requirement, RequirementStatus};
use nomifun_db::models::RequirementRow;
/// Map a DB row to the API response object.
pub fn row_to_dto(row: &RequirementRow) -> Requirement {
Requirement {
id: row.id.clone(),
title: row.title.clone(),
content: row.content.clone(),
tag: row.tag.clone(),
order_key: row.order_key.clone(),
status: RequirementStatus::from_db(&row.status),
completion_note: row.completion_note.clone(),
owner_session_id: row.owner_session_id.clone(),
owner_kind: row.owner_kind.clone(),
started_at: row.started_at,
completed_at: row.completed_at,
attempt_count: row.attempt_count,
created_by: row.created_by.clone(),
created_at: row.created_at,
updated_at: row.updated_at,
attachments: Vec::new(),
}
}
@@ -0,0 +1,53 @@
use std::sync::Arc;
use nomifun_api_types::{AutoWorkState, Requirement, RequirementDeletedPayload, TagPausedPayload, WebSocketMessage};
use nomifun_realtime::EventBroadcaster;
use tracing::error;
/// Emits Requirements-Platform WebSocket events (`domain.camelCaseAction`).
#[derive(Clone)]
pub struct RequirementEventEmitter {
broadcaster: Arc<dyn EventBroadcaster>,
}
impl RequirementEventEmitter {
pub fn new(broadcaster: Arc<dyn EventBroadcaster>) -> Self {
Self { broadcaster }
}
pub fn emit_created(&self, req: &Requirement) {
self.broadcast("requirement.created", req);
}
pub fn emit_updated(&self, req: &Requirement) {
self.broadcast("requirement.updated", req);
}
pub fn emit_status_changed(&self, req: &Requirement) {
self.broadcast("requirement.statusChanged", req);
}
pub fn emit_deleted(&self, id: i64) {
self.broadcast("requirement.deleted", &RequirementDeletedPayload { id });
}
pub fn emit_autowork_changed(&self, state: &AutoWorkState) {
self.broadcast("autowork.statusChanged", state);
}
/// AutoWork paused a tag after a requirement exhausted its retries.
pub fn emit_tag_paused(&self, payload: &TagPausedPayload) {
self.broadcast("autowork.tagPaused", payload);
}
fn broadcast<T: serde::Serialize>(&self, event_name: &str, payload: &T) {
let value = match serde_json::to_value(payload) {
Ok(v) => v,
Err(e) => {
error!(event_name, error = %e, "Failed to serialize requirement event payload");
return;
}
};
self.broadcaster.broadcast(WebSocketMessage::new(event_name, value));
}
}
@@ -0,0 +1,32 @@
//! One-directional integration seam between AutoWork and IDMM.
//!
//! AutoWork (this crate) drives turn execution; IDMM (the `nomifun-idmm` crate)
//! supervises a session for stalls. To let AutoWork ensure a session is being
//! supervised while a turn runs — WITHOUT this crate depending on `nomifun-idmm`
//! (which would be a cycle, since idmm conceptually sits above requirement) —
//! AutoWork defines this trait and holds an optional handle. `nomifun-idmm`
//! implements it; `nomifun-app` injects the implementation at assembly time.
use nomifun_api_types::AutoWorkTargetKind;
/// Implemented by `nomifun-idmm::IdmmManager`. AutoWork calls
/// `ensure_supervising` at the top of each loop iteration so that, if the user
/// enabled IDMM for this target, supervision is (idempotently) running while the
/// turn executes. The implementation resolves the session owner and config
/// internally; this call is cheap and a no-op when IDMM is disabled or already
/// supervising the target.
pub trait IdmmHandle: Send + Sync {
fn ensure_supervising(&self, kind: AutoWorkTargetKind, target_id: &str);
/// Whether a supervisor is currently live for `(kind, target_id)`. AutoWork
/// uses this to decide whether to WAIT THROUGH a retryable error (IDMM owns
/// in-turn recovery and will retry) instead of immediately failing the turn
/// and racing a fresh requirement into the same session. Returns false when
/// IDMM is disabled / not supervising — then AutoWork keeps the legacy
/// "first error fails the turn" behavior.
///
/// `kind` is part of the identity: a conversation and a terminal can share a
/// numeric `target_id`, so supervision state is keyed by `(kind, target_id)`
/// (spec §2.2 C3).
fn is_supervising(&self, kind: AutoWorkTargetKind, target_id: &str) -> bool;
}
@@ -0,0 +1,25 @@
//! Requirements Platform: CRUD store + AutoWork orchestrator for "requirements".
pub mod attachments;
mod convert;
pub mod events;
pub mod hooks;
pub mod mcp_server;
pub mod notifier;
pub mod orchestrator;
pub mod order_key;
pub mod prompt;
pub mod routes;
pub mod service;
pub mod sink;
pub mod state;
pub use attachments::{AttachmentStore, PromptAttachment};
pub use events::RequirementEventEmitter;
pub use hooks::IdmmHandle;
pub use mcp_server::RequirementMcpServer;
pub use notifier::CompletionNotifier;
pub use orchestrator::{Orchestrator, OrchestratorDeps};
pub use routes::requirement_routes;
pub use service::RequirementService;
pub use sink::RequirementServiceSink;
pub use state::RequirementRouterState;
@@ -0,0 +1,742 @@
//! In-process HTTP MCP server exposing requirement *declaration* tools to ACP
//! agent sessions (claude / codex / gemini CLIs).
//!
//! ## Why this exists
//!
//! AutoWork drives ACP sessions, but ACP CLIs have no in-process tool bus we
//! can register `RequirementCompleteTool` into (only the nomi engine does).
//! Without a declaration channel, a clean turn that did NOT actually finish the
//! requirement is silently recorded as `done` — the original "失败却标成成功"
//! bug. This server gives ACP agents the SAME `requirement_complete` /
//! `requirement_update_status` surface the nomi engine has natively, so the
//! orchestrator can park an un-declared clean turn as `needs_review` instead of
//! assuming success (`expects_verdict`).
//!
//! ## Shape (mirrors `nomifun-team::guide::GuideMcpServer`)
//!
//! This is the in-process HTTP half. ACP CLIs spawn a SEPARATE stdio process
//! (`nomicore mcp-requirement-stdio`) that cannot share this process's
//! `RequirementService`; it forwards each tool call back here as an
//! authenticated `POST /tool`. The transport is stdio because claude / codex /
//! gemini advertise stdio-only MCP capabilities (HTTP/SSE servers are dropped
//! by the ACP capability filter), so a direct-HTTP injection would never reach
//! them.
//!
//! ## Security
//!
//! A random opaque bearer token gates every request (per-process, like the
//! guide server). On top of that, `verify_scope` refuses to mutate a
//! requirement owned by a *different* conversation than the calling session —
//! defense-in-depth so a stale/incorrect id cannot let one AutoWork session
//! complete another's requirement.
use std::net::SocketAddr;
use std::sync::{Arc, Weak};
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::IntoResponse;
use nomifun_api_types::RequirementStatus;
use nomifun_common::generate_id;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use crate::service::RequirementService;
/// Late-bound handle to the singleton `RequirementService`. Held as a `Weak` so
/// the server never keeps the service alive on its own (matches the guide
/// server's slot pattern). Wired via [`RequirementMcpServer::set_service`].
type ServiceSlot = Arc<RwLock<Weak<RequirementService>>>;
#[derive(Clone)]
struct ReqMcpState {
auth_token: String,
service: ServiceSlot,
}
/// In-process HTTP MCP server for requirement declaration tools.
pub struct RequirementMcpServer {
http_addr: SocketAddr,
auth_token: String,
shutdown_handle: Option<tokio::task::JoinHandle<()>>,
service_slot: ServiceSlot,
}
impl RequirementMcpServer {
/// Bind a fresh `127.0.0.1:0` listener, mint a random bearer token, and
/// start serving `POST /tool`. The service must be wired separately via
/// [`set_service`](Self::set_service) before the first tool call arrives.
pub async fn start() -> Result<Self, String> {
let auth_token = generate_id();
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind requirement MCP HTTP listener: {e}"))?;
let http_addr = listener
.local_addr()
.map_err(|e| format!("Failed to read requirement MCP local addr: {e}"))?;
let service_slot: ServiceSlot = Arc::new(RwLock::new(Weak::new()));
let state = ReqMcpState {
auth_token: auth_token.clone(),
service: service_slot.clone(),
};
let app = axum::Router::new()
.route("/tool", axum::routing::post(handle_tool_request))
.with_state(state);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
warn!(error = %e, "Requirement MCP axum server exited with error");
}
});
debug!(http_port = http_addr.port(), "Requirement MCP Server started (axum)");
Ok(Self {
http_addr,
auth_token,
shutdown_handle: Some(handle),
service_slot,
})
}
/// Wire the singleton `RequirementService` after it is constructed. Must be
/// called once before the first tool request arrives.
pub async fn set_service(&self, service: Weak<RequirementService>) {
*self.service_slot.write().await = service;
}
pub fn http_port(&self) -> u16 {
self.http_addr.port()
}
pub fn auth_token(&self) -> &str {
&self.auth_token
}
pub fn stop(&mut self) {
if let Some(handle) = self.shutdown_handle.take() {
handle.abort();
debug!(http_port = self.http_addr.port(), "Requirement MCP Server stop requested");
}
}
}
impl Drop for RequirementMcpServer {
fn drop(&mut self) {
self.stop();
}
}
// ---------------------------------------------------------------------------
// Axum handler
// ---------------------------------------------------------------------------
async fn handle_tool_request(
State(state): State<ReqMcpState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> impl IntoResponse {
let provided_token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if provided_token != state.auth_token {
warn!("Requirement MCP: unauthorized request");
return (StatusCode::UNAUTHORIZED, Json(json!({"error": "unauthorized"}))).into_response();
}
let tool = body.get("tool").and_then(Value::as_str).unwrap_or("");
let args = body.get("args").cloned().unwrap_or(Value::Null);
// The caller's conversation id is an integer (single-track, spec §2.3). It is
// tolerated as a JSON number OR a numeric string (the stdio bridge forwards
// the `ENV_CONVERSATION_ID` env value, which is a string). `None` = no
// conversation context → `verify_scope` is lenient (single-session / tests).
let caller_conv = json_to_i64(body.get("conversation_id"));
// owner_kind: "conversation" (default, back-compat when field absent) or
// "terminal". Controls cross-domain scope check in verify_scope.
let caller_kind = body
.get("owner_kind")
.and_then(Value::as_str)
.unwrap_or("conversation");
let svc = match state.service.read().await.upgrade() {
Some(s) => s,
None => {
warn!(tool, "Requirement MCP: service not available");
return finish(json!({"error": "service_unavailable"}));
}
};
info!(tool, "Requirement MCP: dispatching tool");
let response_body = match tool {
"requirement_complete" => exec_complete(&svc, &args, caller_conv, caller_kind).await,
"requirement_update_status" => exec_update_status(&svc, &args, caller_conv, caller_kind).await,
unknown => {
warn!(tool = unknown, "Requirement MCP: unknown tool");
json!({"error": format!("Unknown tool: {unknown}")})
}
};
finish(response_body)
}
/// Extract an integer id from a JSON value, tolerating both a JSON number and a
/// numeric string (agents occasionally stringify; the stdio bridge forwards the
/// env-sourced `conversation_id` as a string). Returns `None` for absent / null
/// / non-numeric — the caller decides whether that is benign.
fn json_to_i64(v: Option<&Value>) -> Option<i64> {
match v {
Some(Value::Number(n)) => n.as_i64(),
Some(Value::String(s)) => s.trim().parse::<i64>().ok(),
_ => None,
}
}
/// Wrap a JSON body as a response and ask the client to close the connection
/// (the stdio bridge runs with `pool_max_idle_per_host(0)` and does not reuse).
fn finish(body: Value) -> axum::response::Response {
let mut resp = Json(body).into_response();
resp.headers_mut()
.insert(header::CONNECTION, HeaderValue::from_static("close"));
resp
}
// ---------------------------------------------------------------------------
// Tool implementations
// ---------------------------------------------------------------------------
async fn exec_complete(svc: &RequirementService, args: &Value, caller_id: Option<i64>, caller_kind: &str) -> Value {
let id = match json_to_i64(args.get("id")) {
Some(id) => id,
None => return json!({"error": "missing or non-integer required field: id"}),
};
let note = args
.get("completion_note")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned);
if let Err(e) = verify_scope(svc, id, caller_id, caller_kind).await {
return json!({"error": e});
}
match svc.complete(id, note).await {
Ok(_) => {
info!(requirement_id = id, "Requirement MCP: requirement_complete succeeded");
json!({"result": format!("Requirement {id} marked complete.")})
}
Err(e) => json!({"error": e.to_string()}),
}
}
async fn exec_update_status(svc: &RequirementService, args: &Value, caller_id: Option<i64>, caller_kind: &str) -> Value {
let id = match json_to_i64(args.get("id")) {
Some(id) => id,
None => return json!({"error": "missing or non-integer required field: id"}),
};
let status_str = args.get("status").and_then(Value::as_str).unwrap_or("");
let status = match status_str {
"in_progress" => RequirementStatus::InProgress,
"done" => RequirementStatus::Done,
"failed" => RequirementStatus::Failed,
other => {
return json!({
"error": format!("invalid status '{other}' (expected one of: in_progress, done, failed)")
});
}
};
let note = args
.get("note")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned);
if let Err(e) = verify_scope(svc, id, caller_id, caller_kind).await {
return json!({"error": e});
}
match svc.set_status(id, status, note).await {
Ok(_) => {
info!(requirement_id = id, status = status_str, "Requirement MCP: requirement_update_status succeeded");
json!({"result": format!("Requirement {id} status set to {status_str}.")})
}
Err(e) => json!({"error": e.to_string()}),
}
}
/// Defense-in-depth: a session may only mutate a requirement that is (a) not
/// bound to any session, or (b) bound to THIS caller in the SAME domain.
/// Lenient only when the caller carries no id (`None`) so single-session / test
/// setups never trip it.
///
/// SECURITY (C1, spec §2.2 + Plan 3 D1): the `caller_kind` is sourced from the
/// env `NOMI_REQ_MCP_OWNER_KIND` baked at bridge spawn (not from the agent
/// model), so it is trustworthy. Rules:
/// - caller_id absent → Ok (lenient: single-session / tests)
/// - owner unset → Ok (unclaimed work is mutable by anyone)
/// - owner is conversation AND caller_kind=="conversation" AND same id → Ok
/// - owner is terminal AND caller_kind=="terminal" AND same id → Ok (NEW)
/// - everything else → Err (cross-domain, cross-id, or kind mismatch)
///
/// A terminal can NEVER complete a conversation-owned req, a conversation can
/// NEVER complete a terminal-owned req, and neither can complete a req owned by
/// a different session of its own kind.
async fn verify_scope(
svc: &RequirementService,
id: i64,
caller_id: Option<i64>,
caller_kind: &str,
) -> Result<(), String> {
let Some(caller_id) = caller_id else {
return Ok(());
};
let req = svc.get(id).await.map_err(|e| e.to_string())?;
match (req.owner_session_id, req.owner_kind.as_deref()) {
// Unowned work is mutable by anyone.
(None, _) => Ok(()),
// Owned by a conversation AND caller is a conversation with the same id.
(Some(owner), Some("conversation")) if caller_kind == "conversation" && owner == caller_id => Ok(()),
// Owned by a terminal AND caller is a terminal with the same id.
(Some(owner), Some("terminal")) if caller_kind == "terminal" && owner == caller_id => Ok(()),
// Everything else: cross-domain, cross-id, or kind mismatch → denied.
_ => Err(format!("requirement {id} is owned by a different session")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::RequirementEventEmitter;
use nomifun_api_types::{AutoWorkTargetKind, CreateRequirementRequest, RequirementStatus};
use nomifun_db::{SqliteRequirementRepository, init_database_memory};
use nomifun_realtime::EventBroadcaster;
#[derive(Default)]
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
}
/// Build a service with one requirement in tag `t`, claimed into `conv_1`
/// (so it is `in_progress` with `conversation_id = conv_1`). Returns the
/// service (keep it alive — the server holds only a `Weak`) and the req id.
async fn service_with_claimed_req() -> (Arc<RequirementService>, i64) {
let db = init_database_memory().await.unwrap();
let repo: Arc<dyn nomifun_db::IRequirementRepository> =
Arc::new(SqliteRequirementRepository::new(db.pool().clone()));
let emitter = RequirementEventEmitter::new(Arc::new(NoopBroadcaster));
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_1', 'tester', 'hash', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
VALUES (1, 'user_1', 'Test Conv', 'acp', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
Box::leak(Box::new(db));
let svc = Arc::new(RequirementService::new(repo, emitter));
let req = svc
.create(CreateRequirementRequest {
title: "Do X".into(),
content: "body".into(),
tag: "t".into(),
order_key: None,
status: None,
created_by: None,
attachments: vec![],
})
.await
.unwrap();
let claimed = svc
.claim_next("t", 1, AutoWorkTargetKind::Conversation, 120_000)
.await
.unwrap()
.expect("a pending requirement should be claimable");
assert_eq!(claimed.id, req.id);
(svc, req.id)
}
async fn started_server(svc: &Arc<RequirementService>) -> RequirementMcpServer {
let server = RequirementMcpServer::start().await.expect("start");
server.set_service(Arc::downgrade(svc)).await;
server
}
async fn post_tool(port: u16, token: Option<&str>, body: Value) -> (u16, Value) {
let client = reqwest::Client::new();
let mut req = client.post(format!("http://127.0.0.1:{port}/tool")).json(&body);
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {t}"));
}
let resp = req.send().await.unwrap();
let status = resp.status().as_u16();
let json: Value = resp.json().await.unwrap_or(Value::Null);
(status, json)
}
#[tokio::test]
async fn start_returns_positive_port_and_token() {
let server = RequirementMcpServer::start().await.unwrap();
assert!(server.http_port() > 0);
assert!(!server.auth_token().is_empty());
}
#[tokio::test]
async fn each_start_uses_a_fresh_auth_token() {
let a = RequirementMcpServer::start().await.unwrap();
let b = RequirementMcpServer::start().await.unwrap();
assert_ne!(a.auth_token(), b.auth_token());
}
#[tokio::test]
async fn tool_call_requires_auth() {
let (svc, _id) = service_with_claimed_req().await;
let server = started_server(&svc).await;
let (status, _) = post_tool(
server.http_port(),
None,
json!({"tool": "requirement_complete", "args": {"id": "x"}}),
)
.await;
assert_eq!(status, 401);
}
#[tokio::test]
async fn complete_marks_requirement_done() {
let (svc, id) = service_with_claimed_req().await;
let server = started_server(&svc).await;
let (status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({
"tool": "requirement_complete",
"conversation_id": 1,
"args": {"id": id, "completion_note": "did the thing"},
}),
)
.await;
assert_eq!(status, 200);
assert!(body.get("result").is_some(), "expected result, got {body}");
let after = svc.get(id).await.unwrap();
assert_eq!(after.status, RequirementStatus::Done);
assert_eq!(after.completion_note.as_deref(), Some("did the thing"));
}
#[tokio::test]
async fn update_status_failed_marks_failed() {
let (svc, id) = service_with_claimed_req().await;
let server = started_server(&svc).await;
let (status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({
"tool": "requirement_update_status",
"conversation_id": 1,
"args": {"id": id, "status": "failed", "note": "could not finish"},
}),
)
.await;
assert_eq!(status, 200);
assert!(body.get("result").is_some(), "expected result, got {body}");
let after = svc.get(id).await.unwrap();
assert_eq!(after.status, RequirementStatus::Failed);
}
#[tokio::test]
async fn update_status_rejects_invalid_status() {
let (svc, id) = service_with_claimed_req().await;
let server = started_server(&svc).await;
let (_status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({
"tool": "requirement_update_status",
"conversation_id": 1,
"args": {"id": id, "status": "bogus"},
}),
)
.await;
assert!(
body.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("bogus")),
"expected an invalid-status error, got {body}"
);
// The requirement must remain untouched (still in_progress).
let after = svc.get(id).await.unwrap();
assert_eq!(after.status, RequirementStatus::InProgress);
}
#[tokio::test]
async fn unknown_tool_returns_error() {
let (svc, _id) = service_with_claimed_req().await;
let server = started_server(&svc).await;
let (status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({"tool": "requirement_explode", "args": {}}),
)
.await;
assert_eq!(status, 200);
assert!(
body.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("Unknown tool")),
"got {body}"
);
}
#[tokio::test]
async fn missing_service_returns_unavailable() {
// Server started but set_service never called → Weak upgrades to None.
let server = RequirementMcpServer::start().await.unwrap();
let (status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({"tool": "requirement_complete", "args": {"id": "x"}}),
)
.await;
assert_eq!(status, 200);
assert_eq!(body.get("error").and_then(Value::as_str), Some("service_unavailable"));
}
#[tokio::test]
async fn complete_rejects_cross_session() {
let (svc, id) = service_with_claimed_req().await;
let server = started_server(&svc).await;
// Requirement is owned by conv_1; a call from conv_other must be refused.
let (_status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({
"tool": "requirement_complete",
"conversation_id": 2,
"args": {"id": id, "completion_note": "sneaky"},
}),
)
.await;
assert!(
body.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("different session")),
"expected a cross-session refusal, got {body}"
);
let after = svc.get(id).await.unwrap();
assert_eq!(after.status, RequirementStatus::InProgress, "must not be mutated");
}
// ── C1 (spec §2.2): cross-domain authz isolation ────────────────────────
//
// The requirement MCP caller is ALWAYS a conversation (the MCP is injected
// into ACP conversation sessions). After integerization `conv#5` and
// `term#5` share the numeric owner value `5`. A conversation caller must
// NEVER be allowed to mutate a requirement owned by a TERMINAL that merely
// shares its number — `verify_scope` pairs the owner with `owner_kind`.
/// Service with one requirement claimed by TERMINAL #5, plus a conversation
/// #5 present (same number, different domain). Returns the service + req id.
async fn service_with_terminal5_claimed_req() -> (Arc<RequirementService>, i64) {
let db = init_database_memory().await.unwrap();
let repo: Arc<dyn nomifun_db::IRequirementRepository> =
Arc::new(SqliteRequirementRepository::new(db.pool().clone()));
let emitter = RequirementEventEmitter::new(Arc::new(NoopBroadcaster));
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_1', 'tester', 'hash', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
VALUES (5, 'user_1', 'Conv Five', 'acp', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO terminal_sessions \
(id, user_id, name, cwd, command, args, cols, rows, last_status, created_at, updated_at) \
VALUES (5, 'user_1', 'Term Five', '/tmp', 'bash', '[]', 80, 24, 'running', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
Box::leak(Box::new(db));
let svc = Arc::new(RequirementService::new(repo, emitter));
let req = svc
.create(CreateRequirementRequest {
title: "Term work".into(),
content: "body".into(),
tag: "t".into(),
order_key: None,
status: None,
created_by: None,
attachments: vec![],
})
.await
.unwrap();
let claimed = svc
.claim_next("t", 5, AutoWorkTargetKind::Terminal, 120_000)
.await
.unwrap()
.expect("claimable");
assert_eq!(claimed.owner_kind.as_deref(), Some("terminal"));
(svc, req.id)
}
#[tokio::test]
async fn c1_verify_scope_rejects_conversation_caller_for_terminal_owned_req() {
let (svc, id) = service_with_terminal5_claimed_req().await;
// Conversation caller #5 — numerically equal to the terminal owner #5.
let result = verify_scope(&svc, id, Some(5), "conversation").await;
assert!(
result.is_err(),
"conversation #5 must be denied on a terminal#5-owned requirement (cross-domain)"
);
}
#[tokio::test]
async fn c1_terminal_owned_req_unmutated_by_conversation_mcp_call() {
// End-to-end through the HTTP tool: a conversation #5 caller's
// requirement_complete on a terminal#5-owned requirement is refused and
// the requirement is left in_progress.
let (svc, id) = service_with_terminal5_claimed_req().await;
let server = started_server(&svc).await;
let (_status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({
"tool": "requirement_complete",
"conversation_id": 5,
"args": {"id": id, "completion_note": "cross-domain"},
}),
)
.await;
assert!(
body.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("different session")),
"expected a cross-domain refusal, got {body}"
);
let after = svc.get(id).await.unwrap();
assert_eq!(after.status, RequirementStatus::InProgress, "terminal#5 work must not be mutated by conv#5");
assert_eq!(after.owner_kind.as_deref(), Some("terminal"));
}
// ── Plan 3 Task 1: terminal-caller verify_scope tests ───────────────────
#[tokio::test]
async fn terminal_caller_can_complete_own_terminal_owned_req() {
// (a) term-owned #5 + caller(terminal, 5) → Ok
let (svc, id) = service_with_terminal5_claimed_req().await;
let result = verify_scope(&svc, id, Some(5), "terminal").await;
assert!(result.is_ok(), "terminal #5 must be allowed to complete its own requirement");
}
#[tokio::test]
async fn terminal_caller_cannot_complete_different_terminal_owned_req() {
// (b) term-owned #5 + caller(terminal, 99) → Err
let (svc, id) = service_with_terminal5_claimed_req().await;
let result = verify_scope(&svc, id, Some(99), "terminal").await;
assert!(
result.is_err(),
"terminal #99 must be denied on terminal#5-owned requirement (cross-session)"
);
}
#[tokio::test]
async fn terminal_caller_cannot_complete_conversation_owned_req() {
// (e) conv-owned #1 + caller(terminal, 1) → Err
let (svc, id) = service_with_claimed_req().await;
// claimed_req is owned by conversation #1
let result = verify_scope(&svc, id, Some(1), "terminal").await;
assert!(
result.is_err(),
"terminal #1 must be denied on conversation#1-owned requirement (cross-domain)"
);
}
#[tokio::test]
async fn conversation_caller_can_complete_own_conversation_owned_req() {
// (d) conv-owned #1 + caller(conversation, 1) → Ok (back-compat)
let (svc, id) = service_with_claimed_req().await;
let result = verify_scope(&svc, id, Some(1), "conversation").await;
assert!(result.is_ok(), "conversation #1 must be allowed to complete its own requirement");
}
#[tokio::test]
async fn absent_owner_kind_defaults_to_conversation_backcompat() {
// When "owner_kind" is absent from the body (old bridge version), the
// server defaults to "conversation" for full back-compatibility.
let (svc, id) = service_with_claimed_req().await;
let server = started_server(&svc).await;
// No "owner_kind" field in body — old-style request.
let (_status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({
"tool": "requirement_complete",
"conversation_id": 1,
"args": {"id": id, "completion_note": "backcompat"},
}),
)
.await;
assert!(
body.get("result").is_some(),
"absent owner_kind should default to conversation and allow same-id completion, got {body}"
);
}
#[tokio::test]
async fn terminal_caller_complete_via_http_with_owner_kind() {
// End-to-end: terminal #5 calls requirement_complete with owner_kind=terminal.
let (svc, id) = service_with_terminal5_claimed_req().await;
let server = started_server(&svc).await;
let (_status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({
"tool": "requirement_complete",
"conversation_id": 5,
"owner_kind": "terminal",
"args": {"id": id, "completion_note": "terminal did it"},
}),
)
.await;
assert!(
body.get("result").is_some(),
"terminal #5 with owner_kind=terminal should complete its own req, got {body}"
);
let after = svc.get(id).await.unwrap();
assert_eq!(after.status, RequirementStatus::Done);
assert_eq!(after.completion_note.as_deref(), Some("terminal did it"));
}
#[tokio::test]
async fn different_terminal_caller_denied_via_http() {
// End-to-end: terminal #99 cannot complete terminal#5-owned req.
let (svc, id) = service_with_terminal5_claimed_req().await;
let server = started_server(&svc).await;
let (_status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({
"tool": "requirement_complete",
"conversation_id": 99,
"owner_kind": "terminal",
"args": {"id": id, "completion_note": "sneaky terminal"},
}),
)
.await;
assert!(
body.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("different session")),
"terminal #99 must be denied on terminal#5-owned requirement, got {body}"
);
let after = svc.get(id).await.unwrap();
assert_eq!(after.status, RequirementStatus::InProgress, "must not be mutated");
}
}
@@ -0,0 +1,9 @@
use async_trait::async_trait;
use nomifun_db::models::RequirementRow;
/// Notified after a requirement reaches a terminal state (done|failed).
/// Implementations MUST be cheap / non-blocking (the caller spawns this).
#[async_trait]
pub trait CompletionNotifier: Send + Sync {
async fn notify_completion(&self, requirement: &RequirementRow);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
//! `order_key` (display form like "1.2") → `sort_seq` (lexically sortable).
//!
//! Each numeric segment is zero-padded to 8 digits and joined with '.'. Because
//! '.' (0x2E) sorts before digits (0x30+), a parent ("1" → "00000001") sorts
//! before its children ("1.1" → "00000001.00000001"). Empty/invalid keys map to
//! a high sentinel so they sort last.
const SEGMENT_WIDTH: usize = 8;
const SENTINEL: &str = "99999999";
/// Normalize a dotted-decimal `order_key` into a lexically-sortable `sort_seq`.
pub fn to_sort_seq(order_key: &str) -> String {
let trimmed = order_key.trim();
if trimmed.is_empty() {
return SENTINEL.to_string();
}
let mut segments = Vec::new();
for raw in trimmed.split('.') {
let raw = raw.trim();
match raw.parse::<u64>() {
Ok(n) => segments.push(format!("{n:0>width$}", width = SEGMENT_WIDTH)),
// Any malformed segment ⇒ whole key sorts last.
Err(_) => return SENTINEL.to_string(),
}
}
segments.join(".")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_examples() {
assert_eq!(to_sort_seq("1"), "00000001");
assert_eq!(to_sort_seq("2"), "00000002");
assert_eq!(to_sort_seq("1.1"), "00000001.00000001");
assert_eq!(to_sort_seq("1.2"), "00000001.00000002");
assert_eq!(to_sort_seq("1.10"), "00000001.00000010");
assert_eq!(to_sort_seq("2.3.1"), "00000002.00000003.00000001");
}
#[test]
fn ordering_is_correct() {
let mut keys = vec!["2", "1.10", "1.2", "1.1", "1", "2.3.1"];
keys.sort_by_key(|k| to_sort_seq(k));
assert_eq!(keys, vec!["1", "1.1", "1.2", "1.10", "2", "2.3.1"]);
}
#[test]
fn empty_and_malformed_sort_last() {
assert_eq!(to_sort_seq(""), SENTINEL);
assert_eq!(to_sort_seq(" "), SENTINEL);
assert_eq!(to_sort_seq("abc"), SENTINEL);
assert_eq!(to_sort_seq("1.x"), SENTINEL);
// A real key sorts before the sentinel.
assert!(to_sort_seq("9999") < to_sort_seq(""));
}
}
@@ -0,0 +1,417 @@
use nomifun_api_types::Requirement;
use nomifun_common::AgentType;
use crate::attachments::PromptAttachment;
/// Whether a chat-style engine has the native `requirement_complete` /
/// `requirement_update_status` tools registered into its tool bus at session
/// build time.
///
/// This must mirror the runtime registration logic: only the Nomi factory
/// (`crates/backend/nomifun-ai-agent/src/factory/nomi.rs`) consumes the
/// `requirement_sink`, and only `NomiAgentManager` registers
/// `RequirementCompleteTool` / `RequirementUpdateStatusTool` on the engine.
/// Every other engine (ACP, Openclaw, Nanobot, Remote, Gemini) ships without
/// them *in-process* — though ACP gains an equivalent declaration channel via
/// the injected requirement MCP server (see [`session_has_requirement_tools`]).
///
/// Keep this in lock-step with the registration site if engines ever change.
pub fn has_native_requirement_tools(agent_type: AgentType) -> bool {
matches!(agent_type, AgentType::Nomi)
}
/// Whether *this session* exposes the requirement declaration tools
/// (`requirement_complete` / `requirement_update_status`) — and therefore the
/// platform should EXPECT an explicit verdict rather than assuming a clean turn
/// means success.
///
/// True when either:
/// - the engine registers them natively in-process (Nomi), or
/// - the requirement MCP server is injected for this ACP session
/// (`requirement_mcp_enabled`), giving claude/codex/gemini the same tools over
/// the stdio bridge.
///
/// `requirement_mcp_enabled` is a bootstrap-level flag (the requirement MCP
/// server started and its config was plumbed into the agent factory). Gating on
/// it — rather than on `agent_type` alone — guarantees the prompt only tells an
/// ACP agent to call `requirement_complete` when that tool actually exists in
/// the session. Otherwise the agent would try to call a missing tool and break
/// the turn, the exact failure the tool-free prompt was written to avoid.
pub fn session_has_requirement_tools(agent_type: AgentType, requirement_mcp_enabled: bool) -> bool {
has_native_requirement_tools(agent_type) || (requirement_mcp_enabled && matches!(agent_type, AgentType::Acp))
}
/// Whether a terminal AutoWork turn should expect a structured verdict from the
/// agent (via the injected `requirement_complete` / `requirement_update_status`
/// MCP tools). True when the requirement MCP is enabled (Task 2 always injects
/// it into agent terminals), so a clean turn where the agent did NOT call those
/// tools → `needs_review` (not silently done).
///
/// Used by the orchestrator's terminal branch to set `expects_verdict = true`
/// when finalizing a terminal turn.
pub fn terminal_expects_verdict(requirement_mcp_enabled: bool) -> bool {
requirement_mcp_enabled
}
/// Render the attachments section appended to every requirement prompt
/// variant. Empty input renders nothing. The model is explicitly told to view
/// the images with its file-reading tool BEFORE starting — this is the
/// path-plus-guidance contract (same pattern as the knowledge context builder).
fn render_attachments_section(attachments: &[PromptAttachment]) -> String {
if attachments.is_empty() {
return String::new();
}
let mut s = format!("\n## Requirement attachments ({} images)\n", attachments.len());
for a in attachments {
if a.missing {
s.push_str(&format!(
"- {} — (missing: the original file could not be found)\n",
a.file_name
));
} else {
s.push_str(&format!("- {}{}\n", a.file_name, a.path));
}
}
s.push_str(
"Before starting the work, view each attached image above with your file-reading tool — \
they are part of the requirement description.\n",
);
s
}
/// Build the message injected into the agent for a claimed requirement.
/// Tells the agent exactly what to do and how to report completion. The agent
/// must NOT pick the next requirement — the platform hands it the next one.
///
/// The instruction text is session-aware: only sessions that actually expose
/// the `requirement_complete` / `requirement_update_status` tools (Nomi
/// natively, or ACP with the requirement MCP injected) are told to call those
/// tools. Every other session is given a tool-free contract — it just does the
/// work and ends the turn, and the platform records completion automatically
/// via `RequirementService::finalize_if_needed` on a clean Finish.
pub fn build_requirement_prompt(
tag: &str,
req: &Requirement,
agent_type: AgentType,
requirement_mcp_enabled: bool,
attachments: &[PromptAttachment],
) -> String {
if session_has_requirement_tools(agent_type, requirement_mcp_enabled) {
build_requirement_prompt_with_native_tools(tag, req, attachments)
} else {
build_requirement_prompt_no_native_tools(tag, req, attachments)
}
}
/// Native-tool variant: the engine has `requirement_complete` /
/// `requirement_update_status` registered, so we tell the model to call them.
fn build_requirement_prompt_with_native_tools(tag: &str, req: &Requirement, attachments: &[PromptAttachment]) -> String {
format!(
"[AutoWork] You are working through requirements in tag \"{tag}\".\n\n\
## Current requirement\n\
id: {id}\n\
title: {title}\n\
order: {order}\n\n\
{content}\n\
{attachments_section}\n\
## When finished\n\
- Call the `requirement_complete` tool with this requirement's id (\"{id}\") and a concise \
completion note describing what you did.\n\
- If you cannot complete it, call `requirement_update_status` with id \"{id}\", \
status=\"failed\", and a reason.\n\
Do not pick the next requirement yourself — the platform will hand you the next one.",
tag = tag,
id = req.id,
title = req.title,
order = req.order_key,
content = req.content,
attachments_section = render_attachments_section(attachments),
)
}
/// Tool-free variant: the engine does NOT have the native requirement tools
/// registered. The model must NOT try to call `requirement_complete` —
/// invoking a tool the session does not expose just produces an apologetic
/// "我无法调用 requirement_complete" message and breaks the turn.
///
/// Instead the contract is simple: do the work, then end the turn with a
/// brief completion note in plain text. The platform records `done`
/// automatically when the turn finishes cleanly (see
/// `RequirementService::finalize_if_needed`); if the turn errors out the
/// platform retries / marks it failed on its own. To clearly signal an
/// inability to complete, the model is asked to surface the failure plainly
/// in its final message — humans reading the conversation see a real reason,
/// and downstream automation has unambiguous text to grep.
fn build_requirement_prompt_no_native_tools(tag: &str, req: &Requirement, attachments: &[PromptAttachment]) -> String {
format!(
"[AutoWork] You are working through requirements in tag \"{tag}\".\n\n\
## Current requirement\n\
id: {id}\n\
title: {title}\n\
order: {order}\n\n\
{content}\n\
{attachments_section}\n\
## When finished\n\
- Do the work, then end your turn with a brief plain-text completion note describing what \
you did. This session has no requirement-management tools registered, so do NOT attempt \
any tool call to record completion — the platform records it automatically when your turn \
ends cleanly.\n\
- If you cannot complete this requirement, end your turn with a plain-text message that \
clearly states the failure and the reason (for example, start the final line with \
\"Requirement failed:\" followed by the reason). Do not retry silently.\n\
Do not pick the next requirement yourself — the platform will hand you the next one.",
tag = tag,
id = req.id,
title = req.title,
order = req.order_key,
content = req.content,
attachments_section = render_attachments_section(attachments),
)
}
/// Build the message injected into a terminal CLI (claude/codex over a PTY)
/// for a claimed requirement.
///
/// The agent is instructed to declare completion via the `requirement_complete`
/// / `requirement_update_status` MCP tools (injected by Task 2 into every
/// AutoWork-enabled agent terminal). This mirrors the ACP `requirement_mcp_enabled`
/// branch of `build_requirement_prompt`: the tools ARE present, so the agent
/// SHOULD call them. A clean turn-end where the agent did NOT call them → the
/// platform parks the requirement as `needs_review` (not silently done).
pub fn build_terminal_requirement_prompt(
tag: &str,
req: &Requirement,
attachments: &[PromptAttachment],
) -> String {
format!(
"[AutoWork] You are working through requirements in tag \"{tag}\". Complete ONLY the \
requirement below.\n\n\
## Current requirement\n\
id: {id}\n\
title: {title}\n\
order: {order}\n\n\
{content}\n\
{attachments_section}\n\
## When finished\n\
- Call the `requirement_complete` tool with this requirement's id (\"{id}\") and a concise \
completion note describing what you did.\n\
- If you cannot complete it, call `requirement_update_status` with id \"{id}\", \
status=\"failed\", and a reason.\n\
Do not pick the next requirement yourself — the platform will hand you the next one.",
tag = tag,
id = req.id,
title = req.title,
order = req.order_key,
content = req.content,
attachments_section = render_attachments_section(attachments),
)
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_api_types::RequirementStatus;
fn req() -> Requirement {
Requirement {
id: 7777,
title: "Do X".into(),
content: "Detailed body".into(),
tag: "t".into(),
order_key: "1.2".into(),
status: RequirementStatus::InProgress,
completion_note: None,
owner_session_id: None,
owner_kind: None,
started_at: None,
completed_at: None,
attempt_count: 1,
created_by: "user".into(),
created_at: 0,
updated_at: 0,
attachments: vec![],
}
}
fn atts() -> Vec<crate::attachments::PromptAttachment> {
vec![
crate::attachments::PromptAttachment {
file_name: "设计稿.png".into(),
path: "./.nomi/requirement-attachments/req_1/设计稿.png".into(),
missing: false,
},
crate::attachments::PromptAttachment {
file_name: "gone.png".into(),
path: String::new(),
missing: true,
},
]
}
#[test]
fn attachments_section_lists_paths_and_missing_marker() {
for at in [AgentType::Nomi, AgentType::Acp] {
let p = build_requirement_prompt("t", &req(), at, false, &atts());
assert!(p.contains("Requirement attachments"));
assert!(p.contains("./.nomi/requirement-attachments/req_1/设计稿.png"));
assert!(p.contains("设计稿.png"));
assert!(p.contains("missing"), "vanished originals are flagged, not silently dropped");
assert!(p.contains("view each attached image"), "must instruct the model to read the images");
}
let p = build_terminal_requirement_prompt("t", &req(), &atts());
assert!(p.contains("Requirement attachments"));
assert!(p.contains("设计稿.png"));
}
#[test]
fn no_attachments_means_no_section() {
let p = build_requirement_prompt("t", &req(), AgentType::Nomi, false, &[]);
assert!(!p.contains("Requirement attachments"));
let p = build_terminal_requirement_prompt("t", &req(), &[]);
assert!(!p.contains("Requirement attachments"));
}
#[test]
fn nomi_prompt_contains_id_and_native_tool_instructions() {
let p = build_requirement_prompt("t", &req(), AgentType::Nomi, false, &[]);
assert!(p.contains("7777"));
assert!(p.contains("Detailed body"));
assert!(
p.contains("requirement_complete"),
"Nomi prompt MUST instruct calling requirement_complete (tool is registered for Nomi)"
);
assert!(
p.contains("requirement_update_status"),
"Nomi prompt MUST instruct calling requirement_update_status on failure"
);
}
#[test]
fn non_native_prompt_does_not_mention_requirement_complete_tool() {
// Every non-Nomi engine WITHOUT the requirement MCP injected: no tool bus
// entry for the native requirement tools, so the prompt must NOT tell the
// model to call them.
for at in [
AgentType::Acp,
AgentType::OpenclawGateway,
AgentType::Nanobot,
AgentType::Remote,
AgentType::Gemini,
] {
let p = build_requirement_prompt("t", &req(), at, false, &[]);
assert!(p.contains("7777"), "{at:?}: must still carry the requirement id");
assert!(p.contains("Detailed body"), "{at:?}: must still carry the body");
assert!(
!p.contains("requirement_complete"),
"{at:?}: prompt MUST NOT name the requirement_complete tool — it isn't registered for this engine"
);
assert!(
!p.contains("requirement_update_status"),
"{at:?}: prompt MUST NOT name the requirement_update_status tool — it isn't registered for this engine"
);
// It SHOULD describe the tool-free contract: end the turn with a note,
// platform records completion automatically; failures are stated in plain text.
assert!(
p.contains("automatically") || p.contains("turn ends"),
"{at:?}: prompt should describe the auto-finalize-on-clean-finish contract"
);
assert!(
p.contains("Requirement failed:"),
"{at:?}: prompt should tell the model how to surface a failure in plain text"
);
}
}
#[test]
fn acp_with_requirement_mcp_uses_native_prompt() {
// Once the requirement MCP is injected, an ACP session DOES expose the
// declaration tools, so it must be told to call them (same contract as
// Nomi). This is the soft-failure fix for ACP backends.
let p = build_requirement_prompt("t", &req(), AgentType::Acp, true, &[]);
assert!(p.contains("7777"));
assert!(
p.contains("requirement_complete"),
"ACP + requirement MCP MUST instruct calling requirement_complete"
);
assert!(
p.contains("requirement_update_status"),
"ACP + requirement MCP MUST instruct calling requirement_update_status on failure"
);
}
#[test]
fn acp_without_requirement_mcp_stays_tool_free() {
let p = build_requirement_prompt("t", &req(), AgentType::Acp, false, &[]);
assert!(
!p.contains("requirement_complete"),
"ACP without the requirement MCP must NOT be told to call a tool it does not have"
);
}
#[test]
fn session_has_requirement_tools_reflects_mcp_for_acp() {
// Nomi always has them in-process, regardless of the MCP flag.
assert!(session_has_requirement_tools(AgentType::Nomi, false));
assert!(session_has_requirement_tools(AgentType::Nomi, true));
// ACP only when the requirement MCP is enabled.
assert!(!session_has_requirement_tools(AgentType::Acp, false));
assert!(session_has_requirement_tools(AgentType::Acp, true));
// Other engines never have them, even with the flag set.
for at in [
AgentType::OpenclawGateway,
AgentType::Nanobot,
AgentType::Remote,
AgentType::Gemini,
] {
assert!(!session_has_requirement_tools(at, true), "{at:?}: no requirement tools");
}
}
#[test]
fn has_native_requirement_tools_only_for_nomi() {
assert!(has_native_requirement_tools(AgentType::Nomi));
for at in [
AgentType::Acp,
AgentType::OpenclawGateway,
AgentType::Nanobot,
AgentType::Remote,
AgentType::Gemini,
] {
assert!(
!has_native_requirement_tools(at),
"{at:?}: the native requirement tools are NOT registered for this engine"
);
}
}
#[test]
fn terminal_prompt_instructs_requirement_complete_and_has_no_knowledge_hint() {
let p = build_terminal_requirement_prompt("t", &req(), &[]);
assert!(p.contains("7777"));
assert!(p.contains("Detailed body"));
// Must instruct the agent to call the requirement completion tools
// (they are injected via the requirement MCP server — Task 2).
assert!(
p.contains("requirement_complete"),
"terminal prompt MUST instruct calling requirement_complete"
);
assert!(
p.contains("requirement_update_status"),
"terminal prompt MUST instruct calling requirement_update_status on failure"
);
// The old knowledge hint is gone (knowledge is now a real MCP tool).
assert!(
!p.contains("knowledge"),
"terminal prompt must NOT contain the old TERMINAL_KNOWLEDGE_HINT"
);
// The old printed-marker protocol is gone.
assert!(!p.contains("NOMI_AUTOWORK_END"), "terminal prompt must not ask for a marker");
}
#[test]
fn terminal_expects_verdict_mirrors_mcp_enabled_flag() {
use crate::prompt::terminal_expects_verdict;
assert!(terminal_expects_verdict(true));
assert!(!terminal_expects_verdict(false));
}
}
@@ -0,0 +1,336 @@
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use nomifun_api_types::{
ApiResponse, AutoWorkConfigRequest, AutoWorkRunState, AutoWorkState, AutoWorkTargetKind, BatchDeleteRequest,
BatchDeleteResponse, BoardResponse, ClaimRequest, CompleteRequest, CreateRequirementRequest, ListRequirementsQuery,
Requirement, ResumeTagRequest, TagBindings, TagSummary, UpdateRequirementRequest, UpdateStatusRequest,
};
use nomifun_auth::CurrentUser;
use nomifun_common::{AppError, PaginatedResult};
use serde::Deserialize;
use crate::state::RequirementRouterState;
pub fn requirement_routes(state: RequirementRouterState) -> Router {
Router::new()
.route("/api/requirements", get(list_requirements).post(create_requirement))
.route("/api/requirements/tags", get(list_tags))
.route("/api/requirements/tags/{tag}/resume", post(resume_tag))
.route("/api/requirements/tag-bindings", get(list_tag_bindings))
.route("/api/requirements/board", get(get_board))
.route("/api/requirements/batch-delete", post(batch_delete_requirements))
.route("/api/requirements/claim", post(claim_requirement))
.route("/api/requirements/autowork", post(set_autowork))
.route("/api/requirements/autowork/{kind}/{target_id}", get(get_autowork))
.route("/api/requirements/{id}/status", post(update_requirement_status))
.route("/api/requirements/{id}/complete", post(complete_requirement))
.route(
"/api/requirements/{id}",
get(get_requirement).put(update_requirement).delete(delete_requirement),
)
.with_state(state)
}
async fn create_requirement(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<CreateRequirementRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<Requirement>>), AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let created = state.requirement_service.create(req).await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(created))))
}
async fn list_requirements(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
Query(query): Query<ListRequirementsQuery>,
) -> Result<Json<ApiResponse<PaginatedResult<Requirement>>>, AppError> {
let page = state.requirement_service.list(&query).await?;
Ok(Json(ApiResponse::ok(page)))
}
async fn get_requirement(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<Requirement>>, AppError> {
let req = state.requirement_service.get(id).await?;
Ok(Json(ApiResponse::ok(req)))
}
async fn update_requirement(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
body: Result<Json<UpdateRequirementRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Requirement>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let updated = state.requirement_service.update(id, req).await?;
Ok(Json(ApiResponse::ok(updated)))
}
async fn delete_requirement(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.requirement_service.delete(id).await?;
Ok(Json(ApiResponse::success()))
}
async fn batch_delete_requirements(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<BatchDeleteRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<BatchDeleteResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if req.ids.is_empty() {
return Err(AppError::BadRequest("ids must not be empty".into()));
}
let deleted = state.requirement_service.delete_many(&req.ids).await?;
Ok(Json(ApiResponse::ok(BatchDeleteResponse { deleted })))
}
async fn list_tags(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<Vec<TagSummary>>>, AppError> {
let tags = state.requirement_service.tags().await?;
Ok(Json(ApiResponse::ok(tags)))
}
/// Resume a paused tag so AutoWork claims its requirements again. Optionally
/// re-queue failed requirements (all, or specific ids) back to pending. Body is
/// optional. Returns the refreshed tag summary.
async fn resume_tag(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(tag): Path<String>,
body: Result<Json<ResumeTagRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<TagSummary>>, AppError> {
let req = body.map(|Json(r)| r).unwrap_or_default();
let mut requeue_ids = req.requeue_ids;
if req.requeue_failed {
// Re-queue every currently-failed requirement in the tag.
let board = state.requirement_service.board(&tag).await?;
requeue_ids.extend(board.failed.into_iter().map(|r| r.id));
}
state.requirement_service.resume_tag(&tag, &requeue_ids).await?;
let summary = state
.requirement_service
.tags()
.await?
.into_iter()
.find(|t| t.tag == tag)
.unwrap_or_else(|| TagSummary {
tag: tag.clone(),
..Default::default()
});
Ok(Json(ApiResponse::ok(summary)))
}
/// AutoWork tag→session bindings for the calling user, grouped by tag. The
/// service returns persisted bindings (every enabled one as `Idle`); here we
/// upgrade `run_state` to `Active` for targets the orchestrator is currently
/// driving (it owns the live progress map). Used by the AutoWork admin.
async fn list_tag_bindings(
State(state): State<RequirementRouterState>,
Extension(user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<Vec<TagBindings>>>, AppError> {
let mut groups = state.requirement_service.tag_bindings(&user.id).await?;
for group in &mut groups {
for binding in &mut group.bindings {
if matches!(state.orchestrator.live_progress(binding.kind, &binding.target_id), Some((Some(_), _))) {
binding.run_state = AutoWorkRunState::Active;
}
}
}
Ok(Json(ApiResponse::ok(groups)))
}
#[derive(Debug, Deserialize)]
struct BoardQuery {
tag: String,
}
async fn get_board(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
Query(query): Query<BoardQuery>,
) -> Result<Json<ApiResponse<BoardResponse>>, AppError> {
let board = state.requirement_service.board(&query.tag).await?;
Ok(Json(ApiResponse::ok(board)))
}
async fn claim_requirement(
State(state): State<RequirementRouterState>,
Extension(user): Extension<CurrentUser>,
body: Result<Json<ClaimRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Option<Requirement>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.requirement_service
.verify_conversation_owner(req.conversation_id, &user.id)
.await?;
let lease = req.lease_ms.unwrap_or(crate::service::DEFAULT_LEASE_MS);
let claimed = state
.requirement_service
.claim_next(&req.tag, req.conversation_id, AutoWorkTargetKind::Conversation, lease)
.await?;
Ok(Json(ApiResponse::ok(claimed)))
}
async fn update_requirement_status(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
body: Result<Json<UpdateStatusRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Requirement>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let updated = state.requirement_service.set_status(id, req.status, req.note).await?;
Ok(Json(ApiResponse::ok(updated)))
}
async fn complete_requirement(
State(state): State<RequirementRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
body: Result<Json<CompleteRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Requirement>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let done = state.requirement_service.complete(id, req.completion_note).await?;
Ok(Json(ApiResponse::ok(done)))
}
async fn set_autowork(
State(state): State<RequirementRouterState>,
Extension(user): Extension<CurrentUser>,
body: Result<Json<AutoWorkConfigRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<AutoWorkState>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if req.target_id.trim().is_empty() {
return Err(AppError::BadRequest("target_id is required".into()));
}
if req.enabled && req.tag.as_deref().unwrap_or("").trim().is_empty() {
return Err(AppError::BadRequest("tag is required when enabling autowork".into()));
}
// Admin guard (标签会话管理): refuse to disable a session that is actively
// executing a requirement when the request comes from the admin backend. The
// user must stop it from the session page so a live turn is not interrupted.
// Session-page toggles leave `from_admin` false and may always disable.
if !req.enabled
&& req.from_admin
&& matches!(state.orchestrator.live_progress(req.kind, &req.target_id), Some((Some(_), _)))
{
return Err(AppError::BadRequest(
"session is actively executing a requirement; stop it from the session page first".into(),
));
}
// Ownership + (terminal) eligibility, per target kind.
match req.kind {
AutoWorkTargetKind::Conversation => {
// `target_id` is the AutoWork (string) target handle; the conversation
// owner check is keyed by the integer conversation id.
let conv_id = req
.target_id
.parse::<i64>()
.map_err(|_| AppError::NotFound(format!("conversation {}", req.target_id)))?;
state
.requirement_service
.verify_conversation_owner(conv_id, &user.id)
.await?;
}
AutoWorkTargetKind::Terminal => {
state
.requirement_service
.verify_terminal_owner(&req.target_id, &user.id)
.await?;
if req.enabled {
state
.requirement_service
.ensure_terminal_autowork_eligible(&req.target_id)
.await?;
}
}
}
// Persist config.
state
.requirement_service
.save_autowork_config(
req.kind,
&req.target_id,
req.enabled,
req.tag.as_deref(),
req.max_requirements,
)
.await?;
// Start/stop the live loop.
if req.enabled {
if let Some(tag) = req.tag.clone() {
state
.orchestrator
.start(req.kind, req.target_id.clone(), tag, req.max_requirements);
}
} else {
state.orchestrator.stop(req.kind, &req.target_id);
}
let st = build_autowork_state(&state, req.kind, &req.target_id).await?;
state.requirement_service.emit_autowork_state(&st);
Ok(Json(ApiResponse::ok(st)))
}
async fn get_autowork(
State(state): State<RequirementRouterState>,
Extension(user): Extension<CurrentUser>,
Path((kind, target_id)): Path<(String, String)>,
) -> Result<Json<ApiResponse<AutoWorkState>>, AppError> {
let kind = AutoWorkTargetKind::parse(&kind)
.ok_or_else(|| AppError::BadRequest(format!("unknown autowork target kind: {kind}")))?;
match kind {
AutoWorkTargetKind::Conversation => {
let conv_id = target_id
.parse::<i64>()
.map_err(|_| AppError::NotFound(format!("conversation {target_id}")))?;
state
.requirement_service
.verify_conversation_owner(conv_id, &user.id)
.await?;
}
AutoWorkTargetKind::Terminal => {
state
.requirement_service
.verify_terminal_owner(&target_id, &user.id)
.await?;
}
}
let st = build_autowork_state(&state, kind, &target_id).await?;
Ok(Json(ApiResponse::ok(st)))
}
async fn build_autowork_state(
state: &RequirementRouterState,
kind: AutoWorkTargetKind,
target_id: &str,
) -> Result<AutoWorkState, AppError> {
let (enabled, tag, _max) = state.requirement_service.read_autowork_config(kind, target_id).await?;
let running = state.orchestrator.is_running(kind, target_id);
let live_tag = state.orchestrator.running_tag(kind, target_id).or(tag);
let (current_requirement_id, completed_count) =
state.orchestrator.live_progress(kind, target_id).unwrap_or((None, 0));
let run_state = AutoWorkState::run_state(enabled, current_requirement_id.as_deref());
Ok(AutoWorkState {
kind,
target_id: target_id.to_string(),
enabled,
tag: live_tag,
running,
run_state,
current_requirement_id,
completed_count,
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
use std::sync::Arc;
use async_trait::async_trait;
use nomifun_ai_agent::RequirementSink;
use nomifun_api_types::{CreateRequirementRequest, RequirementStatus};
use nomifun_common::RequirementCreator;
use crate::service::RequirementService;
/// Backend implementation of the agent-side `RequirementSink` trait, delegating
/// to `RequirementService`. Injected into the nomi engine via the agent factory.
pub struct RequirementServiceSink {
service: Arc<RequirementService>,
}
impl RequirementServiceSink {
/// Build the sink as a trait object ready to inject into the agent factory.
pub fn into_arc(service: Arc<RequirementService>) -> Arc<dyn RequirementSink> {
Arc::new(Self { service })
}
/// Build the same sink as a [`RequirementCreator`] trait object for the
/// opt-in IM → requirement pipeline (channel inbound → tracked requirement).
pub fn creator_arc(service: Arc<RequirementService>) -> Arc<dyn RequirementCreator> {
Arc::new(Self { service })
}
}
#[async_trait]
impl RequirementCreator for RequirementServiceSink {
async fn create_from_message(
&self,
title: &str,
content: &str,
tag: &str,
created_by: &str,
) -> Result<String, String> {
let req = CreateRequirementRequest {
title: title.to_string(),
content: content.to_string(),
tag: tag.to_string(),
order_key: None,
status: None, // None → Pending → wakes AutoWork
created_by: Some(created_by.to_string()),
attachments: Vec::new(),
};
self.service
.create(req)
.await
.map(|r| r.id.to_string())
.map_err(|e| e.to_string())
}
}
#[async_trait]
impl RequirementSink for RequirementServiceSink {
async fn complete(&self, requirement_id: &str, note: &str) -> Result<(), String> {
let id = parse_req_id(requirement_id)?;
self.service
.complete(id, Some(note.to_string()))
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn update_status(&self, requirement_id: &str, status: &str, note: Option<&str>) -> Result<(), String> {
let id = parse_req_id(requirement_id)?;
let parsed = match status {
"in_progress" => RequirementStatus::InProgress,
"done" => RequirementStatus::Done,
"failed" => RequirementStatus::Failed,
other => return Err(format!("invalid status '{other}'")),
};
self.service
.set_status(id, parsed, note.map(|s| s.to_string()))
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
}
/// Parse the requirement id the nomi engine passes from the prompt (an integer,
/// single-track per spec §2.3) — carried as a string across the agent-engine
/// seam. A non-numeric id (e.g. a stale id replayed from a persisted transcript,
/// spec §2.5/§7.4) is rejected explicitly rather than silently coerced.
fn parse_req_id(requirement_id: &str) -> Result<i64, String> {
requirement_id
.trim()
.parse::<i64>()
.map_err(|_| format!("invalid requirement id '{requirement_id}' (expected an integer)"))
}
@@ -0,0 +1,10 @@
use std::sync::Arc;
use crate::orchestrator::Orchestrator;
use crate::service::RequirementService;
#[derive(Clone)]
pub struct RequirementRouterState {
pub requirement_service: Arc<RequirementService>,
pub orchestrator: Arc<Orchestrator>,
}
@@ -0,0 +1,111 @@
//! Spec §9.B wiring test: deleting a terminal session must dispatch the
//! `OnTerminalDelete` hook into `RequirementService::clear_owner_for_session`,
//! clearing the dual-domain `owner_session_id`/`owner_kind` (no FK to cascade)
//! of every requirement that terminal owned and re-pending any `in_progress`
//! one. This exercises the real wiring (`TerminalService::with_delete_hook` +
//! dispatch in `delete()`), not just the service method in isolation.
use std::sync::Arc;
use nomifun_api_types::{AutoWorkTargetKind, CreateRequirementRequest, RequirementStatus};
use nomifun_common::OnTerminalDelete;
use nomifun_db::{
CreateTerminalParams, IRequirementRepository, ITerminalRepository, SqliteRequirementRepository,
SqliteTerminalRepository, init_database_memory,
};
use nomifun_realtime::EventBroadcaster;
use nomifun_requirement::{RequirementEventEmitter, RequirementService};
use nomifun_terminal::{TerminalEventEmitter, TerminalService};
#[derive(Default)]
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
}
#[tokio::test]
async fn deleting_terminal_clears_requirement_owner_via_hook() {
let db = init_database_memory().await.unwrap();
let pool = db.pool().clone();
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_1', 'tester', 'h', 0, 0)",
)
.execute(&pool)
.await
.unwrap();
let term_repo: Arc<dyn ITerminalRepository> = Arc::new(SqliteTerminalRepository::new(pool.clone()));
let req_repo: Arc<dyn IRequirementRepository> = Arc::new(SqliteRequirementRepository::new(pool.clone()));
// Requirement service is the hook target.
let req_service = Arc::new(RequirementService::new(
req_repo,
RequirementEventEmitter::new(Arc::new(NoopBroadcaster)),
));
// Terminal service wired exactly as `nomifun-app::build_terminal_state` does:
// register the requirement service as an `OnTerminalDelete` hook.
let term_service = TerminalService::new(
term_repo.clone(),
TerminalEventEmitter::new(Arc::new(NoopBroadcaster)),
std::env::temp_dir(),
);
term_service.with_delete_hook(req_service.clone() as Arc<dyn OnTerminalDelete>);
// Persist a terminal row (no live PTY needed — delete tolerates that). The
// id is minted by SQLite and returned on the row.
let term = term_repo
.create(&CreateTerminalParams {
name: "Term One".into(),
cwd: std::env::temp_dir().to_string_lossy().into_owned(),
command: "claude".into(),
args: "[]".into(),
env: None,
backend: Some("claude".into()),
mode: None,
cols: 80,
rows: 24,
user_id: "user_1".into(),
})
.await
.unwrap();
let term_id = term.id;
// Create a requirement and let the terminal claim it (owner=term_1, in_progress).
let r = req_service
.create(CreateRequirementRequest {
title: "T".into(),
content: String::new(),
tag: "auto".into(),
order_key: Some("1".into()),
status: None,
created_by: None,
attachments: vec![],
})
.await
.unwrap();
let claimed = req_service
.claim_next("auto", term_id, AutoWorkTargetKind::Terminal, 60_000)
.await
.unwrap()
.unwrap();
assert_eq!(claimed.owner_session_id, Some(term_id));
assert_eq!(claimed.owner_kind.as_deref(), Some("terminal"));
assert_eq!(claimed.status, RequirementStatus::InProgress);
// Delete the terminal through the service → the hook fires and clears owner.
term_service.delete(term_id).await.unwrap();
let after = req_service.get(r.id).await.unwrap();
assert_eq!(after.owner_session_id, None, "owner_session_id cleared on terminal delete");
assert_eq!(after.owner_kind, None, "owner_kind cleared alongside (paired-NULL)");
assert_eq!(
after.status,
RequirementStatus::Pending,
"the orphaned in_progress requirement is re-pended"
);
assert_eq!(after.attempt_count, 1, "clearing owner must not consume an attempt");
Box::leak(Box::new(db));
}
@@ -0,0 +1,96 @@
//! Verifies `RequirementService::set_status` fires the `CompletionNotifier`
//! exactly once on a terminal transition, and not on no-op / non-terminal ones.
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use nomifun_api_types::{CreateRequirementRequest, RequirementStatus};
use nomifun_db::models::RequirementRow;
use nomifun_db::{IRequirementRepository, SqliteRequirementRepository, init_database_memory};
use nomifun_realtime::EventBroadcaster;
use nomifun_requirement::{CompletionNotifier, RequirementEventEmitter, RequirementService};
#[derive(Default)]
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
}
#[derive(Default)]
struct RecordingNotifier {
ids: Mutex<Vec<i64>>,
}
#[async_trait]
impl CompletionNotifier for RecordingNotifier {
async fn notify_completion(&self, requirement: &RequirementRow) {
self.ids.lock().unwrap().push(requirement.id);
}
}
async fn svc(notifier: Arc<RecordingNotifier>) -> RequirementService {
let db = init_database_memory().await.unwrap();
let repo: Arc<dyn IRequirementRepository> = Arc::new(SqliteRequirementRepository::new(db.pool().clone()));
let emitter = RequirementEventEmitter::new(Arc::new(NoopBroadcaster));
Box::leak(Box::new(db));
RequirementService::new(repo, emitter).with_completion_notifier(notifier)
}
fn new_req(tag: &str) -> CreateRequirementRequest {
CreateRequirementRequest {
title: "T".into(),
content: "body".into(),
tag: tag.into(),
order_key: Some("1".into()),
status: None,
created_by: None,
attachments: vec![],
}
}
/// Let the detached `tokio::spawn`(notify) task run.
async fn settle() {
tokio::time::sleep(Duration::from_millis(50)).await;
}
#[tokio::test]
async fn fires_once_on_done() {
let notifier = Arc::new(RecordingNotifier::default());
let s = svc(notifier.clone()).await;
let r = s.create(new_req("alpha")).await.unwrap();
s.set_status(r.id, RequirementStatus::Done, Some("note".into()))
.await
.unwrap();
settle().await;
assert_eq!(notifier.ids.lock().unwrap().as_slice(), std::slice::from_ref(&r.id));
// Re-setting the same terminal status is an idempotent no-op → no extra fire.
let _ = s.set_status(r.id, RequirementStatus::Done, None).await.unwrap();
settle().await;
assert_eq!(notifier.ids.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn fires_on_failed() {
let notifier = Arc::new(RecordingNotifier::default());
let s = svc(notifier.clone()).await;
let r = s.create(new_req("alpha")).await.unwrap();
s.set_status(r.id, RequirementStatus::Failed, Some("oops".into()))
.await
.unwrap();
settle().await;
assert_eq!(notifier.ids.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn does_not_fire_on_non_terminal() {
let notifier = Arc::new(RecordingNotifier::default());
let s = svc(notifier.clone()).await;
let r = s.create(new_req("alpha")).await.unwrap();
s.set_status(r.id, RequirementStatus::InProgress, None).await.unwrap();
settle().await;
assert!(notifier.ids.lock().unwrap().is_empty());
}
@@ -0,0 +1,123 @@
//! Tests for `RequirementService::tag_bindings`: enumerates conversation +
//! terminal AutoWork bindings, grouped by tag, only for enabled ones.
use std::sync::Arc;
use nomifun_db::models::ConversationRow;
use nomifun_db::{
CreateTerminalParams, IConversationRepository, IRequirementRepository, ITerminalRepository,
SqliteConversationRepository, SqliteRequirementRepository, SqliteTerminalRepository, init_database_memory,
};
use nomifun_realtime::EventBroadcaster;
use nomifun_requirement::{RequirementEventEmitter, RequirementService};
#[derive(Default)]
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
}
fn conv(id: i64, name: &str, autowork_json: &str) -> ConversationRow {
ConversationRow {
id,
user_id: "user_1".into(),
name: name.into(),
r#type: "nomi".into(),
extra: autowork_json.into(),
model: None,
status: Some("pending".into()),
source: None,
channel_chat_id: None,
pinned: false,
pinned_at: None,
cron_job_id: None,
created_at: 0,
updated_at: 0,
}
}
#[tokio::test]
async fn groups_enabled_conversation_and_terminal_bindings_by_tag() {
let db = init_database_memory().await.unwrap();
let pool = db.pool().clone();
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_1', 'tester', 'h', 0, 0)",
)
.execute(&pool)
.await
.unwrap();
let conv_repo: Arc<dyn IConversationRepository> = Arc::new(SqliteConversationRepository::new(pool.clone()));
let term_repo: Arc<dyn ITerminalRepository> = Arc::new(SqliteTerminalRepository::new(pool.clone()));
let req_repo: Arc<dyn IRequirementRepository> = Arc::new(SqliteRequirementRepository::new(pool.clone()));
// Two conversations enabled on tag "x", one disabled, one with no autowork.
// The id field is ignored on insert (SQLite mints the PK).
conv_repo
.create(&conv(1, "Alpha A", r#"{"autowork":{"enabled":true,"tag":"x"}}"#))
.await
.unwrap();
conv_repo
.create(&conv(2, "Alpha B", r#"{"autowork":{"enabled":true,"tag":"x"}}"#))
.await
.unwrap();
conv_repo
.create(&conv(
3,
"Disabled",
r#"{"autowork":{"enabled":false,"tag":"x"}}"#,
))
.await
.unwrap();
conv_repo.create(&conv(4, "No autowork", "{}")).await.unwrap();
// One terminal enabled on tag "y". The id is minted by SQLite and returned.
let term = term_repo
.create(&CreateTerminalParams {
name: "Term One".into(),
cwd: "/tmp".into(),
command: "claude".into(),
args: "[]".into(),
env: None,
backend: Some("claude".into()),
mode: None,
cols: 80,
rows: 24,
user_id: "user_1".into(),
})
.await
.unwrap();
let term_id = term.id;
term_repo
.update_autowork(term_id, Some(r#"{"enabled":true,"tag":"y"}"#))
.await
.unwrap();
let svc = RequirementService::new(req_repo, RequirementEventEmitter::new(Arc::new(NoopBroadcaster)))
.with_conversation_repo(conv_repo)
.with_terminal_repo(term_repo);
Box::leak(Box::new(db));
let groups = svc.tag_bindings("user_1").await.unwrap();
// tag "x" has the two enabled conversations; "y" has the terminal. Disabled +
// no-autowork conversations are excluded.
let x = groups.iter().find(|g| g.tag == "x").expect("tag x present");
assert_eq!(x.bindings.len(), 2);
let mut names: Vec<&str> = x.bindings.iter().map(|b| b.name.as_str()).collect();
names.sort();
assert_eq!(names, vec!["Alpha A", "Alpha B"]);
let y = groups.iter().find(|g| g.tag == "y").expect("tag y present");
assert_eq!(y.bindings.len(), 1);
assert_eq!(y.bindings[0].target_id, term_id.to_string());
// No "active" run_state without a live orchestrator (route enriches that).
assert!(
groups
.iter()
.flat_map(|g| &g.bindings)
.all(|b| b.run_state == nomifun_api_types::AutoWorkRunState::Idle)
);
}