Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd051ec895 |
@@ -24,7 +24,7 @@ jobs:
|
||||
echo "SUBMODULE_TOKEN is not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@gitea.naitangbaobao.cn/".insteadOf "ssh://git@gitea.naitangbaobao.cn:52222/"
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
|
||||
git -c fetch.recurseSubmodules=false submodule update --init --recursive
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
parsed_server = urlsplit(SERVER)
|
||||
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
|
||||
sys.exit("Checkout server must be the platform-injected http(s) URL")
|
||||
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
|
||||
REPOSITORIES = {
|
||||
"deps/development-guidelines": "Development-Guidelines",
|
||||
|
||||
+10
-10
@@ -1,30 +1,30 @@
|
||||
[submodule "deps/agent-system-core"]
|
||||
path = deps/agent-system-core
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-System-Core.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git
|
||||
[submodule "deps/agent-native"]
|
||||
path = deps/agent-native
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
|
||||
[submodule "deps/agent-intent"]
|
||||
path = deps/agent-intent
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
|
||||
[submodule "deps/logging-system"]
|
||||
path = deps/logging-system
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Logging-System.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
|
||||
[submodule "deps/error-code-system"]
|
||||
path = deps/error-code-system
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Error-Code-System.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
|
||||
[submodule "deps/agent-squad"]
|
||||
path = deps/agent-squad
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Squad.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git
|
||||
[submodule "deps/agent-command"]
|
||||
path = deps/agent-command
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Command.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git
|
||||
[submodule "deps/agent-document-renderer"]
|
||||
path = deps/agent-document-renderer
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Document-Renderer.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Document-Renderer.git
|
||||
[submodule "deps/development-guidelines"]
|
||||
path = deps/development-guidelines
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
|
||||
[submodule "deps/development-manager"]
|
||||
path = deps/development-manager
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
|
||||
见到一律按过期处理,不要再试。当前权威通道:
|
||||
|
||||
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
|
||||
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
|
||||
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||
(各仓 origin 与 gitlink 均为此格式)。
|
||||
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||
@@ -33,7 +33,7 @@
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
|
||||
## Agent 执行与新项目准入(强制)
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-command updated: 47bd0607be...fe7987dc03
Vendored
+1
-1
Submodule deps/agent-document-renderer updated: 40e5c671d1...3f259990b5
Vendored
+1
-1
Submodule deps/agent-intent updated: 28833bcfca...765a7e7177
Vendored
+1
-1
Submodule deps/agent-native updated: 8f19a4d78d...f2fb9ffa6d
Vendored
+1
-1
Submodule deps/agent-squad updated: a28aad5282...b6d87638cc
Vendored
+1
-1
Submodule deps/agent-system-core updated: 614dcbff4b...0cf03446c4
Vendored
+1
-1
Submodule deps/development-guidelines updated: 3ed982138c...2688fd2be6
Vendored
+1
-1
Submodule deps/development-manager updated: 24e8a5c8ba...67784f43eb
Vendored
+1
-1
Submodule deps/error-code-system updated: 00c03035b5...a3ee67bba2
Vendored
+1
-1
Submodule deps/logging-system updated: 7005493077...49b5d8be60
@@ -1,7 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
@@ -54,33 +53,16 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
|
||||
t.Fatalf("snapshot=%+v", snapshot)
|
||||
}
|
||||
permission := snapshot.Permissions["project.create"]
|
||||
if !permission.Allowed || !permission.CapAllowed {
|
||||
if len(snapshot.Permissions) != 1 || !permission.Allowed || !permission.CapAllowed {
|
||||
t.Fatalf("permissions=%+v", snapshot.Permissions)
|
||||
}
|
||||
// 「项目管理」一个开关即带出项目内工作默认;三权与知识发布不在其中。
|
||||
for _, actionID := range catalog.ProjectManagementImpliedActions() {
|
||||
row, ok := snapshot.Permissions[actionID]
|
||||
if !ok || !row.Allowed || !row.CapAllowed {
|
||||
t.Fatalf("implied %s missing: %+v", actionID, snapshot.Permissions)
|
||||
}
|
||||
}
|
||||
for _, duty := range []string{"document.review", "document.approve", "document.publish", "document.duties.assign", "knowledge.publish"} {
|
||||
if row, ok := snapshot.Permissions[duty]; ok && row.Allowed {
|
||||
t.Fatalf("duty %s implied: %+v", duty, snapshot.Permissions)
|
||||
}
|
||||
}
|
||||
decision, err := provider.CheckRoleAction(context.Background(), alice.ID, "project.create")
|
||||
if err != nil || !decision.Allowed || !decision.CapAllowed || decision.RoleID != role.RoleID {
|
||||
t.Fatalf("decision=%+v err=%v", decision, err)
|
||||
}
|
||||
// 项目内工作动作随「项目管理」默认放行;三权仍按未配置处理。
|
||||
implied, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
|
||||
if err != nil || !implied.Allowed || !implied.CapAllowed {
|
||||
t.Fatalf("implied project action=%+v err=%v", implied, err)
|
||||
}
|
||||
duty, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.publish")
|
||||
if err != nil || duty.Allowed || duty.Reason != "action-unconfigured" {
|
||||
t.Fatalf("duty action=%+v err=%v", duty, err)
|
||||
legacy, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
|
||||
if err != nil || legacy.Allowed || legacy.Reason != "action-unconfigured" {
|
||||
t.Fatalf("non-project role action=%+v err=%v", legacy, err)
|
||||
}
|
||||
systemProvider, ok := any(h).(extension.SystemActionProvider)
|
||||
if !ok {
|
||||
@@ -146,22 +128,14 @@ func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 系统范围动作仍然拒绝;项目范围动作随职位定义。
|
||||
if _, err := admin.SetRolePermission(context.Background(), role.RoleID, "system.settings.write", true, true, "legacy-action"); !errors.Is(err, store.ErrInvalidRolePolicy) {
|
||||
t.Fatalf("system role action accepted: %v", err)
|
||||
if _, err := admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "legacy-action"); !errors.Is(err, store.ErrInvalidRolePolicy) {
|
||||
t.Fatalf("non-project role action accepted: %v", err)
|
||||
}
|
||||
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "project.create", true, true, "project-access")
|
||||
access := role.Permissions["project.create"]
|
||||
if err != nil || !access.Allowed || !access.CapAllowed {
|
||||
if err != nil || len(role.Permissions) != 1 || !access.Allowed || !access.CapAllowed {
|
||||
t.Fatalf("role=%+v err=%v", role, err)
|
||||
}
|
||||
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "project-scope")
|
||||
if err != nil {
|
||||
t.Fatalf("project scope action: %v", err)
|
||||
}
|
||||
if read := role.Permissions["document.read"]; !read.Allowed || !read.CapAllowed {
|
||||
t.Fatalf("project scope permissions=%+v", role.Permissions)
|
||||
}
|
||||
binding, err := admin.AssignRoleByOwner(context.Background(), "alice", role.RoleID, "legacy-assign")
|
||||
if err != nil || binding.RoleID != role.RoleID {
|
||||
t.Fatalf("binding=%+v err=%v", binding, err)
|
||||
|
||||
@@ -96,31 +96,3 @@ func IsProjectCreateLinkedAction(id string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// projectManagementImpliedActions 是「项目管理」蕴含的项目内工作默认:拿到项目管理的
|
||||
// 账号在自己参与的项目里可以直接读写文档、管成员、看追溯、推进阶段。三权与知识发布
|
||||
// (document.review/approve/publish/duties.assign、knowledge.publish)不在其中——它们
|
||||
// 仍按项目内的职责分配与三权约束判定。
|
||||
var projectManagementImpliedActions = []string{
|
||||
"document.read",
|
||||
"document.author",
|
||||
"project.stage.advance",
|
||||
"project.archive",
|
||||
"project.audit.read",
|
||||
"trace.edit",
|
||||
}
|
||||
|
||||
// ProjectManagementImpliedActions 返回「项目管理」开启时随角色投影的项目内默认动作。
|
||||
func ProjectManagementImpliedActions() []string {
|
||||
return append([]string(nil), projectManagementImpliedActions...)
|
||||
}
|
||||
|
||||
// IsProjectManagementImpliedAction 报告该动作是否随「项目管理」默认放行。
|
||||
func IsProjectManagementImpliedAction(id string) bool {
|
||||
for _, action := range projectManagementImpliedActions {
|
||||
if action == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ func (f *fencedAuditStore) AppendAuditAccountChecked(string, agentcommand.AuditE
|
||||
// TestStrictAccountWriteAuditLossKeepsOperationID:围栏档下 handler 已提交、
|
||||
// 审计写失败时,回执仍是 unknown 且带上 handler 拿到的 operationID,
|
||||
// 调用方才能在围栏外按记录对账(F10 Bug 2 在围栏路径上的同一语义)。
|
||||
//
|
||||
// 上游 agent-command main 移除了 ExecReceipt.SideEffects 与 NewUnknownAfterExecution,
|
||||
// 且契约禁止 unknown 携带 OperationID;对账句柄改由 Reason 承载(见 cmdbus.unknownReceipt)。
|
||||
func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) {
|
||||
audit := &fencedAuditStore{}
|
||||
b := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
@@ -55,11 +52,11 @@ func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) {
|
||||
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 != "" || !strings.Contains(resp.Receipt.Reason, seen) {
|
||||
t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
|
||||
if seen == "" || resp.Receipt.OperationID != seen {
|
||||
t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||||
}
|
||||
if err := resp.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("fenced receipt must satisfy the library contract: %v", err)
|
||||
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)
|
||||
|
||||
+27
-21
@@ -558,17 +558,17 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
// rejected(回执契约禁止 rejected 携带 operationID)。
|
||||
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
|
||||
}
|
||||
// handler 已开始执行才报错:宿主无法断言"无副作用"。按"已执行随后不确定"
|
||||
// 归 unknown,并带上本次分发已签发的 operation ID(写在回执说明里,理由见
|
||||
// unknownReceipt),调用方据此按记录对账,而不是盲目重试。本路径不做任何重放。
|
||||
// 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 记录并返回"已执行随后不确定"的回执:审计信封带
|
||||
// state=unknown 与已签发的 operationID(可查凭证),回执的说明字段带上同一个
|
||||
// operationID,保证 Validate() 通过且对账句柄不丢(口径见 unknownReceipt)。
|
||||
// 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)}
|
||||
}
|
||||
@@ -591,7 +591,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)
|
||||
@@ -601,7 +601,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
|
||||
@@ -612,11 +612,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)
|
||||
@@ -677,7 +677,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(),
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -711,26 +711,32 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
|
||||
return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC())
|
||||
}
|
||||
if state == agentcommand.StateUnknown && op != "" {
|
||||
// 已执行随后不确定:对账句柄随说明一起给出(unknownReceipt)。
|
||||
return unknownReceipt(req.ID, state, op, reason, b.now().UTC())
|
||||
// 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。
|
||||
return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC())
|
||||
}
|
||||
return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason,
|
||||
Time: b.now().UTC()}
|
||||
SideEffects: sideEffectsFor(state), Time: b.now().UTC()}
|
||||
}
|
||||
|
||||
// unknownReceipt 构造"结果未知"回执。
|
||||
//
|
||||
// 上游 command.ExecReceipt 已移除 NewUnknownAfterExecution,且现行契约禁止
|
||||
// StateUnknown 携带 OperationID(Validate 直接拒绝)。最接近的等价表达:已签发过
|
||||
// 操作标识时把该标识写进 Reason(unknown 的稳定说明字段),状态与文案语义不变,
|
||||
// 对账句柄不丢;state=rejected 恒不带标识。
|
||||
// unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识
|
||||
// (供对账),否则返回不带标识的 unknown。state=rejected 恒不带标识。
|
||||
func unknownReceipt(commandID string, state agentcommand.ResultState, op, reason string, at time.Time) agentcommand.ExecReceipt {
|
||||
if op != "" && state != agentcommand.StateRejected {
|
||||
reason += " (operation " + op + ")"
|
||||
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 {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -119,8 +119,9 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) {
|
||||
if err := failed.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("rejected receipt must satisfy the library contract: %v", err)
|
||||
}
|
||||
// 上游 agent-command main 移除了 ExecReceipt.SideEffects(“确定无副作用”
|
||||
// 的显式声明),rejected 不得携带 operationID 这条库契约即是等价断言。
|
||||
if failed.Receipt.SideEffects != agentcommand.SideEffectsNone {
|
||||
t.Fatalf("rejected receipt must declare no side effects, got %q", failed.Receipt.SideEffects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationIDsRemainUniqueWithFixedClockAndConcurrentDispatch(t *testing.T) {
|
||||
@@ -378,28 +379,9 @@ func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcomman
|
||||
}
|
||||
}
|
||||
|
||||
// operationHandle 取回 unknown 回执给出的对账句柄。agent-command main 契约禁止
|
||||
// StateUnknown 携带 OperationID,句柄改写在 Reason 里("(operation <id>)"),
|
||||
// 这里统一读出来供断言与按记录对账使用。
|
||||
func operationHandle(r agentcommand.ExecReceipt) string {
|
||||
if r.OperationID != "" {
|
||||
return r.OperationID
|
||||
}
|
||||
const marker = " (operation "
|
||||
i := strings.Index(r.Reason, marker)
|
||||
if i < 0 || !strings.HasSuffix(r.Reason, ")") {
|
||||
return ""
|
||||
}
|
||||
return r.Reason[i+len(marker) : len(r.Reason)-1]
|
||||
}
|
||||
|
||||
// TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归:
|
||||
// handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的
|
||||
// operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。
|
||||
//
|
||||
// 上游 agent-command main 移除了 ExecReceipt.SideEffects 与 NewUnknownAfterExecution,
|
||||
// 且 Validate 禁止 unknown 携带 OperationID;对账句柄改由 Reason 承载(见
|
||||
// cmdbus.unknownReceipt),断言随之改写到新等价行为。
|
||||
func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
|
||||
audit := &memAudit{}
|
||||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
@@ -416,14 +398,17 @@ func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
|
||||
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 != "" || !strings.Contains(resp.Receipt.Reason, seen) {
|
||||
t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
|
||||
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("unknown receipt must satisfy the library contract: %v", err)
|
||||
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))
|
||||
@@ -434,7 +419,7 @@ func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
|
||||
}
|
||||
// 宿主不得自动重放:再次分发是一次新操作(新的 operationID),旧的不被复用。
|
||||
again := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"})
|
||||
if again.Receipt.State != agentcommand.StateUnknown || operationHandle(again.Receipt) == operationHandle(resp.Receipt) || len(writes) != 2 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -459,8 +444,8 @@ func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) {
|
||||
if resp.Receipt.Reason != "rejected: permission denied" {
|
||||
t.Fatalf("not-executed marking must not distort the reason: %q", resp.Receipt.Reason)
|
||||
}
|
||||
if err := resp.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("rejected receipt must satisfy the library contract: %v", err)
|
||||
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)
|
||||
@@ -492,11 +477,11 @@ func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) {
|
||||
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 != "" || !strings.Contains(resp.Receipt.Reason, seen) {
|
||||
t.Fatalf("audit loss dropped the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
|
||||
if seen == "" || resp.Receipt.OperationID != seen {
|
||||
t.Fatalf("audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||||
}
|
||||
if err := resp.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("audit-loss receipt must satisfy the library contract: %v", err)
|
||||
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)
|
||||
@@ -527,8 +512,8 @@ func TestValidationRejectionNeverRunsHandler(t *testing.T) {
|
||||
if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("validation rejection: %+v", resp.Receipt)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("validation rejection ran the handler: calls=%d %+v", calls, 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)
|
||||
@@ -580,14 +565,14 @@ func TestLookupOperationIsOwnerProjectScoped(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Owner: "alice", Actor: "alice"})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || operationHandle(resp.Receipt) == "" {
|
||||
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 := operationHandle(resp.Receipt)
|
||||
op := resp.Receipt.OperationID
|
||||
record, err := bus.LookupOperation("alice", "proj_1", op)
|
||||
if err != nil {
|
||||
t.Fatalf("owner-scoped lookup: %v", err)
|
||||
|
||||
@@ -45,7 +45,6 @@ func (p *failingPendingStore) TakePendingConfirm(string) (string, string, bool,
|
||||
|
||||
// TestConfirmPendingWriteFailureKeepsCardOperationID:挂起态落盘失败不得吞掉操作
|
||||
// 标识——卡 ID 就是本次受理的句柄,unknown 必须带出去(否则受理事实无凭证)。
|
||||
// agent-command main 契约禁止 unknown 携带 OperationID,句柄随 Reason 给出。
|
||||
func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) {
|
||||
audit, cards := &memAudit{}, &memCards{}
|
||||
b := newTestBus(audit, cards)
|
||||
@@ -56,7 +55,7 @@ func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) {
|
||||
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 != "" || operationHandle(resp.Receipt) != "card_x1" || resp.Receipt.Validate() != nil {
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
package docgen
|
||||
|
||||
const rendererRevision = "40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
|
||||
const rendererRevision = "3f259990b5d43ec0d318f39d496816140ab559b5"
|
||||
|
||||
const RendererIdentity = "agent-document-renderer@" + rendererRevision
|
||||
|
||||
@@ -367,7 +367,7 @@ func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T)
|
||||
}
|
||||
|
||||
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
|
||||
const want = "agent-document-renderer@40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
|
||||
const want = "agent-document-renderer@3f259990b5d43ec0d318f39d496816140ab559b5"
|
||||
if RendererIdentity != want {
|
||||
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ type SlotQuestion struct {
|
||||
}
|
||||
|
||||
// SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09):
|
||||
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠),
|
||||
// Reason 是库层原因原文;
|
||||
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/
|
||||
// 生成路径的未知键),Reason 是库层原因原文;
|
||||
// - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。
|
||||
//
|
||||
// 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。
|
||||
@@ -39,15 +39,11 @@ type MatchResult struct {
|
||||
ID string
|
||||
Slots map[string]string
|
||||
Missing []SlotQuestion
|
||||
// Invalid 是捕获到但未通过权威校验的槽位。
|
||||
// Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。
|
||||
// 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。
|
||||
Invalid []SlotDefect
|
||||
// Risk/RequiresConfirm/Action 是权威候选的声明:风险档、是否要求显式确认、
|
||||
// spec.action 显式动作引用。nil Action 表示未声明。
|
||||
//
|
||||
// 上游 agent-intent main 已移除生成路径的候选合成(candidateFromGenerated
|
||||
// 与 validateProvidedSlots):生成出来的 Top 只带槽位,不再挂 risk/confirm/
|
||||
// action,也不再跑 enum/format 校验。这三个字段目前只由规则路径填充。
|
||||
// Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款):
|
||||
// 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。
|
||||
Risk string
|
||||
RequiresConfirm bool
|
||||
Action *ActionRef
|
||||
@@ -202,7 +198,8 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
out.Slots[k] = v
|
||||
}
|
||||
}
|
||||
// 风险/确认/动作声明:规则路径由库层填充;宿主只投影、不在本包内裁决。
|
||||
// 风险/确认/动作声明:规则路径与生成路径(candidateFromGenerated)同款字段,
|
||||
// 宿主据此决定是否必须走确认,不在本包内裁决。
|
||||
out.Risk = res.Top.Risk
|
||||
out.RequiresConfirm = res.Top.RequiresConfirm
|
||||
if res.Top.Action != nil {
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestGateGeneratorProducesCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径)----
|
||||
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)----
|
||||
|
||||
// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。
|
||||
func modeSpec(t *testing.T) json.RawMessage {
|
||||
@@ -239,9 +239,7 @@ func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成路径与规则路径同款:库层对生成槽位跑全量权威校验(candidateFromGenerated
|
||||
// → validateProvidedSlots:enum/format/validator 失败进 Invalid,必填未给进 Missing),
|
||||
// 并按权威 spec 合成 risk/confirm/action;宿主只做投影,不重复裁决、不二次拦。
|
||||
// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。
|
||||
func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
@@ -256,59 +254,27 @@ func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
|
||||
if res.Branch != "matched" || res.ID != "demo.mode" {
|
||||
t.Fatalf("生成路径识别:%+v", res)
|
||||
}
|
||||
// 非法枚举值由库层拦下并给出权威原因,原因原文不做改写。
|
||||
if res.Complete() {
|
||||
t.Fatalf("非法枚举值不得算作完整:%+v", res)
|
||||
if len(res.Invalid) != 1 || res.Invalid[0].Key != "mode" || res.Invalid[0].Reason != "value not in enum" ||
|
||||
res.Invalid[0].Question != "要哪种模式?" {
|
||||
t.Fatalf("生成路径非法详情:%+v", res.Invalid)
|
||||
}
|
||||
if len(res.Missing) != 0 {
|
||||
// 必填且非法只进 Invalid,不重复进 Missing。
|
||||
t.Fatalf("非法槽位不应重复计入缺失:%+v", res.Missing)
|
||||
if _, leaked := res.Slots["mode"]; leaked || res.Complete() {
|
||||
t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots)
|
||||
}
|
||||
if len(res.Invalid) != 1 {
|
||||
t.Fatalf("生成路径非法槽位详情:%+v", res.Invalid)
|
||||
}
|
||||
d := res.Invalid[0]
|
||||
if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" {
|
||||
t.Fatalf("生成路径非法原因/提问未透传:%+v", d)
|
||||
}
|
||||
if _, leaked := res.Slots["mode"]; leaked {
|
||||
t.Fatalf("非法值不得进入已校验槽位表:%+v", res.Slots)
|
||||
}
|
||||
if res.Slots["topic"] != "核能" {
|
||||
t.Fatalf("生成路径合法槽位丢失:%+v", res.Slots)
|
||||
}
|
||||
// 风险/确认/动作声明按 modeSpec 合成(生成路径与规则路径一致)。
|
||||
if res.Risk != "high" || !res.RequiresConfirm {
|
||||
if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil {
|
||||
t.Fatalf("生成路径风险元数据:%+v", res)
|
||||
}
|
||||
if res.Action == nil || res.Action.Kind != ActionCommand || res.Action.ID != "demo.mode" {
|
||||
t.Fatalf("生成路径动作声明:%+v", res.Action)
|
||||
}
|
||||
|
||||
// 合法槽位:matched、完整、无缺陷,元数据同样齐备。
|
||||
// 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
Slots: map[string]string{"mode": "摘要", "topic": "核能"},
|
||||
}}})
|
||||
clean := g.Recognize(context.Background(), "跟昨天一样")
|
||||
if !clean.Complete() || clean.Slots["mode"] != "摘要" || len(clean.Invalid) != 0 {
|
||||
if !clean.Complete() || clean.Slots["mode"] != "摘要" {
|
||||
t.Fatalf("生成路径合法槽位:%+v", clean)
|
||||
}
|
||||
if clean.Risk != "high" || !clean.RequiresConfirm || clean.Action == nil {
|
||||
t.Fatalf("生成路径合法槽位的风险元数据:%+v", clean)
|
||||
}
|
||||
|
||||
// 未知槽位键仍由库层 fail-closed 拦住(整单回 fallback,不带 Top)。
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
Slots: map[string]string{"unknown_key": "值"},
|
||||
}}})
|
||||
unknown := g.Recognize(context.Background(), "跟昨天一样")
|
||||
if unknown.Branch != "fallback" || unknown.ID != "" || len(unknown.Slots) != 0 {
|
||||
t.Fatalf("未知槽位键必须 fail-closed:%+v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
// Package rundiag 把单次 run 的诊断证据聚合成一份只读结论:卡在哪、谁负责、
|
||||
// 为什么在等、最后一个稳定检查点、已知副作用、下一步动作。
|
||||
//
|
||||
// 全包统一口径(任务卡 A2 的契约):
|
||||
// - 只读派生:Build 是纯函数,Gather 只跑 SELECT;本包不提供任何写路径。
|
||||
// - 证据优先于推断:状态只由已落库的记录驱动。读不到就降级为 unknown + note,
|
||||
// 读不到隔离记录时更按 fail-closed 处理(不声称"可以安全重试")。
|
||||
// - 「没有记录」≠「没有发生」:side_effects 为空必须带 note,说明这是"未记录"
|
||||
// 而不是"未发生"。
|
||||
// - 归属边界:HITL 决策卡、危险命令确认卡、命令审计信封都按 owner + 会话归属,
|
||||
// 其中决策卡与确认卡表里根本没有 run 列,审计信封虽有 runId 字段但宿主总线
|
||||
// 当前不写。因此"归到这个 run"是调用方按签发时间窗做的有界推断,报告以
|
||||
// note 标出,绝不把会话里更早的记录算成本次 run 的副作用。
|
||||
package rundiag
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// responsibility.state 的取值域(任务卡固定)。
|
||||
const (
|
||||
StateRunning = "running"
|
||||
StateWaitingHITL = "waiting_hitl"
|
||||
StateConfirmationPending = "confirmation_pending"
|
||||
StateRecoveryHold = "recovery_hold"
|
||||
StateReconciling = "reconciling"
|
||||
StateFailed = "failed"
|
||||
StateCompleted = "completed"
|
||||
StateUnknown = "unknown"
|
||||
)
|
||||
|
||||
// responsibility.role:这一刻该谁动。
|
||||
const (
|
||||
RoleRunner = "runner" // 执行器自己在推进
|
||||
RoleHuman = "human" // 等人裁决(HITL / 危险命令确认)
|
||||
RoleOperationOwner = "operation-owner" // 等副作用责任人对账与解除
|
||||
RoleNone = "none" // 没有待办责任方
|
||||
)
|
||||
|
||||
// next_step.action 的取值域。
|
||||
const (
|
||||
ActionConfirmCard = "confirm_card"
|
||||
ActionReleaseRecoveryHold = "release_recovery_hold"
|
||||
ActionSafeRetry = "safe_retry"
|
||||
ActionNeedsManualReconciliation = "needs_manual_reconciliation"
|
||||
ActionNone = "none"
|
||||
)
|
||||
|
||||
// side_effects[].kind:证据来源类别。
|
||||
const (
|
||||
EffectOperation = "operation" // 命令审计信封(U7b 对账记录)
|
||||
EffectRecoveryHold = "recovery_hold" // 恢复隔离行(U3b 的"副作用未知"证据)
|
||||
)
|
||||
|
||||
// 读取上界:诊断是给人和值班看的,不是全量导出。
|
||||
const (
|
||||
maxSideEffects = 50
|
||||
maxAuditScan = 200 // 审计信封按会话+时间窗有界扫描
|
||||
maxConfirms = 50
|
||||
)
|
||||
|
||||
// Report 是诊断响应(JSON 契约,字段名用任务卡给定的 snake_case)。
|
||||
type Report struct {
|
||||
Run RunSummary `json:"run"`
|
||||
Responsibility Responsibility `json:"responsibility"`
|
||||
LastCheckpoint *Checkpoint `json:"last_checkpoint"` // 无记录时为 null(不编造)
|
||||
SideEffects []SideEffect `json:"side_effects"` // 恒为数组:空数组 = 未记录到
|
||||
NextStep NextStep `json:"next_step"`
|
||||
Notes []string `json:"notes,omitempty"` // 降级与边界说明
|
||||
}
|
||||
|
||||
// RunSummary 是 run 自身的身份与时间戳。updated_at 的口径:已终态取 ended_at,
|
||||
// 未终态取最后一个已持久化事件的时间;两者都没有则为 null(并在 notes 说明)。
|
||||
type RunSummary struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
Project string `json:"project"`
|
||||
Conversation string `json:"conversation"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt *string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Responsibility 回答"卡在哪、谁负责、为什么等、从什么时候开始等"。
|
||||
type Responsibility struct {
|
||||
State string `json:"state"`
|
||||
Role string `json:"role"`
|
||||
WaitReason string `json:"wait_reason"`
|
||||
Since string `json:"since,omitempty"` // 进入该状态的时间;存储没记就省略
|
||||
}
|
||||
|
||||
// Checkpoint 指向最后一个稳定落盘点(事件流上的 seq 可直接作重放游标)。
|
||||
type Checkpoint struct {
|
||||
ID string `json:"id"`
|
||||
At string `json:"at"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// SideEffect 是一条已记录的外部副作用证据。operation 行为命令回执对账记录;
|
||||
// recovery_hold 行是 U3b 落下的"副作用结果未知"隔离证据(state = held/released)。
|
||||
type SideEffect struct {
|
||||
Kind string `json:"kind"`
|
||||
OperationID string `json:"operation_id,omitempty"`
|
||||
CommandID string `json:"command_id,omitempty"`
|
||||
State string `json:"state"`
|
||||
At string `json:"at,omitempty"`
|
||||
ReleasedAt string `json:"released_at,omitempty"`
|
||||
}
|
||||
|
||||
// NextStep 是唯一建议的下一步动作(诊断面只建议,不执行)。
|
||||
type NextStep struct {
|
||||
Action string `json:"action"`
|
||||
CardID string `json:"card_id,omitempty"` // confirm_card 可解析时给出卡 ID
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Source 是诊断所需的只读端口(*store.Store 直接满足;测试可替身)。
|
||||
type Source interface {
|
||||
GetRecoveryHold(runID string) (store.RecoveryHold, error)
|
||||
ActiveHITLCardsByRun(runID string) ([]core.HITLCard, error)
|
||||
PendingHumanDecisionsByConversation(owner, conversationID string, now time.Time) ([]store.HumanDecision, error)
|
||||
ListOwnerPendingConfirms(owner string, limit int) ([]store.PendingConfirm, error)
|
||||
LastRunCheckpoint(conversationID, runID string) (store.RunCheckpoint, bool, error)
|
||||
ListAudits(owner, conversationID string, limit int) ([]agentcommand.AuditEnvelope, error)
|
||||
}
|
||||
|
||||
// Evidence 是 Build 的全部输入(合成数据即可表驱动测试,无需真实存储)。
|
||||
type Evidence struct {
|
||||
Run core.Run
|
||||
|
||||
Hold *store.RecoveryHold // nil = 没有隔离行;HoldUnknown = 读失败
|
||||
HoldUnknown bool
|
||||
Cards []core.HITLCard // 该 run 未关闭的 HITL 卡
|
||||
Decisions []store.HumanDecision // 会话内未过期的 pending 决策卡
|
||||
Confirms []store.PendingConfirm // owner 名下未确认的危险命令挂起行
|
||||
Checkpoint *store.RunCheckpoint // 最后一个已持久化事件
|
||||
Audits []agentcommand.AuditEnvelope
|
||||
Notes []string
|
||||
}
|
||||
|
||||
// Gather 只读地把一个 run 的证据读齐。任何一条可选查询失败都不让整个诊断失败:
|
||||
// 失败降级为 note;隔离记录读不到按 fail-closed 标记(HoldUnknown),因为"读不到
|
||||
// hold"绝不能被读成"没有 hold"。
|
||||
func Gather(src Source, run core.Run, now time.Time) Evidence {
|
||||
ev := Evidence{Run: run}
|
||||
if src == nil {
|
||||
ev.HoldUnknown = true
|
||||
return ev.withNote("diagnostics source unavailable; responsibility is fail-closed unknown")
|
||||
}
|
||||
owner := string(run.Owner)
|
||||
conversation := string(run.ConversationID)
|
||||
|
||||
hold, err := src.GetRecoveryHold(string(run.ID))
|
||||
switch {
|
||||
case err == nil:
|
||||
ev.Hold = &hold
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
// 没有隔离行是正常态,不记 note。
|
||||
default:
|
||||
ev.HoldUnknown = true
|
||||
ev = ev.withNote("recovery hold unreadable: " + err.Error())
|
||||
}
|
||||
|
||||
cards, err := src.ActiveHITLCardsByRun(string(run.ID))
|
||||
if err != nil {
|
||||
ev = ev.withNote("hitl cards unreadable: " + err.Error())
|
||||
} else {
|
||||
ev.Cards = cards
|
||||
}
|
||||
|
||||
decisions, err := src.PendingHumanDecisionsByConversation(owner, conversation, now)
|
||||
if err != nil {
|
||||
ev = ev.withNote("human decisions unreadable: " + err.Error())
|
||||
} else {
|
||||
ev.Decisions = decisions
|
||||
}
|
||||
|
||||
confirms, err := src.ListOwnerPendingConfirms(owner, maxConfirms)
|
||||
if err != nil {
|
||||
ev = ev.withNote("pending confirmations unreadable: " + err.Error())
|
||||
} else {
|
||||
ev.Confirms = confirms
|
||||
}
|
||||
|
||||
cp, ok, err := src.LastRunCheckpoint(conversation, string(run.ID))
|
||||
switch {
|
||||
case err != nil:
|
||||
ev = ev.withNote("last checkpoint unreadable: " + err.Error())
|
||||
case ok:
|
||||
ev.Checkpoint = &cp
|
||||
}
|
||||
|
||||
audits, err := src.ListAudits(owner, conversation, maxAuditScan)
|
||||
if err != nil {
|
||||
ev = ev.withNote("command audit unreadable: " + err.Error())
|
||||
} else {
|
||||
ev.Audits = audits
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// Build 把证据聚合成诊断结论(纯函数,不读写任何状态)。
|
||||
func Build(ev Evidence) Report {
|
||||
rep := Report{Run: runSummary(ev), SideEffects: []SideEffect{}}
|
||||
if ev.Checkpoint != nil {
|
||||
rep.LastCheckpoint = &Checkpoint{ID: fmt.Sprintf("%d", ev.Checkpoint.Seq), At: ev.Checkpoint.TS, Type: ev.Checkpoint.Type}
|
||||
}
|
||||
rep.SideEffects = sideEffects(ev)
|
||||
rep.Notes = append([]string(nil), ev.Notes...)
|
||||
|
||||
card := waitingCard(ev)
|
||||
decision := waitingDecision(ev)
|
||||
confirm := waitingConfirm(ev)
|
||||
|
||||
switch {
|
||||
case ev.HoldUnknown:
|
||||
// 隔离记录读不到:不猜没有 hold,直接 unknown,且不给"安全重试"。
|
||||
rep.Notes = append(rep.Notes, "recovery hold state unknown; treat as quarantined until reconciled")
|
||||
rep.Responsibility = Responsibility{
|
||||
State: StateUnknown, Role: RoleNone,
|
||||
WaitReason: "recovery hold state could not be read",
|
||||
}
|
||||
rep.NextStep = NextStep{Action: ActionNone, Reason: "cannot prove the run is free of an open recovery hold"}
|
||||
case openHold(ev):
|
||||
hold := ev.Hold
|
||||
rep.Responsibility = Responsibility{
|
||||
State: StateRecoveryHold, Role: RoleOperationOwner,
|
||||
WaitReason: holdReason(hold), Since: hold.CreatedAt,
|
||||
}
|
||||
rep.NextStep = NextStep{Action: ActionReleaseRecoveryHold, Reason: holdReason(hold)}
|
||||
case needsReconcile(ev):
|
||||
reason := "recovery hold released but the run is still interrupted"
|
||||
since := ""
|
||||
if ev.Hold != nil {
|
||||
since = ev.Hold.ReleasedAt
|
||||
if ev.Hold.Reason != "" {
|
||||
reason += "; hold reason: " + ev.Hold.Reason
|
||||
}
|
||||
}
|
||||
rep.Responsibility = Responsibility{State: StateReconciling, Role: RoleOperationOwner, WaitReason: reason, Since: since}
|
||||
rep.NextStep = NextStep{Action: ActionNeedsManualReconciliation, Reason: reason}
|
||||
case ev.Run.Status == core.RunWaitingHITL || card != nil || decision != nil:
|
||||
reason := "waiting for a human decision"
|
||||
since := ""
|
||||
if decision != nil {
|
||||
reason = decision.Question
|
||||
if reason == "" {
|
||||
reason = "pending human decision on command " + decision.CommandID
|
||||
}
|
||||
since = decision.IssuedAt.UTC().Format(time.RFC3339)
|
||||
} else if card != nil {
|
||||
reason = card.Question
|
||||
if reason == "" {
|
||||
reason = "run is waiting on a HITL card"
|
||||
}
|
||||
since = card.CreatedAt
|
||||
}
|
||||
rep.Responsibility = Responsibility{State: StateWaitingHITL, Role: RoleHuman, WaitReason: reason, Since: since}
|
||||
if card == nil && decision == nil {
|
||||
// 只由 run 状态驱动:没有可解析的卡,也不知道等待从何时开始。
|
||||
rep.Notes = append(rep.Notes, "run is marked waiting_hitl with no open card recorded; the wait entry time is not stored")
|
||||
}
|
||||
rep.NextStep = NextStep{Action: ActionConfirmCard, CardID: hitlCardID(card, decision), Reason: reason}
|
||||
case confirm != nil:
|
||||
reason := "dangerous command " + confirm.command + " awaits explicit confirmation"
|
||||
rep.Responsibility = Responsibility{
|
||||
State: StateConfirmationPending, Role: RoleHuman,
|
||||
WaitReason: reason, Since: confirm.row.CreatedAt,
|
||||
}
|
||||
rep.NextStep = NextStep{Action: ActionConfirmCard, CardID: confirm.row.CardID, Reason: reason}
|
||||
case ev.Run.Status == core.RunFailed:
|
||||
reason := "run failed"
|
||||
if ev.Run.Error != nil {
|
||||
reason = fmt.Sprintf("run failed: %s: %s", ev.Run.Error.Code, ev.Run.Error.Message)
|
||||
}
|
||||
rep.Responsibility = Responsibility{State: StateFailed, Role: RoleRunner, WaitReason: reason, Since: endedAt(ev.Run)}
|
||||
// 重试安全性看已记录的副作用证据:没有 open hold 且没有 unknown 回执时,
|
||||
// 重试不会复制本次运行的后果;否则先对账再重试,不说"安全"。
|
||||
if unknown := unknownOperations(rep.SideEffects); unknown > 0 {
|
||||
reason := fmt.Sprintf("terminal failure, but %d recorded operation(s) have an unknown outcome; reconcile side effects before retrying", unknown)
|
||||
rep.Notes = append(rep.Notes, reason)
|
||||
rep.NextStep = NextStep{Action: ActionNeedsManualReconciliation, Reason: reason}
|
||||
} else {
|
||||
rep.NextStep = NextStep{Action: ActionSafeRetry, Reason: "terminal failure with no open recovery hold and no unknown receipt; retry is safe to attempt"}
|
||||
}
|
||||
case ev.Run.Status == core.RunCompleted:
|
||||
rep.Responsibility = Responsibility{State: StateCompleted, Role: RoleNone, WaitReason: "run completed", Since: endedAt(ev.Run)}
|
||||
rep.NextStep = NextStep{Action: ActionNone, Reason: "run already completed"}
|
||||
case ev.Run.Status == core.RunRunning || ev.Run.Status == core.RunPending:
|
||||
rep.Responsibility = Responsibility{
|
||||
State: StateRunning, Role: RoleRunner,
|
||||
WaitReason: "run is executing", Since: ev.Run.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
rep.NextStep = NextStep{Action: ActionNone, Reason: "runner is executing; no operator action"}
|
||||
default:
|
||||
reason := fmt.Sprintf("run status %q has no diagnostic mapping", ev.Run.Status)
|
||||
rep.Notes = append(rep.Notes, reason)
|
||||
rep.Responsibility = Responsibility{State: StateUnknown, Role: RoleNone, WaitReason: reason, Since: endedAt(ev.Run)}
|
||||
rep.NextStep = NextStep{Action: ActionNone, Reason: "status is unmapped; inspect the run record manually"}
|
||||
}
|
||||
|
||||
if ev.Checkpoint == nil {
|
||||
rep.Notes = append(rep.Notes, "no persisted checkpoint reference for this run (no durable stream event)")
|
||||
}
|
||||
if len(rep.SideEffects) == 0 {
|
||||
rep.Notes = append(rep.Notes, "no recorded side effect evidence; absence of records is not proof of no effect")
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
func runSummary(ev Evidence) RunSummary {
|
||||
summary := RunSummary{
|
||||
ID: string(ev.Run.ID), Status: string(ev.Run.Status), Kind: string(ev.Run.Kind),
|
||||
Phase: string(ev.Run.Phase), Owner: string(ev.Run.Owner), Project: string(ev.Run.ProjectID),
|
||||
Conversation: string(ev.Run.ConversationID),
|
||||
}
|
||||
if !ev.Run.CreatedAt.IsZero() {
|
||||
summary.CreatedAt = ev.Run.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
// updated:终态取 ended_at,未终态取最后一次落盘(检查点)的时间。
|
||||
if ended := endedAt(ev.Run); ended != "" {
|
||||
summary.UpdatedAt = &ended
|
||||
} else if ev.Checkpoint != nil && ev.Checkpoint.TS != "" {
|
||||
ts := ev.Checkpoint.TS
|
||||
summary.UpdatedAt = &ts
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// sideEffects 汇总已记录的外部副作用证据:命令回执对账记录(U7b 审计信封)加上
|
||||
// 恢复隔离行(U3b 的"结果未知"证据)。
|
||||
func sideEffects(ev Evidence) []SideEffect {
|
||||
out := []SideEffect{}
|
||||
if ev.Hold != nil {
|
||||
state := "held"
|
||||
if ev.Hold.ReleasedAt != "" {
|
||||
state = "released"
|
||||
}
|
||||
out = append(out, SideEffect{
|
||||
Kind: EffectRecoveryHold, State: state, At: ev.Hold.CreatedAt, ReleasedAt: ev.Hold.ReleasedAt,
|
||||
})
|
||||
}
|
||||
for _, env := range ev.Audits {
|
||||
if !belongsToRun(ev, env) {
|
||||
continue
|
||||
}
|
||||
out = append(out, SideEffect{
|
||||
Kind: EffectOperation, OperationID: env.OperationID, CommandID: env.CommandID,
|
||||
State: string(env.State), At: env.Time.UTC().Format(time.RFC3339),
|
||||
})
|
||||
if len(out) >= maxSideEffects {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// belongsToRun 是审计信封的归属边界:信封自带 runId 时以它为准;否则只在"同一
|
||||
// owner + 会话且签发时间不早于 run 创建时间"的窗口内归属——会话里更早的记录属于
|
||||
// 更早的 run,不算进本次副作用。
|
||||
func belongsToRun(ev Evidence, env agentcommand.AuditEnvelope) bool {
|
||||
if env.RunID != "" {
|
||||
return env.RunID == string(ev.Run.ID)
|
||||
}
|
||||
if string(env.ConversationID) != "" && string(env.ConversationID) != string(ev.Run.ConversationID) {
|
||||
return false
|
||||
}
|
||||
if !ev.Run.CreatedAt.IsZero() && !env.Time.IsZero() && env.Time.Before(ev.Run.CreatedAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func openHold(ev Evidence) bool {
|
||||
return ev.Hold != nil && ev.Hold.ReleasedAt == ""
|
||||
}
|
||||
|
||||
// unknownOperations 统计结果未知的已记录 operation(四态语义里的 unknown:
|
||||
// 执行过但结果不确定)。它是"重试会不会复制副作用"的直接证据。
|
||||
func unknownOperations(effects []SideEffect) int {
|
||||
n := 0
|
||||
for _, e := range effects {
|
||||
if e.Kind == EffectOperation && e.State == string(agentcommand.StateUnknown) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// needsReconcile 是 U3b 隔离解除后的残留态:隔离已解除,但 run 仍停在中断态——
|
||||
// 没有人把它推进到终态,需要人工对账。
|
||||
func needsReconcile(ev Evidence) bool {
|
||||
return ev.Hold != nil && ev.Hold.ReleasedAt != "" && ev.Run.Status == core.RunInterrupted
|
||||
}
|
||||
|
||||
func holdReason(hold *store.RecoveryHold) string {
|
||||
if hold == nil || hold.Reason == "" {
|
||||
return "external side effect outcome unknown (recovery hold)"
|
||||
}
|
||||
return hold.Reason
|
||||
}
|
||||
|
||||
func endedAt(run core.Run) string {
|
||||
if run.EndedAt == nil {
|
||||
return ""
|
||||
}
|
||||
return run.EndedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// waitingCard 取该 run 未关闭的 HITL 卡(最早一张;只看未关闭的)。
|
||||
func waitingCard(ev Evidence) *core.HITLCard {
|
||||
for i := range ev.Cards {
|
||||
if ev.Cards[i].Resolved || ev.Cards[i].Blocked {
|
||||
continue
|
||||
}
|
||||
return &ev.Cards[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitingDecision 取本 run 生命周期内签发、仍未过期的人工决策卡。
|
||||
func waitingDecision(ev Evidence) *store.HumanDecision {
|
||||
for i := range ev.Decisions {
|
||||
rec := &ev.Decisions[i]
|
||||
if rec.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
if !rec.IssuedAt.IsZero() && !ev.Run.CreatedAt.IsZero() && rec.IssuedAt.Before(ev.Run.CreatedAt) {
|
||||
continue // 签发在本次 run 之前:属于更早的等待,不算本次
|
||||
}
|
||||
return rec
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type scopedConfirm struct {
|
||||
row store.PendingConfirm
|
||||
command string
|
||||
}
|
||||
|
||||
// waitingConfirm 取本 run 生命周期内、同一会话挂起的危险命令确认行。载荷是总线
|
||||
// 落下的分发请求;解析不出会话的旧行按"无记录"处理,不回显原始载荷。
|
||||
func waitingConfirm(ev Evidence) *scopedConfirm {
|
||||
for _, row := range ev.Confirms {
|
||||
req := cmdbus.Request{}
|
||||
if err := json.Unmarshal([]byte(row.Payload), &req); err != nil {
|
||||
continue
|
||||
}
|
||||
if req.ConversationID != "" && req.ConversationID != string(ev.Run.ConversationID) {
|
||||
continue
|
||||
}
|
||||
if req.Owner != "" && string(ev.Run.Owner) != "" && req.Owner != string(ev.Run.Owner) {
|
||||
continue
|
||||
}
|
||||
created, err := time.Parse(time.RFC3339, row.CreatedAt)
|
||||
if err == nil && !ev.Run.CreatedAt.IsZero() && created.Before(ev.Run.CreatedAt) {
|
||||
continue // 挂起早于本次 run:属于更早的等待
|
||||
}
|
||||
return &scopedConfirm{row: row, command: req.ID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hitlCardID 给出可解析的卡标识:HITL 卡优先,其次决策卡 ID。
|
||||
func hitlCardID(card *core.HITLCard, decision *store.HumanDecision) string {
|
||||
if card != nil {
|
||||
return string(card.ID)
|
||||
}
|
||||
if decision != nil {
|
||||
return decision.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ev Evidence) withNote(note string) Evidence {
|
||||
ev.Notes = append(ev.Notes, note)
|
||||
return ev
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
package rundiag
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
var (
|
||||
baseTime = time.Date(2026, 10, 20, 10, 0, 0, 0, time.UTC)
|
||||
afterRun = baseTime.Add(5 * time.Minute)
|
||||
)
|
||||
|
||||
// fakeSource 是只读端口的合成替身:每个字段都能单独触发"没有记录"或"读取失败"。
|
||||
type fakeSource struct {
|
||||
hold store.RecoveryHold
|
||||
holdErr error
|
||||
cards []core.HITLCard
|
||||
decisions []store.HumanDecision
|
||||
confirms []store.PendingConfirm
|
||||
cp store.RunCheckpoint
|
||||
cpOK bool
|
||||
audits []agentcommand.AuditEnvelope
|
||||
}
|
||||
|
||||
func (f fakeSource) GetRecoveryHold(string) (store.RecoveryHold, error) {
|
||||
if f.holdErr != nil {
|
||||
return store.RecoveryHold{}, f.holdErr
|
||||
}
|
||||
if f.hold.RunID == "" {
|
||||
return store.RecoveryHold{}, store.ErrNotFound
|
||||
}
|
||||
return f.hold, nil
|
||||
}
|
||||
|
||||
func (f fakeSource) ActiveHITLCardsByRun(string) ([]core.HITLCard, error) { return f.cards, nil }
|
||||
|
||||
func (f fakeSource) PendingHumanDecisionsByConversation(string, string, time.Time) ([]store.HumanDecision, error) {
|
||||
return f.decisions, nil
|
||||
}
|
||||
|
||||
func (f fakeSource) ListOwnerPendingConfirms(string, int) ([]store.PendingConfirm, error) {
|
||||
return f.confirms, nil
|
||||
}
|
||||
|
||||
func (f fakeSource) LastRunCheckpoint(string, string) (store.RunCheckpoint, bool, error) {
|
||||
return f.cp, f.cpOK, nil
|
||||
}
|
||||
|
||||
func (f fakeSource) ListAudits(string, string, int) ([]agentcommand.AuditEnvelope, error) {
|
||||
return f.audits, nil
|
||||
}
|
||||
|
||||
func run(status core.RunStatus) core.Run {
|
||||
return core.Run{
|
||||
ID: "run_1", ConversationID: "cnv_1", ProjectID: "prj_1", Owner: "alice",
|
||||
Kind: core.RunConversation, Status: status, Phase: core.PhaseResponding, CreatedAt: baseTime,
|
||||
}
|
||||
}
|
||||
|
||||
func ended(r core.Run, at time.Time) core.Run {
|
||||
r.EndedAt = &at
|
||||
return r
|
||||
}
|
||||
|
||||
func checkpoint() *store.RunCheckpoint {
|
||||
return &store.RunCheckpoint{Seq: 7, TS: baseTime.Add(2 * time.Minute).Format(time.RFC3339), Type: "agent.progress"}
|
||||
}
|
||||
|
||||
// A2 契约:每个 responsibility.state 都有驱动它的记录,且 next_step 逐状态映射正确。
|
||||
func TestBuildResponsibilityStatesAndNextStep(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ev Evidence
|
||||
state string
|
||||
role string
|
||||
action string
|
||||
cardID string
|
||||
waitHint string
|
||||
since string
|
||||
}{
|
||||
{
|
||||
name: "unreleased recovery hold wins over everything",
|
||||
ev: Evidence{
|
||||
Run: run(core.RunRunning),
|
||||
Hold: &store.RecoveryHold{RunID: "run_1", Owner: "alice", Reason: "unknown external effect",
|
||||
CreatedAt: afterRun.Format(time.RFC3339)},
|
||||
Cards: []core.HITLCard{{ID: "card_hitl", Resolved: false, Question: "approve?"}},
|
||||
},
|
||||
state: StateRecoveryHold, role: RoleOperationOwner, action: ActionReleaseRecoveryHold,
|
||||
waitHint: "unknown external effect", since: afterRun.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "released hold on an interrupted run needs reconciliation",
|
||||
ev: Evidence{
|
||||
Run: run(core.RunInterrupted),
|
||||
Hold: &store.RecoveryHold{RunID: "run_1", Owner: "alice", Reason: "unknown external effect",
|
||||
CreatedAt: afterRun.Format(time.RFC3339), ReleasedAt: afterRun.Add(time.Minute).Format(time.RFC3339)},
|
||||
},
|
||||
state: StateReconciling, role: RoleOperationOwner, action: ActionNeedsManualReconciliation,
|
||||
waitHint: "still interrupted", since: afterRun.Add(time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "waiting on a HITL card",
|
||||
ev: Evidence{
|
||||
Run: run(core.RunWaitingHITL),
|
||||
Cards: []core.HITLCard{{ID: "card_hitl", CreatedAt: afterRun.Format(time.RFC3339), Question: "approve the plan?"}},
|
||||
},
|
||||
state: StateWaitingHITL, role: RoleHuman, action: ActionConfirmCard, cardID: "card_hitl",
|
||||
waitHint: "approve the plan?", since: afterRun.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "waiting on a pending human decision without a run card",
|
||||
ev: Evidence{
|
||||
Run: run(core.RunRunning),
|
||||
Decisions: []store.HumanDecision{{ID: "hd_1", CommandID: "rdm.confirm", Status: "pending",
|
||||
Question: "approve delivery?", IssuedAt: afterRun, ExpiresAt: afterRun.Add(time.Hour)}},
|
||||
},
|
||||
state: StateWaitingHITL, role: RoleHuman, action: ActionConfirmCard, cardID: "hd_1",
|
||||
waitHint: "approve delivery?", since: afterRun.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "dangerous command awaiting confirmation",
|
||||
ev: Evidence{
|
||||
Run: run(core.RunRunning),
|
||||
Confirms: []store.PendingConfirm{{CardID: "card_cmd", Owner: "alice", CreatedAt: afterRun.Format(time.RFC3339),
|
||||
Payload: mustRequest(t, cmdbus.Request{ID: "rdm.docs.instantiate", Owner: "alice", ConversationID: "cnv_1"})}},
|
||||
},
|
||||
state: StateConfirmationPending, role: RoleHuman, action: ActionConfirmCard, cardID: "card_cmd",
|
||||
waitHint: "rdm.docs.instantiate", since: afterRun.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "terminal failure is a safe retry",
|
||||
ev: Evidence{
|
||||
Run: ended(func() core.Run {
|
||||
r := run(core.RunFailed)
|
||||
r.Error = &core.ErrorPart{Code: "run.tool_failed", Message: "tool crashed"}
|
||||
return r
|
||||
}(), afterRun),
|
||||
},
|
||||
state: StateFailed, role: RoleRunner, action: ActionSafeRetry,
|
||||
waitHint: "run.tool_failed: tool crashed", since: afterRun.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "terminal failure with an unknown receipt needs reconciliation, not a retry",
|
||||
ev: Evidence{
|
||||
Run: ended(run(core.RunFailed), afterRun),
|
||||
Audits: []agentcommand.AuditEnvelope{{Time: afterRun, CommandID: "rdm.docs.instantiate",
|
||||
OperationID: "op_unknown", State: agentcommand.StateUnknown, ConversationID: "cnv_1"}},
|
||||
},
|
||||
state: StateFailed, role: RoleRunner, action: ActionNeedsManualReconciliation,
|
||||
waitHint: "run failed",
|
||||
},
|
||||
{
|
||||
name: "completed needs no action",
|
||||
ev: Evidence{Run: ended(run(core.RunCompleted), afterRun)},
|
||||
state: StateCompleted, role: RoleNone, action: ActionNone,
|
||||
waitHint: "completed", since: afterRun.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "executing run stays with the runner",
|
||||
ev: Evidence{Run: run(core.RunRunning)},
|
||||
state: StateRunning, role: RoleRunner, action: ActionNone,
|
||||
waitHint: "executing", since: baseTime.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "pending run reads as running",
|
||||
ev: Evidence{Run: run(core.RunPending)},
|
||||
state: StateRunning, role: RoleRunner, action: ActionNone,
|
||||
waitHint: "executing",
|
||||
},
|
||||
{
|
||||
name: "cancelled has no mapping and degrades to unknown",
|
||||
ev: Evidence{Run: ended(run(core.RunCancelled), afterRun)},
|
||||
state: StateUnknown, role: RoleNone, action: ActionNone,
|
||||
waitHint: "no diagnostic mapping",
|
||||
},
|
||||
{
|
||||
name: "interrupted without any hold record degrades to unknown",
|
||||
ev: Evidence{Run: run(core.RunInterrupted)},
|
||||
state: StateUnknown, role: RoleNone, action: ActionNone,
|
||||
waitHint: "no diagnostic mapping",
|
||||
},
|
||||
{
|
||||
name: "unreadable hold is fail-closed unknown, never a safe retry",
|
||||
ev: Evidence{Run: run(core.RunFailed), HoldUnknown: true},
|
||||
state: StateUnknown, role: RoleNone, action: ActionNone,
|
||||
waitHint: "could not be read",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rep := Build(tc.ev)
|
||||
if rep.Responsibility.State != tc.state {
|
||||
t.Fatalf("state=%q want %q (responsibility=%+v)", rep.Responsibility.State, tc.state, rep.Responsibility)
|
||||
}
|
||||
if rep.Responsibility.Role != tc.role {
|
||||
t.Fatalf("role=%q want %q", rep.Responsibility.Role, tc.role)
|
||||
}
|
||||
if rep.NextStep.Action != tc.action {
|
||||
t.Fatalf("next_step=%q want %q (reason=%q)", rep.NextStep.Action, tc.action, rep.NextStep.Reason)
|
||||
}
|
||||
if rep.NextStep.CardID != tc.cardID {
|
||||
t.Fatalf("next_step.card_id=%q want %q", rep.NextStep.CardID, tc.cardID)
|
||||
}
|
||||
if tc.waitHint != "" && !strings.Contains(rep.Responsibility.WaitReason, tc.waitHint) {
|
||||
t.Fatalf("wait_reason=%q does not contain %q", rep.Responsibility.WaitReason, tc.waitHint)
|
||||
}
|
||||
if tc.since != "" && rep.Responsibility.Since != tc.since {
|
||||
t.Fatalf("since=%q want %q", rep.Responsibility.Since, tc.since)
|
||||
}
|
||||
if rep.NextStep.Reason == "" {
|
||||
t.Fatal("next_step.reason must never be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 副作用聚合:命令回执对账记录并入;会话里更早的记录不算本次 run 的副作用。
|
||||
func TestBuildSideEffectsAggregateRecordedOperations(t *testing.T) {
|
||||
ev := Evidence{
|
||||
Run: run(core.RunFailed),
|
||||
Audits: []agentcommand.AuditEnvelope{
|
||||
{Time: baseTime.Add(-time.Hour), CommandID: "rdm.reqs.add", OperationID: "op_older",
|
||||
State: agentcommand.StateCommitted, ConversationID: "cnv_1"},
|
||||
{Time: afterRun, CommandID: "rdm.docs.instantiate", OperationID: "op_1",
|
||||
State: agentcommand.StateUnknown, ConversationID: "cnv_1"},
|
||||
{Time: afterRun, CommandID: "other.run.command", OperationID: "op_2",
|
||||
State: agentcommand.StateCommitted, ConversationID: "cnv_other"},
|
||||
{Time: afterRun, CommandID: "rdm.trace.confirm", OperationID: "op_3",
|
||||
State: agentcommand.StateAccepted, ConversationID: "cnv_1", RunID: "run_1"},
|
||||
},
|
||||
}
|
||||
rep := Build(ev)
|
||||
if len(rep.SideEffects) != 2 {
|
||||
t.Fatalf("side effects must hold only this run's receipts: %+v", rep.SideEffects)
|
||||
}
|
||||
got := map[string]SideEffect{}
|
||||
for _, se := range rep.SideEffects {
|
||||
got[se.OperationID] = se
|
||||
}
|
||||
recorded, ok := got["op_1"]
|
||||
if !ok || recorded.Kind != EffectOperation || recorded.CommandID != "rdm.docs.instantiate" || recorded.State != string(agentcommand.StateUnknown) {
|
||||
t.Fatalf("recorded operation missing from aggregation: %+v", rep.SideEffects)
|
||||
}
|
||||
if _, ok := got["op_older"]; ok {
|
||||
t.Fatal("a receipt issued before the run started must not be attributed to it")
|
||||
}
|
||||
if _, ok := got["op_2"]; ok {
|
||||
t.Fatal("a receipt from another conversation must not be attributed to this run")
|
||||
}
|
||||
if _, ok := got["op_3"]; !ok {
|
||||
t.Fatal("a receipt carrying this run id must be attributed to it")
|
||||
}
|
||||
}
|
||||
|
||||
// hold 行本身也是"副作用结果未知"的持久证据(U3b),并标出是否已解除。
|
||||
func TestBuildSideEffectsIncludeRecoveryHoldEvidence(t *testing.T) {
|
||||
ev := Evidence{Run: run(core.RunInterrupted), Hold: &store.RecoveryHold{RunID: "run_1", Owner: "alice",
|
||||
Reason: "unknown", CreatedAt: afterRun.Format(time.RFC3339)}}
|
||||
rep := Build(ev)
|
||||
if len(rep.SideEffects) != 1 || rep.SideEffects[0].Kind != EffectRecoveryHold || rep.SideEffects[0].State != "held" {
|
||||
t.Fatalf("open hold must show as held evidence: %+v", rep.SideEffects)
|
||||
}
|
||||
ev.Hold.ReleasedAt = afterRun.Add(time.Minute).Format(time.RFC3339)
|
||||
rep = Build(ev)
|
||||
if rep.SideEffects[0].State != "released" || rep.SideEffects[0].ReleasedAt == "" {
|
||||
t.Fatalf("released hold must keep its release time: %+v", rep.SideEffects[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 没有落盘检查点时给 null + note,绝不编一个;空副作用同样带 note。
|
||||
func TestBuildNeverFabricatesCheckpointOrAbsence(t *testing.T) {
|
||||
rep := Build(Evidence{Run: run(core.RunRunning)})
|
||||
if rep.LastCheckpoint != nil {
|
||||
t.Fatalf("checkpoint must stay null when nothing is persisted: %+v", rep.LastCheckpoint)
|
||||
}
|
||||
if !containsNote(rep.Notes, "checkpoint") {
|
||||
t.Fatalf("missing checkpoint note: %v", rep.Notes)
|
||||
}
|
||||
if !containsNote(rep.Notes, "absence of records is not proof") {
|
||||
t.Fatalf("missing side effect absence note: %v", rep.Notes)
|
||||
}
|
||||
if rep.SideEffects == nil {
|
||||
t.Fatal("side_effects must serialize as an array, never null")
|
||||
}
|
||||
withCheckpoint := Build(Evidence{Run: run(core.RunRunning), Checkpoint: checkpoint()})
|
||||
if withCheckpoint.LastCheckpoint == nil || withCheckpoint.LastCheckpoint.ID != "7" || withCheckpoint.Run.UpdatedAt == nil {
|
||||
t.Fatalf("persisted checkpoint must be surfaced: %+v", withCheckpoint)
|
||||
}
|
||||
if *withCheckpoint.Run.UpdatedAt != checkpoint().TS {
|
||||
t.Fatalf("updated_at must fall back to the last persisted event: %v", *withCheckpoint.Run.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Gather:只读端口的读齐与降级(读不到隔离记录按 fail-closed)。
|
||||
func TestGatherReadsEvidenceAndDegradesOnError(t *testing.T) {
|
||||
src := fakeSource{
|
||||
hold: store.RecoveryHold{RunID: "run_1", Owner: "alice", Reason: "unknown", CreatedAt: afterRun.Format(time.RFC3339)},
|
||||
cards: []core.HITLCard{{ID: "card_hitl"}},
|
||||
cp: store.RunCheckpoint{Seq: 3, TS: afterRun.Format(time.RFC3339), Type: "agent.progress"},
|
||||
cpOK: true,
|
||||
audits: []agentcommand.AuditEnvelope{{Time: afterRun, CommandID: "rdm.confirm", State: agentcommand.StateAccepted}},
|
||||
confirms: []store.PendingConfirm{{CardID: "card_cmd", Owner: "alice", CreatedAt: afterRun.Format(time.RFC3339), Payload: "{}"}},
|
||||
}
|
||||
ev := Gather(src, run(core.RunRunning), afterRun.Add(time.Minute))
|
||||
if ev.Hold == nil || ev.HoldUnknown || len(ev.Cards) != 1 || ev.Checkpoint == nil || len(ev.Audits) != 1 || len(ev.Confirms) != 1 {
|
||||
t.Fatalf("gather missed evidence: %+v", ev)
|
||||
}
|
||||
if rep := Build(ev); rep.Responsibility.State != StateRecoveryHold {
|
||||
t.Fatalf("state=%q want %q", rep.Responsibility.State, StateRecoveryHold)
|
||||
}
|
||||
|
||||
degraded := Gather(fakeSource{holdErr: errors.New("db gone")}, run(core.RunRunning), afterRun)
|
||||
if !degraded.HoldUnknown || degraded.Hold != nil {
|
||||
t.Fatalf("an unreadable hold must stay unknown, never absent: %+v", degraded)
|
||||
}
|
||||
rep := Build(degraded)
|
||||
if rep.Responsibility.State != StateUnknown || rep.NextStep.Action == ActionSafeRetry {
|
||||
t.Fatalf("unreadable hold must not yield a retry: %+v", rep)
|
||||
}
|
||||
if !containsNote(rep.Notes, "fail-closed") && !containsNote(rep.Notes, "quarantined") {
|
||||
t.Fatalf("missing fail-closed note: %v", rep.Notes)
|
||||
}
|
||||
|
||||
// 没有隔离行是正常态:不产生噪音 note。
|
||||
normal := Build(Gather(fakeSource{}, run(core.RunRunning), afterRun))
|
||||
if containsNote(normal.Notes, "recovery hold unreadable") {
|
||||
t.Fatalf("a missing hold row is not a degraded read: %v", normal.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
// 契约形状:snake_case 字段名固定,last_checkpoint 为 null 时显式出现。
|
||||
func TestReportJSONContract(t *testing.T) {
|
||||
raw, err := json.Marshal(Build(Evidence{Run: run(core.RunRunning), Checkpoint: checkpoint()}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"run", "responsibility", "last_checkpoint", "side_effects", "next_step"} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Fatalf("missing contract key %q in %s", key, raw)
|
||||
}
|
||||
}
|
||||
responsibility, _ := out["responsibility"].(map[string]any)
|
||||
for _, key := range []string{"state", "role", "wait_reason", "since"} {
|
||||
if _, ok := responsibility[key]; !ok {
|
||||
t.Fatalf("missing responsibility key %q in %s", key, raw)
|
||||
}
|
||||
}
|
||||
runSummary, _ := out["run"].(map[string]any)
|
||||
for _, key := range []string{"id", "status", "owner", "project", "conversation", "created_at", "updated_at"} {
|
||||
if _, ok := runSummary[key]; !ok {
|
||||
t.Fatalf("missing run key %q in %s", key, raw)
|
||||
}
|
||||
}
|
||||
nextStep, _ := out["next_step"].(map[string]any)
|
||||
for _, key := range []string{"action", "reason"} {
|
||||
if _, ok := nextStep[key]; !ok {
|
||||
t.Fatalf("missing next_step key %q in %s", key, raw)
|
||||
}
|
||||
}
|
||||
|
||||
empty, err := json.Marshal(Build(Evidence{Run: run(core.RunRunning)}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var nulled map[string]any
|
||||
if err := json.Unmarshal(empty, &nulled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nulled["last_checkpoint"] != nil {
|
||||
t.Fatalf("last_checkpoint must be null (not omitted) when nothing is persisted: %s", empty)
|
||||
}
|
||||
if _, ok := nulled["side_effects"].([]any); !ok {
|
||||
t.Fatalf("side_effects must be an array: %s", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// 归属边界:会话里更早签发的决策卡与确认行不算本次 run 在等的东西。
|
||||
func TestBuildIgnoresEvidenceIssuedBeforeTheRun(t *testing.T) {
|
||||
ev := Evidence{
|
||||
Run: run(core.RunRunning),
|
||||
Decisions: []store.HumanDecision{{ID: "hd_old", Status: "pending", Question: "older?",
|
||||
IssuedAt: baseTime.Add(-time.Hour), ExpiresAt: baseTime.Add(time.Hour)}},
|
||||
Confirms: []store.PendingConfirm{{CardID: "card_old", Owner: "alice",
|
||||
CreatedAt: baseTime.Add(-time.Hour).Format(time.RFC3339),
|
||||
Payload: mustRequest(t, cmdbus.Request{ID: "rdm.confirm", Owner: "alice", ConversationID: "cnv_1"})}},
|
||||
}
|
||||
rep := Build(ev)
|
||||
if rep.Responsibility.State != StateRunning {
|
||||
t.Fatalf("state=%q want %q: earlier conversation evidence must not hold this run", rep.Responsibility.State, StateRunning)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有 run 状态写着 waiting_hitl、没有可解析的卡时:状态仍然成立,但必须说清楚
|
||||
// 等待起点没被存储。
|
||||
func TestBuildStatusDrivenWaitHasNoCardAndSaysSo(t *testing.T) {
|
||||
rep := Build(Evidence{Run: run(core.RunWaitingHITL)})
|
||||
if rep.Responsibility.State != StateWaitingHITL || rep.NextStep.Action != ActionConfirmCard {
|
||||
t.Fatalf("status-driven wait: %+v", rep.Responsibility)
|
||||
}
|
||||
if rep.NextStep.CardID != "" {
|
||||
t.Fatalf("an unresolvable card must not be invented: %q", rep.NextStep.CardID)
|
||||
}
|
||||
if rep.Responsibility.Since != "" {
|
||||
t.Fatalf("unknown wait entry time must stay empty: %q", rep.Responsibility.Since)
|
||||
}
|
||||
if !containsNote(rep.Notes, "wait entry time is not stored") {
|
||||
t.Fatalf("missing status-driven wait note: %v", rep.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRequest(t *testing.T, req cmdbus.Request) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func containsNote(notes []string, want string) bool {
|
||||
for _, n := range notes {
|
||||
if strings.Contains(n, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -11,30 +11,38 @@ import (
|
||||
// toolResultSummaryLen 是工具结果摘要的截断长度(与既有展示一致)。
|
||||
const toolResultSummaryLen = 160
|
||||
|
||||
// toolStatusOK 是宿主展示的工具结局取值域(ok | error | denied | canceled)。
|
||||
// squad.ProcessEvent 的 Status 字段与 squad.ToolStatus* 常量已在上游 main 被移除,
|
||||
// 这里保留同名的宿主侧常量,使部件与发射载荷继续用同一取值域,不改语义。
|
||||
const toolStatusOK = "ok"
|
||||
|
||||
// toolEventID 是宿主侧的工具调用身份。
|
||||
//
|
||||
// 上游 squad.ProcessEvent 已不再携带每次调用的 CallID(并行同名工具的按 ID 配对
|
||||
// 能力随该字段一并移除),因此退到既有的 legacy 口径:按工具名配对 start/end。
|
||||
// 上游恢复 CallID 时只需在此处换回优先取 ID。
|
||||
func toolEventID(ev squad.ProcessEvent) string { return ev.Tool }
|
||||
|
||||
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的结局。
|
||||
//
|
||||
// 上游不再提供 Status/ErrorReason,无法区分 error/denied/canceled,故按最接近的
|
||||
// 等价口径处理:结局报 ok(不臆造上游没给的判定),摘要取上游实际带来的工具结果
|
||||
// ——新 API 把结果放在 Text(Detail 只在 start 帧携带调用参数),Text 为空时回退
|
||||
// Detail,保持 legacy 帧的字节一致。
|
||||
func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) {
|
||||
summary = ev.Text
|
||||
if summary == "" {
|
||||
summary = ev.Detail
|
||||
// toolEventID 是宿主侧的工具调用身份:优先用上游 CallID(并行同名工具据此配对,
|
||||
// 而不是按到达顺序);空串 = legacy/未知,退回工具名,保持既有展示不变。
|
||||
func toolEventID(ev squad.ProcessEvent) string {
|
||||
if id := strings.TrimSpace(ev.CallID); id != "" {
|
||||
return id
|
||||
}
|
||||
return toolStatusOK, truncateRun(summary, toolResultSummaryLen)
|
||||
return ev.Tool
|
||||
}
|
||||
|
||||
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的真实结局。
|
||||
//
|
||||
// 取值域与上游一致(ok | error | denied | canceled),不新增、不改写上游状态:
|
||||
// - Status 非空:原样透传;
|
||||
// - Status 为空且没有失败信号(ErrorReason 也空):legacy/未知,沿用旧的 "ok"
|
||||
// 与 Detail 摘要(字节一致,不臆造结局);
|
||||
// - Status 为空但有 ErrorReason:有失败信号而状态未知,报 error 而不是 ok;
|
||||
// - 非 ok 时摘要取 ErrorReason 原文(截断),上游没给原因才回退 Detail。
|
||||
func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) {
|
||||
status = strings.TrimSpace(ev.Status)
|
||||
if status == "" {
|
||||
if strings.TrimSpace(ev.ErrorReason) == "" {
|
||||
return squad.ToolStatusOK, truncateRun(ev.Detail, toolResultSummaryLen)
|
||||
}
|
||||
status = squad.ToolStatusError
|
||||
}
|
||||
if status == squad.ToolStatusOK {
|
||||
return status, truncateRun(ev.Detail, toolResultSummaryLen)
|
||||
}
|
||||
if strings.TrimSpace(ev.ErrorReason) != "" {
|
||||
return status, truncateRun(ev.ErrorReason, toolResultSummaryLen)
|
||||
}
|
||||
return status, truncateRun(ev.Detail, toolResultSummaryLen)
|
||||
}
|
||||
|
||||
// toolFailure 是一次未被恢复的工具失败(用于终局文本一致性检查,不覆盖工具部件)。
|
||||
@@ -100,7 +108,7 @@ func (l *squadEventLog) onEvent(ev squad.ProcessEvent) {
|
||||
// 发射与部件同源:前端过程行与落库部件说的是同一件事。
|
||||
_ = l.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: id, Status: status, Summary: summary})
|
||||
l.parts = append(l.parts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: id, Status: status, Summary: summary}})
|
||||
if status == toolStatusOK {
|
||||
if status == squad.ToolStatusOK {
|
||||
l.noteRecovered(ev.Tool)
|
||||
} else {
|
||||
l.failure = &toolFailure{Tool: ev.Tool, CallID: id, Status: status, Summary: summary}
|
||||
@@ -151,8 +159,7 @@ func (l *squadEventLog) unresolvedFailure() *toolFailure {
|
||||
|
||||
// finalReply 决定终局文本。模型文本原样保留(不代写、不改写);只有宿主兜底的
|
||||
// 「已完成。」在仍有未恢复的工具失败时会被换成如实说明,避免收尾文本与失败部件
|
||||
// 自相矛盾。上游 main 的事件帧不带结局判定(见 toolEventOutcome),宿主不臆造
|
||||
// 失败,因此该分支暂时走不到:失败聚合原样保留,上游恢复结局信号即重新生效。
|
||||
// 自相矛盾。legacy 事件(无 CallID/Status)走不到这里,展示与旧行为一致。
|
||||
func (l *squadEventLog) finalReply(modelText, summary, fallback string) string {
|
||||
reply := strings.TrimSpace(modelText)
|
||||
if reply == "" {
|
||||
|
||||
@@ -69,47 +69,43 @@ func toolResultsOf(parts []core.Part) []core.ToolResultPart {
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSquadEventsPairSameNameToolsByToolName:squad.ProcessEvent 在上游 main 已不再
|
||||
// 携带 CallID,宿主退回 legacy 口径按工具名配对 start/end:同名工具不再按 ID 区分,
|
||||
// 但到达顺序与每帧的内容必须一一落成部件,不得丢失或错位。
|
||||
func TestSquadEventsPairSameNameToolsByToolName(t *testing.T) {
|
||||
// TestSquadEventsPairByCallID:两个同名工具的 start/end 必须按 CallID 配对,
|
||||
// 不得因名字相同而互相对调(这是并行同名工具显示错乱的根因)。
|
||||
func TestSquadEventsPairByCallID(t *testing.T) {
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"a"}`})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"b"}`})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_a", Detail: `{"q":"a"}`})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_b", Detail: `{"q":"b"}`})
|
||||
// 交错到达:B 先结束。
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "命中 B"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "路径不存在"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_b", Status: squad.ToolStatusOK, Detail: "命中 B"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_a", Status: squad.ToolStatusError, ErrorReason: "路径不存在"})
|
||||
|
||||
parts := p.parts()
|
||||
calls := toolCallsOf(parts)
|
||||
if len(calls) != 2 || calls[0].ID != "grep" || calls[1].ID != "grep" {
|
||||
if len(calls) != 2 || calls[0].ID != "call_a" || calls[1].ID != "call_b" {
|
||||
t.Fatalf("tool call parts = %+v", calls)
|
||||
}
|
||||
emittedCalls := p.calls()
|
||||
if len(emittedCalls) != 2 || emittedCalls[0].CallID != "grep" || emittedCalls[1].CallID != "grep" || emittedCalls[0].Name != "grep" {
|
||||
if len(emittedCalls) != 2 || emittedCalls[0].CallID != "call_a" || emittedCalls[1].CallID != "call_b" {
|
||||
t.Fatalf("emitted tool calls = %+v", emittedCalls)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("tool result parts = %+v", results)
|
||||
}
|
||||
// 到达顺序保留:先到的 end 先落部件。
|
||||
if results[0].CallID != "grep" || results[0].Status != toolStatusOK || results[0].Summary != "命中 B" {
|
||||
if results[0].CallID != "call_b" || results[0].Status != squad.ToolStatusOK || results[0].Summary != "命中 B" {
|
||||
t.Fatalf("result B = %+v", results[0])
|
||||
}
|
||||
if results[1].CallID != "grep" || results[1].Status != toolStatusOK || results[1].Summary != "路径不存在" {
|
||||
if results[1].CallID != "call_a" || results[1].Status != squad.ToolStatusError || results[1].Summary != "路径不存在" {
|
||||
t.Fatalf("result A = %+v", results[1])
|
||||
}
|
||||
// 上游没给失败结局,宿主不得自己臆造一次失败。
|
||||
if p.rec.unresolvedFailure() != nil {
|
||||
t.Fatalf("no upstream verdict must not register a failure: %+v", p.rec.unresolvedFailure())
|
||||
if p.rec.unresolvedFailure() == nil || p.rec.unresolvedFailure().CallID != "call_a" {
|
||||
t.Fatalf("unresolved failure = %+v", p.rec.unresolvedFailure())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsOutcomeFollowsUpstreamFields:上游 main 移除了 ProcessEvent.Status /
|
||||
// ErrorReason(ToolStatus* 常量同时消失),结局只能取最接近的等价口径:恒为 ok,
|
||||
// 摘要取上游实际传来的工具结果文本(Text,为空时回退 Detail)。
|
||||
func TestSquadEventsOutcomeFollowsUpstreamFields(t *testing.T) {
|
||||
// TestSquadEventsStatusIsTruthful:失败/取消/拒绝必须原样透传,不得一律报 ok,
|
||||
// 摘要取真实原因(截断),部件与发射载荷完全一致。
|
||||
func TestSquadEventsStatusIsTruthful(t *testing.T) {
|
||||
long := strings.Repeat("失败原因", 80)
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -117,11 +113,17 @@ func TestSquadEventsOutcomeFollowsUpstreamFields(t *testing.T) {
|
||||
status string
|
||||
summary string
|
||||
}{
|
||||
{"result text", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "命中 3 处"}, toolStatusOK, "命中 3 处"},
|
||||
{"legacy detail", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Detail: "命中 3 处"}, toolStatusOK, "命中 3 处"},
|
||||
{"no payload", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep"}, toolStatusOK, ""},
|
||||
// 上游把失败原因也放在结果文本里(无独立字段)时不得丢。
|
||||
{"failure text", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", Text: "权限不足"}, toolStatusOK, "权限不足"},
|
||||
{"error", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c1",
|
||||
Status: squad.ToolStatusError, ErrorReason: "权限不足", Detail: "旧摘要"}, squad.ToolStatusError, "权限不足"},
|
||||
{"denied", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
|
||||
Status: squad.ToolStatusDenied, ErrorReason: "策略拒绝执行"}, squad.ToolStatusDenied, "策略拒绝执行"},
|
||||
{"canceled", squad.ProcessEvent{Type: "tool_execution_end", Tool: "web_search", CallID: "c3",
|
||||
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"}, squad.ToolStatusCanceled, "用户取消"},
|
||||
{"ok", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c4",
|
||||
Status: squad.ToolStatusOK, Detail: "命中 3 处"}, squad.ToolStatusOK, "命中 3 处"},
|
||||
// 有失败信号但状态未知:不得报 ok。
|
||||
{"status-less error signal", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c5",
|
||||
ErrorReason: "连接被拒绝", Detail: "旧摘要"}, squad.ToolStatusError, "连接被拒绝"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -130,28 +132,28 @@ func TestSquadEventsOutcomeFollowsUpstreamFields(t *testing.T) {
|
||||
t.Fatalf("outcome = %q/%q, want %q/%q", status, summary, tc.status, tc.summary)
|
||||
}
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool, CallID: tc.ev.CallID})
|
||||
p.onEvent(tc.ev)
|
||||
got := toolResultsOf(p.parts())
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("parts = %+v", p.parts())
|
||||
}
|
||||
// 落库部件与发射载荷同源。
|
||||
if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.Tool {
|
||||
if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.CallID {
|
||||
t.Fatalf("result part = %+v", got[0])
|
||||
}
|
||||
wantPayload := core.ToolResultPayload{CallID: tc.ev.Tool, Status: tc.status, Summary: tc.summary}
|
||||
wantPayload := core.ToolResultPayload{CallID: tc.ev.CallID, Status: tc.status, Summary: tc.summary}
|
||||
if gotPayloads := p.results(); len(gotPayloads) != 1 || gotPayloads[0] != wantPayload {
|
||||
t.Fatalf("emitted payload = %+v", gotPayloads)
|
||||
}
|
||||
if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.Tool || gotCalls[0].Name != tc.ev.Tool {
|
||||
if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.CallID || gotCalls[0].Name != tc.ev.Tool {
|
||||
t.Fatalf("emitted tool call = %+v", gotCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("summary truncated", func(t *testing.T) {
|
||||
status, summary := toolEventOutcome(squad.ProcessEvent{Text: long})
|
||||
if status != toolStatusOK {
|
||||
status, summary := toolEventOutcome(squad.ProcessEvent{Status: squad.ToolStatusError, ErrorReason: long})
|
||||
if status != squad.ToolStatusError {
|
||||
t.Fatalf("status = %q", status)
|
||||
}
|
||||
if want := truncateRun(long, toolResultSummaryLen); summary != want {
|
||||
@@ -200,19 +202,30 @@ func TestSquadEventsLegacyFramesUnchanged(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSquadEventsConcurrentCallbacksKeepEveryEntry:并行回调(-race)下不得丢条目、
|
||||
// 不得串字段;每次调用自己的 start/end 仍成对。身份退到工具名后用互不相同的
|
||||
// 工具名保证配对关系仍然可断言。
|
||||
// 不得串字段;每次调用自己的 start/end 仍成对。
|
||||
func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) {
|
||||
const calls = 64
|
||||
p := newEventProbe()
|
||||
wantStatus := map[string]string{}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < calls; i++ {
|
||||
name := fmt.Sprintf("tool_%02d", i)
|
||||
id := fmt.Sprintf("call_%02d", i)
|
||||
wantStatus[id] = squad.ToolStatusOK
|
||||
failing := i%8 == 0
|
||||
if failing {
|
||||
wantStatus[id] = squad.ToolStatusError
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: name, Detail: name})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: name, Text: "ok " + name})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: id, Detail: id})
|
||||
if failing {
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
|
||||
Status: squad.ToolStatusError, ErrorReason: "boom " + id})
|
||||
return
|
||||
}
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
|
||||
Status: squad.ToolStatusOK, Detail: "ok " + id})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -243,8 +256,8 @@ func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for id, status := range emitted {
|
||||
if status != toolStatusOK {
|
||||
t.Fatalf("end %s status = %q, want %q", id, status, toolStatusOK)
|
||||
if status != wantStatus[id] {
|
||||
t.Fatalf("end %s status = %q, want %q", id, status, wantStatus[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,35 +295,37 @@ func TestSquadEventsConcurrentToolCallbacksShareArtifactList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsFinalTextFollowsUpstreamOutcome:上游未给出失败结局时,宿主不得
|
||||
// 自己臆造一次失败去改写收尾文本——模型原话与兜底文案都保持不变,工具结果部件
|
||||
// 照实记录上游传来的结果文本。宿主侧的失败聚合结构原样保留,等上游恢复结局信号
|
||||
// 即重新生效。
|
||||
func TestSquadEventsFinalTextFollowsUpstreamOutcome(t *testing.T) {
|
||||
// TestSquadEventsFinalTextMatchesToolOutcome:未恢复的失败不得被收尾文本改写成成功;
|
||||
// 同名工具重试成功后收尾文本恢复旧行为。
|
||||
func TestSquadEventsFinalTextMatchesToolOutcome(t *testing.T) {
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", Text: "用户取消"})
|
||||
if got := p.rec.finalReply("", "", "已完成。"); got != "已完成。" {
|
||||
t.Fatalf("no upstream verdict must not rewrite the fallback: %q", got)
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
|
||||
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"})
|
||||
if got := p.rec.finalReply("", "", "已完成。"); got == "已完成。" || !strings.Contains(got, "run_command") || !strings.Contains(got, "用户取消") {
|
||||
t.Fatalf("fallback text = %q", got)
|
||||
}
|
||||
if got := p.rec.finalReply("已按你的要求处理。", "", "已完成。"); got != "已按你的要求处理。" {
|
||||
t.Fatalf("model text must be preserved: %q", got)
|
||||
}
|
||||
// 结果部件仍在最终部件里,不被收尾文本覆盖。
|
||||
if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != toolStatusOK || results[0].Summary != "用户取消" {
|
||||
t.Fatalf("result part dropped: %+v", results)
|
||||
}
|
||||
if f := p.rec.unresolvedFailure(); f != nil {
|
||||
t.Fatalf("no upstream verdict must not register a failure: %+v", f)
|
||||
// 失败部件必须留在最终部件里,不被收尾文本覆盖。
|
||||
if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != squad.ToolStatusCanceled {
|
||||
t.Fatalf("failure part dropped: %+v", results)
|
||||
}
|
||||
|
||||
// 摘要截断口径不变。
|
||||
long := strings.Repeat("结果", 200)
|
||||
// 同名工具重试成功 → 失败记为已恢复,兜底文本回到旧行为。
|
||||
q := newEventProbe()
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: long})
|
||||
got := toolResultsOf(q.parts())
|
||||
if len(got) != 1 || got[0].Summary != truncateRun(long, toolResultSummaryLen) {
|
||||
t.Fatalf("truncated summary = %+v", got)
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
|
||||
Status: squad.ToolStatusError, ErrorReason: "超时"})
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c2"})
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
|
||||
Status: squad.ToolStatusOK, Detail: "done"})
|
||||
if f := q.rec.unresolvedFailure(); f != nil {
|
||||
t.Fatalf("retry should clear the failure: %+v", f)
|
||||
}
|
||||
if got := q.rec.finalReply("", "", "已完成。"); got != "已完成。" {
|
||||
t.Fatalf("recovered run fallback = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,12 +365,9 @@ func (p *entryProbe) resultPayloads() []core.ToolResultPayload {
|
||||
return append([]core.ToolResultPayload(nil), p.results...)
|
||||
}
|
||||
|
||||
// TestSquadEntryToolFramesReachFinalParts:真实跑一次小队入口,工具帧必须落成部件。
|
||||
//
|
||||
// 上游 squad.ProcessEvent 在 pin 前移后不再携带 CallID/Status:调用身份退到工具名,
|
||||
// 结局恒为 ok(上游没给判定就不臆造),摘要取上游传来的工具结果文本——失败工具
|
||||
// 的失败原因也在这段文本里,仍会如实展示给用户。
|
||||
func TestSquadEntryToolFramesReachFinalParts(t *testing.T) {
|
||||
// TestSquadEntryFailedToolReachesFinalParts:真实跑一次小队入口,同名并行工具
|
||||
// 必须按各自 CallID 落部件,失败必须显示为失败,且不被收尾文本改写成成功。
|
||||
func TestSquadEntryFailedToolReachesFinalParts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := baseEntry(dir)
|
||||
rc := &entryProbe{entryFake: base}
|
||||
@@ -381,52 +393,50 @@ func TestSquadEntryToolFramesReachFinalParts(t *testing.T) {
|
||||
}
|
||||
parts := base.parts
|
||||
calls := toolCallsOf(parts)
|
||||
search := 0
|
||||
ids := map[string]bool{}
|
||||
for _, c := range calls {
|
||||
if c.Name != "knowledge_search" {
|
||||
continue
|
||||
}
|
||||
search++
|
||||
if c.ID != "knowledge_search" {
|
||||
t.Fatalf("tool call part lost its identity: %+v (all %+v)", c, calls)
|
||||
if c.ID == "knowledge_search" || ids[c.ID] {
|
||||
t.Fatalf("tool call part lost its call id: %+v (all %+v)", c, calls)
|
||||
}
|
||||
ids[c.ID] = true
|
||||
}
|
||||
if search != 2 {
|
||||
t.Fatalf("knowledge_search call parts = %d, want 2 (all %+v)", search, calls)
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("distinct call ids = %v", ids)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
var searchResults []core.ToolResultPart
|
||||
search := map[string]core.ToolResultPart{}
|
||||
for _, r := range results {
|
||||
if r.CallID == "knowledge_search" {
|
||||
searchResults = append(searchResults, r)
|
||||
if ids[r.CallID] {
|
||||
search[r.CallID] = r
|
||||
}
|
||||
}
|
||||
if len(searchResults) != 2 {
|
||||
if len(search) != 2 {
|
||||
t.Fatalf("tool results for knowledge_search = %+v", results)
|
||||
}
|
||||
for _, r := range searchResults {
|
||||
if r.Status != toolStatusOK {
|
||||
t.Fatalf("%s status = %q, want %q", r.CallID, r.Status, toolStatusOK)
|
||||
for id, r := range search {
|
||||
if r.Status != squad.ToolStatusError {
|
||||
t.Fatalf("%s status = %q, want error", id, r.Status)
|
||||
}
|
||||
if r.Summary != "索引不可用" {
|
||||
t.Fatalf("%s summary = %q", r.CallID, r.Summary)
|
||||
t.Fatalf("%s summary = %q", id, r.Summary)
|
||||
}
|
||||
}
|
||||
// 发射载荷与落库部件一致。
|
||||
emitted := 0
|
||||
// 发射载荷与落库部件一致:前端过程行不得说 ok。
|
||||
emitted := map[string]string{}
|
||||
for _, pl := range rc.resultPayloads() {
|
||||
if pl.CallID != "knowledge_search" {
|
||||
continue
|
||||
}
|
||||
emitted++
|
||||
if pl.Status != toolStatusOK || pl.Summary != "索引不可用" {
|
||||
t.Fatalf("emitted payload = %+v", pl)
|
||||
if ids[pl.CallID] {
|
||||
emitted[pl.CallID] = pl.Status
|
||||
}
|
||||
}
|
||||
if emitted != 2 {
|
||||
t.Fatalf("emitted knowledge_search results = %d, want 2", emitted)
|
||||
for id, r := range search {
|
||||
if emitted[id] != r.Status {
|
||||
t.Fatalf("emitted %s status = %q, part status = %q", id, emitted[id], r.Status)
|
||||
}
|
||||
}
|
||||
// 收尾文本保留模型原话。
|
||||
// 收尾文本保留模型原话,失败部件不被覆盖。
|
||||
if last := parts[len(parts)-1]; last.Type != core.PartText || last.Text != "写完了。" {
|
||||
t.Fatalf("final part = %+v", last)
|
||||
}
|
||||
|
||||
@@ -161,22 +161,17 @@ func TestMemberBasicCollaborateAndDocumentQualifyOverHTTP(t *testing.T) {
|
||||
t.Fatalf("%s=%+v want %+v", actionID, duty, want)
|
||||
}
|
||||
}
|
||||
// 三权动作是项目范围动作:现在随职位定义,并立刻成为默认值。
|
||||
// Member content is refused at the policy endpoint instead of being stored.
|
||||
role := decodeRoleHTTP(t, do(h, "POST", "/api/admin/roles", `{"name":"文档职责"}`, admin))
|
||||
accepted := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":1,"idempotencyKey":"duty-policy","changes":[{"actionId":"document.author","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if accepted.Code != http.StatusOK {
|
||||
t.Fatalf("document duty rejected: %d %s", accepted.Code, accepted.Body.String())
|
||||
}
|
||||
// 系统范围动作仍然整批拒绝,角色保持原样。
|
||||
refused := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":2,"idempotencyKey":"system-policy","changes":[{"actionId":"system.settings.write","allowed":true,"capAllowed":true}]}`, admin)
|
||||
`{"expectedRoleVersion":1,"idempotencyKey":"duty-policy","changes":[{"actionId":"document.author","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if refused.Code != http.StatusBadRequest {
|
||||
t.Fatalf("system row accepted: %d %s", refused.Code, refused.Body.String())
|
||||
t.Fatalf("document duty accepted: %d %s", refused.Code, refused.Body.String())
|
||||
}
|
||||
// The refused batch left the role untouched: same version, no permissions.
|
||||
stored := decodeRoleProjectAccess(t, do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin))
|
||||
if stored.RoleVersion != 2 || stored.ProjectAccess || len(stored.Permissions) != 1 || stored.Permissions[0].ActionID != "document.author" {
|
||||
t.Fatalf("refused system row changed the role: %+v", stored)
|
||||
if stored.RoleVersion != 1 || stored.ProjectAccess || len(stored.Permissions) != 0 {
|
||||
t.Fatalf("refused duty row changed the role: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -42,7 +41,6 @@ func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
|
||||
}
|
||||
|
||||
// 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。
|
||||
// agent-command main 契约禁止 unknown 携带 OperationID,句柄写在 Reason 里。
|
||||
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)
|
||||
@@ -50,17 +48,10 @@ func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
|
||||
resp := srv.Bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "demo.write", Owner: "alice", Actor: "alice", ConversationID: conversation.ID,
|
||||
})
|
||||
const marker = " (operation "
|
||||
reason := resp.Receipt.Reason
|
||||
at := strings.Index(reason, marker)
|
||||
operationID := ""
|
||||
if resp.Receipt.State == agentcommand.StateUnknown && resp.Receipt.OperationID == "" &&
|
||||
at >= 0 && strings.HasSuffix(reason, ")") {
|
||||
operationID = reason[at+len(marker) : len(reason)-1]
|
||||
}
|
||||
if operationID == "" {
|
||||
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()
|
||||
|
||||
@@ -638,19 +638,9 @@ func TestProjectIAMCapabilityExplainCreatorAndMember(t *testing.T) {
|
||||
t.Fatalf("linked cap accepted: %v", err)
|
||||
}
|
||||
permissions, err := f.st.RolePermissions(capped.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byAction := map[string]store.RolePermissionRecord{}
|
||||
for _, permission := range permissions {
|
||||
byAction[permission.ActionID] = permission
|
||||
}
|
||||
if !byAction["project.create"].Allowed || !byAction["project.create"].CapAllowed {
|
||||
if err != nil || len(permissions) != 1 || !permissions[0].Allowed || !permissions[0].CapAllowed {
|
||||
t.Fatalf("refused cap changed the role: %+v err=%v", permissions, err)
|
||||
}
|
||||
if row := byAction["project.members.manage"]; row.Allowed {
|
||||
t.Fatalf("refused cap persisted: %+v", permissions)
|
||||
}
|
||||
if role, err := f.st.GetRole(capped.RoleID); err != nil || role.RoleVersion != receipt.RoleVersion {
|
||||
t.Fatalf("refused cap bumped the role version: %+v err=%v", role, err)
|
||||
}
|
||||
|
||||
@@ -87,12 +87,13 @@ func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) {
|
||||
const enableCreate = `{"actionId":"project.create","allowed":true,"capAllowed":true}`
|
||||
const denyLinked = `{"actionId":"project.members.manage","allowed":false,"capAllowed":false}`
|
||||
|
||||
// 系统范围动作仍然整批拒绝;项目范围动作(linked 的管理动作)现在随职位定义。
|
||||
// 开启项目准入时不得在同一职位收回 linked 管理动作(既有不变量)。
|
||||
// A batch that enables project.create and explicitly denies a linked
|
||||
// own-project action is refused as a whole: the linked action is not role
|
||||
// authority any more, so nothing in that batch is written.
|
||||
both := do(h, "PUT", permissions,
|
||||
`{"expectedRoleVersion":1,"idempotencyKey":"create-and-deny-linked","changes":[`+enableCreate+`,`+denyLinked+`]}`, admin)
|
||||
if both.Code != http.StatusBadRequest {
|
||||
t.Fatalf("linked deny beside create: %d %s", both.Code, both.Body.String())
|
||||
t.Fatalf("linked deny accepted beside create: %d %s", both.Code, both.Body.String())
|
||||
}
|
||||
stale := do(h, "PUT", permissions,
|
||||
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[`+enableCreate+`]}`, admin)
|
||||
@@ -114,26 +115,14 @@ func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) {
|
||||
}
|
||||
requireRoleProjectAccess(t, roleView(), 2, true)
|
||||
|
||||
// 项目范围动作(document.read)可以与项目准入并存;系统范围动作整批拒绝。
|
||||
projectRead := do(h, "PUT", permissions,
|
||||
`{"expectedRoleVersion":2,"idempotencyKey":"create-and-read","changes":[{"actionId":"document.read","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if projectRead.Code != http.StatusOK {
|
||||
t.Fatalf("project read beside create: %d %s", projectRead.Code, projectRead.Body.String())
|
||||
}
|
||||
bothSystem := do(h, "PUT", permissions,
|
||||
`{"expectedRoleVersion":3,"idempotencyKey":"create-and-system","changes":[{"actionId":"system.settings.write","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if bothSystem.Code != http.StatusBadRequest {
|
||||
t.Fatalf("system row accepted: %d %s", bothSystem.Code, bothSystem.Body.String())
|
||||
}
|
||||
|
||||
// Split across two submissions the linked deny is refused on its own, so the
|
||||
// role keeps the project.create policy the first submission wrote.
|
||||
denyLater := do(h, "PUT", permissions,
|
||||
`{"expectedRoleVersion":3,"idempotencyKey":"deny-linked-later","changes":[`+denyLinked+`]}`, admin)
|
||||
`{"expectedRoleVersion":2,"idempotencyKey":"deny-linked-later","changes":[`+denyLinked+`]}`, admin)
|
||||
if denyLater.Code != http.StatusBadRequest {
|
||||
t.Fatalf("linked deny accepted in a later batch: %d %s", denyLater.Code, denyLater.Body.String())
|
||||
}
|
||||
requireRoleProjectAccess(t, roleView(), 3, true)
|
||||
requireRoleProjectAccess(t, roleView(), 2, true)
|
||||
|
||||
assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role",
|
||||
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin)
|
||||
@@ -467,17 +456,13 @@ func TestLegacyRoleRowsDoNotAuthorizeSystemActionsOrAccess(t *testing.T) {
|
||||
roleRec := do(h, "GET", "/api/admin/roles/"+roleID, "", superAdmin)
|
||||
requireRoleProjectAccess(t, roleRec, 1, false)
|
||||
list := do(h, "GET", "/api/admin/roles", "", superAdmin)
|
||||
// 系统范围旧行仍不对外暴露;项目范围旧行现在作为职位默认值投影。
|
||||
for _, body := range []string{roleRec.Body.String(), list.Body.String()} {
|
||||
for _, actionID := range []string{"system.settings", "system.users", "system.roles"} {
|
||||
for _, actionID := range []string{"system.settings", "system.users", "system.roles", "document.read"} {
|
||||
if strings.Contains(body, actionID) {
|
||||
t.Fatalf("role API exposed legacy %s: %s", actionID, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(roleRec.Body.String(), "document.read") {
|
||||
t.Fatalf("project scope legacy row hidden: %s", roleRec.Body.String())
|
||||
}
|
||||
|
||||
legacy("project.create", true, true)
|
||||
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"旧行允许项目"}`, key); rec.Code != http.StatusOK {
|
||||
@@ -577,7 +562,7 @@ func TestBatchRolePermissionPatchOverHTTP(t *testing.T) {
|
||||
for _, role := range []roleHTTPView{first, second} {
|
||||
rec := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
|
||||
view := decodeRoleProjectAccess(t, rec)
|
||||
if view.RoleVersion != 2 || !view.ProjectAccess || !hasRolePermission(view.Permissions, "project.create") {
|
||||
if view.RoleVersion != 2 || !view.ProjectAccess || len(view.Permissions) != 1 {
|
||||
t.Fatalf("stale batch wrote %s: %+v", role.RoleID, view)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,25 +89,14 @@ func TestRoleHTTPCreateSearchPatchPermissionAndAssignment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type rolePermissionHTTPView struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Allowed bool `json:"allowed"`
|
||||
CapAllowed bool `json:"capAllowed"`
|
||||
}
|
||||
|
||||
type roleProjectAccessHTTPView struct {
|
||||
RoleVersion int64 `json:"roleVersion"`
|
||||
ProjectAccess bool `json:"projectAccess"`
|
||||
Permissions []rolePermissionHTTPView `json:"permissions"`
|
||||
}
|
||||
|
||||
func hasRolePermission(permissions []rolePermissionHTTPView, actionID string) bool {
|
||||
for _, permission := range permissions {
|
||||
if permission.ActionID == actionID {
|
||||
return permission.Allowed && permission.CapAllowed
|
||||
}
|
||||
}
|
||||
return false
|
||||
RoleVersion int64 `json:"roleVersion"`
|
||||
ProjectAccess bool `json:"projectAccess"`
|
||||
Permissions []struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Allowed bool `json:"allowed"`
|
||||
CapAllowed bool `json:"capAllowed"`
|
||||
} `json:"permissions"`
|
||||
}
|
||||
|
||||
func decodeRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder) roleProjectAccessHTTPView {
|
||||
@@ -119,28 +108,20 @@ func decodeRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder) roleP
|
||||
return view
|
||||
}
|
||||
|
||||
// requireRoleProjectAccess asserts the normalized project-access boolean and
|
||||
// that every projected row is a project-scope role default.
|
||||
// requireRoleProjectAccess asserts the role endpoint exposes exactly the
|
||||
// normalized project-access boolean and nothing else.
|
||||
func requireRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder, version int64, want bool) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read role: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
view := decodeRoleProjectAccess(t, rec)
|
||||
if view.RoleVersion != version || view.ProjectAccess != want {
|
||||
if view.RoleVersion != version || view.ProjectAccess != want || len(view.Permissions) != 1 {
|
||||
t.Fatalf("role access=%+v want version=%d access=%v", view, version, want)
|
||||
}
|
||||
var access *rolePermissionHTTPView
|
||||
for i, permission := range view.Permissions {
|
||||
if permission.ActionID == "project.create" {
|
||||
access = &view.Permissions[i]
|
||||
}
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatalf("project access row missing=%+v", view.Permissions)
|
||||
}
|
||||
if access.Allowed != want || access.CapAllowed != want {
|
||||
t.Fatalf("role permission=%+v want=%v", access, want)
|
||||
permission := view.Permissions[0]
|
||||
if permission.ActionID != "project.create" || permission.Allowed != want || permission.CapAllowed != want {
|
||||
t.Fatalf("role permission=%+v want=%v", permission, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/rundiag"
|
||||
)
|
||||
|
||||
// getRunDiagnostics 是单次 run 的只读诊断入口(feature unit A2):
|
||||
//
|
||||
// GET /api/runs/{id}/diagnostics
|
||||
//
|
||||
// 它回答值班/排障最常问的六个问题:这个 run 卡在哪(responsibility.state)、
|
||||
// 谁该动(responsibility.role)、为什么在等(responsibility.wait_reason)、最后一个
|
||||
// 稳定落盘点(last_checkpoint)、已知的外部副作用(side_effects)、下一步动作
|
||||
// (next_step)。
|
||||
//
|
||||
// 授权口径与既有 run 读入口逐字同款:owner 只取自认证上下文,绝不接受请求参数;
|
||||
// run 由 store.GetRun(owner, id) 做租户过滤,跨租户与不存在同为 404(不泄露存在性)。
|
||||
// handler 路径上只有 SELECT——不给写操作、不改状态、不触发任何副作用;聚合逻辑在
|
||||
// internal/rundiag,证据缺失一律降级为 unknown + notes,不编造检查点,也不把"没有
|
||||
// 记录"读成"没有发生"。
|
||||
func (s *Server) getRunDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
run, err := s.st.GetRun(s.owner(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
evidence := rundiag.Gather(s.st, run, time.Now().UTC())
|
||||
writeJSON(w, http.StatusOK, rundiag.Build(evidence))
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// diagnosticsFixture 建一个 owner 名下的项目/会话/run(合成数据,无执行器)。
|
||||
func diagnosticsFixture(t *testing.T, st *store.Store, owner string, status core.RunStatus) (core.Run, core.Conversation) {
|
||||
t.Helper()
|
||||
project, err := st.CreateProject(owner, "诊断项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := st.CreateConversation(owner, project.ID, "诊断会话", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := core.Run{
|
||||
ConversationID: conversation.ID, ProjectID: project.ID, Owner: owner,
|
||||
Kind: core.RunConversation, Status: status, Phase: core.PhaseActing, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run, conversation
|
||||
}
|
||||
|
||||
func getDiagnostics(t *testing.T, h http.Handler, key, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeReport(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("diagnostics: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode diagnostics: %v (%s)", err, rec.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 授权口径与既有 run 读入口同款:未认证 401、跨 owner 404(不泄露存在性)、
|
||||
// 不存在的 run 同样 404。
|
||||
func TestRunDiagnosticsOwnerScopingMatchesRunReads(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
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)
|
||||
}
|
||||
run, _ := diagnosticsFixture(t, st, "alice", core.RunRunning)
|
||||
path := "/api/runs/" + string(run.ID) + "/diagnostics"
|
||||
|
||||
if rec := getDiagnostics(t, h, "", path); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated diagnostics must be rejected: %d", rec.Code)
|
||||
}
|
||||
if rec := getDiagnostics(t, h, bobKey, path); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-owner diagnostics must not leak the payload: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := getDiagnostics(t, h, aliceKey, "/api/runs/run_missing/diagnostics"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing run must be 404: %d", rec.Code)
|
||||
}
|
||||
rec := getDiagnostics(t, h, aliceKey, path)
|
||||
report := decodeReport(t, rec)
|
||||
runSection, _ := report["run"].(map[string]any)
|
||||
if runSection["id"] != string(run.ID) || runSection["owner"] != "alice" || runSection["project"] != string(run.ProjectID) {
|
||||
t.Fatalf("run identity wrong: %v", runSection)
|
||||
}
|
||||
responsibility, _ := report["responsibility"].(map[string]any)
|
||||
if responsibility["state"] != "running" {
|
||||
t.Fatalf("a running run with no blockers reads as running: %v", responsibility)
|
||||
}
|
||||
}
|
||||
|
||||
// 隔离中的 run:责任在副作用责任人,下一步是解除隔离;检查点与已记录的命令
|
||||
// 回执一并给出。
|
||||
func TestRunDiagnosticsReportsHoldCheckpointAndSideEffects(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, conversation := diagnosticsFixture(t, st, "alice", core.RunRunning)
|
||||
if _, err := st.AppendEvents(string(conversation.ID), []core.StreamEvent{
|
||||
{RunID: run.ID, Type: core.EvThinkingDelta, Payload: json.RawMessage(`{"index":0,"text":"核对差异"}`)},
|
||||
{RunID: run.ID, Type: core.EvAgentProgress, Payload: json.RawMessage(`{"note":"核对中"}`)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{
|
||||
Time: time.Now().UTC(), Actor: "alice", CommandID: "rdm.docs.instantiate",
|
||||
OperationID: "op_diag_1", ConversationID: string(conversation.ID), State: agentcommand.StateUnknown,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetRecoveryHold(string(run.ID), "alice", string(conversation.ID), "外部副作用结果未知"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report := decodeReport(t, getDiagnostics(t, h, key, "/api/runs/"+string(run.ID)+"/diagnostics"))
|
||||
responsibility, _ := report["responsibility"].(map[string]any)
|
||||
if responsibility["state"] != "recovery_hold" || responsibility["role"] != "operation-owner" {
|
||||
t.Fatalf("held run responsibility: %v", responsibility)
|
||||
}
|
||||
nextStep, _ := report["next_step"].(map[string]any)
|
||||
if nextStep["action"] != "release_recovery_hold" {
|
||||
t.Fatalf("held run next step: %v", nextStep)
|
||||
}
|
||||
checkpoint, _ := report["last_checkpoint"].(map[string]any)
|
||||
if checkpoint == nil || checkpoint["type"] != string(core.EvAgentProgress) {
|
||||
t.Fatalf("last persisted checkpoint must be surfaced: %v", report["last_checkpoint"])
|
||||
}
|
||||
effects, _ := report["side_effects"].([]any)
|
||||
var foundOperation bool
|
||||
for _, raw := range effects {
|
||||
effect, _ := raw.(map[string]any)
|
||||
if effect["kind"] == "operation" && effect["operation_id"] == "op_diag_1" {
|
||||
foundOperation = true
|
||||
if effect["command_id"] != "rdm.docs.instantiate" || effect["state"] != string(agentcommand.StateUnknown) {
|
||||
t.Fatalf("operation projection: %v", effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOperation {
|
||||
t.Fatalf("recorded operation missing from side effects: %v", effects)
|
||||
}
|
||||
}
|
||||
|
||||
// 只读契约:GET 不改任何状态,写方法不注册。
|
||||
func TestRunDiagnosticsIsReadOnly(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, conversation := diagnosticsFixture(t, st, "alice", core.RunRunning)
|
||||
if err := st.SetRecoveryHold(string(run.ID), "alice", string(conversation.ID), "unknown"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := st.GetRun("alice", string(run.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/api/runs/" + string(run.ID) + "/diagnostics"
|
||||
if rec := getDiagnostics(t, h, key, path); rec.Code != http.StatusOK {
|
||||
t.Fatalf("diagnostics: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
after, err := st.GetRun("alice", string(run.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.Status != after.Status || before.Phase != after.Phase || !before.CreatedAt.Equal(after.CreatedAt) {
|
||||
t.Fatalf("read-only endpoint mutated the run: %+v -> %+v", before, after)
|
||||
}
|
||||
hold, err := st.GetRecoveryHold(string(run.ID))
|
||||
if err != nil || hold.ReleasedAt != "" {
|
||||
t.Fatalf("read-only endpoint changed the recovery hold: %+v err=%v", hold, err)
|
||||
}
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK || rec.Code == http.StatusAccepted {
|
||||
t.Fatalf("%s must not be served on a read-only diagnostics path: %d", method, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 读不到的证据降级为 unknown + note,不让整个诊断失败,也不给出安全重试。
|
||||
func TestRunDiagnosticsUnknownStateDegradesWithNote(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _ := diagnosticsFixture(t, st, "alice", core.RunCancelled)
|
||||
|
||||
report := decodeReport(t, getDiagnostics(t, h, key, "/api/runs/"+string(run.ID)+"/diagnostics"))
|
||||
responsibility, _ := report["responsibility"].(map[string]any)
|
||||
if responsibility["state"] != "unknown" {
|
||||
t.Fatalf("unmapped status must degrade to unknown: %v", responsibility)
|
||||
}
|
||||
if report["last_checkpoint"] != nil {
|
||||
t.Fatalf("no persisted event means no checkpoint reference: %v", report["last_checkpoint"])
|
||||
}
|
||||
notes, _ := report["notes"].([]any)
|
||||
var sawStatusNote, sawEffectNote bool
|
||||
for _, raw := range notes {
|
||||
note, _ := raw.(string)
|
||||
if strings.Contains(note, "no diagnostic mapping") {
|
||||
sawStatusNote = true
|
||||
}
|
||||
if strings.Contains(note, "absence of records is not proof") {
|
||||
sawEffectNote = true
|
||||
}
|
||||
}
|
||||
if !sawStatusNote || !sawEffectNote {
|
||||
t.Fatalf("missing degradation notes: %v", notes)
|
||||
}
|
||||
}
|
||||
@@ -392,8 +392,6 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/prompt-commands/{id}", s.deleteUserPromptCommand)
|
||||
mux.HandleFunc("POST /api/runs/{id}/cancel", s.cancelRun)
|
||||
mux.HandleFunc("GET /api/runs/{id}", s.getRun)
|
||||
// 只读运行诊断(A2):卡在哪、谁负责、已知副作用、下一步;无写操作。
|
||||
mux.HandleFunc("GET /api/runs/{id}/diagnostics", s.getRunDiagnostics)
|
||||
|
||||
// ---- HITL ----
|
||||
mux.HandleFunc("POST /api/hitl/{id}/resolve", s.resolveHITL)
|
||||
|
||||
@@ -65,9 +65,9 @@ func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel is the
|
||||
// explanation face of the role model (R2): a role stores the project-access
|
||||
// boolean plus project-scope defaults (never system-scope rows), and an
|
||||
// explanation therefore never reports a conflict or a migration note.
|
||||
// explanation face of the narrowed role model (R2): a role stores the single
|
||||
// project-access boolean, a linked action row cannot be written any more, and
|
||||
// an explanation therefore never reports a conflict or a migration note.
|
||||
func TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
admin, alice, _ := rolePolicyAccounts(t, st)
|
||||
@@ -75,13 +75,14 @@ func TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel(t *testing
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 没有项目范围行时职位仍然不携带任何权限;系统范围行整批拒绝。
|
||||
// A linked action row is not role authority any more: the write is refused
|
||||
// whole and the role keeps no permissions at all.
|
||||
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "system.settings.write", Allowed: true, CapAllowed: true}}, "system-row"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("system row accepted: %v", err)
|
||||
[]RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("linked cap accepted: %v", err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, capped.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("refused write persisted: %+v", permissions)
|
||||
t.Fatalf("refused cap persisted: %+v", permissions)
|
||||
}
|
||||
// Bootstrap admin keeps project.create through is_admin even without the
|
||||
// grant, so the bound role cannot contradict it either.
|
||||
@@ -184,40 +185,30 @@ func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) {
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "member-assign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 项目范围动作现在是职位默认的一部分,直接随角色写入。
|
||||
// Member content is not role authority any more, so the batch is refused
|
||||
// and the role keeps the project-access boolean it already had.
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "document.read", Allowed: true, CapAllowed: true},
|
||||
}, "member-content"); err != nil {
|
||||
t.Fatalf("member content rejected: %v", err)
|
||||
}, "member-content"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("member content accepted: %v", err)
|
||||
}
|
||||
explanation, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
|
||||
// document.read 现在来自角色默认;其余成员动作仍未配置。
|
||||
if read := actions["document.read"]; !read.Allowed || read.Source != "role" {
|
||||
t.Fatalf("document.read=%+v", read)
|
||||
}
|
||||
if len(actions) != len(memberBasicCollaborateActions) {
|
||||
t.Fatalf("actions=%+v", explanation.MemberBasicCollaborate.Actions)
|
||||
}
|
||||
for _, actionID := range memberBasicCollaborateActions {
|
||||
want := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeMemberProjects, Reason: "action-unconfigured"}
|
||||
if actionID == "document.read" {
|
||||
want = CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeMemberProjects, Allowed: true, CapAllowed: true, Source: "role"}
|
||||
}
|
||||
if actions[actionID] != want {
|
||||
t.Fatalf("%s=%+v want %+v", actionID, actions[actionID], want)
|
||||
}
|
||||
}
|
||||
// A disabled role stops every member action at the role instead.
|
||||
disabled := false
|
||||
roleAfter, err := st.GetRole(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.PatchRole("admin", role.RoleID, roleAfter.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, err = st.ExplainCapabilities("alice")
|
||||
@@ -270,11 +261,12 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "duty-assign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 三权动作同样属于职位默认,随角色一起定义。
|
||||
// Assigning a document duty to the role is refused whole; the role keeps the
|
||||
// project-access boolean and nothing else.
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "document.author", Allowed: true, CapAllowed: true},
|
||||
}, "duty-row"); err != nil {
|
||||
t.Fatalf("duty row rejected: %v", err)
|
||||
}, "duty-row"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("duty row accepted: %v", err)
|
||||
}
|
||||
explanation, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
@@ -287,22 +279,14 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
|
||||
if len(got) != len(documentQualifyDuties) {
|
||||
t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties)
|
||||
}
|
||||
// document.author 已随角色定义,其他三权仍按未配置处理。
|
||||
for _, actionID := range documentQualifyDuties {
|
||||
want := DocumentDutyQualify{ActionID: actionID, Reason: "action-unconfigured"}
|
||||
if actionID == "document.author" {
|
||||
want = DocumentDutyQualify{ActionID: actionID, Qualified: true, DefaultAllowed: true, Source: "role"}
|
||||
}
|
||||
if got[actionID] != want {
|
||||
t.Fatalf("%s=%+v want %+v", actionID, got[actionID], want)
|
||||
}
|
||||
}
|
||||
disabled := false
|
||||
roleAfter, err := st.GetRole(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.PatchRole("admin", role.RoleID, roleAfter.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, err = st.ExplainCapabilities("alice")
|
||||
|
||||
@@ -405,43 +405,17 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha
|
||||
next := map[string]RolePermissionChange{}
|
||||
for _, change := range changes {
|
||||
descriptor, ok := catalog.DescriptorByID(change.ActionID)
|
||||
// 角色可携带项目范围动作:职位默认(负责人上下限)由 Backend 角色快照
|
||||
// 投影到 RDM 的 evalPerm,没有它则项目内读/写一律拒绝。受保护的系统
|
||||
// 授权仍只能按用户单独授予。
|
||||
if !ok || descriptor.Protected ||
|
||||
(change.ActionID != catalog.ProjectAccessAction && descriptor.Scope != catalog.ScopeProject) ||
|
||||
if !ok || descriptor.Protected || change.ActionID != catalog.ProjectAccessAction ||
|
||||
seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
|
||||
return ErrInvalidRolePolicy
|
||||
}
|
||||
seen[change.ActionID] = true
|
||||
next[change.ActionID] = change
|
||||
}
|
||||
// 准入与 linked 管理动作的一致性门:开启 project.create 的职位不得收回
|
||||
// 自己的四个管理动作(有绑定或系统授权的账号同理)。
|
||||
pending := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, bindings: map[string]string{}}
|
||||
currentBits, err := s.rolePermissionBitsTx(tx, roleID)
|
||||
if err != nil {
|
||||
// Rows for other actions are inert legacy audit data and stay untouched.
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, catalog.ProjectAccessAction); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, change := range changes {
|
||||
currentBits[change.ActionID] = change
|
||||
}
|
||||
pending.roles[roleID] = currentBits
|
||||
if err := s.checkCapabilityAssociationTx(tx, nil, nil, &pending); err != nil {
|
||||
return err
|
||||
}
|
||||
// 受本次变更影响的动作先清后写;其他动作的历史审计行保持不动。
|
||||
// 空批次沿用旧语义:只清除 project.create(“关闭项目准入”)。
|
||||
if len(changes) == 0 {
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, catalog.ProjectAccessAction); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, change := range changes {
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, change.ActionID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := nowISO()
|
||||
for _, change := range changes {
|
||||
access := change.Allowed && change.CapAllowed
|
||||
@@ -584,47 +558,21 @@ func (s *Store) RoleAdmin(roleID string) (RoleAdminView, error) {
|
||||
// RolePermissions returns the role's normalized capability: at most one
|
||||
// project.create record whose allowed and capAllowed both equal the stored
|
||||
// allowed&&capAllowed. Legacy rows for any other action are filtered out.
|
||||
// RolePermissions 投影角色上的全部动作行:项目准入(project.create)与项目范围
|
||||
// 动作(document.*、project.settings 等)都进入快照,否则项目内读写没有任何职位
|
||||
// 默认来源。project.create 继续按 allowed && cap_allowed 归一。
|
||||
func (s *Store) RolePermissions(roleID string) ([]RolePermissionRecord, error) {
|
||||
permissions, err := s.rolePermissionRecords(s.db, roleID)
|
||||
var permission RolePermissionRecord
|
||||
var allowed, capAllowed bool
|
||||
err := s.db.QueryRow(s.q(`SELECT allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? AND action_id=?`),
|
||||
roleID, catalog.ProjectAccessAction).Scan(&allowed, &capAllowed, &permission.PermissionVersion)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return []RolePermissionRecord{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RolePermissionRecord, 0, len(permissions))
|
||||
projectManagement := false
|
||||
for _, permission := range permissions {
|
||||
descriptor, known := catalog.DescriptorByID(permission.ActionID)
|
||||
switch {
|
||||
case permission.ActionID == catalog.ProjectAccessAction:
|
||||
permission.Allowed = permission.Allowed && permission.CapAllowed
|
||||
permission.CapAllowed = permission.Allowed
|
||||
projectManagement = permission.Allowed
|
||||
case known && descriptor.Scope == catalog.ScopeProject:
|
||||
// 项目范围动作按职位默认值投影
|
||||
default:
|
||||
// 系统范围旧行仍是不授权的审计数据,不进快照。
|
||||
continue
|
||||
}
|
||||
out = append(out, permission)
|
||||
}
|
||||
// 角色只承载一个「项目管理」开关:开启即在参与的项目里获得项目内工作默认,
|
||||
// 无需逐项配置;显式写出的项目范围行仍然优先。
|
||||
if projectManagement {
|
||||
seen := map[string]bool{}
|
||||
for _, permission := range out {
|
||||
seen[permission.ActionID] = true
|
||||
}
|
||||
for _, actionID := range catalog.ProjectManagementImpliedActions() {
|
||||
if seen[actionID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, RolePermissionRecord{ActionID: actionID, Allowed: true, CapAllowed: true})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ActionID < out[j].ActionID })
|
||||
return out, nil
|
||||
permission.ActionID = catalog.ProjectAccessAction
|
||||
permission.Allowed = allowed && capAllowed
|
||||
permission.CapAllowed = permission.Allowed
|
||||
return []RolePermissionRecord{permission}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit int) ([]RoleAdminView, string, error) {
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
)
|
||||
|
||||
func rolePolicyAccounts(t *testing.T, st *Store) (Account, Account, Account) {
|
||||
@@ -210,26 +208,6 @@ func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// assertProjectManagementDefaults 断言「项目管理」开启时投影出的项目内默认动作。
|
||||
func assertProjectManagementDefaults(t *testing.T, permissions map[string]RolePermissionRecord) {
|
||||
t.Helper()
|
||||
for _, actionID := range catalog.ProjectManagementImpliedActions() {
|
||||
row, ok := permissions[actionID]
|
||||
if !ok || !row.Allowed || !row.CapAllowed {
|
||||
t.Fatalf("project management default %s missing: %+v", actionID, permissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func projectAccessRecord(permissions []RolePermissionRecord) *RolePermissionRecord {
|
||||
for i, permission := range permissions {
|
||||
if permission.ActionID == "project.create" {
|
||||
return &permissions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func capabilityRolePermissions(t *testing.T, st *Store, roleID string) map[string]RolePermissionRecord {
|
||||
t.Helper()
|
||||
permissions, err := st.RolePermissions(roleID)
|
||||
@@ -267,16 +245,9 @@ func TestRoleProjectCreateLinksFullManagement(t *testing.T) {
|
||||
}
|
||||
permissions := capabilityRolePermissions(t, st, role.RoleID)
|
||||
access := permissions["project.create"]
|
||||
if !access.Allowed || !access.CapAllowed {
|
||||
if len(permissions) != 1 || !access.Allowed || !access.CapAllowed {
|
||||
t.Fatalf("permissions=%+v", permissions)
|
||||
}
|
||||
// 项目管理蕴含项目内工作默认;三权与知识发布不在其中。
|
||||
assertProjectManagementDefaults(t, permissions)
|
||||
for _, duty := range []string{"document.review", "document.approve", "document.publish", "document.duties.assign", "knowledge.publish"} {
|
||||
if row, ok := permissions[duty]; ok && row.Allowed {
|
||||
t.Fatalf("duty action %s implied by project management: %+v", duty, permissions)
|
||||
}
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-creator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -306,7 +277,7 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
conflict := []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "system.settings.write", Allowed: false, CapAllowed: false},
|
||||
{ActionID: "project.settings", Allowed: false, CapAllowed: false},
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, conflict, "explicit-conflict"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("explicit role conflict: %v", err)
|
||||
@@ -316,22 +287,20 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
|
||||
if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 || after.RoleVersion != role.RoleVersion || afterEpoch != epoch {
|
||||
t.Fatalf("partial write: role=%+v epoch=%d->%d", after, epoch, afterEpoch)
|
||||
}
|
||||
// 项目范围动作可以单独写入并即刻生效(项目内读写的职位默认值来源)。
|
||||
version := role.RoleVersion
|
||||
// A linked action on its own is the same rejection: the role has no row to
|
||||
// cap off any more, so nothing is written.
|
||||
for i, only := range [][]RolePermissionChange{
|
||||
{{ActionID: "document.read", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "document.author", Allowed: false, CapAllowed: true}},
|
||||
{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}},
|
||||
{{ActionID: "project.settings", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "document.duties.assign", Allowed: false, CapAllowed: true}},
|
||||
} {
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, version, only, "project-scope-"+string(rune('a'+i)))
|
||||
if err != nil {
|
||||
t.Fatalf("project-scope batch %d rejected: %v", i, err)
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, only, "linked-only-"+string(rune('a'+i))); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("linked-only batch %d accepted: %v", i, err)
|
||||
}
|
||||
version = receipt.RoleVersion
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) == 0 {
|
||||
t.Fatalf("project-scope batch %d persisted nothing", i)
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("linked-only batch %d persisted=%+v", i, permissions)
|
||||
}
|
||||
}
|
||||
role.RoleVersion = version
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "enable-create")
|
||||
if err != nil {
|
||||
@@ -342,20 +311,18 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
|
||||
}
|
||||
permissions := capabilityRolePermissions(t, st, role.RoleID)
|
||||
access := permissions["project.create"]
|
||||
if !access.Allowed || !access.CapAllowed {
|
||||
if len(permissions) != 1 || !access.Allowed || !access.CapAllowed {
|
||||
t.Fatalf("permissions=%+v", permissions)
|
||||
}
|
||||
// 项目范围动作可以与项目准入并存在同一批次。
|
||||
current, err := st.GetRole(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
// A later batch cannot resolve a conflict either, because no second row is
|
||||
// accepted beside the project-access boolean.
|
||||
resolved := append([]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
|
||||
RolePermissionChange{ActionID: "project.members.manage", Allowed: false, CapAllowed: true})
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, resolved, "explicitly-resolved"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("explicit resolution: %v", err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, current.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "document.publish", Allowed: true, CapAllowed: true}}, "project-publish"); err != nil {
|
||||
t.Fatalf("project publish row: %v", err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); !permissions["document.publish"].Allowed {
|
||||
t.Fatalf("project publish row missing=%+v", permissions)
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 1 || !permissions["project.create"].Allowed {
|
||||
t.Fatalf("rejected resolution changed permissions=%+v", permissions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,13 +388,16 @@ func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) {
|
||||
if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("roleless role kept permissions=%+v", permissions)
|
||||
}
|
||||
// 项目范围动作与系统授权并存:系统授权仍是唯一项目准入来源。
|
||||
// A linked row cannot be written beside that grant holder either.
|
||||
if _, err := st.ReplaceRolePermissions("admin", open.RoleID, open.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "read-under-holder"); err != nil {
|
||||
t.Fatalf("project read under grant holder: %v", err)
|
||||
[]RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, "cap-under-holder"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("role cap under grant holder: %v", err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, open.RoleID); !permissions["document.read"].Allowed {
|
||||
t.Fatalf("project read row under grant holder=%+v", permissions)
|
||||
if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("rejected role cap persisted: %+v", permissions)
|
||||
}
|
||||
if role, _ := st.GetRole(open.RoleID); role.RoleVersion != open.RoleVersion {
|
||||
t.Fatalf("rejected write bumped role version: %+v", role)
|
||||
}
|
||||
|
||||
// Revoking the grant closes the loop again and reports it.
|
||||
@@ -635,40 +605,20 @@ func TestRolePermissionsNormalizeLegacyRowsToProjectAccess(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
insertLegacyRolePermission(t, st, role.RoleID, "project.create", tc.allowed, tc.capAllowed)
|
||||
// 项目范围旧行(document.read)随职位投影;系统范围旧行仍被丢弃;
|
||||
// 项目准入开启时另带出项目管理蕴含的项目内默认。
|
||||
permissions, err := st.RolePermissions(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if err != nil || len(permissions) != 1 {
|
||||
t.Fatalf("permissions=%+v err=%v", permissions, err)
|
||||
}
|
||||
byAction := map[string]RolePermissionRecord{}
|
||||
for _, permission := range permissions {
|
||||
byAction[permission.ActionID] = permission
|
||||
}
|
||||
got := byAction["project.create"]
|
||||
if got.Allowed != tc.want || got.CapAllowed != tc.want {
|
||||
got := permissions[0]
|
||||
if got.ActionID != "project.create" || got.Allowed != tc.want || got.CapAllowed != tc.want {
|
||||
t.Fatalf("normalized=%+v want=%v", got, tc.want)
|
||||
}
|
||||
if read := byAction["document.read"]; !read.Allowed || !read.CapAllowed {
|
||||
t.Fatalf("project scope row dropped=%+v", permissions)
|
||||
}
|
||||
if tc.want {
|
||||
assertProjectManagementDefaults(t, byAction)
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
if strings.HasPrefix(permission.ActionID, "system.") {
|
||||
t.Fatalf("system row projected=%+v", permissions)
|
||||
}
|
||||
}
|
||||
view, err := st.RoleAdmin(role.RoleID)
|
||||
if err != nil || view.ProjectAccess != tc.want || len(view.Permissions) < 2 {
|
||||
if err != nil || view.ProjectAccess != tc.want || len(view.Permissions) != 1 || view.Permissions[0].ActionID != "project.create" {
|
||||
t.Fatalf("role admin=%+v err=%v", view, err)
|
||||
}
|
||||
if access := projectAccessRecord(view.Permissions); access == nil || access.Allowed != tc.want {
|
||||
t.Fatalf("role admin access row=%+v want=%v", access, tc.want)
|
||||
}
|
||||
roles, _, err := st.ListRoles("", "fuzzy", "all", "", "", 20)
|
||||
if err != nil || len(roles) != 1 || roles[0].ProjectAccess != tc.want || len(roles[0].Permissions) < 2 {
|
||||
if err != nil || len(roles) != 1 || roles[0].ProjectAccess != tc.want || len(roles[0].Permissions) != 1 {
|
||||
t.Fatalf("list roles=%+v err=%v", roles, err)
|
||||
}
|
||||
allowed, capAllowed, err := st.RolePermissionAllowed("alice", "project.create")
|
||||
@@ -761,14 +711,12 @@ func TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean(t *testing.T) {
|
||||
}
|
||||
return allowed, capAllowed, rows
|
||||
}
|
||||
// 项目范围动作(如 document.read)现在可以随职位一起定义;受保护的系统授权
|
||||
// 仍然拒绝,并且任何失败批次都不部分写入。
|
||||
for i, bad := range [][]RolePermissionChange{
|
||||
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "system.settings.read", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "system.users.create", Allowed: false, CapAllowed: false}},
|
||||
{{ActionID: "document.read", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "project.create", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "project.create", Allowed: true, CapAllowed: false}},
|
||||
{{ActionID: "document.read", Allowed: true, CapAllowed: false}},
|
||||
} {
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, bad, "bad-"+string(rune('a'+i))); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("batch %d accepted: %v", i, err)
|
||||
@@ -1304,73 +1252,3 @@ func getRoleByName(st *Store, name string) (RoleRecord, bool) {
|
||||
}
|
||||
return roles[0].RoleRecord, true
|
||||
}
|
||||
|
||||
// TestRoleCarriesProjectScopeDefaults covers the project-scope role rows: a role
|
||||
// may define the project defaults that RDM projects them onto project work, and
|
||||
// a row that caps a linked management action is reported as a conflict instead of
|
||||
// being silently ignored.
|
||||
func TestRoleCarriesProjectScopeDefaults(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, _ := rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "项目工作职位"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alice, _ = st.GetAccount(alice.ID)
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-work"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "document.read", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "document.author", Allowed: false, CapAllowed: true},
|
||||
}, "project-defaults")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissions, err := st.RolePermissions(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byAction := map[string]RolePermissionRecord{}
|
||||
for _, permission := range permissions {
|
||||
byAction[permission.ActionID] = permission
|
||||
}
|
||||
if !byAction["project.create"].Allowed || !byAction["document.read"].Allowed {
|
||||
t.Fatalf("project defaults=%+v", byAction)
|
||||
}
|
||||
if byAction["document.author"].Allowed {
|
||||
t.Fatalf("explicit revoke=%+v", byAction["document.author"])
|
||||
}
|
||||
alice, _ = st.GetAccount(alice.ID)
|
||||
explanation, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
|
||||
if read := actions["document.read"]; !read.Allowed || read.Source != "role" {
|
||||
t.Fatalf("member read=%+v", read)
|
||||
}
|
||||
if author := actions["document.author"]; author.Allowed {
|
||||
t.Fatalf("member author=%+v", author)
|
||||
}
|
||||
// 项目准入开启时不得在同一职位里收回 linked 管理动作(既有不变量)。
|
||||
capped, err := st.CreateRole("admin", RoleCreate{Name: "受限工作职位"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.members.manage", Allowed: false, CapAllowed: false},
|
||||
}, "cap-linked"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("linked deny beside create accepted: %v", err)
|
||||
}
|
||||
// 同一动作的显式授予合法,并立即成为快照里的默认值。
|
||||
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.members.manage", Allowed: true, CapAllowed: true},
|
||||
}, "allow-linked"); err != nil {
|
||||
t.Fatalf("linked allow beside create: %v", err)
|
||||
}
|
||||
_ = receipt
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 运行诊断的只读投影(feature unit A2)。本文件只放 SELECT:不加表、不加索引、
|
||||
// 不动迁移,也不引入新的写语义。诊断面回答的是"已经落库的证据是什么",因此
|
||||
// 每个查询都遵守同一条口径:查不到就是"没有记录",调用方不得把它读成"没发生"
|
||||
// (与 /api/commands/operations 的对账查询同源,见 cmdbus.OperationQuery)。
|
||||
|
||||
// RunCheckpoint 是 run 事件流上最后一个已持久化事件的稳定引用。
|
||||
//
|
||||
// 诚实边界:runs 行本身没有"检查点"列——事件流(events)才是 run 唯一的持久
|
||||
// 进度痕迹(续跑、SSE 重放都以它为准)。所以这里给的是"最后一次稳定落盘"的
|
||||
// 定位点(seq 可作游标),不是推断出来的中间状态;run 没有任何事件时不存在这条
|
||||
// 记录,调用方必须显式处理"没有"而不是编一个。
|
||||
type RunCheckpoint struct {
|
||||
Seq int64 // 会话事件序(可作重放游标)
|
||||
TS string // RFC3339(写入方原样落库)
|
||||
Type string // 事件类型
|
||||
}
|
||||
|
||||
// LastRunCheckpoint 返回该 run 在会话事件流里的最后一条已持久化事件。
|
||||
// 没有事件时 ok=false,err 为 nil。
|
||||
func (s *Store) LastRunCheckpoint(conversationID, runID string) (RunCheckpoint, bool, error) {
|
||||
if conversationID == "" || runID == "" {
|
||||
return RunCheckpoint{}, false, nil
|
||||
}
|
||||
var cp RunCheckpoint
|
||||
err := s.db.QueryRow(s.q(`SELECT seq, ts, type FROM events
|
||||
WHERE conversation_id=? AND run_id=? ORDER BY seq DESC LIMIT 1`), conversationID, runID).
|
||||
Scan(&cp.Seq, &cp.TS, &cp.Type)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RunCheckpoint{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RunCheckpoint{}, false, err
|
||||
}
|
||||
return cp, true, nil
|
||||
}
|
||||
|
||||
// PendingHumanDecisionsByConversation 列出会话内未过期、尚未裁决的人工决策卡
|
||||
// (owner + 会话双谓词,跨租户天然不可见;过期卡按 PendingHumanDecisions 的同一
|
||||
// 口径过滤)。诊断面据此判断 run 是否在等人工裁决。
|
||||
//
|
||||
// 口径说明:决策卡按命令与会话归属,表里没有 run 列——把它归到某个 run 是调用方
|
||||
// 的时间窗推断,不是存储事实(见 rundiag 的归属边界注释)。
|
||||
func (s *Store) PendingHumanDecisionsByConversation(owner, conversationID string, now time.Time) ([]HumanDecision, error) {
|
||||
if owner == "" || conversationID == "" {
|
||||
return []HumanDecision{}, nil
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id,
|
||||
proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at
|
||||
FROM human_decisions WHERE owner=? AND conversation_id=? AND status='pending' ORDER BY issued_at`),
|
||||
owner, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []HumanDecision{}
|
||||
for rows.Next() {
|
||||
rec, err := scanHumanDecision(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if now.Before(rec.ExpiresAt) {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PendingConfirm 是危险命令确认挂起态(pending_confirms)的只读行。
|
||||
// Payload 是 cmdbus 落下的分发请求 JSON(确认后据此二次分发);诊断面只解析出
|
||||
// 归属与命令标识,不回显原始载荷。
|
||||
type PendingConfirm struct {
|
||||
CardID string
|
||||
Owner string
|
||||
Payload string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// ListOwnerPendingConfirms 列出该租户名下尚未确认的危险命令挂起行(时间正序)。
|
||||
// owner 为空视为无作用域:不返回任何行(纵深防御,认证层恒注入非空 owner)。
|
||||
func (s *Store) ListOwnerPendingConfirms(owner string, limit int) ([]PendingConfirm, error) {
|
||||
if owner == "" {
|
||||
return []PendingConfirm{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`SELECT card_id, owner, payload, created_at FROM pending_confirms
|
||||
WHERE owner=? ORDER BY created_at LIMIT ?`), owner, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []PendingConfirm{}
|
||||
for rows.Next() {
|
||||
var p PendingConfirm
|
||||
if err := rows.Scan(&p.CardID, &p.Owner, &p.Payload, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 运行诊断只读投影:检查点定位、HITL 决策卡的会话+租户作用域、确认挂起行的租户
|
||||
// 作用域。口径与既有读入口一致:查不到就是"没有记录",不推断"没发生"。
|
||||
func TestRunDiagnosticsReadHelpers(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureUser("bob", ""); 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)
|
||||
}
|
||||
run := core.Run{ConversationID: conversation.ID, ProjectID: project.ID, Owner: "alice",
|
||||
Kind: core.RunConversation, Status: core.RunRunning, CreatedAt: base}
|
||||
if err := st.CreateRun(&run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := core.Run{ConversationID: conversation.ID, ProjectID: project.ID, Owner: "alice",
|
||||
Kind: core.RunSubagent, Status: core.RunRunning, CreatedAt: base}
|
||||
if err := st.CreateRun(&other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 检查点:只认本 run 的最后一条事件,且不跨 run 串味。
|
||||
if cp, ok, err := st.LastRunCheckpoint(string(conversation.ID), string(run.ID)); err != nil || ok {
|
||||
t.Fatalf("a run without events has no checkpoint: %+v ok=%v err=%v", cp, ok, err)
|
||||
}
|
||||
if _, err := st.AppendEvents(string(conversation.ID), []core.StreamEvent{
|
||||
{RunID: run.ID, Type: core.EvThinkingDelta, TS: base.Format(time.RFC3339), Payload: json.RawMessage(`{}`)},
|
||||
{RunID: other.ID, Type: core.EvToolOutput, TS: base.Format(time.RFC3339), Payload: json.RawMessage(`{}`)},
|
||||
{RunID: run.ID, Type: core.EvAgentProgress, TS: base.Format(time.RFC3339), Payload: json.RawMessage(`{}`)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cp, ok, err := st.LastRunCheckpoint(string(conversation.ID), string(run.ID))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("checkpoint read: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if cp.Type != string(core.EvAgentProgress) || cp.TS == "" || cp.Seq <= 0 {
|
||||
t.Fatalf("checkpoint must point at the newest persisted event of this run: %+v", cp)
|
||||
}
|
||||
if child, ok, err := st.LastRunCheckpoint(string(conversation.ID), string(other.ID)); err != nil || !ok || child.Type != string(core.EvToolOutput) {
|
||||
t.Fatalf("child run checkpoint: %+v ok=%v err=%v", child, ok, err)
|
||||
}
|
||||
if cp, ok, err := st.LastRunCheckpoint("cnv_other", string(run.ID)); err != nil || ok {
|
||||
t.Fatalf("another conversation must not expose this run's events: %+v ok=%v err=%v", cp, ok, err)
|
||||
}
|
||||
|
||||
// HITL 决策卡:pending + 未过期才可见,且按 owner+会话双谓词隔离。
|
||||
now := base.Add(time.Minute)
|
||||
issue := func(id, owner, conversationID string, ttl time.Duration) {
|
||||
t.Helper()
|
||||
if _, err := st.IssueHumanDecision(HumanDecision{PluginID: "p", Audience: "a", CommandID: "rdm.confirm",
|
||||
HumanActor: owner, Owner: owner, ProjectID: string(project.ID), ProposalID: id, ProposalRevision: 1,
|
||||
PayloadHash: "h" + id, Question: "approve " + id + "?", ConversationID: conversationID},
|
||||
now.Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 过期口径:把 expires_at 改到过去,模拟签发即过期的卡(走方言绑定)。
|
||||
if ttl < 0 {
|
||||
if _, err := st.DB().Exec(st.q(`UPDATE human_decisions SET expires_at=? WHERE proposal_id=?`),
|
||||
now.Add(-time.Hour).Format(time.RFC3339Nano), id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
issue("alive", "alice", string(conversation.ID), time.Hour)
|
||||
issue("expired", "alice", string(conversation.ID), -time.Hour)
|
||||
issue("otherowner", "bob", "cnv_bob", time.Hour)
|
||||
|
||||
pending, err := st.PendingHumanDecisionsByConversation("alice", string(conversation.ID), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].ProposalID != "alive" || pending[0].Status != "pending" {
|
||||
t.Fatalf("only live pending cards in scope: %+v", pending)
|
||||
}
|
||||
if cross, err := st.PendingHumanDecisionsByConversation("bob", string(conversation.ID), now); err != nil || len(cross) != 0 {
|
||||
t.Fatalf("cross-owner cards must stay invisible: %+v err=%v", cross, err)
|
||||
}
|
||||
if none, err := st.PendingHumanDecisionsByConversation("", string(conversation.ID), now); err != nil || len(none) != 0 {
|
||||
t.Fatalf("an empty owner has no scope: %+v err=%v", none, err)
|
||||
}
|
||||
|
||||
// 确认挂起行:按租户隔离,空 owner 无作用域。
|
||||
if err := st.SavePendingConfirm("card_alice", "alice", `{"ID":"rdm.confirm"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SavePendingConfirm("card_bob", "bob", `{"ID":"rdm.confirm"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confirms, err := st.ListOwnerPendingConfirms("alice", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(confirms) != 1 || confirms[0].CardID != "card_alice" || confirms[0].Owner != "alice" || confirms[0].Payload == "" || confirms[0].CreatedAt == "" {
|
||||
t.Fatalf("owner-scoped pending confirms: %+v", confirms)
|
||||
}
|
||||
if none, err := st.ListOwnerPendingConfirms("", 0); err != nil || len(none) != 0 {
|
||||
t.Fatalf("an empty owner has no scope: %+v err=%v", none, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user