feat(agent): subscription.list, decision.list, download.inspect; grant-scoped requiresApproval

- three read-only summary capabilities (38 registered); task.inspect points
  download task ids to download.inspect
- probe queries reject empty / single-letter / wildcard-only terms; externalId
  documented as string; every description says when to use it
- download.plan requiresApproval uses coveringGrant (shared with authorize)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 01:58:28 +08:00
1 parent 3134e3a55e
commit 975a14ace7
7 files changed
+510 -94

No files matched your search

+73 -56
View File
@@ -102,6 +102,76 @@ export async function planBinding(db: Kysely<DB>, planType: string, planId: stri
return null;
}
/**
* 持续授权是否覆盖本能力(不含单次审批与会话内审批):action 覆盖、active 未过期、绑定策略仍发布、
* 对象范围与每日预算。download.plan 用它判定 requiresApproval,authorize 用它放行。
*/
export async function coveringGrant(
db: Kysely<DB>,
capabilityId: string,
target: PlanBinding | null,
now: Date,
): Promise<{ grantId: string } | { reasons: string[] }> {
const d = { id: capabilityId };
const actions = [...(GRANT_ACTIONS[d.id] ?? [d.id]), "*"];
const grants = await db
.selectFrom("automation_grant as g")
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
.select(["g.id", "g.action", "g.scope", "g.budget", "g.policy_revision_id", "p.status as policy_status"])
.where("g.status", "=", "active")
.where("g.action", "in", actions)
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)]))
.orderBy("g.created_at", "desc")
.execute();
const reasons: string[] = [];
for (const g of grants) {
if (g.policy_revision_id && g.policy_status !== "published") {
reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
continue;
}
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
if (scope.capabilities?.length && !scope.capabilities.includes(d.id)) {
reasons.push(`授权 ${g.id} 不含能力 ${d.id}`);
continue;
}
if (
scope.mediaEntityIds?.length &&
(!target?.mediaEntityId || !scope.mediaEntityIds.includes(target.mediaEntityId))
) {
reasons.push(`授权 ${g.id} 的对象范围不含目标作品`);
continue;
}
if (scope.mediaKinds?.length && target?.mediaKind && !kindCovered(scope.mediaKinds, target.mediaKind)) {
reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${target.mediaKind}`);
continue;
}
const budget = (g.budget ?? {}) as { maxInvocationsPerDay?: number; maxBytesPerDay?: number };
if (budget.maxInvocationsPerDay !== undefined || budget.maxBytesPerDay !== undefined) {
const day = new Date(now);
day.setHours(0, 0, 0, 0);
const used = await sql<{ n: number }>`
select count(*)::int as n from capability_invocation
where grant_id = ${g.id} and status = 'succeeded' and created_at >= ${day}`.execute(db);
if (budget.maxInvocationsPerDay !== undefined && (used.rows[0]?.n ?? 0) >= budget.maxInvocationsPerDay) {
reasons.push(`授权 ${g.id} 今日次数预算已用尽`);
continue;
}
if (budget.maxBytesPerDay !== undefined && d.id === "download.submit") {
const bytes = await sql<{ b: string }>`
select coalesce(sum(p.reserved_bytes), 0)::bigint as b
from capability_invocation ci join acquisition_plan p on p.id::text = ci.input->>'planId'
where ci.grant_id = ${g.id} and ci.status = 'succeeded' and ci.created_at >= ${day}`.execute(db);
if (Number(bytes.rows[0]?.b ?? 0) + (target?.bytes ?? 0) > budget.maxBytesPerDay) {
reasons.push(`授权 ${g.id} 今日字节预算不足`);
continue;
}
}
}
return { grantId: g.id };
}
return { reasons };
}
export function createAuthorizer(deps: {
db: Kysely<DB>;
settings: SettingsReader;
@@ -168,62 +238,9 @@ export function createAuthorizer(deps: {
}
// 2) 持续授权
const actions = [...(GRANT_ACTIONS[d.id] ?? [d.id]), "*"];
const grants = await db
.selectFrom("automation_grant as g")
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
.select(["g.id", "g.action", "g.scope", "g.budget", "g.policy_revision_id", "p.status as policy_status"])
.where("g.status", "=", "active")
.where("g.action", "in", actions)
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now())]))
.orderBy("g.created_at", "desc")
.execute();
const reasons: string[] = [];
for (const g of grants) {
if (g.policy_revision_id && g.policy_status !== "published") {
reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
continue;
}
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
if (scope.capabilities?.length && !scope.capabilities.includes(d.id)) {
reasons.push(`授权 ${g.id} 不含能力 ${d.id}`);
continue;
}
if (
scope.mediaEntityIds?.length &&
(!target?.mediaEntityId || !scope.mediaEntityIds.includes(target.mediaEntityId))
) {
reasons.push(`授权 ${g.id} 的对象范围不含目标作品`);
continue;
}
if (scope.mediaKinds?.length && target?.mediaKind && !kindCovered(scope.mediaKinds, target.mediaKind)) {
reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${target.mediaKind}`);
continue;
}
const budget = (g.budget ?? {}) as { maxInvocationsPerDay?: number; maxBytesPerDay?: number };
if (budget.maxInvocationsPerDay !== undefined || budget.maxBytesPerDay !== undefined) {
const day = new Date(now());
day.setHours(0, 0, 0, 0);
const used = await sql<{ n: number }>`
select count(*)::int as n from capability_invocation
where grant_id = ${g.id} and status = 'succeeded' and created_at >= ${day}`.execute(db);
if (budget.maxInvocationsPerDay !== undefined && (used.rows[0]?.n ?? 0) >= budget.maxInvocationsPerDay) {
reasons.push(`授权 ${g.id} 今日次数预算已用尽`);
continue;
}
if (budget.maxBytesPerDay !== undefined && d.id === "download.submit") {
const bytes = await sql<{ b: string }>`
select coalesce(sum(p.reserved_bytes), 0)::bigint as b
from capability_invocation ci join acquisition_plan p on p.id::text = ci.input->>'planId'
where ci.grant_id = ${g.id} and ci.status = 'succeeded' and ci.created_at >= ${day}`.execute(db);
if (Number(bytes.rows[0]?.b ?? 0) + (target?.bytes ?? 0) > budget.maxBytesPerDay) {
reasons.push(`授权 ${g.id} 今日字节预算不足`);
continue;
}
}
}
return { allowed: true, grantId: g.id };
}
const covered = await coveringGrant(db, d.id, target, now());
if ("grantId" in covered) return { allowed: true, grantId: covered.grantId };
const reasons = covered.reasons;
// 3) 会话内审批:控制台管理员会话里,没有持续授权的计划类动作在步骤处发起审批并挂起会话
if (ap && target && !approvalId) {
const planId = inp[ap.field] as string;
+52 -1
View File
@@ -188,7 +188,20 @@ export async function cancelTask(ctx: ServerContext, taskId: string, reason: str
export function registerTaskCapabilities(registry: CapabilityRegistry, ctx: ServerContext): void {
registry.register(capabilitySpec("task.inspect"), async (input) => {
const t = await ctx.db.selectFrom("task").selectAll().where("id", "=", input.taskId).executeTakeFirst();
if (!t) throw new CapabilityError("not_found", "任务不存在");
if (!t) {
// 模型常把下载任务 ID 当业务任务 ID 传进来:明确指出该用哪个能力
const dt = await ctx.db
.selectFrom("download_task")
.select("id")
.where("id", "=", input.taskId)
.executeTakeFirst();
if (dt)
throw new CapabilityError(
"invalid_input",
"这是下载任务 ID,不是业务任务 ID;下载任务用 download.inspect 查看(参数 downloadTaskId)",
);
throw new CapabilityError("not_found", "任务不存在");
}
const children = await ctx.db
.selectFrom("task")
.selectAll()
@@ -203,6 +216,44 @@ export function registerTaskCapabilities(registry: CapabilityRegistry, ctx: Serv
};
});
// AG-3:待处理事项摘要(含可选处理 options);缺省只列未关闭的
registry.register(capabilitySpec("decision.list"), async (input) => {
const status = input.status?.length ? input.status : (["open", "investigating", "awaiting_user"] as const);
let q = ctx.db.selectFrom("decision").where("status", "in", [...status]);
if (input.kind?.length) q = q.where("kind", "in", input.kind);
const [byKind, rows] = await Promise.all([
q
.select(["kind", sql<number>`count(*)::int`.as("count")])
.groupBy("kind")
.orderBy("count", "desc")
.execute(),
q
.selectAll()
.orderBy(sql`case severity when 'critical' then 0 when 'warning' then 1 else 2 end`)
.orderBy("updated_at", "desc")
.limit(input.limit)
.execute(),
]);
return {
total: byKind.reduce((n, k) => n + Number(k.count), 0),
byKind: byKind.map((k) => ({ kind: k.kind, count: Number(k.count) })),
items: rows.map((d) => ({
id: d.id,
kind: d.kind,
status: d.status,
severity: d.severity,
title: d.title,
summary: d.summary,
subjectType: d.subject_type,
subjectId: d.subject_id,
attempts: d.attempts,
options: (Array.isArray(d.options) ? d.options : []) as never,
createdAt: d.created_at.toISOString(),
updatedAt: d.updated_at.toISOString(),
})),
};
});
registry.register(capabilitySpec("task.retry"), async (input, c) => {
const row = await retryTask(ctx, input.taskId, c.actorId, input.reason);
return { taskId: row.id, status: row.status as TaskStatus };