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

@@ -2,6 +2,7 @@ import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { capabilitySpec } from "@umt/contracts";
import { sql } from "@umt/db";
import { normalizeTitle, transition } from "@umt/domain";
import { coveringGrant, type PlanBinding, planBinding } from "../agent/authorize.ts";
import { previewPolicy, publishRevision } from "./routes/rules.ts";
import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts";
import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts";
@@ -76,6 +77,178 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
return { items } as never;
});
// AG-3:订阅摘要(分组计数 + 条目),版本目标只给总数 / 已满足数,细节用 inventory.query
registry.register(capabilitySpec("subscription.list"), async (input) => {
const db = deps().ctx.db;
let q = db
.selectFrom("subscription as s")
.leftJoin("media_entity as e", "e.id", "s.media_entity_id")
.where((eb) => eb.or([eb("e.status", "is", null), eb("e.status", "<>", "merged")]));
if (input.status?.length) q = q.where("s.status", "in", input.status);
if (input.mediaKind) q = q.where("s.media_kind", "=", input.mediaKind);
if (input.query) {
const like = `%${input.query.replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
q = q.where((eb) =>
eb.or([
eb("e.title", "ilike", like),
eb("e.original_title", "ilike", like),
eb("s.title_query", "ilike", like),
]),
);
}
const groupCol = input.groupBy === "mediaKind" ? "s.media_kind" : "s.status";
const [groups, rows] = await Promise.all([
input.groupBy === "none"
? q.select(sql<string>`'all'`.as("key")).select(sql<number>`count(*)::int`.as("count")).execute()
: q
.select(sql<string>`${sql.ref(groupCol)}::text`.as("key"))
.select(sql<number>`count(*)::int`.as("count"))
.groupBy(groupCol)
.orderBy("count", "desc")
.execute(),
q
.select([
"s.id",
"s.media_entity_id",
"s.title_query",
"s.media_kind",
"s.mode",
"s.status",
"s.priority",
"s.status_reason",
"s.last_searched_at",
"s.next_search_at",
"s.last_progress_at",
"e.title as entity_title",
])
.select((eb) => [
eb
.selectFrom("acquisition_slot as a")
.select(sql<number>`count(*)::int`.as("n"))
.whereRef("a.subscription_id", "=", "s.id")
.as("slot_total"),
eb
.selectFrom("acquisition_slot as a")
.select(sql<number>`count(*)::int`.as("n"))
.whereRef("a.subscription_id", "=", "s.id")
.where("a.status", "=", "satisfied")
.as("slot_satisfied"),
])
.orderBy("s.updated_at", "desc")
.limit(input.limit)
.execute(),
]);
const iso = (d: Date | null) => (d ? d.toISOString() : null);
return {
total: groups.reduce((n, g) => n + Number(g.count), 0),
groups: groups.map((g) => ({ key: g.key, count: Number(g.count) })),
items: rows.map((r) => ({
id: r.id,
title: r.entity_title ?? r.title_query,
mediaEntityId: r.media_entity_id,
mediaKind: r.media_kind,
mode: r.mode,
status: r.status,
priority: r.priority,
statusReason: r.status_reason,
slots: { total: Number(r.slot_total ?? 0), satisfied: Number(r.slot_satisfied ?? 0) },
lastSearchedAt: iso(r.last_searched_at),
nextSearchAt: iso(r.next_search_at),
lastProgressAt: iso(r.last_progress_at),
})),
} as never;
});
// AG-3:下载任务详情(按 id 或 hash);关联计划 / 资源 / 业务任务 / 整理记录 / 待处理事项
registry.register(capabilitySpec("download.inspect"), async (input) => {
const db = deps().ctx.db;
const hash = input.hash?.toLowerCase();
const found = await db
.selectFrom("download_task")
.selectAll()
.$if(!!input.downloadTaskId, (qb) => qb.where("id", "=", input.downloadTaskId!))
.$if(!input.downloadTaskId && !!hash, (qb) =>
qb.where((eb) =>
eb.or([eb("torrent_hash", "=", hash!), eb("infohash_v1", "=", hash!), eb("infohash_v2", "=", hash!)]),
),
)
.orderBy("updated_at", "desc")
.limit(10)
.execute();
const dt = found[0];
if (!dt) {
if (input.downloadTaskId) {
const t = await db.selectFrom("task").select("id").where("id", "=", input.downloadTaskId).executeTakeFirst();
if (t) throw new CapabilityError("not_found", "这是业务任务 ID,不是下载任务 ID;业务任务用 task.inspect 查看");
}
throw new CapabilityError("not_found", "下载任务不存在");
}
const plan = dt.acquisition_plan_id
? await db
.selectFrom("acquisition_plan")
.select(["id", "status", "status_reason", "slot_id", "task_id"])
.where("id", "=", dt.acquisition_plan_id)
.executeTakeFirst()
: undefined;
const [release, tasks, organization, decisions] = await Promise.all([
dt.source_release_id
? db
.selectFrom("source_release")
.select(["id", "title", "site_id"])
.where("id", "=", dt.source_release_id)
.executeTakeFirst()
: Promise.resolve(undefined),
db
.selectFrom("task")
.select(["id", "kind", "status", "last_error"])
.where((eb) =>
eb.or([
eb.and([eb("subject_type", "=", "download_task"), eb("subject_id", "=", dt.id)]),
...(plan ? [eb.and([eb("subject_type", "=", "acquisition_plan"), eb("subject_id", "=", plan.id)])] : []),
]),
)
.orderBy("created_at", "desc")
.limit(20)
.execute(),
db
.selectFrom("organization_record")
.select(["id", "status", "target_path"])
.where("download_task_id", "=", dt.id)
.orderBy("created_at", "desc")
.limit(20)
.execute(),
db
.selectFrom("decision")
.select(["id", "kind", "status", "title"])
.where("subject_id", "=", dt.id)
.orderBy("created_at", "desc")
.limit(20)
.execute(),
]);
return {
downloadTask: toApi(dt) as never,
plan: plan
? {
id: plan.id,
status: plan.status,
statusReason: plan.status_reason,
slotId: plan.slot_id,
taskId: plan.task_id,
}
: null,
release: release ? { id: release.id, title: release.title, siteId: release.site_id } : null,
tasks: tasks.map((t) => ({
id: t.id,
kind: t.kind,
status: t.status,
lastError: (t.last_error ?? null) as Record<string, unknown> | null,
})),
organization: organization.map((o) => ({ id: o.id, status: o.status, targetPath: o.target_path })),
decisions: decisions.map((x) => ({ id: x.id, kind: x.kind, status: x.status, title: x.title })),
otherMatches: found.slice(1).map((x) => x.id),
} as never;
});
registry.register(capabilitySpec("download.plan"), async (input) => {
const d = deps();
const db = d.ctx.db;
@@ -119,17 +292,19 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
.selectAll()
.where("id", "=", input.slotId)
.executeTakeFirstOrThrow();
const grant = await db
.selectFrom("automation_grant")
.select("id")
.where("status", "=", "active")
.where("action", "in", ["download.submit", "policy.acquisition", "*"])
.executeTakeFirst();
// 按授权范围判定(AG-3):有覆盖本计划(作品 / 类型 / 每日预算)的持续授权 → 不需审批;否则需要
const target = plan ? await planBinding(db, "acquisition_plan", plan.id) : null;
const covered = await coveringGrant(
db,
"download.submit",
target ?? (await slotBinding(db, fresh.media_entity_id)),
new Date(),
);
return {
plan: plan ? (toApi(plan) as never) : null,
slot: toApi(fresh) as never,
rejected,
requiresApproval: !grant,
requiresApproval: !("grantId" in covered),
};
});
@@ -412,3 +587,13 @@ function planHash(request: object, frozen: FrozenPlan): string {
}
const isUuid = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
/** 没生成计划时按 Slot 的作品判定授权范围(字节预算按 0 计) */
async function slotBinding(db: AoDeps["ctx"]["db"], mediaEntityId: string): Promise<PlanBinding> {
const e = await db
.selectFrom("media_entity")
.select(["id", "kind"])
.where("id", "=", mediaEntityId)
.executeTakeFirst();
return { payloadHash: "", revision: 0, mediaEntityId: e?.id ?? null, mediaKind: e?.kind ?? null, bytes: 0 };
}