合并原分支的 M1C-1 审查修复

This commit is contained in:
2026-08-17 05:51:30 +00:00
23 changed files with 2023 additions and 112 deletions
@@ -1,5 +1,86 @@
use super::*;
struct PlanGddBlockerRuntimeProjection {
phase: &'static str,
current_action: &'static str,
waiting_on: &'static str,
next_step: &'static str,
}
/// `runtime.plan_gdd` blocker 的类型化子状态 → 运行时投影。
///
/// 抽成纯函数是为了让这条分支可测:主循环整个文件此前没有 `mod tests`,而原来的
/// 判别是对 detail 做 `contains("approvalPending=awaiting_decision")`——三个 blocked
/// 子状态里只有一个含这个子串,另外两个会掉进 else 被打成 needs-reconciliation
/// 把最正常的早期推进态和收尾态当成故障停掉。判不出 kind 时保持 fail-closed。
fn plan_gdd_blocker_runtime_projection(
kind: Option<PlanGddCompletionBlockerKind>,
) -> PlanGddBlockerRuntimeProjection {
match kind {
Some(PlanGddCompletionBlockerKind::SubmissionNotStarted) => {
PlanGddBlockerRuntimeProjection {
phase: "planning",
current_action: "推进本根 Run 的 Fast GDD 提交",
waiting_on: "策划子 Agent 完成本根 Run 的 plan.submit_gdd",
next_step: "调用 agent.delegate 派出策划子 Agent;上一根 Run 遗留的 game/fast_gdd.md 或 Acceptance Graph 不能代替本根提交",
}
}
Some(PlanGddCompletionBlockerKind::AwaitingApprovalDecision) => {
PlanGddBlockerRuntimeProjection {
phase: "waiting-for-user-input",
current_action: "等待 Fast GDD 审批决定",
waiting_on: "用户在审批卡选择批准、修改或退回",
next_step: "等待 decide_game_creator_plan_gdd;不得重新提交同一 GDD 或自行创建审批 pending",
}
}
Some(PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending) => {
PlanGddBlockerRuntimeProjection {
phase: "planning",
current_action: "等待 Fast GDD 审批投影收尾",
waiting_on: "原 plan.submit_gdd 恢复锚点由审批投影清理",
next_step: "等待审批投影恢复清理锚点后继续;不得重新提交同一 GDD 或自行创建审批 pending",
}
}
Some(PlanGddCompletionBlockerKind::NeedsReconciliation) | None => {
PlanGddBlockerRuntimeProjection {
phase: "needs-reconciliation",
current_action: "Fast GDD 审批投影需要人工核对",
waiting_on:
"planning pending、receipt、原提交锚点、terminal observation、audit 与 session 的精确身份",
next_step: "先恢复或核对现有 durable 事实,不能请求新 Provider 计划",
}
}
}
}
/// 只有「等用户决定」和「要人工核对」才终结本轮后台任务;尚未提交与锚点收尾都是
/// 继续推进态,和 `runtime.plan_update` 一样不产生等待态,让本轮循环继续。
fn plan_gdd_blocker_waiting_kind(
kind: Option<PlanGddCompletionBlockerKind>,
) -> Option<(
&'static str,
&'static str,
&'static str,
AgentBackgroundTaskOutcome,
)> {
match kind {
Some(PlanGddCompletionBlockerKind::AwaitingApprovalDecision) => Some((
"agent.runtime.plan.gdd.waiting",
"waiting-for-user-input",
"Fast GDD 审批等待状态持久化失败",
AgentBackgroundTaskOutcome::WaitingForUserInput,
)),
Some(PlanGddCompletionBlockerKind::NeedsReconciliation) | None => Some((
"agent.runtime.plan.gdd.reconciliation",
"needs-reconciliation",
"Fast GDD 审批投影需要人工核对",
AgentBackgroundTaskOutcome::NeedsReconciliation,
)),
Some(PlanGddCompletionBlockerKind::SubmissionNotStarted)
| Some(PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending) => None,
}
}
pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(root: &Path) -> bool {
let Ok(manifest) = read_manifest_for_project(root) else {
return false;
@@ -1889,6 +1970,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
if plan.actions.is_empty() {
// blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与
// next_step 必须按类型化子状态选,不能回去猜 detail 字符串。
let mut plan_gdd_blocker_kind: Option<PlanGddCompletionBlockerKind> = None;
let completion_blocker = structured_plan_completion_blocker(&runtime)
.or_else(|| {
provider_action_batch_completion_blocker_at_locked(
@@ -1898,7 +1982,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
)
})
.or_else(|| {
plan_gdd_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id)
plan_gdd_typed_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id)
.map(|blocker| {
plan_gdd_blocker_kind = Some(blocker.kind);
blocker.observation
})
})
.or_else(|| game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime))
.or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime))
@@ -1948,22 +2036,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
runtime.next_step = "先恢复原批次,不能请求新计划或提交最终回复".to_string();
} else if blocker.tool == "runtime.plan_gdd" {
runtime.status = "running".to_string();
if blocker.status == "blocked"
&& blocker.detail.as_deref().is_some_and(|detail| {
detail.contains("approvalPending=awaiting_decision")
})
{
runtime.phase = "waiting-for-user-input".to_string();
runtime.current_action = "等待 Fast GDD 审批决定".to_string();
runtime.waiting_on = "用户在审批卡选择批准、修改或退回".to_string();
runtime.next_step = "等待 decide_game_creator_plan_gdd;不得重新提交同一 GDD 或自行创建审批 pending".to_string();
} else {
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "Fast GDD 审批投影需要人工核对".to_string();
runtime.waiting_on = "planning pending、receipt、原提交锚点、terminal observation、audit 与 session 的精确身份".to_string();
runtime.next_step =
"先恢复或核对现有 durable 事实,不能请求新 Provider 计划".to_string();
}
let projection = plan_gdd_blocker_runtime_projection(plan_gdd_blocker_kind);
runtime.phase = projection.phase.to_string();
runtime.current_action = projection.current_action.to_string();
runtime.waiting_on = projection.waiting_on.to_string();
runtime.next_step = projection.next_step.to_string();
} else if blocker.tool == "runtime.collaboration_policy" {
runtime.status = "running".to_string();
runtime.phase = "planning".to_string();
@@ -2126,23 +2203,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
AgentBackgroundTaskOutcome::WaitingForDelegateReceipts,
))
} else if observation.tool == "runtime.plan_gdd" {
if observation.status == "blocked"
&& detail.contains("approvalPending=awaiting_decision")
{
Some((
"agent.runtime.plan.gdd.waiting",
"waiting-for-user-input",
"Fast GDD 审批等待状态持久化失败",
AgentBackgroundTaskOutcome::WaitingForUserInput,
))
} else {
Some((
"agent.runtime.plan.gdd.reconciliation",
"needs-reconciliation",
"Fast GDD 审批投影需要人工核对",
AgentBackgroundTaskOutcome::NeedsReconciliation,
))
}
plan_gdd_blocker_waiting_kind(plan_gdd_blocker_kind)
} else {
None
}
@@ -4211,3 +4272,78 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
}
}
#[cfg(test)]
mod plan_gdd_blocker_projection_tests {
use super::*;
/// 三个 blocked 子状态必须落到各自的 phase。旧的子串判别只认得
/// AwaitingApprovalDecision,另外两个会被打成 needs-reconciliation。
#[test]
fn each_blocked_kind_projects_its_own_phase() {
assert_eq!(
plan_gdd_blocker_runtime_projection(Some(
PlanGddCompletionBlockerKind::SubmissionNotStarted
))
.phase,
"planning"
);
assert_eq!(
plan_gdd_blocker_runtime_projection(Some(
PlanGddCompletionBlockerKind::AwaitingApprovalDecision
))
.phase,
"waiting-for-user-input"
);
assert_eq!(
plan_gdd_blocker_runtime_projection(Some(
PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending
))
.phase,
"planning"
);
}
/// 判不出 kind 与显式的人工核对一样,保持 fail-closed。
#[test]
fn reconciliation_and_unknown_kind_stay_fail_closed() {
assert_eq!(
plan_gdd_blocker_runtime_projection(Some(
PlanGddCompletionBlockerKind::NeedsReconciliation
))
.phase,
"needs-reconciliation"
);
assert_eq!(
plan_gdd_blocker_runtime_projection(None).phase,
"needs-reconciliation"
);
assert!(matches!(
plan_gdd_blocker_waiting_kind(None),
Some((_, _, _, AgentBackgroundTaskOutcome::NeedsReconciliation))
));
}
/// 继续推进态不产生等待态,本轮后台任务不该在这里终结。
#[test]
fn only_user_decision_and_reconciliation_end_the_background_task() {
assert!(matches!(
plan_gdd_blocker_waiting_kind(Some(
PlanGddCompletionBlockerKind::AwaitingApprovalDecision
)),
Some((_, "waiting-for-user-input", _, _))
));
assert!(matches!(
plan_gdd_blocker_waiting_kind(Some(PlanGddCompletionBlockerKind::NeedsReconciliation)),
Some((_, "needs-reconciliation", _, _))
));
assert!(plan_gdd_blocker_waiting_kind(Some(
PlanGddCompletionBlockerKind::SubmissionNotStarted
))
.is_none());
assert!(plan_gdd_blocker_waiting_kind(Some(
PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending
))
.is_none());
}
}
@@ -819,12 +819,94 @@ fn durable_process_session_recovery_exists_at(root: &Path) -> bool {
false
}
/// Fast GDD approval 投影恢复失败时,把 fail-closed 收敛到受影响的那个 run。
///
/// 返回 `Ok(true)` 表示已经把策划根 Supervisor 标成 needs-reconciliation,调用方可以
/// 继续扫描其余 Agent`Ok(false)` 表示不该、或无法精确收敛,调用方必须把原错误照旧
/// 上抛,保持全局 fail-closed。
fn contain_plan_gdd_approval_recovery_failure_at(root: &Path, error: &str) -> Result<bool, String> {
// 瞬时错误绝不能收敛成 needs-reconciliation。最常见的就是 `.agent/project.lock`
// 正被另一个写操作占用——什么都没坏,下一轮扫描重试即可;把它标成人工核对等于
// 用一次转瞬即逝的锁争用永久停掉策划根 run,比原来的强传播更糟。这里照旧上抛,
// 调用方把它变成 recovery_pending 并在下一轮重试,与本函数出现之前的行为一致。
// 判据复用委派唤醒那条既有的瞬时特征串,避免两处各写一套导致分类漂移。
if static_delegate_parent_wake_error_is_transient(error) {
return Ok(false);
}
let Some(_runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
)?
else {
return Ok(false);
};
let mut runtime =
read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)?.state;
if runtime.run_id.trim().is_empty()
|| matches!(
runtime.phase.as_str(),
"completed" | "cancelled" | "needs-reconciliation"
)
{
return Ok(false);
}
let error = sanitize_agent_runtime_text(error, 500);
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "Fast GDD 审批投影恢复需要人工核对".to_string();
runtime.waiting_on = "开发者核对 planning pending、receipt 与原提交锚点".to_string();
runtime.next_step = "修复审批投影后显式恢复该 run".to_string();
runtime.error = Some(error.clone());
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &runtime)?;
refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?;
write_game_creator_agent_runtime_state(root, &runtime)?;
let _ = append_game_creator_agent_runtime_event(
root,
&runtime,
"plan.gdd.approval_recovery.needs_reconciliation",
"failed",
"needs-reconciliation",
"Fast GDD 审批投影恢复已停止自动重放,等待开发者核对。",
Some(&error),
);
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.plan.gdd.approval_recovery.needs_reconciliation",
"agentId": runtime.agent_id,
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"error": error,
}),
);
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
Ok(true)
}
pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at(
root: &Path,
) -> Result<Vec<AgentRuntimeResult>, String> {
validate_project_root(root)?;
reconcile_plan_gdd_approval_projections_at(root)
.map_err(|error| format!("恢复 GDD approval 投影失败:{error}"))?;
// 这是一条只覆盖策划根 Supervisor 的窄投影恢复,却挂在整轮 resume 的最前面。
// 原来用 `?` 强传播:一次 Fast GDD 投影失败会掐掉全项目所有 Agent 的恢复——而它
// 本身正是 receipt 投影失败后的重试入口,掐掉它等于连兜底一起废掉。
//
// 失败必须分三路,不能两路。此前把「瞬时」和「归属不了」并成同一个 false,
// 结果瞬时锁争用走了全局上抛,让这条可反复调用的恢复入口整轮失败——而锁被占
// 恰恰说明别处正在推进,是最不该失败的时候。
if let Err(error) = reconcile_plan_gdd_approval_projections_at(root) {
let error = format!("恢复 GDD approval 投影失败:{error}");
if static_delegate_parent_wake_error_is_transient(&error) {
// 瞬时争用(最常见的是 `.agent/project.lock` 正被另一个写操作占用):
// 跳过本轮投影恢复,其余恢复照常走,下一轮 resume 重试。与下面拿不到
// runtime task 锁时直接跳过的处理是同一套语义,因此同样不落审计。
} else if !contain_plan_gdd_approval_recovery_failure_at(root, &error)? {
// 持久失败但归属不到具体 run:只能退回原来的全局上抛。
return Err(error);
}
}
if external_agent_runner_owns_background_execution() {
resume_external_agent_runner(root)?;
return read_game_creator_agent_runtimes_at(root);
@@ -1770,3 +1852,183 @@ mod orphaned_external_generation_recovery_tests {
);
}
}
#[cfg(test)]
mod plan_gdd_approval_wait_recovery_tests {
use super::*;
/// Fast GDD 审批等待期,根 Supervisor 到底还能不能被恢复扫描拉起。
///
/// 判据不在 `phase` 上:`read_recoverable_game_creator_agent_runtime_task` 按
/// task record 的 **status** 分类,而 record 的 status 由
/// `game_creator_agent_runtime_task_status` 从 `state.status` 推导。审批等待
/// `main_loop` 的 `runtime.plan_gdd` 分支)只改 phase、保留 `status="running"`
/// 真正的澄清等待(`action_projection`)才会把 status 一并写成
/// `waiting-for-user-input`,那才是恢复扫描要让路的外部输入等待。
///
/// 这个区别决定了审批决定之后还有没有生产路径驱动父 run:审批命令只调通用
/// wake,一旦有人把审批等待也写成 `status="waiting-for-user-input"`,通用 wake
/// 会静默变成 no-op,用户点了批准/修改/退回之后不会有任何东西继续跑。这条测试
/// 把这个区别钉成不变量。
#[test]
fn plan_gdd_approval_wait_stays_recoverable_while_real_user_input_wait_does_not() {
let temporary = crate::tests::canonical_test_tempdir("plan-gdd-approval-wait-recovery-");
let root = temporary.path();
init_local_game_project_at(root, "plan-gdd-approval-wait", "Fast GDD 审批等待恢复判定")
.expect("init project");
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"推进立项策划",
"plan-gdd-approval-wait-run",
"agent-ready-task-scheduler",
"准备推进立项策划",
vec!["推进立项策划".to_string()],
)
.expect("start plan-root supervisor runtime");
// main_loop.rs 的 Fast GDD 审批等待形状。
runtime.status = "running".to_string();
runtime.phase = "waiting-for-user-input".to_string();
runtime.current_action = "等待 Fast GDD 审批决定".to_string();
runtime.waiting_on = "用户在审批卡选择批准、修改或退回".to_string();
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &runtime).expect("append approval wait task");
refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)
.expect("refresh approval wait task queue");
write_game_creator_agent_runtime_state(root, &runtime).expect("write approval wait state");
let task =
read_recoverable_runnable_game_creator_agent_runtime_task(root, &runtime.agent_id)
.expect("read approval wait task")
.expect("审批等待态必须仍可被恢复扫描拉起,否则审批决定后没有生产路径驱动父 run");
assert_eq!(task.status, "running");
assert_eq!(task.phase, "waiting-for-user-input");
assert!(has_recoverable_game_creator_agent_background_tasks_at(root)
.expect("public preflight must agree with the recoverable task read"));
// 对照组:真正的用户输入等待把 status 一并写成 waiting-for-user-input。
runtime.status = "waiting-for-user-input".to_string();
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &runtime)
.expect("append user input wait task");
refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)
.expect("refresh user input wait task queue");
write_game_creator_agent_runtime_state(root, &runtime)
.expect("write user input wait state");
assert!(
read_recoverable_runnable_game_creator_agent_runtime_task(root, &runtime.agent_id)
.expect("read user input wait task")
.is_none(),
"status 写成 waiting-for-user-input 才是恢复扫描让路的外部输入等待"
);
}
/// Fast GDD approval 投影恢复失败,不能再掐掉整轮 resume。
///
/// 它挂在 `resume_game_creator_agent_background_tasks_unredacted_at` 的第一行,
/// 原来用 `?` 强传播;而这条 reconcile 本身正是 receipt 投影失败后的重试入口,
/// 掐掉它等于连兜底一起废掉。现在 fail-closed 精确收敛到策划根 Supervisor 这个 run。
#[test]
fn failed_plan_gdd_approval_recovery_contains_itself_instead_of_aborting_the_scan() {
let temporary = crate::tests::canonical_test_tempdir("plan-gdd-approval-recovery-contain-");
let root = temporary.path();
init_local_game_project_at(root, "plan-gdd-approval-contain", "审批投影恢复失败收敛")
.expect("init project");
let runtime = start_game_creator_agent_runtime_task_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"推进立项策划",
"plan-gdd-approval-contain-run",
"agent-ready-task-scheduler",
"准备推进立项策划",
vec!["推进立项策划".to_string()],
)
.expect("start plan-root supervisor runtime");
let planning_directory = root.join(".agent/planning");
fs::create_dir_all(&planning_directory).expect("create planning directory");
fs::write(planning_directory.join("gdd.v1.json"), b"{")
.expect("corrupt the GDD lineage so approval recovery fails");
assert!(reconcile_plan_gdd_approval_projections_at(root).is_err());
let resumed = resume_game_creator_agent_background_tasks_at(root)
.expect("窄投影恢复失败不能把整轮 resume 掐掉");
assert!(resumed
.iter()
.all(|result| result.state.phase != "completed"));
let contained = read_game_creator_agent_runtime_at(root, &runtime.agent_id)
.expect("read contained supervisor runtime");
assert_eq!(contained.state.phase, "needs-reconciliation");
assert!(
contained
.state
.error
.as_deref()
.is_some_and(|error| error.contains("恢复 GDD approval 投影失败")),
"unexpected error: {:?}",
contained.state.error
);
let audit_count = read_agent_db_records_bounded(root, 1024 * 1024)
.expect("read approval recovery audit")
.0
.iter()
.filter(|record| {
record.get("recordType").and_then(|value| value.as_str())
== Some("agent.runtime.plan.gdd.approval_recovery.needs_reconciliation")
})
.count();
assert_eq!(audit_count, 1);
}
/// 瞬时错误不能被收敛成 needs-reconciliation。
///
/// `.agent/project.lock` 正被另一个写操作占用时什么都没坏,下一轮扫描重试即可;
/// 把它标成人工核对,等于用一次转瞬即逝的锁争用永久停掉策划根 run——那比这条
/// 收敛出现之前的强传播还糟。此时必须照旧上抛,由调用方转成 recovery_pending。
#[test]
fn transient_plan_gdd_approval_recovery_failure_is_retried_instead_of_reconciled() {
let temporary =
crate::tests::canonical_test_tempdir("plan-gdd-approval-recovery-transient-");
let root = temporary.path();
init_local_game_project_at(root, "plan-gdd-approval-transient", "审批投影恢复瞬时失败")
.expect("init project");
let runtime = start_game_creator_agent_runtime_task_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"推进立项策划",
"plan-gdd-approval-transient-run",
"agent-ready-task-scheduler",
"准备推进立项策划",
vec!["推进立项策划".to_string()],
)
.expect("start plan-root supervisor runtime");
let held = acquire_project_write_lock(root, "test.hold-project-write-lock")
.expect("hold the project write lock");
// 锁被占说明别处正在推进,这恰恰是 resume 最不该失败的时候:本轮跳过投影
// 恢复即可,整轮 resume 必须照常成功。上抛会让 resume 这个可反复调用的恢复
// 入口在一次普通锁争用下整体失败——真实调用方就是连着调它来断言幂等的。
resume_game_creator_agent_background_tasks_at(root)
.expect("瞬时锁争用只能跳过本轮投影恢复,不得让整轮 resume 失败");
drop(held);
let contained = read_game_creator_agent_runtime_at(root, &runtime.agent_id)
.expect("read supervisor runtime after transient failure");
assert_ne!(
contained.state.phase, "needs-reconciliation",
"瞬时锁争用不得把策划根 run 永久停掉"
);
assert!(contained.state.error.is_none());
assert!(!read_agent_db_records_bounded(root, 1024 * 1024)
.expect("read approval recovery audit")
.0
.iter()
.any(
|record| record.get("recordType").and_then(|value| value.as_str())
== Some("agent.runtime.plan.gdd.approval_recovery.needs_reconciliation")
));
}
}
@@ -1288,16 +1288,69 @@ pub(crate) fn decide_plan_gdd_at(
const PLAN_GDD_COMPLETION_BLOCKER_TOOL: &str = "runtime.plan_gdd";
/// 为什么 `runtime.plan_gdd` 的 blocker 需要一个类型化的子状态:
///
/// `status == "needs-reconciliation"` 已经把「要人工核对」和「继续推进」分开了,
/// 但 `"blocked"` 一侧有三种彼此完全不同的继续推进态,驱动侧必须区分才能选对
/// phase 与 next_step。原来的做法是在 `main_loop` 里对 detail 做
/// `contains("approvalPending=awaiting_decision")`:三个 blocked 里只有一个含这个
/// 子串,另外两个会掉进 else 被打成 needs-reconciliation——把最正常的早期推进态和
/// 收尾态当成故障停掉。子状态判别必须由构造方给出,不能让消费方去猜字符串。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PlanGddCompletionBlockerKind {
/// 根 Run 还没提交 Fast GDD,下一步是 `agent.delegate`。
SubmissionNotStarted,
/// Fast GDD 已提交,等待用户在审批卡上做决定。
AwaitingApprovalDecision,
/// receipt 已落盘,原 `plan.submit_gdd` 恢复锚点还没清理完。
ReceiptAnchorCleanupPending,
/// 其余一律要人工核对。
NeedsReconciliation,
}
pub(crate) struct PlanGddCompletionBlocker {
pub(crate) observation: AgentRuntimeToolObservation,
pub(crate) kind: PlanGddCompletionBlockerKind,
}
fn plan_gdd_completion_blocker(
status: &str,
summary: impl Into<String>,
detail: impl Into<String>,
) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(),
status: status.to_string(),
summary: summary.into(),
detail: Some(detail.into()),
) -> PlanGddCompletionBlocker {
debug_assert_ne!(
status, "blocked",
"blocked 子状态必须走 plan_gdd_blocked_completion_blocker 显式给出 kind"
);
PlanGddCompletionBlocker {
observation: AgentRuntimeToolObservation {
tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(),
status: status.to_string(),
summary: summary.into(),
detail: Some(detail.into()),
},
kind: PlanGddCompletionBlockerKind::NeedsReconciliation,
}
}
fn plan_gdd_blocked_completion_blocker(
kind: PlanGddCompletionBlockerKind,
summary: impl Into<String>,
detail: impl Into<String>,
) -> PlanGddCompletionBlocker {
debug_assert_ne!(
kind,
PlanGddCompletionBlockerKind::NeedsReconciliation,
"blocked blocker 不能声明成人工核对"
);
PlanGddCompletionBlocker {
observation: AgentRuntimeToolObservation {
tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(),
status: "blocked".to_string(),
summary: summary.into(),
detail: Some(detail.into()),
},
kind,
}
}
@@ -1585,11 +1638,22 @@ fn plan_root_completion_identity_at(
/// committed GDD therefore remains blocked until the pending/receipt,
/// generic child anchors, terminal observation, decision audit and planning
/// session all form one exact durable state.
/// 只要 blocker 本身,不关心 blocked 的子状态。驱动侧(`main_loop`)必须改用
/// `plan_gdd_typed_completion_blocker_at_locked`,否则又要去猜 detail 字符串。
pub(crate) fn plan_gdd_completion_blocker_at_locked(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Option<AgentRuntimeToolObservation> {
plan_gdd_typed_completion_blocker_at_locked(root, agent_id, run_id)
.map(|blocker| blocker.observation)
}
pub(crate) fn plan_gdd_typed_completion_blocker_at_locked(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Option<PlanGddCompletionBlocker> {
let is_plan_root = match plan_root_completion_identity_at(root, agent_id, run_id) {
Ok(value) => value,
Err(error) => {
@@ -1629,8 +1693,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
) {
Ok(Some(_)) => Some(plan_gdd_completion_blocker(
"blocked",
Ok(Some(_)) => Some(plan_gdd_blocked_completion_blocker(
PlanGddCompletionBlockerKind::SubmissionNotStarted,
"当前立项策划根 Run 尚未提交 Fast GDD,不能收束任务",
format!(
"rootRunId={run_id} · nextRequiredAction=agent.delegate;上一根 Run 遗留的 game/fast_gdd.md 或 Acceptance Graph 不能代替本根提交"
@@ -1818,8 +1882,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked(
),
));
}
return Some(plan_gdd_completion_blocker(
"blocked",
return Some(plan_gdd_blocked_completion_blocker(
PlanGddCompletionBlockerKind::AwaitingApprovalDecision,
"Fast GDD 已提交,等待用户审批决定,不能收束任务",
format!(
"gddVersion={} · approvalPending=awaiting_decision · childPending={} · childBatch={};pending 只能由验收取证通过后的 acceptance-gate caller 创建",
@@ -1852,8 +1916,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked(
}
}
if pending_anchor != PlanGddAnchorState::Absent || batch_anchor != PlanGddAnchorState::Absent {
return Some(plan_gdd_completion_blocker(
"blocked",
return Some(plan_gdd_blocked_completion_blocker(
PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending,
"Fast GDD receipt 已提交,但原 plan.submit_gdd 恢复锚点尚未清理",
format!(
"gddVersion={} · terminalObservation={} · childPending={} · childBatch={}",
@@ -2913,8 +2913,10 @@ pub(crate) fn validate_plan_gdd_index_against_gdds_and_approvals(
}
fn approval_directory_is_present(root: &Path) -> Result<bool, PlanningStorageError> {
let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_DIR)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_DIR)?;
// 下面这次 stat 仍然必要:`resolve_planning_path` 只保证解析那一刻整条链
// 可信,而判定「目录存在」要读的是使用时刻的那一项,顺带还要排掉链接以外
// 的另一种不可信形态——普通文件占位。
match fs::symlink_metadata(path) {
Ok(metadata) => {
if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
@@ -3166,8 +3168,7 @@ pub(crate) fn read_plan_gdd_approvals(
pub(crate) fn read_plan_gdd_approvals_locked(
root: &Path,
) -> Result<Vec<PlanGddApprovalV1>, PlanningStorageError> {
let approvals_root = resolve_local_project_path(root, PLAN_GDD_APPROVAL_DIR)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let approvals_root = resolve_planning_path(root, PLAN_GDD_APPROVAL_DIR)?;
let metadata = match fs::symlink_metadata(&approvals_root) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
@@ -3228,8 +3229,7 @@ pub(crate) fn read_plan_gdd_approval_for_version_locked(
return Err(invalid("approval receipt version 越界"));
}
let relative = format!("{PLAN_GDD_APPROVAL_DIR}/v{version}.json");
let path = resolve_local_project_path(root, &relative)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let path = resolve_planning_path(root, &relative)?;
match fs::symlink_metadata(&path) {
Ok(_) => {
let bytes =
@@ -4032,8 +4032,7 @@ pub(crate) fn read_plan_gdd_approval_pending(
pub(crate) fn read_plan_gdd_approval_pending_locked(
root: &Path,
) -> Result<Option<PlanGddApprovalPendingV1>, PlanningStorageError> {
let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?;
match fs::symlink_metadata(&path) {
Ok(_) => {
let bytes = read_regular_planning_file(&path, "GDD approval pending")?;
@@ -4061,8 +4060,7 @@ pub(crate) fn write_plan_gdd_approval_pending_atomic_locked(
value: &PlanGddApprovalPendingV1,
) -> Result<(), PlanningStorageError> {
let bytes = canonical_plan_gdd_approval_pending_bytes(value)?;
let target = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let target = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?;
let parent = ensure_planning_parent(&target)?;
if let Ok(_) = fs::symlink_metadata(&target) {
verify_regular_planning_file(&target, "现有 GDD approval pending")?;
@@ -4094,8 +4092,7 @@ pub(crate) fn write_plan_gdd_approval_pending_atomic_locked(
pub(crate) fn remove_plan_gdd_approval_pending_locked(
root: &Path,
) -> Result<(), PlanningStorageError> {
let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?;
match fs::symlink_metadata(&path) {
Ok(_) => {
verify_regular_planning_file(&path, "GDD approval pending")?;
@@ -5331,4 +5328,76 @@ mod tests {
"PLAN_UNTRUSTED_PATH"
);
}
/// M1C-1 新增的审批路径最初绕过 `resolve_planning_path`,直接用通用解析器。
/// 通用解析器只认 `is_symlink()`,把结果一律映射成 `PLAN_INVALID_PATH`;而
/// 规划解析器认的是 `FILE_ATTRIBUTE_REPARSE_POINT` 全量重解析标记,并把被
/// 篡改的路径如实报成 `PLAN_UNTRUSTED_PATH`。审批回执正是 GDD 完成门的判据,
/// 它的路径分类必须和 GDD/session 一致,否则调用方按错误码分流时会把「路径
/// 不可信」当成「路径写错了」。
#[cfg(any(unix, windows))]
#[test]
fn approval_paths_classify_a_linked_planning_root_as_untrusted_not_merely_invalid() {
let directory = tempfile::tempdir().expect("temp root");
let root = directory.path();
// 旁路目录留在项目内:真正要挡的是「planning 根被指向别处」,不是「逃出根」。
let decoy = root.join("decoy-planning");
fs::create_dir_all(decoy.join("approvals")).expect("decoy approvals");
fs::create_dir_all(root.join(".agent")).expect("agent dir");
let planning_link = {
let mut path = root.to_path_buf();
for part in PLAN_STORAGE_ROOT.split('/') {
path.push(part);
}
path
};
#[cfg(unix)]
std::os::unix::fs::symlink(&decoy, &planning_link).expect("planning symlink");
// 用 junction 而不是 `symlink_dir`:后者要开发者模式/管理员权限,普通开发
// 机上建不起来,用例会静默跳过成永远通过的空壳;junction 无需提权,而且它
// 正是本仓各处点名要挡的那种 Windows 重解析点。
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let status = std::process::Command::new("cmd")
.arg("/C")
.raw_arg(format!(
"mklink /J \"{}\" \"{}\"",
planning_link.display(),
decoy.display()
))
.status()
.expect("spawn mklink");
assert!(status.success(), "junction 建不起来则本用例失去判据");
}
// 写入路径不在列:它的父链早已由 `ensure_planning_parent` 逐段校验,
// 本来就会报 PLAN_UNTRUSTED_PATH,不构成这次改动的判据。
assert_eq!(
approval_directory_is_present(root).unwrap_err().code(),
"PLAN_UNTRUSTED_PATH"
);
assert_eq!(
read_plan_gdd_approvals_locked(root).unwrap_err().code(),
"PLAN_UNTRUSTED_PATH"
);
assert_eq!(
read_plan_gdd_approval_for_version_locked(root, 1)
.unwrap_err()
.code(),
"PLAN_UNTRUSTED_PATH"
);
assert_eq!(
read_plan_gdd_approval_pending_locked(root)
.unwrap_err()
.code(),
"PLAN_UNTRUSTED_PATH"
);
assert_eq!(
remove_plan_gdd_approval_pending_locked(root)
.unwrap_err()
.code(),
"PLAN_UNTRUSTED_PATH"
);
}
}
@@ -1252,6 +1252,14 @@ fn validate_current_session_cas(
"plan.submit_gdd 必须绑定当前活跃策划子 Run",
));
}
// 这条 phase 判据实际只可能看到 `collecting`:上面的 activeRunId 判据要求
// session 绑着当前策划子 run,而 schema 不变量禁止 `awaiting_user_input`、
// `awaiting_gdd_approval`、`revision_requested`、`approved`、`rejected`、
// `recovery_required` 保留 activeRunIdplanning_storage.rs 的
// 「session 进入审批/终态/recovery_required 后不得保留 activeRunId」)。
// 因此 revise/reject 之后能不能重做,不由这条门决定,而由 M1C-2b 的 continuation
// 起点 writer 决定——它必须以新 activeRunId 写 revision+1 successorphase 只能落回
// `collecting`。这里保留 `revision_requested` 作为既有冗余,不再新增更多不可达分支。
if !matches!(session.phase.as_str(), "collecting" | "revision_requested") {
return Err(submit_error(
"PLAN_PENDING_GDD_EXISTS",
@@ -3267,6 +3275,17 @@ mod tests {
.expect("awaiting approval must block completion");
assert_eq!(blocker.status, "blocked");
assert!(blocker.summary.contains("等待用户审批"));
// 驱动侧按类型化子状态选 phase:这一条必须是「等用户决定」,不能被当成人工核对。
assert_eq!(
plan_gdd_typed_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("awaiting approval blocker")
.kind,
PlanGddCompletionBlockerKind::AwaitingApprovalDecision
);
let decision_input = approval_input(
&gdd,
@@ -3377,6 +3396,18 @@ mod tests {
.expect("current root without submission must block");
assert_eq!(blocker.status, "blocked");
assert!(blocker.summary.contains("尚未提交 Fast GDD"));
// 这是策划最正常的早期推进态,下一步是 agent.delegate。它的 detail 里没有
// approvalPending 字段,旧的子串判别会把它打成 needs-reconciliation 停掉整条 run。
assert_eq!(
plan_gdd_typed_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("submission-not-started blocker")
.kind,
PlanGddCompletionBlockerKind::SubmissionNotStarted
);
cleanup_fixture(root);
}
@@ -3421,6 +3452,16 @@ mod tests {
"unexpected blocker: {}",
blocker.summary
);
assert_eq!(
plan_gdd_typed_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("mismatched pending blocker")
.kind,
PlanGddCompletionBlockerKind::NeedsReconciliation
);
cleanup_fixture(root);
}
@@ -3502,6 +3543,66 @@ mod tests {
cleanup_fixture(root);
}
/// reject 之后能不能在同一 lineage 重做,**不由提交门的 phase 判据决定**。
///
/// 提交门要求 session 的 activeRunId 等于当前策划子 run,而 schema 不变量禁止
/// `rejected`(以及 `revision_requested`、`approved`、`awaiting_*`)保留
/// activeRunId——两者互斥,所以终态 phase 永远到不了那条 phase 判据。真正决定重做
/// 能力的是 M1C-2b 的 continuation 起点 writer:技术方案 §8.6 要求它「以新
/// activeRunId 写 revision+1 successor」,而唯一能同时带 activeRunId 又过提交门的
/// phase 只有 `collecting`。
///
/// 这条测试把该约束锁住,免得日后有人以为「把 rejected 加进提交门允许集」就能让
/// reject 重做——那只会多一个不可达分支,真正的续跑仍然起不来。
#[test]
fn rejected_session_needs_a_collecting_continuation_not_a_wider_submit_gate() {
let (root, context, input) = submit_fixture();
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
let gdd = read_plan_gdd_chain(&root)
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
decide_plan_gdd_at(
&root,
&approval_input(
&gdd,
"reject",
"gdd-response-00000000-0000-4000-8000-000000000040",
Some("当前方向需要重新梳理".to_string()),
),
)
.expect("commit reject receipt");
let session = read_plan_session_with_recovery(&root)
.expect("read session after reject")
.expect("session exists after reject");
assert_eq!(session.phase, "rejected");
assert!(session.active_run_id.is_none());
// 终态 session 直接挂 activeRunId 连指纹都算不出来——schema 层就禁止。
let mut forged = session.clone();
forged.active_run_id = Some(context.created_by_run_id.clone());
let error =
plan_session_fingerprint(&forged).expect_err("终态 session 不得保留 activeRunId");
assert_eq!(error.code(), "PLAN_INVALID_SCHEMA");
// continuation 起点把 phase 落回 collecting 之后,同一 lineage 才能继续提交。
let mut continuation = session.clone();
continuation.session_revision += 1;
continuation.previous_fingerprint = Some(session.session_fingerprint.clone());
continuation.phase = "collecting".to_string();
continuation.active_run_id = Some(context.created_by_run_id.clone());
continuation.session_fingerprint =
plan_session_fingerprint(&continuation).expect("continuation session fp");
let mut next_context = context.clone();
next_context.source_session_revision = continuation.session_revision;
next_context.source_session_fingerprint = continuation.session_fingerprint.clone();
validate_current_session_cas(&continuation, &next_context, &input)
.expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本");
cleanup_fixture(root);
}
#[test]
fn approval_actions_rebuild_receipt_aware_index_and_are_idempotent() {
for (action, comment, expected_status) in [
@@ -315,6 +315,14 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
if barrier.has_waiting() {
return Ok(false);
}
// 用户修订是一条显式的 Supervisor 决策边界:在它派出续作之前,父 run 绝不能
// 被自动恢复。这条语义由 `has_waiting()` 承担(它把 userRevisionPending 计入
// 等待),所以上面那道门已经覆盖。断言把这份跨文件依赖钉在使用现场——若哪天
// `has_waiting()` 不再计入该计数,这里会立刻炸而不是静默跨过决策边界。
debug_assert_eq!(
barrier.user_revision_pending_count, 0,
"userRevisionPending 必须已被 has_waiting() 拦下,否则父 run 会越过用户修订边界自动恢复"
);
let state = read_game_creator_agent_runtime_for_session_at(
root,
&current_task.agent_id,
@@ -337,11 +345,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?;
return Ok(true);
}
if barrier.user_revision_pending_count > 0 {
// A user revision is an explicit Supervisor decision boundary. Do
// not auto-resume the parent before it dispatches the continuation.
return Ok(false);
}
let state = advance_game_creator_agent_runtime_turn_at(
root,
state,
@@ -2465,13 +2465,19 @@ fn agent_db_capacity_reserves_terminal_receipt_space_without_rotation() {
#[test]
fn ordinary_append_soft_limit_preserves_action_receipt_record_slots() {
// 守恒律必须覆盖全部预留车道。M1C-1 新增了 planning 决策车道
// `AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES`(16 KiB+1)×128)并从
// `AGENT_DB_MAX_ORDINARY_APPEND_BYTES` 里扣掉,这条断言却还停在两车道,
// 差额恰好是新车道的 2_097_280 字节。少一条车道就意味着这条断言不再能证明
// 「普通追加不会吃掉任何终态预留」——它才是这个测试存在的理由。
assert_eq!(
AGENT_DB_MAX_ORDINARY_APPEND_BYTES
+ AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES
+ AGENT_DB_TERMINAL_RESERVE_BYTES,
+ AGENT_DB_TERMINAL_RESERVE_BYTES
+ AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES,
AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES
);
assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_808);
assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_680);
assert_eq!(
AGENT_DB_MAX_SCAN_RECORDS
- usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS)
@@ -86,6 +86,50 @@ fn mark_and_claim_static_delegate_needs_repair(
.expect("claim needs-repair receipt");
}
/// 把一条已认领的 delivery 改写成「用户修订」形态,用于模拟审批卡上的 revise。
///
/// M1C-1 之后 `UserRevisionRequested` 与 `EvidenceReady` 共用同一套客观证据要求
/// terminal completed、无缺失产物、验证已满足),所以模拟修订不能只翻
/// `contract_status`:沿用 needs-repair 的证据落盘时会被判成「evidence-ready/
/// user-revision-requested 与客观证据冲突」。这里先让 expected_artifacts 真实落盘,
/// 再按真实证据重建 structuredResult——用户是在**已交付**的产物上要求修订。
fn rewrite_claimed_static_delegate_as_user_revision_requested(
root: &Path,
delegation_id: &str,
expected_artifacts: &[String],
) {
for artifact in expected_artifacts {
let path = root.join(artifact);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create delivered artifact parent");
}
fs::write(&path, b"# user revision evidence\n").expect("write delivered artifact");
}
let mut result = build_static_delegate_structured_result_at(
root,
"completed",
expected_artifacts,
false,
None,
None,
None,
None,
)
.expect("build delivered result");
assert_eq!(
result.contract_status,
StaticDelegateContractStatus::EvidenceReady,
"模拟用户修订之前,客观证据必须先真实达到 evidence-ready"
);
result.contract_status = StaticDelegateContractStatus::UserRevisionRequested;
let mut delivery = read_static_delegate_delivery_at(root, delegation_id)
.expect("read claimed delivery")
.expect("claimed delivery exists");
delivery.structured_result = Some(result);
write_static_delegate_delivery_at(root, &delivery)
.expect("persist simulated approval revision status");
}
/// 把一条已 dispatched 的 delivery 标记为 needs-user-input 终态并认领。
/// 注意:即便这一跳是纯粹的用户澄清、不产出文件,structuredResult 也必须完整覆盖
/// delivery 记录自身声明的 expected_artifacts(校验在 write_static_delegate_delivery_at
@@ -3893,16 +3937,7 @@ fn user_revision_continuation_passes_real_gate_at_depth_one_but_stays_single_chi
"user-revision-real-gate-d1-claim",
);
let mut d1_user_revision = read_static_delegate_delivery_at(&root, &d1_id)
.expect("read claimed D1")
.expect("claimed D1 exists");
d1_user_revision
.structured_result
.as_mut()
.expect("D1 structured result")
.contract_status = StaticDelegateContractStatus::UserRevisionRequested;
write_static_delegate_delivery_at(&root, &d1_user_revision)
.expect("persist simulated approval revision status");
rewrite_claimed_static_delegate_as_user_revision_requested(&root, &d1_id, &expected_artifacts);
// D1 的链上 depth 已经是 1;只有 UserRevisionRequested 分支能让这次真实委派继续。
let d2_action = "user-revision-real-gate-d2-revision-action";
@@ -4065,16 +4100,7 @@ fn concurrent_user_revision_dispatch_creates_exactly_one_delivery() {
&expected_artifacts,
"concurrent-user-revision-d1-claim",
);
let mut d1_user_revision = read_static_delegate_delivery_at(&root, &d1_id)
.expect("read claimed D1")
.expect("claimed D1 exists");
d1_user_revision
.structured_result
.as_mut()
.expect("D1 structured result")
.contract_status = StaticDelegateContractStatus::UserRevisionRequested;
write_static_delegate_delivery_at(&root, &d1_user_revision)
.expect("persist simulated approval revision status");
rewrite_claimed_static_delegate_as_user_revision_requested(&root, &d1_id, &expected_artifacts);
let start = Arc::new(Barrier::new(3));
let mut workers = Vec::new();
@@ -24,6 +24,8 @@ beforeEach(() => {
vi.mocked(api.createSession).mockResolvedValue({ authenticated: true });
vi.mocked(api.deleteSession).mockResolvedValue(undefined);
vi.mocked(api.listDeployments).mockResolvedValue([]);
vi.mocked(api.searchBranches).mockResolvedValue([]);
vi.mocked(api.searchCommits).mockResolvedValue([]);
vi.mocked(api.createDeployment).mockResolvedValue({
id: 'preview-1',
branch: 'master',
@@ -86,6 +88,7 @@ test('shows health and web url, then confirms uninstall', async () => {
resolvedCommit: '1234567890abcdef',
status: 'running',
health: 'healthy',
webPort: 8400,
webUrl: 'http://192.168.35.82:8400',
createdAt: 1_787_270_400,
updatedAt: 1_787_270_460,
@@ -94,6 +97,7 @@ test('shows health and web url, then confirms uninstall', async () => {
render(<PreviewDeployerApp />);
expect(await screen.findByText('健康')).toBeTruthy();
expect(screen.getByText('端口 8400')).toBeTruthy();
expect(
screen.getByRole('link', { name: / Web/u }).getAttribute('href'),
).toBe('http://192.168.35.82:8400');
@@ -106,3 +110,86 @@ test('shows health and web url, then confirms uninstall', async () => {
expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2');
});
});
test('shows debounced branch results and selects one with the keyboard', async () => {
const user = userEvent.setup();
vi.mocked(api.searchBranches).mockResolvedValue([
{ name: 'feature/search-one', commitHash: '111111111111' },
{ name: 'feature/search-two', commitHash: '222222222222' },
]);
render(<PreviewDeployerApp />);
const branchInput = await screen.findByRole('combobox', { name: '分支名' });
await user.clear(branchInput);
await user.type(branchInput, 'feature/search');
expect(
await screen.findByRole('option', { name: /feature\/search-one/u }),
).toBeTruthy();
expect(api.searchBranches).toHaveBeenLastCalledWith(
'feature/search',
expect.any(AbortSignal),
);
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect((branchInput as HTMLInputElement).value).toBe('feature/search-two');
});
test('searches commits in the current branch and selects a result', async () => {
const user = userEvent.setup();
vi.mocked(api.searchCommits).mockResolvedValue([
{
commitHash: 'aa5221abcdef0123456789',
shortHash: 'aa5221a',
subject: '补充预览搜索',
},
]);
render(<PreviewDeployerApp />);
const commitInput = await screen.findByRole('combobox', {
name: 'Commit Hash',
});
await user.type(commitInput, 'aa5221a');
const option = await screen.findByRole('option', {
name: /aa5221a.*/u,
});
expect(api.searchCommits).toHaveBeenCalledWith(
'master',
'aa5221a',
expect.any(AbortSignal),
);
await user.click(option);
expect((commitInput as HTMLInputElement).value).toBe(
'aa5221abcdef0123456789',
);
});
test('clears commit and ignores stale commit responses when branch changes', async () => {
const user = userEvent.setup();
let resolveOldSearch: ((items: api.CommitRef[]) => void) | undefined;
vi.mocked(api.searchCommits).mockImplementation(
() =>
new Promise<api.CommitRef[]>((resolve) => {
resolveOldSearch = resolve;
}),
);
render(<PreviewDeployerApp />);
const branchInput = await screen.findByRole('combobox', { name: '分支名' });
const commitInput = screen.getByRole('combobox', { name: 'Commit Hash' });
await user.type(commitInput, 'abcdef1');
await waitFor(() => expect(api.searchCommits).toHaveBeenCalled());
await user.clear(branchInput);
await user.type(branchInput, 'feature/new');
expect((commitInput as HTMLInputElement).value).toBe('');
resolveOldSearch?.([
{
commitHash: 'abcdef1234567890',
shortHash: 'abcdef1',
subject: '旧分支提交',
},
]);
await Promise.resolve();
expect(screen.queryByText('旧分支提交')).toBeNull();
});
@@ -13,15 +13,27 @@ import {
TriangleAlert,
XCircle,
} from 'lucide-react';
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import {
FormEvent,
KeyboardEvent as ReactKeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
type BranchRef,
type CommitRef,
createDeployment,
createSession,
deleteSession,
getSession,
listDeployments,
PreviewDeployerApiError,
searchBranches,
searchCommits,
uninstallDeployment,
} from './api';
import type {
@@ -32,6 +44,7 @@ import type {
import { validateBranch, validateCommitHash } from './validation';
const POLL_INTERVAL_MS = 5000;
const SEARCH_DEBOUNCE_MS = 300;
const STATUS_LABELS: Record<DeploymentStatus, string> = {
queued: '排队中',
@@ -66,6 +79,16 @@ export function PreviewDeployerApp() {
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [branchSearch, setBranchSearch] =
useState<SearchState<BranchRef>>(emptySearchState());
const [commitSearch, setCommitSearch] =
useState<SearchState<CommitRef>>(emptySearchState());
const [branchActiveIndex, setBranchActiveIndex] = useState(-1);
const [commitActiveIndex, setCommitActiveIndex] = useState(-1);
const [branchSearchEnabled, setBranchSearchEnabled] = useState(false);
const [commitSearchEnabled, setCommitSearchEnabled] = useState(false);
const branchRequestId = useRef(0);
const commitRequestId = useRef(0);
const [pendingUninstall, setPendingUninstall] =
useState<PreviewDeployment | null>(null);
const [uninstallingId, setUninstallingId] = useState('');
@@ -129,6 +152,94 @@ export function PreviewDeployerApp() {
return () => window.clearInterval(interval);
}, [refresh, sessionStatus]);
useEffect(() => {
const query = branch.trim();
const requestId = ++branchRequestId.current;
const controller = new AbortController();
if (sessionStatus !== 'authenticated' || !branchSearchEnabled || !query) {
setBranchSearch(emptySearchState());
setBranchActiveIndex(-1);
return () => controller.abort();
}
setBranchSearch({ items: [], loading: true, open: true, error: '' });
setBranchActiveIndex(-1);
const timer = window.setTimeout(() => {
void searchBranches(query, controller.signal)
.then((items) => {
if (requestId !== branchRequestId.current) return;
setBranchSearch({ items, loading: false, open: true, error: '' });
})
.catch((searchError) => {
if (
controller.signal.aborted ||
requestId !== branchRequestId.current
)
return;
if (isUnauthorized(searchError)) {
handleUnauthorized();
return;
}
setBranchSearch({
items: [],
loading: false,
open: true,
error: formatError(searchError),
});
});
}, SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [branch, branchSearchEnabled, sessionStatus]);
useEffect(() => {
const query = commitHash.trim();
const selectedBranch = branch.trim();
const requestId = ++commitRequestId.current;
const controller = new AbortController();
if (
sessionStatus !== 'authenticated' ||
!commitSearchEnabled ||
!query ||
validateBranch(selectedBranch)
) {
setCommitSearch(emptySearchState());
setCommitActiveIndex(-1);
return () => controller.abort();
}
setCommitSearch({ items: [], loading: true, open: true, error: '' });
setCommitActiveIndex(-1);
const timer = window.setTimeout(() => {
void searchCommits(selectedBranch, query, controller.signal)
.then((items) => {
if (requestId !== commitRequestId.current) return;
setCommitSearch({ items, loading: false, open: true, error: '' });
})
.catch((searchError) => {
if (
controller.signal.aborted ||
requestId !== commitRequestId.current
)
return;
if (isUnauthorized(searchError)) {
handleUnauthorized();
return;
}
setCommitSearch({
items: [],
loading: false,
open: true,
error: formatError(searchError),
});
});
}, SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [branch, commitHash, commitSearchEnabled, sessionStatus]);
const runningCount = useMemo(
() =>
deployments.filter((deployment) => deployment.status === 'running')
@@ -356,36 +467,166 @@ export function PreviewDeployerApp() {
className="deploy-form"
onSubmit={(event) => void handleSubmit(event)}
>
<label>
<span></span>
<div className="input-shell">
<div className="search-field">
<label htmlFor="branch-input"></label>
<div className="input-shell" role="presentation">
<GitBranch size={17} />
<input
aria-activedescendant={
branchSearch.open && branchActiveIndex >= 0
? `branch-option-${branchActiveIndex}`
: undefined
}
aria-autocomplete="list"
aria-controls="branch-search-list"
aria-expanded={branchSearch.open}
aria-haspopup="listbox"
aria-label="分支名"
role="combobox"
id="branch-input"
autoComplete="off"
disabled={submitting}
maxLength={200}
placeholder="master"
value={branch}
onChange={(event) => setBranch(event.target.value)}
onChange={(event) => {
setBranchSearchEnabled(true);
setBranch(event.target.value);
if (commitHash) {
setCommitHash('');
setCommitSearchEnabled(false);
}
}}
onFocus={() => {
setBranchSearchEnabled(true);
setBranchSearch((current) => ({ ...current, open: true }));
}}
onKeyDown={(event) => {
handleSearchKeyDown(
event,
branchSearch,
branchActiveIndex,
(index) => setBranchActiveIndex(index),
(item) => {
setBranch(item.name);
setCommitHash('');
setBranchSearchEnabled(false);
setCommitSearchEnabled(false);
setBranchSearch((current) => ({
...current,
open: false,
}));
},
() =>
setBranchSearch((current) => ({
...current,
open: false,
})),
);
}}
onBlur={() =>
window.setTimeout(
() =>
setBranchSearch((current) => ({
...current,
open: false,
})),
120,
)
}
/>
</div>
</label>
<label>
<span>
<SearchResults
id="branch-search-list"
type="branch"
state={branchSearch}
onSelect={(item) => {
setBranch(item.name);
setCommitHash('');
setBranchSearchEnabled(false);
setCommitSearchEnabled(false);
setBranchSearch((current) => ({ ...current, open: false }));
}}
activeIndex={branchActiveIndex}
/>
</div>
<div className="search-field">
<label htmlFor="commit-input">
Commit Hash <small></small>
</span>
<div className="input-shell">
</label>
<div className="input-shell" role="presentation">
<Hash size={17} />
<input
aria-activedescendant={
commitSearch.open && commitActiveIndex >= 0
? `commit-option-${commitActiveIndex}`
: undefined
}
aria-autocomplete="list"
aria-controls="commit-search-list"
aria-expanded={commitSearch.open}
aria-haspopup="listbox"
aria-label="Commit Hash"
role="combobox"
id="commit-input"
autoComplete="off"
disabled={submitting}
maxLength={40}
placeholder="留空则构建分支最新提交"
value={commitHash}
onChange={(event) => setCommitHash(event.target.value)}
onChange={(event) => {
setCommitSearchEnabled(true);
setCommitHash(event.target.value);
}}
onFocus={() => {
setCommitSearchEnabled(true);
setCommitSearch((current) => ({ ...current, open: true }));
}}
onKeyDown={(event) => {
handleSearchKeyDown(
event,
commitSearch,
commitActiveIndex,
(index) => setCommitActiveIndex(index),
(item) => {
setCommitHash(item.commitHash);
setCommitSearchEnabled(false);
setCommitSearch((current) => ({
...current,
open: false,
}));
},
() =>
setCommitSearch((current) => ({
...current,
open: false,
})),
);
}}
onBlur={() =>
window.setTimeout(
() =>
setCommitSearch((current) => ({
...current,
open: false,
})),
120,
)
}
/>
</div>
</label>
<SearchResults
id="commit-search-list"
type="commit"
state={commitSearch}
onSelect={(item) => {
setCommitHash(item.commitHash);
setCommitSearchEnabled(false);
setCommitSearch((current) => ({ ...current, open: false }));
}}
activeIndex={commitActiveIndex}
/>
</div>
<button
className="primary-button"
type="submit"
@@ -530,6 +771,9 @@ function DeploymentCard({
<span className="health-dot" />
{HEALTH_LABELS[deployment.health]}
</span>
{deployment.webPort ? (
<span className="badge port-badge"> {deployment.webPort}</span>
) : null}
</div>
</div>
@@ -620,6 +864,121 @@ function Summary({
);
}
type SearchState<T> = {
items: T[];
loading: boolean;
open: boolean;
error: string;
};
function emptySearchState<T>(): SearchState<T> {
return { items: [], loading: false, open: false, error: '' };
}
function handleSearchKeyDown<T>(
event: ReactKeyboardEvent<HTMLInputElement>,
state: SearchState<T>,
activeIndex: number,
setActiveIndex: (index: number) => void,
onSelect: (item: T) => void,
onClose: () => void,
) {
if (!state.open || (!state.items.length && event.key !== 'Escape')) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
setActiveIndex((activeIndex + 1) % state.items.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActiveIndex(activeIndex <= 0 ? state.items.length - 1 : activeIndex - 1);
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
const item = state.items[activeIndex];
if (item) onSelect(item);
} else if (event.key === 'Escape') {
event.preventDefault();
setActiveIndex(-1);
onClose();
}
}
function SearchResults({
id,
type,
state,
activeIndex,
onSelect,
}:
| {
id: string;
type: 'branch';
state: SearchState<BranchRef>;
activeIndex: number;
onSelect: (item: BranchRef) => void;
}
| {
id: string;
type: 'commit';
state: SearchState<CommitRef>;
activeIndex: number;
onSelect: (item: CommitRef) => void;
}) {
if (!state.open) return null;
return (
<div className="search-results" id={id} role="listbox">
{state.loading ? (
<div className="search-state">
<LoaderCircle className="spin" size={15} />
</div>
) : state.error ? (
<div className="search-state search-state-error">{state.error}</div>
) : state.items.length === 0 ? (
<div className="search-state"></div>
) : type === 'branch' ? (
state.items.map((item, index) => (
<button
aria-selected={index === activeIndex}
className={`search-option${index === activeIndex ? ' active' : ''}`}
id={`branch-option-${index}`}
key={item.name}
role="option"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelect(item)}
>
<GitBranch size={15} />
<span>{item.name}</span>
{item.commitHash ? (
<small>{shortCommit(item.commitHash)}</small>
) : null}
</button>
))
) : (
state.items.map((item, index) => (
<button
aria-selected={index === activeIndex}
className={`search-option${index === activeIndex ? ' active' : ''}`}
id={`commit-option-${index}`}
key={item.commitHash}
role="option"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelect(item)}
>
<Hash size={15} />
<span>
<strong>{item.shortHash || shortCommit(item.commitHash)}</strong>
{item.subject ? <small>{item.subject}</small> : null}
</span>
</button>
))
)}
</div>
);
}
function isActive(status: DeploymentStatus) {
return ['queued', 'building', 'deploying', 'uninstalling'].includes(status);
}
+44
View File
@@ -6,6 +6,8 @@ import {
deleteSession,
getSession,
listDeployments,
searchBranches,
searchCommits,
uninstallDeployment,
} from './api';
@@ -112,3 +114,45 @@ test('submits only branch and optional commit to fixed endpoints', async () => {
expect.objectContaining({ method: 'POST' }),
);
});
test('searches branches and branch-scoped commits with encoded queries', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [{ name: 'feature/search', commitHash: 'abcdef123456' }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [
{
commitHash: 'abcdef123456',
shortHash: 'abcdef1',
subject: '搜索提交',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
expect(await searchBranches('feature/search')).toHaveLength(1);
expect(await searchCommits('feature/search', 'abc def')).toHaveLength(1);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/preview-deployer/refs/branches?q=feature%2Fsearch',
expect.objectContaining({ credentials: 'same-origin' }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/preview-deployer/refs/commits?branch=feature%2Fsearch&q=abc%20def',
expect.objectContaining({ credentials: 'same-origin' }),
);
});
+31
View File
@@ -16,6 +16,17 @@ export interface PreviewDeployerSession {
authenticated: boolean;
}
export interface BranchRef {
name: string;
commitHash?: string | null;
}
export interface CommitRef {
commitHash: string;
shortHash: string;
subject?: string | null;
}
export async function getSession(signal?: AbortSignal) {
const session = await request<PreviewDeployerSession | null>('/session', {
signal,
@@ -41,6 +52,26 @@ export async function listDeployments(signal?: AbortSignal) {
return Array.isArray(payload) ? payload : payload.deployments;
}
export async function searchBranches(query: string, signal?: AbortSignal) {
const payload = await request<BranchRef[] | { items: BranchRef[] }>(
`/refs/branches?q=${encodeURIComponent(query)}`,
{ signal },
);
return Array.isArray(payload) ? payload : payload.items;
}
export async function searchCommits(
branch: string,
query: string,
signal?: AbortSignal,
) {
const payload = await request<CommitRef[] | { items: CommitRef[] }>(
`/refs/commits?branch=${encodeURIComponent(branch)}&q=${encodeURIComponent(query)}`,
{ signal },
);
return Array.isArray(payload) ? payload : payload.items;
}
export function createDeployment(input: CreateDeploymentInput) {
return request<PreviewDeployment>('/deployments', {
method: 'POST',
+70 -2
View File
@@ -190,14 +190,77 @@ a {
padding: 22px;
box-shadow: 0 12px 32px rgba(28, 37, 51, 0.055);
}
.deploy-form label {
.deploy-form label,
.search-field {
display: grid;
gap: 8px;
color: #39465a;
font-size: 13px;
font-weight: 700;
}
.deploy-form label small {
.search-field {
position: relative;
min-width: 0;
}
.search-results {
position: absolute;
z-index: 20;
top: calc(100% - 1px);
right: 0;
left: 0;
display: grid;
max-height: 230px;
overflow-y: auto;
border: 1px solid #cfd7e3;
border-radius: 0 0 9px 9px;
background: #fff;
box-shadow: 0 10px 25px rgba(28, 37, 51, 0.13);
}
.search-option {
display: flex;
min-height: 39px;
align-items: center;
gap: 8px;
border: 0;
color: #344054;
background: #fff;
padding: 7px 11px;
text-align: left;
}
.search-option:hover,
.search-option.active {
background: #eff6ff;
}
.search-option > span {
display: grid;
min-width: 0;
gap: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-option small {
overflow: hidden;
color: #8792a2;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-state {
display: flex;
min-height: 39px;
align-items: center;
gap: 7px;
color: #8792a2;
padding: 0 11px;
font-size: 12px;
}
.search-state-error {
color: #b42318;
}
.deploy-form label small,
.search-field label small {
color: #8994a5;
font-weight: 500;
}
@@ -381,6 +444,11 @@ a {
font-size: 11px;
font-weight: 700;
}
.port-badge {
color: #475569;
background: #f1f5f9;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.badge-running {
color: #14734a;
background: #e9f9f0;
+1
View File
@@ -17,6 +17,7 @@ export interface PreviewDeployment {
resolvedCommit?: string | null;
status: DeploymentStatus;
health: DeploymentHealth;
webPort?: number | null;
webUrl?: string | null;
jenkinsBuildUrl?: string | null;
createdAt: string | number;
+4
View File
@@ -3,6 +3,10 @@ GENARRATIVE_PREVIEW_DEPLOYER_BIND=127.0.0.1:8410
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL=http://127.0.0.1:8080/jenkins/
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token>
# 只读源码查询固定使用本机 Gitea SSH 入口,控制服务不接受客户端传入 remote。
GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git
# 使用 Jenkins 用户专用只读 deploy key 与独立 known_hosts;禁止关闭主机密钥校验。
GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND=ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts
GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口令>
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82
@@ -86,12 +86,18 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
- `GET /api/preview-deployer/session`:查询当前会话状态。
- `DELETE /api/preview-deployer/session`:退出。
- `GET /api/preview-deployer/deployments`:列出由控制面触发和恢复的部署。
- `GET /api/preview-deployer/refs/branches?q=`:按输入内容搜索固定源码仓库中的分支,最多返回 20 条 `{ name, commitHash }`
- `GET /api/preview-deployer/refs/commits?branch=&q=`:在已确认存在的目标分支历史中搜索 commit,最多返回 20 条 `{ commitHash, shortHash, subject }`
- `POST /api/preview-deployer/deployments`:提交 `{ branch, commitHash? }`
- `GET /api/preview-deployer/deployments/{id}`:刷新队列、构建与 artifact 状态。
- `POST /api/preview-deployer/deployments/{id}/uninstall`:触发固定 Job 的卸载动作。
页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`
发布记录卡片直接展示后端校验后的 `webPort`。卸载成功的 `stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。
分支名和 commit 输入框采用 300ms 防抖搜索,并在输入框下方显示服务端结果;分支变化时清空已输入的 commit,避免把旧分支 commit 带入新请求。搜索结果只负责辅助填写,不作为构建授权或存在性真相。`POST /deployments` 在写入排队状态和触发 Jenkins 前必须重新查询固定远端:分支不存在时拒绝;填写 commit 时必须确认它可解析为 commit object 且是目标分支 HEAD 的祖先。远端查询失败时失败关闭,不得触发 Jenkins。Jenkins checkout 继续执行相同的最终归属校验,以覆盖预检到排队之间的分支变化。
Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`
控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。
@@ -101,6 +107,7 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短
- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer``Job/Read``Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer``Job/Configure``Job/Delete`
- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。
- Jenkins POST 支持动态 CrumbAPI Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
@@ -675,7 +675,7 @@ npm run container:down
容器方案默认暴露 `http://127.0.0.1:18080``api-server` 在容器内监听 `0.0.0.0:8082`Nginx 通过 `api-server:8082` upstream 反代 `/api/``/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins TokenSPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。每个分支使用稳定 `deploymentId` 和独立 Compose projectWeb 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康。安装资产为 `deploy/systemd/genarrative-preview-deployer.service``deploy/env/preview-deployer.env.example``deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins TokenSPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 JenkinsJenkins checkout 仍保留最终复核。每个分支使用稳定 `deploymentId` 和独立 Compose projectWeb 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康。安装资产为 `deploy/systemd/genarrative-preview-deployer.service``deploy/env/preview-deployer.env.example``deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test``npm run bgfilter-worker:load-smoke``npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。
@@ -10,7 +10,7 @@ reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal", "process", "fs", "io-util"] }
tower-http = { workspace = true, features = ["fs", "trace"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
@@ -11,6 +11,8 @@ pub struct Config {
pub jenkins_base_url: Url,
pub jenkins_username: String,
pub jenkins_api_token: String,
pub git_remote_url: String,
pub git_ssh_command: Option<String>,
pub access_token: String,
pub allowed_hosts: Vec<String>,
pub allowed_origins: Vec<String>,
@@ -36,6 +38,11 @@ impl fmt::Debug for Config {
"jenkins_api_token_configured",
&!self.jenkins_api_token.is_empty(),
)
.field("git_remote_url", &self.git_remote_url)
.field(
"git_ssh_command_configured",
&self.git_ssh_command.is_some(),
)
.field("access_token_configured", &!self.access_token.is_empty())
.field("allowed_hosts", &self.allowed_hosts)
.field("allowed_origins", &self.allowed_origins)
@@ -72,7 +79,6 @@ impl Config {
let jenkins_base_url = jenkins_root_url
.join(JOB_PATH)
.map_err(|_| "无法构造固定 Jenkins Job URL".to_string())?;
let access_token = required("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN")?;
if access_token.len() < 24 {
return Err("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN 至少需要 24 个字符".to_string());
@@ -120,6 +126,13 @@ impl Config {
jenkins_base_url,
jenkins_username: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME")?,
jenkins_api_token: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN")?,
git_remote_url: validate_git_remote_url(&required(
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL",
)?)?,
git_ssh_command: env::var("GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND")
.ok()
.map(|value| validate_git_ssh_command(value.trim()))
.transpose()?,
access_token,
allowed_hosts,
allowed_origins,
@@ -132,6 +145,31 @@ impl Config {
}
}
fn validate_git_ssh_command(value: &str) -> Result<String, String> {
const TRUSTED_COMMAND: &str = "ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts";
if value != TRUSTED_COMMAND {
return Err(
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND 必须使用固定只读密钥和严格主机校验参数"
.to_string(),
);
}
Ok(value.to_string())
}
fn validate_git_remote_url(value: &str) -> Result<String, String> {
const TRUSTED_REMOTE: &str = "ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git";
#[cfg(test)]
if value.starts_with("test://") {
return Ok(value.to_string());
}
if value != TRUSTED_REMOTE {
return Err(format!(
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL 只允许固定内网仓库 {TRUSTED_REMOTE}"
));
}
Ok(value.to_string())
}
fn validate_state_file(path: &std::path::Path) -> Result<(), String> {
if !path.is_absolute() || path == std::path::Path::new("/") || path.file_name().is_none() {
return Err(
@@ -0,0 +1,282 @@
use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration};
use serde::Serialize;
use tokio::{process::Command, sync::Mutex};
const MAX_RESULTS: usize = 20;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct GitRepository {
remote_url: String,
ssh_command: Option<String>,
cache_dir: PathBuf,
lock: Arc<Mutex<()>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchMatch {
pub name: String,
pub commit_hash: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitMatch {
pub commit_hash: String,
pub short_hash: String,
pub subject: String,
}
impl GitRepository {
pub fn new(remote_url: String, ssh_command: Option<String>, cache_dir: PathBuf) -> Self {
Self {
remote_url,
ssh_command,
cache_dir,
lock: Arc::new(Mutex::new(())),
}
}
pub async fn search_branches(&self, query: &str) -> Result<Vec<BranchMatch>, String> {
#[cfg(test)]
if self.remote_url == "test://preview-repository" {
let candidates = [
(
"feature/preview-ui",
"0123456789abcdef0123456789abcdef01234567",
),
("feature/busy", "89abcdef0123456789abcdef0123456789abcdef"),
];
return Ok(candidates
.into_iter()
.filter(|(name, _)| name.contains(query))
.map(|(name, commit_hash)| BranchMatch {
name: name.to_string(),
commit_hash: commit_hash.to_string(),
})
.collect());
}
let output = self
.run_remote(&["ls-remote", "--heads", &self.remote_url])
.await?;
let query = query.to_ascii_lowercase();
let mut matches: Vec<_> = output
.lines()
.filter_map(|line| {
let (commit_hash, reference) = line.split_once('\t')?;
let name = reference.strip_prefix("refs/heads/")?;
(name.to_ascii_lowercase().contains(&query)
&& super::validate_branch(name).is_ok()
&& super::validate_commit(commit_hash).is_ok())
.then(|| BranchMatch {
name: name.to_string(),
commit_hash: commit_hash.to_ascii_lowercase(),
})
})
.collect();
matches.sort_by(|left, right| {
branch_rank(&left.name, query.as_str())
.cmp(&branch_rank(&right.name, query.as_str()))
.then_with(|| left.name.cmp(&right.name))
});
matches.truncate(MAX_RESULTS);
Ok(matches)
}
pub async fn branch_exists(&self, branch: &str) -> Result<bool, String> {
#[cfg(test)]
if self.remote_url == "test://preview-repository" {
return Ok(matches!(branch, "feature/preview-ui" | "feature/busy"));
}
let reference = format!("refs/heads/{branch}");
let output = self
.run_remote(&["ls-remote", "--heads", &self.remote_url, &reference])
.await?;
Ok(output.lines().any(|line| {
line.split_once('\t')
.is_some_and(|(_, returned)| returned == reference)
}))
}
pub async fn search_commits(
&self,
branch: &str,
query: &str,
) -> Result<Vec<CommitMatch>, String> {
#[cfg(test)]
if self.remote_url == "test://preview-repository" {
if !self.branch_exists(branch).await? {
return Err("test branch missing".to_string());
}
return Ok(vec![CommitMatch {
commit_hash: "0123456789abcdef0123456789abcdef01234567".to_string(),
short_hash: "0123456".to_string(),
subject: "test preview commit".to_string(),
}]);
}
let _guard = self.lock.lock().await;
self.fetch_branch(branch).await?;
let branch_ref = format!("refs/remotes/origin/{branch}");
let output = self
.run_cached(&[
"log",
"--format=%H%x09%h%x09%s",
"--max-count=500",
&branch_ref,
])
.await?;
let query = query.to_ascii_lowercase();
let mut matches: Vec<_> = output
.lines()
.filter_map(|line| {
let mut parts = line.splitn(3, '\t');
let hash = parts.next()?;
let short_hash = parts.next()?;
let subject = parts.next().unwrap_or_default();
(query.is_empty()
|| hash.to_ascii_lowercase().starts_with(&query)
|| subject.to_ascii_lowercase().contains(&query))
.then(|| CommitMatch {
commit_hash: hash.to_ascii_lowercase(),
short_hash: short_hash.to_ascii_lowercase(),
subject: subject.chars().take(200).collect(),
})
})
.take(MAX_RESULTS)
.collect();
matches.truncate(MAX_RESULTS);
Ok(matches)
}
pub async fn commit_belongs_to_branch(
&self,
branch: &str,
commit: &str,
) -> Result<bool, String> {
#[cfg(test)]
if self.remote_url == "test://preview-repository" {
return Ok(branch == "feature/preview-ui" && commit == "abcdef1");
}
let _guard = self.lock.lock().await;
self.fetch_branch(branch).await?;
let branch_ref = format!("refs/remotes/origin/{branch}");
let resolved = self
.run_cached_status(&["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
.await?;
if !resolved {
return Ok(false);
}
self.run_cached_status(&["merge-base", "--is-ancestor", commit, &branch_ref])
.await
}
async fn fetch_branch(&self, branch: &str) -> Result<(), String> {
self.ensure_cache().await?;
let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}");
let status = self
.command()
.arg("-C")
.arg(&self.cache_dir)
.args(["fetch", "--no-tags", "--prune", "origin", &refspec])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let status = tokio::time::timeout(COMMAND_TIMEOUT, status)
.await
.map_err(|_| "Git 分支同步超时".to_string())?
.map_err(|error| format!("无法执行 Git 分支同步: {error}"))?;
if status.success() {
Ok(())
} else {
Err("无法从固定仓库同步目标分支".to_string())
}
}
async fn ensure_cache(&self) -> Result<(), String> {
if !self.cache_dir.exists() {
tokio::fs::create_dir_all(&self.cache_dir)
.await
.map_err(|error| format!("无法创建 Git 查询缓存: {error}"))?;
self.run_cached(&["init", "--bare"]).await?;
}
let metadata = tokio::fs::symlink_metadata(&self.cache_dir)
.await
.map_err(|error| format!("无法读取 Git 查询缓存: {error}"))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err("Git 查询缓存必须是普通目录且不能是符号链接".to_string());
}
if !self
.run_cached_status(&["remote", "get-url", "origin"])
.await?
{
self.run_cached(&["remote", "add", "origin", &self.remote_url])
.await?;
} else {
let current = self.run_cached(&["remote", "get-url", "origin"]).await?;
if current.trim() != self.remote_url {
return Err("Git 查询缓存的固定远端不匹配".to_string());
}
}
Ok(())
}
async fn run_remote(&self, args: &[&str]) -> Result<String, String> {
self.run(self.command().args(args)).await
}
async fn run_cached(&self, args: &[&str]) -> Result<String, String> {
self.run(self.command().arg("-C").arg(&self.cache_dir).args(args))
.await
}
async fn run_cached_status(&self, args: &[&str]) -> Result<bool, String> {
let status = self
.command()
.arg("-C")
.arg(&self.cache_dir)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
Ok(tokio::time::timeout(COMMAND_TIMEOUT, status)
.await
.map_err(|_| "Git 查询超时".to_string())?
.map_err(|error| format!("无法执行 Git 查询: {error}"))?
.success())
}
fn command(&self) -> Command {
let mut command = Command::new("git");
command
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CONFIG_NOSYSTEM", "1");
if let Some(value) = &self.ssh_command {
command.env("GIT_SSH_COMMAND", value);
}
command
}
async fn run(&self, command: &mut Command) -> Result<String, String> {
let output = tokio::time::timeout(COMMAND_TIMEOUT, command.output())
.await
.map_err(|_| "Git 查询超时".to_string())?
.map_err(|error| format!("无法执行 Git 查询: {error}"))?;
if !output.status.success() {
return Err("固定 Git 仓库查询失败".to_string());
}
String::from_utf8(output.stdout).map_err(|_| "Git 查询返回了无效文本".to_string())
}
}
fn branch_rank(name: &str, query: &str) -> u8 {
let lower = name.to_ascii_lowercase();
if lower == query {
0
} else if lower.starts_with(query) {
1
} else {
2
}
}
@@ -58,6 +58,7 @@ pub struct PreviewResult {
pub health: Option<HealthStatus>,
pub phase: Option<String>,
pub health_status: Option<String>,
pub web_port: Option<u16>,
pub web_url: Option<String>,
pub message: Option<String>,
}
@@ -1,4 +1,5 @@
mod config;
mod git_refs;
mod jenkins;
use std::{
@@ -10,7 +11,7 @@ use std::{
use axum::extract::DefaultBodyLimit;
use axum::{
Json, Router,
extract::{Path, Request, State},
extract::{Path, Query, Request, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
@@ -24,9 +25,11 @@ use tower_http::{
trace::TraceLayer,
};
use tracing::{error, warn};
use url::Url;
use uuid::Uuid;
pub use config::Config;
use git_refs::{BranchMatch, CommitMatch, GitRepository};
use jenkins::{BuildAction, BuildReference, JenkinsClient, JenkinsOutcome};
const SESSION_COOKIE: &str = "genarrative_preview_session";
@@ -36,6 +39,7 @@ const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60);
pub struct AppState {
config: Arc<Config>,
jenkins: JenkinsClient,
git: GitRepository,
deployments: Arc<RwLock<HashMap<String, DeploymentRecord>>>,
sessions: Arc<RwLock<HashMap<String, u64>>>,
}
@@ -44,9 +48,27 @@ impl AppState {
pub fn new(config: Config) -> Result<Self, String> {
let jenkins = JenkinsClient::new(&config)?;
let deployments = load_deployments(&config)?;
let git_cache_dir = config
.state_file
.parent()
.expect("validated state path has a parent")
.join(format!(
".{}-git-ref-cache",
config
.state_file
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("preview-deployer")
));
let git = GitRepository::new(
config.git_remote_url.clone(),
config.git_ssh_command.clone(),
git_cache_dir,
);
Ok(Self {
config: Arc::new(config),
jenkins,
git,
deployments: Arc::new(RwLock::new(deployments)),
sessions: Arc::new(RwLock::new(HashMap::new())),
})
@@ -86,6 +108,8 @@ pub struct Deployment {
pub resolved_commit: Option<String>,
pub status: DeploymentStatus,
pub health: HealthStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -138,6 +162,33 @@ struct DeployRequest {
commit_hash: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct BranchSearchQuery {
#[serde(default)]
q: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CommitSearchQuery {
branch: String,
#[serde(default)]
q: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BranchSearchResponse {
items: Vec<BranchMatch>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CommitSearchResponse {
items: Vec<CommitMatch>,
}
#[derive(Debug, Serialize)]
struct DeploymentList {
deployments: Vec<Deployment>,
@@ -196,6 +247,22 @@ impl ApiError {
message: message.into(),
}
}
fn source_ref(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNPROCESSABLE_ENTITY,
code: "source_ref_invalid",
message: message.into(),
}
}
fn git_unavailable() -> Self {
Self {
status: StatusCode::BAD_GATEWAY,
code: "git_unavailable",
message: "暂时无法查询固定源码仓库".to_string(),
}
}
}
impl IntoResponse for ApiError {
@@ -223,6 +290,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/preview-deployer/deployments",
get(list_deployments).post(create_deployment),
)
.route("/api/preview-deployer/refs/branches", get(search_branches))
.route("/api/preview-deployer/refs/commits", get(search_commits))
.route(
"/api/preview-deployer/deployments/{id}",
get(get_deployment),
@@ -385,12 +454,52 @@ async fn list_deployments(
.read()
.await
.values()
.filter(|record| record.public.status != DeploymentStatus::Stopped)
.map(|record| record.public.clone())
.collect();
deployments.sort_by(|left, right| right.created_at.cmp(&left.created_at));
Ok(Json(DeploymentList { deployments }))
}
async fn search_branches(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<BranchSearchQuery>,
) -> Result<Json<BranchSearchResponse>, ApiError> {
require_session(&state, &headers).await?;
let query = validate_search_query(&query.q)?;
let branches = state.git.search_branches(&query).await.map_err(|cause| {
warn!(error = %cause, "failed to search fixed Git remote branches");
ApiError::git_unavailable()
})?;
Ok(Json(BranchSearchResponse { items: branches }))
}
async fn search_commits(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<CommitSearchQuery>,
) -> Result<Json<CommitSearchResponse>, ApiError> {
require_session(&state, &headers).await?;
let branch = validate_branch(&query.branch)?;
let query = validate_commit_search_query(&query.q)?;
if !state.git.branch_exists(&branch).await.map_err(|cause| {
warn!(error = %cause, "failed to validate branch before commit search");
ApiError::git_unavailable()
})? {
return Err(ApiError::source_ref("分支不存在"));
}
let commits = state
.git
.search_commits(&branch, &query)
.await
.map_err(|cause| {
warn!(error = %cause, "failed to search fixed Git remote commits");
ApiError::git_unavailable()
})?;
Ok(Json(CommitSearchResponse { items: commits }))
}
async fn get_deployment(
State(state): State<AppState>,
headers: HeaderMap,
@@ -485,6 +594,26 @@ async fn create_deployment(
.as_deref()
.map(validate_commit)
.transpose()?;
if !state.git.branch_exists(&branch).await.map_err(|cause| {
warn!(error = %cause, "failed to validate branch before deployment");
ApiError::git_unavailable()
})? {
return Err(ApiError::source_ref("分支不存在,未触发 Jenkins 构建"));
}
if let Some(commit) = commit_hash.as_deref()
&& !state
.git
.commit_belongs_to_branch(&branch, commit)
.await
.map_err(|cause| {
warn!(error = %cause, "failed to validate commit before deployment");
ApiError::git_unavailable()
})?
{
return Err(ApiError::source_ref(
"commit 不存在或不属于目标分支,未触发 Jenkins 构建",
));
}
let id = derive_deployment_id(&branch);
let now = unix_now();
let deployment = Deployment {
@@ -494,6 +623,7 @@ async fn create_deployment(
resolved_commit: None,
status: DeploymentStatus::Queued,
health: HealthStatus::Pending,
web_port: None,
web_url: None,
jenkins_build_url: None,
created_at: now,
@@ -770,8 +900,23 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
record.public.status = DeploymentStatus::Failed;
record.public.health = HealthStatus::Unknown;
record.public.message = Some("Jenkins 返回了无效 Web 地址".to_string());
} else if result
.web_port
.is_some_and(|value| !(8400..=8499).contains(&value))
|| result
.web_url
.as_deref()
.zip(result.web_port)
.is_some_and(|(url, port)| {
Url::parse(url).ok().and_then(|url| url.port()) != Some(port)
})
{
record.public.status = DeploymentStatus::Failed;
record.public.health = HealthStatus::Unknown;
record.public.message = Some("Jenkins 返回了无效 Web 端口".to_string());
} else if record.operation == Operation::Deploy
&& (result.resolved_commit.is_none()
|| result.web_port.is_none()
|| result.web_url.is_none()
|| result.phase.as_deref() != Some("RUNNING")
|| result.health_status.as_deref() != Some("HEALTHY"))
@@ -794,6 +939,9 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
if let Some(value) = result.web_url {
record.public.web_url = Some(value);
}
if let Some(value) = result.web_port {
record.public.web_port = Some(value);
}
if let Some(value) = result.health {
record.public.health = value;
}
@@ -838,6 +986,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
Operation::Uninstall => {
record.public.status = DeploymentStatus::Stopped;
record.public.health = HealthStatus::Unknown;
record.public.web_port = None;
record.public.web_url = None;
record.public.can_uninstall = false;
if record.public.message.is_none() {
@@ -877,7 +1026,7 @@ fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>
return Err("预览部署状态文件 schemaVersion 不受支持".to_string());
}
let mut deployments = HashMap::new();
for record in persisted.deployments {
for mut record in persisted.deployments {
validate_deployment_id(&record.public.id)
.map_err(|_| "状态文件包含无效部署 ID".to_string())?;
let branch = validate_branch(&record.public.branch)
@@ -896,6 +1045,27 @@ fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>
{
return Err("状态文件包含无效 Web 地址".to_string());
}
let url_port = record
.public
.web_url
.as_deref()
.and_then(|value| Url::parse(value).ok())
.and_then(|url| url.port());
if record
.public
.web_port
.is_some_and(|value| !(8400..=8499).contains(&value))
|| record
.public
.web_port
.zip(url_port)
.is_some_and(|(saved, parsed)| saved != parsed)
{
return Err("状态文件包含无效 Web 端口".to_string());
}
if record.public.web_port.is_none() {
record.public.web_port = url_port;
}
if deployments
.insert(record.public.id.clone(), record)
.is_some()
@@ -1009,6 +1179,27 @@ fn validate_commit(raw: &str) -> Result<String, ApiError> {
Ok(value.to_ascii_lowercase())
}
fn validate_search_query(raw: &str) -> Result<String, ApiError> {
let value = raw.trim();
if value.len() > 80 || !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) {
return Err(ApiError::bad_request("搜索关键词格式无效"));
}
Ok(value.to_ascii_lowercase())
}
fn validate_commit_search_query(raw: &str) -> Result<String, ApiError> {
let value = raw.trim();
if value.len() > 80 || !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) {
return Err(ApiError::bad_request("commit 搜索关键词格式无效"));
}
Ok(value.to_ascii_lowercase())
}
fn derive_deployment_id(branch: &str) -> String {
let digest = format!("{:x}", Sha256::digest(branch.as_bytes()));
format!("preview-{}", &digest[..16])
}
fn validate_deployment_id(value: &str) -> Result<(), ApiError> {
if value.len() != 24
|| !value.starts_with("preview-")
@@ -1021,11 +1212,6 @@ fn validate_deployment_id(value: &str) -> Result<(), ApiError> {
Ok(())
}
fn derive_deployment_id(branch: &str) -> String {
let digest = format!("{:x}", Sha256::digest(branch.as_bytes()));
format!("preview-{}", &digest[..16])
}
fn map_artifact_status(value: &str) -> Option<DeploymentStatus> {
match value.to_ascii_uppercase().as_str() {
"QUEUED" => Some(DeploymentStatus::Queued),
@@ -133,6 +133,7 @@ async fn mock_artifact(axum::extract::Path(build): axum::extract::Path<u64>) ->
"resolvedCommit": "0123456789abcdef0123456789abcdef01234567",
"phase": "RUNNING",
"healthStatus": "HEALTHY",
"webPort": 8400,
"webUrl": "http://192.168.35.82:8400",
"message": "预览实例已发布"
}))
@@ -156,10 +157,12 @@ fn test_config(jenkins_base_url: Url) -> Config {
.unwrap();
Config {
bind_address: "127.0.0.1:0".to_string(),
jenkins_root_url: jenkins_base_url,
jenkins_root_url: jenkins_base_url.clone(),
jenkins_base_url: job_url,
jenkins_username: "preview-service".to_string(),
jenkins_api_token: "server-only-jenkins-token".to_string(),
git_remote_url: "test://preview-repository".to_string(),
git_ssh_command: None,
access_token: "correct horse battery staple".to_string(),
allowed_hosts: vec![HOST.to_string()],
allowed_origins: vec![ORIGIN.to_string()],
@@ -365,6 +368,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
.clone();
assert_eq!(deployment.status, super::DeploymentStatus::Running);
assert_eq!(deployment.health, super::HealthStatus::Healthy);
assert_eq!(deployment.web_port, Some(8400));
assert_eq!(
deployment.web_url.as_deref(),
Some("http://192.168.35.82:8400")
@@ -421,8 +425,27 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
.clone();
assert_eq!(deployment.status, super::DeploymentStatus::Stopped);
assert_eq!(deployment.health, super::HealthStatus::Unknown);
assert_eq!(deployment.web_port, None);
assert_eq!(deployment.web_url, None);
assert!(!deployment.can_uninstall);
let list_request = axum::http::Request::builder()
.method("GET")
.uri("/api/preview-deployer/deployments")
.header(header::HOST, HOST)
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let list_response = app.clone().oneshot(list_request).await.unwrap();
assert_eq!(list_response.status(), StatusCode::OK);
let list_body = list_response
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
let list: Value = serde_json::from_slice(&list_body).unwrap();
assert_eq!(list["deployments"], json!([]));
let state_file = state.config.state_file.clone();
let mut recovered_config = test_config(state.config.jenkins_root_url.clone());
recovered_config.state_file = state_file.clone();
@@ -443,6 +466,78 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
std::fs::remove_file(state_file).unwrap();
}
#[tokio::test]
async fn ref_search_requires_session_and_returns_server_side_matches() {
let (jenkins_url, _) = start_mock_jenkins().await;
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
let anonymous = app
.clone()
.oneshot(api_request(
"GET",
"/api/preview-deployer/refs/branches?q=preview",
Body::empty(),
))
.await
.unwrap();
assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
let cookie = login_cookie(&app).await;
let request = axum::http::Request::builder()
.method("GET")
.uri("/api/preview-deployer/refs/branches?q=preview")
.header(header::HOST, HOST)
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let value: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["items"][0]["name"], "feature/preview-ui");
let request = axum::http::Request::builder()
.method("GET")
.uri("/api/preview-deployer/refs/commits?branch=feature%2Fpreview-ui&q=0123456")
.header(header::HOST, HOST)
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let value: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
value["items"][0]["commitHash"],
"0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(value["items"][0]["shortHash"], "0123456");
}
#[tokio::test]
async fn deployment_rejects_missing_or_unrelated_refs_before_jenkins() {
let (jenkins_url, mock) = start_mock_jenkins().await;
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
let cookie = login_cookie(&app).await;
for payload in [
r#"{"branch":"feature/missing"}"#,
r#"{"branch":"feature/preview-ui","commitHash":"deadbee"}"#,
] {
let request = axum::http::Request::builder()
.method("POST")
.uri("/api/preview-deployer/deployments")
.header(header::HOST, HOST)
.header(header::ORIGIN, ORIGIN)
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(payload))
.unwrap();
assert_eq!(
app.clone().oneshot(request).await.unwrap().status(),
StatusCode::UNPROCESSABLE_ENTITY
);
}
assert!(mock.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn transient_invalid_build_status_is_retried_before_reading_artifact() {
let (jenkins_url, mock) = start_mock_jenkins().await;
@@ -499,6 +594,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
resolved_commit: None,
status: super::DeploymentStatus::Building,
health: super::HealthStatus::Pending,
web_port: None,
web_url: None,
jenkins_build_url: None,
created_at: now,
@@ -576,3 +672,43 @@ fn branch_commit_and_web_url_validation_are_strict() {
"192.168.35.82"
));
}
#[test]
fn legacy_running_state_recovers_web_port_from_validated_url() {
let config = test_config(Url::parse("http://127.0.0.1:18080/jenkins/").unwrap());
let id = super::derive_deployment_id("feature/legacy-running");
std::fs::write(
&config.state_file,
serde_json::to_vec(&json!({
"schemaVersion": 1,
"deployments": [{
"public": {
"id": id,
"branch": "feature/legacy-running",
"status": "running",
"health": "healthy",
"webUrl": "http://192.168.35.82:8407",
"createdAt": 1,
"updatedAt": 2,
"canUninstall": true
},
"operation": "deploy"
}]
}))
.unwrap(),
)
.unwrap();
let state = AppState::new(config).unwrap();
assert_eq!(
state
.deployments
.blocking_read()
.get(&id)
.unwrap()
.public
.web_port,
Some(8407)
);
std::fs::remove_file(&state.config.state_file).unwrap();
}