合并原分支的 M1C-1 审查修复
This commit is contained in:
@@ -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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+76
-12
@@ -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={}",
|
||||
|
||||
+81
-12
@@ -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` 保留 activeRunId(planning_storage.rs 的
|
||||
// 「session 进入审批/终态/recovery_required 后不得保留 activeRunId」)。
|
||||
// 因此 revise/reject 之后能不能重做,不由这条门决定,而由 M1C-2b 的 continuation
|
||||
// 起点 writer 决定——它必须以新 activeRunId 写 revision+1 successor,phase 只能落回
|
||||
// `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,
|
||||
¤t_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);
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 支持动态 Crumb;API 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 Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。每个分支使用稳定 `deploymentId` 和独立 Compose project,Web 端口从 `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 Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定 `deploymentId` 和独立 Compose project,Web 端口从 `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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user