Files
Agent-System-Backend/internal/server/operation_query.go
weichen 4030449f4b Report honest receipts when handlers err after side effects
Why: F10 - any handler error became a no-side-effect rejected receipt
and an audit-store failure dropped the generated operation ID, losing
the reconciliation handle and inviting unsafe retries.
Evidence: cmdbus tests pass (verify-u7b.json); post-execution-unknown,
audit-failure-keeps-ID and confirmation-idempotency cases added.
Impact: unknown receipts now carry the trusted operation ID via the
U7A constructor; rejected stays ID-free; no auto-replay anywhere.
Refs: feature/agent-stack-audit-remediation-b1 U7B
2026-10-21 16:55:01 +00:00

88 lines
3.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"errors"
"net/http"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
)
// commandOperationQuery 把"已记录 operation 的状态查询"落在既有的 owner+project
// 作用域审计查询上(store.ListProjectCommandAudits),不新建通用幂等平台:不加表、
// 不加索引、不引入新的重试语义。命令回执(审计信封)本就是宿主侧的操作记录,
// handler 自己事务/发件箱里的领域操作表(如文档投递 document_render_deliveries)
// 仍由各自领域查询负责。
//
// 诚实边界:
// - 只有成功落下审计信封的 operation 查得到。审计不可用时总线回 unknown +
// operationID,但那一行没有落库:这里返回"无记录",绝不推断"未执行"。
// - 扫描有界(最近若干页):超出窗口的旧操作返回无记录,而不是全表扫描。
// - project_id 是追加时按可信会话快照下来的归属;改绑/删除会话不改写历史归属,
// 与既有项目审计查询同一口径。
type commandOperationQuery struct{ st *store.Store }
const (
operationLookupPageSize = 100
operationLookupMaxPages = 20 // 有界扫描:最近 2000 行,超窗即"无记录"
)
func (q commandOperationQuery) LookupOperation(owner, projectID, operationID string) (cmdbus.OperationRecord, bool, error) {
if q.st == nil || owner == "" || projectID == "" || operationID == "" {
return cmdbus.OperationRecord{}, false, nil
}
var before, cutoff int64
for page := 0; page < operationLookupMaxPages; page++ {
rows, resolvedCutoff, next, err := q.st.ListProjectCommandAudits(owner, projectID, before, cutoff, operationLookupPageSize)
if err != nil {
return cmdbus.OperationRecord{}, false, err
}
cutoff = resolvedCutoff // 首帧固定游标,后续页在同一提交前缀内翻页
for _, row := range rows {
if row.Envelope.OperationID != operationID {
continue
}
return cmdbus.OperationRecord{
OperationID: row.Envelope.OperationID,
CommandID: row.Envelope.CommandID,
State: row.Envelope.State,
Time: row.Envelope.Time,
}, true, nil
}
if next == 0 {
return cmdbus.OperationRecord{}, false, nil
}
before = next
}
return cmdbus.OperationRecord{}, false, nil
}
// getCommandOperation 是 unknown 回执的对账入口(R7/KTD6):
// GET /api/commands/operations/{operationId}?projectId=…
//
// 授权复用既有口径:owner 只取自认证上下文(不读请求参数),projectId 仅用于
// 收窄到该 owner 名下的项目——与 /api/commands/audit 同源,跨租户天然不可见。
// 查不到一律 404:HTTP 层的"没有记录"同样不得被读成"未执行"。
func (s *Server) getCommandOperation(w http.ResponseWriter, r *http.Request) {
if s.Bus == nil {
http.Error(w, "commands disabled", http.StatusServiceUnavailable)
return
}
record, err := s.Bus.LookupOperation(s.owner(r), r.URL.Query().Get("projectId"), r.PathValue("operationId"))
switch {
case errors.Is(err, cmdbus.ErrOperationScope):
http.Error(w, "projectId and operationId are required", http.StatusBadRequest)
return
case errors.Is(err, cmdbus.ErrOperationQueryUnavailable):
http.Error(w, "operation query unavailable", http.StatusServiceUnavailable)
return
case errors.Is(err, cmdbus.ErrOperationNotRecorded):
http.Error(w, "no operation record in this scope", http.StatusNotFound)
return
case err != nil:
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"operation": record})
}