From a3a4f3e98021118bd80c1feca1d11ab2365e23b8 Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 2 Apr 2026 22:46:41 +0800 Subject: [PATCH] refactor: extract execution permission policy --- .../2026-04-02-boss-execution-foundation.md | 5 + .../v1/projects/[projectId]/messages/route.ts | 62 ++++------- src/lib/boss-data.ts | 13 ++- src/lib/execution/permission-policy.ts | 104 ++++++++++++++++++ src/lib/execution/tool-registry.ts | 23 ++++ tests/dispatch-plan-confirmation.test.ts | 7 ++ tests/execution-permission-policy.test.ts | 22 ++++ tests/group-message-dispatch-plan.test.ts | 7 ++ 8 files changed, 204 insertions(+), 39 deletions(-) create mode 100644 src/lib/execution/permission-policy.ts create mode 100644 src/lib/execution/tool-registry.ts create mode 100644 tests/execution-permission-policy.test.ts diff --git a/docs/superpowers/plans/2026-04-02-boss-execution-foundation.md b/docs/superpowers/plans/2026-04-02-boss-execution-foundation.md index b50532a..87eaa4f 100644 --- a/docs/superpowers/plans/2026-04-02-boss-execution-foundation.md +++ b/docs/superpowers/plans/2026-04-02-boss-execution-foundation.md @@ -500,6 +500,11 @@ export function listExecutionTools() { } ``` +如果在落地时发现当前生产审批流需要把“先生成推荐、再等待确认”的动作显式建模,允许在同一个 `tool-registry.ts` 中补一个 `group_dispatch_plan` 工具定义,并同步把 `PermissionPolicy` 和测试调整为**以真实运行时语义为准**。约束如下: +- `approval_required` 群聊不能再暴露与 `canDispatchDirectly=false` 矛盾的 `dispatch_execution` 直接权限; +- `toolPolicy` 必须能清楚表达“当前允许先生成推荐,但不允许直接跨线程执行”的状态; +- 任何新增工具定义都必须只服务于现有审批主链,不提前引入 Task 4+ 的执行后端能力。 + 在 `/Users/kris/code/boss/src/app/api/v1/projects/[projectId]/messages/route.ts` 中,把 `approval_required + pending plan` 判断替换成: ```ts diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 3eefb8e..3cd714d 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -1,31 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; -import { appendProjectMessage, readState } from "@/lib/boss-data"; +import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data"; import { queueGroupDispatchPlan, queueThreadConversationReplyTask, replyToMasterAgentUserMessage, } from "@/lib/boss-master-agent"; - -function buildCollaborationGate(project?: { - isGroup: boolean; - collaborationMode: "development" | "approval_required"; - approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected"; -}) { - return project - ? { - isGroup: project.isGroup, - collaborationMode: project.collaborationMode, - requiresMasterAgentApproval: project.isGroup && project.collaborationMode === "approval_required", - approvalState: project.approvalState, - } - : { - isGroup: false, - collaborationMode: "development" as const, - requiresMasterAgentApproval: false, - approvalState: "not_required" as const, - }; -} +import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy"; function dispatchFailureNotice(error?: string) { switch (error) { @@ -61,23 +42,28 @@ export async function POST( (body.kind ?? "text") === "text" && (body.body ?? "").trim().length > 0; - if (shouldCreateDispatchPlan && project.collaborationMode === "approval_required") { - const pendingPlan = [...state.dispatchPlans] - .filter( - (plan) => plan.groupProjectId === projectId && plan.status === "pending_user_confirmation", - ) - .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0]; - if (pendingPlan) { - return NextResponse.json( - { - ok: false, - message: "当前还有一条主 Agent 推荐等待你确认,请先确认或拒绝后再继续发送新指令。", - pendingPlan, - collaborationGate: buildCollaborationGate(project), - }, - { status: 409 }, - ); - } + const pendingPlan = shouldCreateDispatchPlan + ? [...state.dispatchPlans] + .filter( + (plan) => plan.groupProjectId === projectId && plan.status === "pending_user_confirmation", + ) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0] + : null; + const permission = evaluatePermissionPolicy({ + project, + hasPendingDispatchPlan: Boolean(pendingPlan), + }); + + if (!permission.allowed) { + return NextResponse.json( + { + ok: false, + message: permission.reason, + pendingPlan, + collaborationGate: buildCollaborationGate(project), + }, + { status: 409 }, + ); } const message = await appendProjectMessage({ diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index c1f3d7f..e71152d 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -354,7 +354,18 @@ export interface DispatchExecution { completedByDeviceId?: string; } -function buildCollaborationGate(project: Pick) { +export function buildCollaborationGate( + project?: Pick, +) { + if (!project) { + return { + isGroup: false, + collaborationMode: "development" as const, + requiresMasterAgentApproval: false, + approvalState: "not_required" as const, + }; + } + return { isGroup: project.isGroup, collaborationMode: project.collaborationMode, diff --git a/src/lib/execution/permission-policy.ts b/src/lib/execution/permission-policy.ts new file mode 100644 index 0000000..c71ca8b --- /dev/null +++ b/src/lib/execution/permission-policy.ts @@ -0,0 +1,104 @@ +import { listExecutionTools, type ExecutionToolName } from "@/lib/execution/tool-registry"; + +type CollaborationMode = "development" | "approval_required"; + +type ApprovalState = "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected"; + +type PermissionPolicyProject = { + id: string; + isGroup: boolean; + collaborationMode: CollaborationMode; + approvalState: ApprovalState; +}; + +type ToolPolicy = { + allowedTools: ExecutionToolName[]; + deniedTools: ExecutionToolName[]; +}; + +type CollaborationPolicy = { + mode: CollaborationMode; + canDispatchDirectly: boolean; + canCrossThreadTalk: boolean; +}; + +type PermissionPolicyResult = { + allowed: boolean; + requiresApproval: boolean; + reason?: string; + toolPolicy: ToolPolicy; + collaborationPolicy: CollaborationPolicy; +}; + +const DEFAULT_APPROVAL_REQUIRED_REASON = + "当前还有一条主 Agent 推荐等待确认,请先确认或拒绝后再继续发送新指令。"; + +const EMPTY_TOOL_POLICY: ToolPolicy = { + allowedTools: [], + deniedTools: [], +}; + +function listDefaultAllowedTools(): ExecutionToolName[] { + return listExecutionTools() + .filter((tool) => tool.kind === "execution") + .map((tool) => tool.name); +} + +function buildCollaborationPolicy(mode: CollaborationMode): CollaborationPolicy { + return { + mode, + canDispatchDirectly: mode === "development", + canCrossThreadTalk: mode === "development", + }; +} + +function buildAllowedPolicy(mode: CollaborationMode, requiresApproval: boolean): PermissionPolicyResult { + return { + allowed: true, + requiresApproval, + toolPolicy: { + allowedTools: + mode === "approval_required" + ? ["group_dispatch_plan"] + : listDefaultAllowedTools(), + deniedTools: [], + }, + collaborationPolicy: buildCollaborationPolicy(mode), + }; +} + +export function evaluatePermissionPolicy(input: { + project?: PermissionPolicyProject; + hasPendingDispatchPlan?: boolean; +}): PermissionPolicyResult { + const project = input.project; + + if (!project) { + return { + allowed: true, + requiresApproval: false, + toolPolicy: EMPTY_TOOL_POLICY, + collaborationPolicy: buildCollaborationPolicy("development"), + }; + } + + if (project.isGroup && project.collaborationMode === "approval_required" && input.hasPendingDispatchPlan) { + return { + allowed: false, + requiresApproval: true, + reason: DEFAULT_APPROVAL_REQUIRED_REASON, + toolPolicy: { + allowedTools: ["group_dispatch_plan"], + deniedTools: ["dispatch_execution"], + }, + collaborationPolicy: buildCollaborationPolicy("approval_required"), + }; + } + + return buildAllowedPolicy( + project.collaborationMode, + project.isGroup && project.collaborationMode === "approval_required", + ); +} + +export const evaluatePermissionPolicyForTesting = evaluatePermissionPolicy; diff --git a/src/lib/execution/tool-registry.ts b/src/lib/execution/tool-registry.ts new file mode 100644 index 0000000..9ead042 --- /dev/null +++ b/src/lib/execution/tool-registry.ts @@ -0,0 +1,23 @@ +export type ExecutionToolKind = "execution" | "analysis"; + +export type ExecutionToolName = + | "conversation_reply" + | "group_dispatch_plan" + | "dispatch_execution" + | "attachment_analysis"; + +export interface ExecutionToolDefinition { + name: ExecutionToolName; + kind: ExecutionToolKind; +} + +const EXECUTION_TOOLS: readonly ExecutionToolDefinition[] = [ + { name: "conversation_reply", kind: "execution" }, + { name: "group_dispatch_plan", kind: "execution" }, + { name: "dispatch_execution", kind: "execution" }, + { name: "attachment_analysis", kind: "analysis" }, +] as const; + +export function listExecutionTools() { + return EXECUTION_TOOLS; +} diff --git a/tests/dispatch-plan-confirmation.test.ts b/tests/dispatch-plan-confirmation.test.ts index fcbe6f3..f554250 100644 --- a/tests/dispatch-plan-confirmation.test.ts +++ b/tests/dispatch-plan-confirmation.test.ts @@ -17,6 +17,7 @@ let isDispatchableThreadProject: (typeof import("../src/lib/boss-data"))["isDisp let readState: (typeof import("../src/lib/boss-data"))["readState"]; let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; let AUTH_SESSION_COOKIE = ""; +let baseState: Awaited>; async function setup() { if (runtimeRoot) { @@ -47,6 +48,7 @@ async function setup() { isDispatchableThreadProject = data.isDispatchableThreadProject; readState = data.readState; writeState = data.writeState; + baseState = structuredClone(await readState()); AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; } @@ -56,6 +58,11 @@ test.after(async () => { } }); +test.beforeEach(async () => { + await setup(); + await writeState(structuredClone(baseState)); +}); + async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) { const session = await createAuthSession({ account: "17600003315", diff --git a/tests/execution-permission-policy.test.ts b/tests/execution-permission-policy.test.ts new file mode 100644 index 0000000..c7b8cd9 --- /dev/null +++ b/tests/execution-permission-policy.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { evaluatePermissionPolicyForTesting } from "@/lib/execution/permission-policy"; + +test("approval_required 群聊在已有待确认推荐时拒绝继续直接执行", () => { + const result = evaluatePermissionPolicyForTesting({ + project: { + id: "group-1", + isGroup: true, + collaborationMode: "approval_required", + approvalState: "pending_user", + }, + hasPendingDispatchPlan: true, + }); + + assert.equal(result.allowed, false); + assert.equal(result.requiresApproval, true); + assert.match(result.reason ?? "", /等待确认/); + assert.deepEqual(result.toolPolicy.allowedTools, ["group_dispatch_plan"]); + assert.deepEqual(result.toolPolicy.deniedTools, ["dispatch_execution"]); + assert.equal(result.collaborationPolicy.canDispatchDirectly, false); +}); diff --git a/tests/group-message-dispatch-plan.test.ts b/tests/group-message-dispatch-plan.test.ts index 81d3b4d..1f5b4c6 100644 --- a/tests/group-message-dispatch-plan.test.ts +++ b/tests/group-message-dispatch-plan.test.ts @@ -12,6 +12,7 @@ let createIndependentGroupChat: (typeof import("../src/lib/boss-data"))["createI let readState: (typeof import("../src/lib/boss-data"))["readState"]; let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; let AUTH_SESSION_COOKIE: string; +let baseState: Awaited>; async function setup() { if (runtimeRoot) { @@ -33,6 +34,7 @@ async function setup() { createIndependentGroupChat = data.createIndependentGroupChat; readState = data.readState; writeState = data.writeState; + baseState = structuredClone(await readState()); AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; } @@ -42,6 +44,11 @@ test.after(async () => { } }); +test.beforeEach(async () => { + await setup(); + await writeState(structuredClone(baseState)); +}); + async function createAuthedRequest(projectId: string, body: { body: string; kind?: string }) { const session = await createAuthSession({ account: "17600003315",