Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user