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

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,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)
);
}