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
88 lines
3.5 KiB
Go
88 lines
3.5 KiB
Go
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})
|
||
}
|