From 4030449f4b542c1a080800ac733ea8e2fc08517f Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 16:55:01 +0000 Subject: [PATCH] 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 --- internal/cmdbus/account_write_fence_test.go | 46 ++++ internal/cmdbus/cmdbus.go | 187 ++++++++++++- internal/cmdbus/cmdbus_test.go | 259 +++++++++++++++++- internal/cmdbus/pending_store_test.go | 34 +++ internal/server/operation_query.go | 87 ++++++ internal/server/operation_query_test.go | 96 +++++++ .../server/project_iam_integration_test.go | 7 +- internal/server/server.go | 6 + 8 files changed, 703 insertions(+), 19 deletions(-) create mode 100644 internal/server/operation_query.go create mode 100644 internal/server/operation_query_test.go diff --git a/internal/cmdbus/account_write_fence_test.go b/internal/cmdbus/account_write_fence_test.go index 6f38692..dec5d8f 100644 --- a/internal/cmdbus/account_write_fence_test.go +++ b/internal/cmdbus/account_write_fence_test.go @@ -2,7 +2,10 @@ package cmdbus import ( "context" + "errors" + "strings" "testing" + "time" agentcommand "agent-command" ) @@ -16,3 +19,46 @@ func TestStrictAccountWritesRejectLegacyBusPorts(t *testing.T) { t.Fatalf("legacy port wrote under strict mode: %+v, cards=%d audit=%d pending=%d", resp.Receipt, len(cards.created), len(audit.logs), len(b.pending)) } } + +// fencedAuditStore 是"账号围栏内审计落盘失败"的替身:围栏写路径已经跑完, +// 只有审计行写不进去(围栏内审计失败=结果不确定,不是"什么都没发生")。 +type fencedAuditStore struct{ appends int } + +func (f *fencedAuditStore) AppendAudit(string, agentcommand.AuditEnvelope) error { + f.appends++ + return errors.New("audit fenced") +} + +func (f *fencedAuditStore) AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error { + f.appends++ + return errors.New("audit fenced") +} + +// TestStrictAccountWriteAuditLossKeepsOperationID:围栏档下 handler 已提交、 +// 审计写失败时,回执仍是 unknown 且带上 handler 拿到的 operationID, +// 调用方才能在围栏外按记录对账(F10 Bug 2 在围栏路径上的同一语义)。 +func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) { + audit := &fencedAuditStore{} + b := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + b.RequireAccountCheckedWrites(true) + var seen string + if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.fenced", Title: "围栏写入"}, "", func(_ context.Context, req Request) (any, error) { + seen = req.OperationID + return "written", nil + }); err != nil { + t.Fatal(err) + } + resp := b.Dispatch(context.Background(), Request{ID: "demo.fenced", Owner: "alice", Actor: "alice"}) + if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") { + t.Fatalf("fenced audit loss must stay unknown: %+v", resp.Receipt) + } + if seen == "" || resp.Receipt.OperationID != seen { + t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil { + t.Fatalf("fenced receipt must declare possible side effects and validate: %+v", resp.Receipt) + } + if audit.appends != 1 { + t.Fatalf("account-checked audit appends = %d, want 1", audit.appends) + } +} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index f3d388c..76fe912 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -26,6 +26,43 @@ type AuditStore interface { AppendAudit(owner string, env agentcommand.AuditEnvelope) error } +// OperationRecord 是"已记录操作"的最小对账投影(R7/KTD6)。它回答的是 +// “这个 operationID 在本 owner/project 名下到底是什么结果”,而不是重放入口。 +// 刻意保持最小:审计信封只存状态与标识,不存理由文本,所以这里没有 Reason—— +// 不为不存在的字段留位置。 +type OperationRecord struct { + OperationID string + CommandID string + State agentcommand.ResultState + Time time.Time +} + +// OperationQuery 是 operationID 的对账查询端口(组合根装配;nil = 不提供)。 +// +// 授权口径:owner 与 projectID 都是可信过滤条件,实现必须把它们放进查询谓词 +// (SQL 的 owner/project 等值条件),不得由调用方参数扩大可见范围;查不到就 +// 返回 not found,不得回退为“未执行”。 +// +// 诚实边界:本端口只解析“已经落成记录”的 operation。宿主侧的命令回执记录就是 +// 命令审计信封(command_audit 按 owner+project 归属);handler 自己的领域操作表 +// (如文档投递 document_render_deliveries)由各自领域查询负责,不经本端口。某些 +// 命令根本不持久化 req.OperationID(内置的 builtin.help、runtime.mock、 +// models.set、conversation.profile、conversation.create 都是无操作表的即时用例), +// 对它们的 operationID 查询恒为 not found——这类命令只能按领域资源(会话、画像、 +// 设置项)对账,不得声称可按 operationID 查询。 +type OperationQuery interface { + LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error) +} + +var ( + // ErrOperationScope 缺少可信 owner/project/operation 作用域:fail-closed,无作用域即无可见性。 + ErrOperationScope = errors.New("cmdbus: operation lookup requires owner, project and operation scope") + // ErrOperationNotRecorded 该作用域内没有此 operation 的记录(未记录 ≠ 未执行)。 + ErrOperationNotRecorded = errors.New("cmdbus: no operation record in this owner and project scope") + // ErrOperationQueryUnavailable 组合根未装配对账查询端口。 + ErrOperationQueryUnavailable = errors.New("cmdbus: operation query unavailable") +) + // CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。 type CardPort interface { Create(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error) @@ -35,6 +72,39 @@ type CardPort interface { // Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。 type Handler func(ctx context.Context, req Request) (any, error) +// ErrNotExecuted 标记"失败确定发生在任何副作用之前"的 handler 错误(F10/U7b)。 +// 语义口径:只有带此标记的错误才可归 rejected(确定未执行、无副作用、回执不得 +// 携带 operationID);未标记的 handler 错误一律按"已执行随后不确定"归 unknown, +// 并带上本次分发已签发的 trusted operation ID 交由对账收敛。宿主不得据 +// "处理方报了错"臆断未执行——那会把已写库的操作诱使成不安全重试。 +// +// 进程内插件(pkg/extension.Handler)当前还没有等价的声明位:插件命令的 +// handler 错误一律按 unknown 对账,直到扩展面补上对应标记为止。 +var ErrNotExecuted = errors.New("cmdbus: not executed") + +// NotExecutedError 是带"确定未执行"声明的错误包装。Error() 保留原始文案 +// (回执 reason 不因标记而变形),Unwrap/Is 让 errors.Is 同时匹配 +// ErrNotExecuted 与被包装的原始错误。 +type NotExecutedError struct{ Err error } + +func (e *NotExecutedError) Error() string { return e.Err.Error() } +func (e *NotExecutedError) Unwrap() error { return e.Err } +func (e *NotExecutedError) Is(target error) bool { + return target == ErrNotExecuted +} + +// NotExecuted 声明该失败确定未执行(校验/权限/确认缺失/领域事务回滚等任何 +// 副作用之前的拒绝)。nil 原样返回;重复标记幂等。 +func NotExecuted(err error) error { + if err == nil || IsNotExecuted(err) { + return err + } + return &NotExecutedError{Err: err} +} + +// IsNotExecuted 报告该错误是否声明了"确定未执行"。 +func IsNotExecuted(err error) bool { return errors.Is(err, ErrNotExecuted) } + // CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。 // Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。 type CommandTool struct { @@ -171,6 +241,8 @@ type Bus struct { strictAccountWrites bool allowImplicitLocal bool projectWorkPreflight func(context.Context, Request) error + // operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。 + operationQuery OperationQuery } func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) { @@ -200,6 +272,33 @@ func (b *Bus) Registry() *agentcommand.Registry { return b.reg } // SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。 func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st } +// SetOperationQuery 装配 operationID 对账查询端口(组合根;启动期一次)。 +func (b *Bus) SetOperationQuery(q OperationQuery) { b.operationQuery = q } + +// LookupOperation 按 owner/project 作用域查一个已记录 operation 的当前状态 +// (R7/KTD6)。这是 unknown 回执的对账入口:拿到 operationID 的调用方先查 +// 记录,再决定下一步,而不是重发命令。 +// +// 三重 fail-closed:作用域任一为空即拒(不猜、不按 owner=“”放行)、未装配端口 +// 即拒、查不到即 ErrOperationNotRecorded(不推断“未执行”)。跨 owner 查询由 +// 端口的 owner 等值谓词拒绝——本层只把 owner 当可信条件往下传。 +func (b *Bus) LookupOperation(owner, projectID, operationID string) (OperationRecord, error) { + if owner == "" || projectID == "" || operationID == "" { + return OperationRecord{}, ErrOperationScope + } + if b.operationQuery == nil { + return OperationRecord{}, ErrOperationQueryUnavailable + } + record, found, err := b.operationQuery.LookupOperation(owner, projectID, operationID) + if err != nil { + return OperationRecord{}, err + } + if !found || record.OperationID == "" { + return OperationRecord{}, ErrOperationNotRecorded + } + return record, nil +} + // SetEffectiveAccountGuard installs the composition-root check at startup. // Handler Store writes still need their own final transaction fence. func (b *Bus) SetEffectiveAccountGuard(g EffectiveAccountGuard) { b.accountGuard = g } @@ -405,10 +504,12 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { if b.pendingStore != nil { raw, _ := json.Marshal(req) if err := b.savePending(card.ID, accountActor(req), string(raw)); err != nil { - return receipt(agentcommand.StateUnknown, "", "confirm pending: "+err.Error()) + // 确认卡已创建(副作用已发生),挂起态却没落库:结果不确定,且 + // 卡 ID 就是本次受理的操作标识——随 unknown 一并给出,供对账。 + return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: "+err.Error()) } } else if b.strictAccountWrites { - return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required") + return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: account-checked store required") } b.pendMu.Lock() b.pending[card.ID] = pending{req: req, cardID: card.ID} @@ -425,7 +526,9 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { } // The domain handler can persist this trusted ID with its transaction/outbox. // Entropy, not the clock or caller Params, distinguishes concurrent operations. - // A handler failure still writes only a rejected audit receipt without an ID. + // A handler error keeps the ID only on the "executed then uncertain" path + // (unknown): rejected must never carry one, and unknown without a queryable + // handle would be a dead end for reconciliation. var opBytes [24]byte if _, err := rand.Read(opBytes[:]); err != nil { return receipt(agentcommand.StateUnknown, "", "operation ID unavailable") @@ -433,11 +536,26 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { req.OperationID = "op_" + hex.EncodeToString(opBytes[:]) payload, err := h(ctx, req) if err != nil { - return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) + if IsNotExecuted(err) { + // handler 声明失败发生在任何副作用之前:确定未执行,可安全归 + // rejected(回执契约禁止 rejected 携带 operationID)。 + return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) + } + // handler 已开始执行才报错:宿主无法断言"无副作用"(F10)。按"已执行 + // 随后不确定"归 unknown,并带上本次分发已签发的 trusted operation ID, + // 调用方据此按记录对账,而不是盲目重试。本路径不做任何自动重放。 + return b.unknownAfterExecution(req, "handler failed after execution began: "+err.Error(), start) } return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload} } +// unknownAfterExecution 记录并返回"已执行随后不确定"的回执(F10/U7b):审计 +// 信封带 state=unknown 与已签发的 operationID(可查凭证),回执走库内受信 +// 构造器 NewUnknownAfterExecution,保证 Validate() 通过且对账句柄不丢。 +func (b *Bus) unknownAfterExecution(req Request, reason string, start time.Time) Response { + return Response{Receipt: b.auditReceipt(req, agentcommand.StateUnknown, req.OperationID, reason, start)} +} + // Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。 func (b *Bus) Confirm(owner, cardID, actionID string) Response { return b.ConfirmContext(context.Background(), owner, cardID, actionID) @@ -456,7 +574,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string p, ok := b.pending[cardID] b.pendMu.Unlock() if ok && p.req.Owner != "" && p.req.Owner != owner { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} } if b.pendingStore != nil { storedOwner, payload, found, err := b.takePending(cardID, owner) @@ -466,7 +584,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string if found { var req Request if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} } p = pending{req: req, cardID: cardID} ok = true @@ -477,11 +595,11 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string ok = false } if !ok { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} } req := p.req if req.Owner != "" && req.Owner != owner { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} } b.pendMu.Lock() delete(b.pending, cardID) @@ -497,15 +615,29 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string } resp := b.Dispatch(ctx, req) if b.cards != nil { - if resp.Receipt.State == agentcommand.StateCommitted { + switch resp.Receipt.State { + case agentcommand.StateCommitted: _, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed") - } else { + case agentcommand.StateUnknown: + // 已执行随后不确定:卡片必须落到终态(悬在 action_required 会诱导 + // 二次确认,而挂起项已被消费,重放只会得到"已解决"),但不得断言 + // 失败——tone 用 warn,note 写明不确定原因与对账用 operationID。 + _, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "warn", uncertainNote(resp.Receipt)) + default: _, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason) } } return resp } +// uncertainNote 组成"结果未知"卡片的说明:不确定原因 + 对账句柄。 +func uncertainNote(r agentcommand.ExecReceipt) string { + if r.OperationID == "" { + return r.Reason + } + return r.Reason + " (operation " + r.OperationID + ")" +} + func accountActor(req Request) string { // Plugin and delegated commands use Actor for an audit label such as a // plugin ID; Owner is the effective account whose state controls effects. @@ -528,7 +660,7 @@ func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error { func (b *Bus) accountRejected(commandID string, start time.Time) Response { return Response{Receipt: agentcommand.ExecReceipt{ CommandID: commandID, State: agentcommand.StateRejected, - Reason: "effective account unavailable", Time: start.UTC(), + Reason: "effective account unavailable", SideEffects: agentcommand.SideEffectsNone, Time: start.UTC(), }} } @@ -555,10 +687,37 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas err = b.audit.AppendAudit(req.Owner, env) } if err != nil { - // 审计不可用:结果标记未知,不臆断成功(四态语义)。 - return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()} + // 审计不可用:结果标记未知,不臆断成功(四态语义)。已经签发过操作标识的 + // 路径(committed/accepted/已执行后不确定)连同该标识一并返回——信封丢失 + // 不等于操作没发生,调用方仍需按 operationID 对账;确定未执行的路径不带 + // 标识(rejected 契约禁止带 ID,unknown 无 ID 时也无需对账)。 + return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC()) } - return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, Time: b.now().UTC()} + if state == agentcommand.StateUnknown && op != "" { + // 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。 + return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC()) + } + return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, + SideEffects: sideEffectsFor(state), Time: b.now().UTC()} +} + +// unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识 +// (供对账),否则返回不带标识的 unknown。state=rejected 恒不带标识。 +func unknownReceipt(commandID string, state agentcommand.ResultState, op, reason string, at time.Time) agentcommand.ExecReceipt { + if op != "" && state != agentcommand.StateRejected { + return agentcommand.NewUnknownAfterExecution(commandID, op, reason, at) + } + return agentcommand.ExecReceipt{CommandID: commandID, State: agentcommand.StateUnknown, Reason: reason, Time: at} +} + +// sideEffectsFor 回执的副作用声明:总线自身发起的 rejected 均为任何副作用之前 +// 的拒绝(未知命令/参数校验/项目前置/账号预检/无处理器/确认取消/handler 声明的 +// not-executed),故断言 none;committed/accepted 维持今日语义(未声明)。 +func sideEffectsFor(state agentcommand.ResultState) agentcommand.SideEffects { + if state == agentcommand.StateRejected { + return agentcommand.SideEffectsNone + } + return agentcommand.SideEffectsUnset } func digest(params map[string]any) string { diff --git a/internal/cmdbus/cmdbus_test.go b/internal/cmdbus/cmdbus_test.go index e3c37bb..3991cea 100644 --- a/internal/cmdbus/cmdbus_test.go +++ b/internal/cmdbus/cmdbus_test.go @@ -52,7 +52,8 @@ func newTestBus(a AuditStore, c CardPort) *Bus { }, "/risky", func(ctx context.Context, req Request) (any, error) { t, _ := req.Params["target"].(string) if t == "boom" { - return nil, errors.New("handler rejected") + // 参数级拒绝发生在任何副作用之前:显式声明 not-executed(F10)。 + return nil, NotExecuted(errors.New("handler rejected")) } return map[string]any{"did": t}, nil }) @@ -106,13 +107,20 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) { if req.OperationID == "" { t.Fatal("handler must receive a tentative host operation ID") } - return nil, errors.New("domain transaction rejected") + // 领域事务回滚 = 确定未执行:声明 not-executed 才是 rejected 的依据。 + return nil, NotExecuted(errors.New("domain transaction rejected")) }); err != nil { t.Fatal(err) } failed := bus.Dispatch(context.Background(), Request{ID: "rdm.fail", Actor: "alice", Owner: "alice"}) if failed.Receipt.State != agentcommand.StateRejected || failed.Receipt.OperationID != "" || len(audit.logs) != 2 || audit.logs[1].OperationID != "" { - t.Fatalf("failed handler was marked committed: %+v audits=%+v", failed.Receipt, audit.logs) + t.Fatalf("not-executed handler was not rejected: %+v audits=%+v", failed.Receipt, audit.logs) + } + if err := failed.Receipt.Validate(); err != nil { + t.Fatalf("rejected receipt must satisfy the library contract: %v", err) + } + if failed.Receipt.SideEffects != agentcommand.SideEffectsNone { + t.Fatalf("rejected receipt must declare no side effects, got %q", failed.Receipt.SideEffects) } } @@ -342,3 +350,248 @@ func TestHighDangerPluginCommandConfirmsFirst(t *testing.T) { t.Fatalf("confirm: %+v executed=%d", resp2.Receipt, executed) } } + +// ---- F10/U7b:确定未执行 vs 已执行后不确定 + operationID 对账 ---- + +// memOperationQuery 是按 owner/project 等值谓词过滤的操作记录替身(模拟 SQL +// 作用域):跨 owner 或跨 project 的 operationID 一律查不到。 +type memOperationQuery struct { + records map[string]OperationRecord // key: owner|projectID|operationID +} + +func operationKey(owner, projectID, operationID string) string { + return owner + "|" + projectID + "|" + operationID +} + +func (q *memOperationQuery) LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error) { + rec, ok := q.records[operationKey(owner, projectID, operationID)] + return rec, ok, nil +} + +// recordAudit 把总线真实落下的审计信封登记为可查操作记录(对齐"审计信封就是 +// 宿主侧操作记录"的口径),用于验证 unknown 回执给出的对账句柄真的查得到。 +func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcommand.AuditEnvelope) { + if env.OperationID == "" { + return + } + q.records[operationKey(owner, projectID, env.OperationID)] = OperationRecord{ + OperationID: env.OperationID, CommandID: env.CommandID, State: env.State, Time: env.Time, + } +} + +// TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归: +// handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的 +// operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。 +func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + var writes []string + var seen string + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(_ context.Context, req Request) (any, error) { + seen = req.OperationID + writes = append(writes, req.OperationID) // 副作用已发生 + return nil, errors.New("commit acknowledgement lost") + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"}) + if resp.Receipt.State != agentcommand.StateUnknown { + t.Fatalf("post-execution failure must not be reported as rejected: %+v", resp.Receipt) + } + if seen == "" || resp.Receipt.OperationID != seen { + t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + } + if !strings.Contains(resp.Receipt.Reason, "after execution began") { + t.Fatalf("reason must name post-execution uncertainty: %q", resp.Receipt.Reason) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible { + t.Fatalf("side effects = %q, want %q", resp.Receipt.SideEffects, agentcommand.SideEffectsPossible) + } + if err := resp.Receipt.Validate(); err != nil { + t.Fatalf("trusted unknown receipt must satisfy the library contract: %v", err) + } + if len(writes) != 1 { + t.Fatalf("handler ran %d times, want 1", len(writes)) + } + // 对账凭证:审计信封落 unknown + 同一个 operationID。 + if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != seen { + t.Fatalf("audit envelope is not reconcilable: %+v", audit.logs) + } + // 宿主不得自动重放:再次分发是一次新操作(新的 operationID),旧的不被复用。 + again := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"}) + if again.Receipt.State != agentcommand.StateUnknown || again.Receipt.OperationID == resp.Receipt.OperationID || len(writes) != 2 { + t.Fatalf("re-dispatch must be a new operation, not a replay: %+v writes=%d", again.Receipt, len(writes)) + } +} + +// TestHandlerNotExecutedErrorStaysRejected:handler 显式声明未执行才是 +// rejected 的依据;回执不得携带 operationID(库契约)。 +func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + calls := 0 + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.guard", Title: "守卫"}, "", func(context.Context, Request) (any, error) { + calls++ + boom := errors.New("permission denied") + return nil, NotExecuted(boom) + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.guard", Owner: "alice", Actor: "alice"}) + if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" { + t.Fatalf("not-executed failure must stay rejected without an ID: %+v", resp.Receipt) + } + if resp.Receipt.Reason != "rejected: permission denied" { + t.Fatalf("not-executed marking must not distort the reason: %q", resp.Receipt.Reason) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || resp.Receipt.Validate() != nil { + t.Fatalf("rejected receipt must declare no side effects and validate: %+v", resp.Receipt) + } + if calls != 1 || len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" { + t.Fatalf("unexpected effects: calls=%d audits=%+v", calls, audit.logs) + } + // 标记语义:errors.Is 同时命中 ErrNotExecuted 与原始错误;nil 幂等。 + wrapped := NotExecuted(errors.New("boom")) + if !IsNotExecuted(wrapped) || !errors.Is(wrapped, ErrNotExecuted) || wrapped.Error() != "boom" { + t.Fatalf("not-executed wrapper semantics: %v / %v", wrapped, IsNotExecuted(wrapped)) + } + if NotExecuted(nil) != nil || !IsNotExecuted(NotExecuted(wrapped)) { + t.Fatal("not-executed marking must be nil-safe and idempotent") + } +} + +// TestCommittedWithAuditFailureKeepsOperationID 是 F10 Bug 2 的回归:审计 +// 落盘失败时回执仍是 unknown,但必须带着已交给 handler 的 operationID, +// 理由点名审计不可用,调用方才能按记录对账而不是重发。 +func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) { + audit := &memAudit{fail: true} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + var seen string + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "", func(_ context.Context, req Request) (any, error) { + seen = req.OperationID + return map[string]any{"pong": true}, nil + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}) + if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") { + t.Fatalf("audit loss must stay unknown and name the audit: %+v", resp.Receipt) + } + if seen == "" || resp.Receipt.OperationID != seen { + t.Fatalf("audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil { + t.Fatalf("audit-loss receipt must declare possible side effects and validate: %+v", resp.Receipt) + } + if len(audit.logs) != 0 { + t.Fatalf("audit rows written while failing: %+v", audit.logs) + } + // 校验:未执行路径(rejected)在审计不可用时不得凭空出现 operationID。 + rejected := bus.Dispatch(context.Background(), Request{ID: "demo.absent"}) + if rejected.Receipt.OperationID != "" || rejected.Receipt.State != agentcommand.StateUnknown { + t.Fatalf("audit loss must not invent an operation ID: %+v", rejected.Receipt) + } +} + +// TestValidationRejectionNeverRunsHandler:显式校验拒绝仍是 rejected、无 ID、 +// 无副作用,且 handler 一次都不许跑。 +func TestValidationRejectionNeverRunsHandler(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + calls := 0 + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ + ID: "demo.needs", Title: "需要参数", + ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`), + }, "", func(context.Context, Request) (any, error) { + calls++ + return "ran", nil + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.needs", Owner: "alice", Params: map[string]any{}}) + if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" || resp.Receipt.Validate() != nil { + t.Fatalf("validation rejection: %+v", resp.Receipt) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || calls != 0 { + t.Fatalf("validation rejection ran the handler or claimed effects: calls=%d %+v", calls, resp.Receipt) + } + if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" { + t.Fatalf("validation audit row: %+v", audit.logs) + } +} + +// TestRepeatedConfirmExecutesOnce:确认卡重复确认不得二次执行(F10 分类改动 +// 不得放宽既有收口语义);unknown 卡面也不得悬在待确认。 +func TestRepeatedConfirmExecutesOnce(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + bus := New(audit, cards, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + executed := 0 + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ + ID: "demo.once", Title: "只执行一次", Danger: agentcommand.DangerHigh, + }, "/once", func(context.Context, Request) (any, error) { + executed++ + return "done", nil + }); err != nil { + t.Fatal(err) + } + if r := bus.Dispatch(context.Background(), Request{ID: "demo.once", Owner: "alice", ConversationID: "cnv_1"}); r.Receipt.State != agentcommand.StateAccepted { + t.Fatalf("pre-confirm: %+v", r.Receipt) + } + first := bus.Confirm("alice", "card_x1", "confirm") + second := bus.Confirm("alice", "card_x1", "confirm") + if first.Receipt.State != agentcommand.StateCommitted || executed != 1 { + t.Fatalf("confirm: %+v executed=%d", first.Receipt, executed) + } + if second.Receipt.State != agentcommand.StateRejected || second.Receipt.OperationID != "" || executed != 1 { + t.Fatalf("re-confirm executed twice: %+v executed=%d", second.Receipt, executed) + } +} + +// TestLookupOperationIsOwnerProjectScoped(R7/KTD6):unknown 回执的对账查询 +// 复用 owner/project 等值作用域;跨 owner、跨 project、缺作用域、未装配端口 +// 一律 fail-closed,查不到绝不推断"未执行"。 +func TestLookupOperationIsOwnerProjectScoped(t *testing.T) { + audit := &memAudit{} + query := &memOperationQuery{records: map[string]OperationRecord{}} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + if _, err := bus.LookupOperation("alice", "proj_1", "op_1"); !errors.Is(err, ErrOperationQueryUnavailable) { + t.Fatalf("unwired query must fail closed: %v", err) + } + bus.SetOperationQuery(query) + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(context.Context, Request) (any, error) { + // 副作用之后失败 → unknown + operationID(对账句柄)。 + return nil, errors.New("ack lost") + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Owner: "alice", Actor: "alice"}) + if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" { + t.Fatalf("setup dispatch: %+v", resp.Receipt) + } + // 对账记录=总线真实落下的审计信封(同一 owner/project 快照)。 + for _, env := range audit.logs { + query.recordAudit("alice", "proj_1", env) + } + op := resp.Receipt.OperationID + record, err := bus.LookupOperation("alice", "proj_1", op) + if err != nil { + t.Fatalf("owner-scoped lookup: %v", err) + } + if record.OperationID != op || record.CommandID != "demo.write" || record.State != agentcommand.StateUnknown { + t.Fatalf("lookup returned the wrong record: %+v", record) + } + if _, err := bus.LookupOperation("bob", "proj_1", op); !errors.Is(err, ErrOperationNotRecorded) { + t.Fatalf("cross-owner lookup must not resolve: %v", err) + } + if _, err := bus.LookupOperation("alice", "proj_other", op); !errors.Is(err, ErrOperationNotRecorded) { + t.Fatalf("cross-project lookup must not resolve: %v", err) + } + for _, scope := range [][3]string{{"", "proj_1", op}, {"alice", "", op}, {"alice", "proj_1", ""}} { + if _, err := bus.LookupOperation(scope[0], scope[1], scope[2]); !errors.Is(err, ErrOperationScope) { + t.Fatalf("scope %v must fail closed: %v", scope, err) + } + } + if _, err := bus.LookupOperation("bob", "proj_1", "op_absent"); !errors.Is(err, ErrOperationNotRecorded) { + t.Fatalf("unknown operation must not be reported as executed: %v", err) + } +} diff --git a/internal/cmdbus/pending_store_test.go b/internal/cmdbus/pending_store_test.go index e515208..ff6d9cf 100644 --- a/internal/cmdbus/pending_store_test.go +++ b/internal/cmdbus/pending_store_test.go @@ -2,7 +2,9 @@ package cmdbus import ( "context" + "errors" "path/filepath" + "strings" "testing" agentcommand "agent-command" @@ -31,3 +33,35 @@ func TestPendingConfirmRestoredAfterRestart(t *testing.T) { t.Fatalf("restored confirm: %+v", again.Receipt) } } + +// failingPendingStore 是"确认卡已创建、挂起态写不进去"的替身:受理已经发生 +// (卡在收件箱里),只是结果不可知。 +type failingPendingStore struct{ err error } + +func (p *failingPendingStore) SavePendingConfirm(string, string, string) error { return p.err } +func (p *failingPendingStore) TakePendingConfirm(string) (string, string, bool, error) { + return "", "", false, p.err +} + +// TestConfirmPendingWriteFailureKeepsCardOperationID:挂起态落盘失败不得吞掉操作 +// 标识——卡 ID 就是本次受理的句柄,unknown 必须带出去(否则受理事实无凭证)。 +func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + b := newTestBus(audit, cards) + b.SetPendingStore(&failingPendingStore{err: errors.New("pending store down")}) + resp := b.Dispatch(context.Background(), Request{ + ID: "demo.risky", Params: map[string]any{"target": "prod"}, ConversationID: "cnv_1", Owner: "alice", + }) + if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "confirm pending") { + t.Fatalf("pending write failure: %+v", resp.Receipt) + } + if resp.Receipt.OperationID != "card_x1" || resp.Receipt.Validate() != nil { + t.Fatalf("pending write failure dropped the card operation handle: %+v", resp.Receipt) + } + if len(cards.created) != 1 || len(b.pending) != 0 { + t.Fatalf("card created=%d pending=%d", len(cards.created), len(b.pending)) + } + if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != "card_x1" { + t.Fatalf("audit envelope: %+v", audit.logs) + } +} diff --git a/internal/server/operation_query.go b/internal/server/operation_query.go new file mode 100644 index 0000000..8816424 --- /dev/null +++ b/internal/server/operation_query.go @@ -0,0 +1,87 @@ +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}) +} diff --git a/internal/server/operation_query_test.go b/internal/server/operation_query_test.go new file mode 100644 index 0000000..e51be2c --- /dev/null +++ b/internal/server/operation_query_test.go @@ -0,0 +1,96 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" +) + +// TestCommandOperationLookupIsOwnerProjectScoped 覆盖 U7b/R7 的对账查询: +// unknown 回执给出的 operationID 能按认证 owner + project 查到落库记录; +// 跨 owner、错 project、缺 projectId 一律查不到或拒(fail-closed),任何 +// "查不到"都不得被读成"未执行"。 +func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) { + srv, st := newAuthTestServer(t) + srv.Bus = cmdbus.New(st, nil, time.Now) + srv.WireBuiltinCommands() // 生产装配点:总线拿到宿主侧对账查询端口 + h := srv.Handler() + + aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") + if err != nil { + t.Fatal(err) + } + bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") + if err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "对账项目") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", project.ID, "对账会话", "") + if err != nil { + t.Fatal(err) + } + + // 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。 + if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, + func(context.Context, cmdbus.Request) (any, error) { return nil, errors.New("ack lost") }); err != nil { + t.Fatal(err) + } + resp := srv.Bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "demo.write", Owner: "alice", Actor: "alice", ConversationID: conversation.ID, + }) + if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" { + t.Fatalf("post-execution failure must be unknown with an operation ID: %+v", resp.Receipt) + } + operationID := resp.Receipt.OperationID + + get := func(key, url string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + rec := get(aliceKey, "/api/commands/operations/"+operationID+"?projectId="+project.ID) + if rec.Code != http.StatusOK { + t.Fatalf("owner lookup: %d %s", rec.Code, rec.Body.String()) + } + var out struct { + Operation struct { + OperationID string `json:"OperationID"` + CommandID string `json:"CommandID"` + State string `json:"State"` + } `json:"operation"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.Operation.OperationID != operationID || out.Operation.CommandID != "demo.write" || out.Operation.State != string(agentcommand.StateUnknown) { + t.Fatalf("lookup projection wrong: %+v", out.Operation) + } + + for _, tc := range []struct { + name, key, url string + want int + }{ + {"cross owner", bobKey, "/api/commands/operations/" + operationID + "?projectId=" + project.ID, http.StatusNotFound}, + {"wrong project", aliceKey, "/api/commands/operations/" + operationID + "?projectId=prj_other", http.StatusNotFound}, + {"missing project", aliceKey, "/api/commands/operations/" + operationID, http.StatusBadRequest}, + {"missing operation", aliceKey, "/api/commands/operations/op_absent?projectId=" + project.ID, http.StatusNotFound}, + } { + if rec := get(tc.key, tc.url); rec.Code != tc.want { + t.Fatalf("%s: %d %s (want %d)", tc.name, rec.Code, rec.Body.String(), tc.want) + } + } +} diff --git a/internal/server/project_iam_integration_test.go b/internal/server/project_iam_integration_test.go index d810e78..58b9c8c 100644 --- a/internal/server/project_iam_integration_test.go +++ b/internal/server/project_iam_integration_test.go @@ -266,10 +266,13 @@ func newIAMFixture(t *testing.T) *iamFixture { member, _ := req.Params["member"].(string) err := f.provider.addMember(ctx, srv.lifecycleHost(req.Owner), projectID, member) if failure, ok := categorizeWorkFailure(extension.WorkOperationMemberAdd, err); ok { - return nil, failure + // 假 provider 的每条失败路径都发生在改成员之前(唯一写点在最后一行 + // 且随后返回 nil),因此显式声明 not-executed:拒绝语义与 4xx 映射 + // 由此成立,而不是靠“报了错就算没执行”(F10)。 + return nil, cmdbus.NotExecuted(failure) } if err != nil { - return nil, err + return nil, cmdbus.NotExecuted(err) } return map[string]any{"ok": true}, nil }); err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index c7de95b..a4075f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -197,11 +197,14 @@ func (s *Server) recognize(ctx context.Context, text string) intentgate.MatchRes // WireBuiltinCommands 注册内置命令(`/` 菜单全清单的数据源;处理器为宿主用例适配)。 // 响应保持既有 {command, ...} 契约(frontend store.sendMessage 按此消费)。 +// 组合根在注入 Bus 之后调用一次(启动期);此处也是 operationID 对账查询端口的 +// 装配点:总线只在启动时刻拿到宿主端口,不在请求路径上改写总线状态。 func (s *Server) WireBuiltinCommands() { b := s.Bus if b == nil { return } + b.SetOperationQuery(commandOperationQuery{st: s.st}) must := func(err error) { if err != nil { panic("server: builtin command: " + err.Error()) // 装配期错误属编程错误 @@ -421,6 +424,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/workspaces", s.listWorkspaces) mux.HandleFunc("POST /api/commands/{id}", s.execCommand) mux.HandleFunc("GET /api/commands/audit", s.listAudits) + // U7b/R7:operationID 对账查询(unknown 回执的收敛入口;owner 取自认证 + // 上下文,projectId 只收窄作用域)。 + mux.HandleFunc("GET /api/commands/operations/{operationId}", s.getCommandOperation) mux.HandleFunc("GET /api/parsers", s.listParsers) mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge) mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge)