feat(server): W2-D agent assembly, session API, runAgent/resolveDecision and backend e2e

- Capabilities: all 14 registered (AO: inventory.query, download.plan/submit, organize.plan/execute,
  policy.preview/apply; agent: task.inspect/retry/cancel); startup asserts count == 14.
  Registry injects server-side authorize (automation_grant by action/scope/policy version/budget,
  one-shot approval bound to plan id + payload hash + revision + expiry; model claims ignored;
  automation switch), audit_event, server idempotency keys and a capability_invocation op ledger
  (migration 0021) used for crash reconciliation. download.plan creates draft plans only;
  download.submit promotes draft -> reserved after authorization.
- Agent session API: POST/GET /agent/runs, GET /agent/runs/{id} (events by seq, messages, report),
  POST .../messages (wake), POST .../abort; automation-grants, approvals, capabilities invoke, tasks.
- runAgent task: Pi or fake provider from settings (apiKey via secrets), events persisted to
  agent_run_event then published, resume + ledger reconciliation when pendingOperations > 0,
  waits for goal-linked downloads/imports and reports from business tables (submitted != imported).
- Background triggers: decision.created -> bounded plan_only investigation (dedupe per decision);
  decision.updated(resolved) -> resolveDecision executes chosen option.
- W2 closure: all OpenAPI routes registered in contracts api.ts (+ subset test); TMDB key from
  settings secret_ref with env fallback; PUT /sites/{id}/credentials documented as alias of
  /site-accounts; completion events documented as existing status events.
- e2e (real PG + OpenWorkflow + testkit mocks): RSS->library, agent delegation with grant/deny,
  background continuation, worker kill -9 recovery (single qB add), auto investigation dedupe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:52:12 +08:00
1 parent 0dec0854df
commit 6280d52ec3
46 files changed
+5133 -111

No files matched your search

+7 -3
View File
@@ -12,7 +12,7 @@ import {
import type { Logger } from "pino";
import type { Config } from "./config.ts";
import type { EventBusPort } from "./http/sse.ts";
import { createCapabilityRegistry } from "./modules/agent/capability-registry.ts";
import { assertCapabilityCount, createCapabilityRegistry } from "./modules/agent/capability-registry.ts";
import { createSecretStore, createSettingsReader } from "./modules/auth-settings/provided.ts";
import type { OutboxPort, ServerContext, ServerModule, ServerRole } from "./modules/types.ts";
@@ -138,6 +138,7 @@ export async function bootstrap(opts: BootstrapOptions): Promise<Bootstrapped> {
const { config, logger, db } = opts;
const engine = new DeferredTaskEngine();
const outbox = createOutboxPort();
const settings = createSettingsReader({ db, logger, events: opts.events });
const ctx: ServerContext = {
role: opts.role,
db,
@@ -147,11 +148,14 @@ export async function bootstrap(opts: BootstrapOptions): Promise<Bootstrapped> {
engine,
outbox,
secrets: createSecretStore({ db, masterKey: config.SECRET_MASTER_KEY }),
settings: createSettingsReader({ db, logger, events: opts.events }),
capabilities: createCapabilityRegistry({ logger }),
settings,
// 授权(automation_grant / approval + 自动化总开关)、审计(audit_event)、幂等键与操作账本在这里注入
capabilities: createCapabilityRegistry({ logger, db, settings }),
};
const tasks = buildTaskRegistry(opts.modules, ctx);
for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx);
// 装配了 agent 模块(即完整模块清单)时,首批 14 个能力必须全部挂上处理器
if (opts.modules.some((m) => m.name === "agent")) assertCapabilityCount(ctx.capabilities);
engine.attach(
opts.engine ??
(await OpenWorkflowEngine.connect({
@@ -0,0 +1,415 @@
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { capabilitySpec } from "@umt/contracts";
import { sql } from "@umt/db";
import { normalizeTitle, transition } from "@umt/domain";
import { previewPolicy, publishRevision } from "./routes/rules.ts";
import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts";
import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts";
import { type FrozenPlan, planImport } from "./services/organize.ts";
import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts";
// 本模块领域的能力处理器(规格一律取 contracts):
// inventory.query / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply
// 授权(grant/approval)、幂等键与操作账本由注册中心统一处理(modules/agent/capability-registry.ts);
// 这里的处理器只调本模块服务,并保证自身幂等(崩溃后同幂等键重跑用于对账)。
const BUSY = ["reserved", "downloading", "verifying"];
export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => AoDeps): void {
registry.register(capabilitySpec("inventory.query"), async (input) => {
const d = deps();
const db = d.ctx.db;
let ids: string[] = [];
if (input.mediaEntityId) ids = [input.mediaEntityId];
else if (input.query) {
const n = normalizeTitle(input.query);
const like = `%${input.query.replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
const rows = await sql<{ id: string }>`
select e.id, max(greatest(coalesce(similarity(a.normalized, ${n}), 0),
case when a.normalized = ${n} then 1 else 0 end)) as score
from media_entity e left join title_alias a on a.media_entity_id = e.id
where e.status <> 'merged'
and (e.title ilike ${like} or e.original_title ilike ${like} or a.normalized = ${n}
or similarity(a.normalized, ${n}) >= 0.3)
group by e.id order by score desc, e.id limit ${input.limit}`.execute(db);
ids = rows.rows.map((r) => r.id);
} else {
ids = (
await db
.selectFrom("media_entity")
.select("id")
.where("status", "<>", "merged")
.orderBy("updated_at", "desc")
.limit(input.limit)
.execute()
).map((r) => r.id);
}
const items = [];
for (const id of ids) {
const e = await db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirst();
if (!e) continue;
const [assets, slots, identities, server] = await Promise.all([
db.selectFrom("media_asset").selectAll().where("media_entity_id", "=", id).execute(),
db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", id).orderBy("created_at").execute(),
db
.selectFrom("external_identity")
.select(["provider", "entity_type", "external_id", "status"])
.where("media_entity_id", "=", id)
.execute(),
input.includeServerInventory
? db.selectFrom("server_mapping").selectAll().where("media_entity_id", "=", id).execute()
: Promise.resolve([]),
]);
items.push({
entity: toApi(e),
assets: toApiList(assets),
slots: toApiList(slots),
identities: identities.map((i) => ({
provider: i.provider,
entityType: i.entity_type,
externalId: i.external_id,
status: i.status,
})),
server: toApiList(server),
});
}
return { items } as never;
});
registry.register(capabilitySpec("download.plan"), async (input) => {
const d = deps();
const db = d.ctx.db;
const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst();
if (!slot) throw new CapabilityError("not_found", "收录目标(Slot)不存在");
const rel = await db
.selectFrom("source_release")
.select(["id", "media_entity_id"])
.where("id", "=", input.sourceReleaseId)
.executeTakeFirst();
if (!rel) throw new CapabilityError("not_found", "来源资源不存在");
const out = await evaluateAcquisition(d, {
sourceReleaseId: input.sourceReleaseId,
acquisitionSlotId: input.slotId,
draft: true,
});
const planId = out.plans[0] ?? null;
const plan = planId
? await db.selectFrom("acquisition_plan").selectAll().where("id", "=", planId).executeTakeFirst()
: undefined;
const rejected: { code: string; message: string }[] = [];
if (!plan) {
for (const s of out.skipped) rejected.push({ code: s.reason.split(":")[0] ?? s.reason, message: s.reason });
// 候选评估记录(硬条件拒绝等)
const ev = await db
.selectFrom("artifact")
.select(["content"])
.where("subject_type", "=", "acquisition_slot")
.where("subject_id", "=", input.slotId)
.where(sql<boolean>`content->>'sourceReleaseId' = ${input.sourceReleaseId}`)
.orderBy("updated_at", "desc")
.executeTakeFirst();
const c = (ev?.content ?? {}) as { rejected?: { code: string; message: string }[]; reasons?: string[] };
for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message });
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划(Slot 忙或条件不满足)" });
}
const fresh = await db
.selectFrom("acquisition_slot")
.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();
return {
plan: plan ? (toApi(plan) as never) : null,
slot: toApi(fresh) as never,
rejected,
requiresApproval: !grant,
};
});
registry.register(capabilitySpec("download.submit"), async (input) => {
const d = deps();
const db = d.ctx.db;
const plan = await db.selectFrom("acquisition_plan").selectAll().where("id", "=", input.planId).executeTakeFirst();
if (!plan) throw new CapabilityError("not_found", "下载计划不存在");
if (plan.status === "draft") {
// 草案 → reserved:Slot 必须与生成计划时一致(revision 未变、没有其它预留)
const ok = await db
.transaction()
.execute(async (trx) => {
const slot = await trx
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", plan.slot_id)
.forUpdate()
.executeTakeFirstOrThrow();
if (slot.revision + 1 !== plan.expected_revision) return "Slot 已变化,请重新生成计划";
if (BUSY.includes(slot.status)) return `Slot 正忙(${slot.status})`;
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
st = transition(st, { type: "reserve" });
await trx
.updateTable("acquisition_plan")
.set({ status: "reserved", status_reason: "authorized" })
.where("id", "=", plan.id)
.where("status", "=", "draft")
.execute();
await trx
.updateTable("acquisition_slot")
.set({
status: st.status,
reserved_plan_id: plan.id,
revision: slot.revision + 1,
status_reason: "reserved:agent",
})
.where("id", "=", slot.id)
.execute();
return null;
})
.catch((err) => {
if ((err as { code?: string }).code === "23505") return "Slot 已有其它有效预留";
throw err;
});
if (ok) throw new CapabilityError("conflict", ok);
} else if (!["reserved", "submitted", "completed"].includes(plan.status)) {
throw new CapabilityError("precondition", `计划状态为 ${plan.status},不能提交`);
}
// 与自动路径同一幂等键:同一计划只会有一个 submitDownload 任务
const taskId = await enqueueTracked(d, {
kind: "submitDownload",
idempotencyKey: `plan:${plan.id}`,
input: { acquisitionPlanId: plan.id },
subjectType: "acquisition_plan",
subjectId: plan.id,
triggeredBy: "agent",
});
await db
.updateTable("acquisition_plan")
.set({ task_id: taskId })
.where("id", "=", plan.id)
.where("task_id", "is", null)
.execute();
const dt = await db
.selectFrom("download_task")
.selectAll()
.where("acquisition_plan_id", "=", plan.id)
.executeTakeFirst();
return { taskId, planId: plan.id, downloadTask: dt ? (toApi(dt) as never) : null };
});
registry.register(capabilitySpec("organize.plan"), async (input, ctx) => {
const d = deps();
if (!input.downloadTaskId)
throw new CapabilityError("precondition", "v0.1 只支持按下载任务(downloadTaskId)生成整理计划");
const dt = await d.ctx.db
.selectFrom("download_task")
.select(["status"])
.where("id", "=", input.downloadTaskId)
.executeTakeFirst();
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
if (!["completed", "importing", "imported"].includes(dt.status))
throw new CapabilityError("precondition", `下载任务尚未完成(${dt.status})`);
const action: "copy" | "move" = input.action === "move" ? "move" : "copy";
const request = {
downloadTaskId: input.downloadTaskId,
action,
...(input.targetLibraryRootId ? { targetLibraryRootId: input.targetLibraryRootId } : {}),
};
const res = await planImport(d, {
...request,
trigger: "agent",
actorType: "agent",
actorId: ctx.actorId,
dryRun: true,
});
const frozen = res.plan;
if (!frozen) {
throw new CapabilityError(
"precondition",
res.recordId
? `该下载任务已有进行中的整理记录 ${res.recordId}`
: `无法生成整理计划:${res.reason ?? res.status}`,
);
}
const entity = await d.ctx.db
.selectFrom("media_entity")
.select(["kind"])
.where("id", "=", frozen.entityId)
.executeTakeFirst();
const payloadHash = planHash(request, frozen);
const art = await d.ctx.db
.insertInto("artifact")
.values({
kind: "plan",
agent_run_id: ctx.agentRunId && isUuid(ctx.agentRunId) ? ctx.agentRunId : null,
subject_type: "download_task",
subject_id: input.downloadTaskId,
title: "organize_plan",
content: jsonb({ request, plan: frozen, mediaEntityId: frozen.entityId, mediaKind: entity?.kind ?? null }),
content_hash: payloadHash,
})
.returning("id")
.executeTakeFirstOrThrow();
const org = frozen.organize;
return {
planId: art.id,
payloadHash,
files: org.files.map((f) => ({
sourcePath: f.src,
targetPath: f.action === "skip" || f.action === "hold" ? null : f.dst,
action,
skipReason: f.skipReason ?? (f.action === "hold" ? "hold" : null),
})),
conflicts: org.conflicts.map((c) => ({
code: c.kind,
message: c.detail ?? c.kind,
path: c.dst ?? c.src ?? null,
})),
requiresApproval: org.requiresApproval,
};
});
registry.register(capabilitySpec("organize.execute"), async (input, ctx) => {
const d = deps();
const db = d.ctx.db;
const art = await db
.selectFrom("artifact")
.selectAll()
.where("id", "=", input.planId)
.where("kind", "=", "plan")
.where("title", "=", "organize_plan")
.executeTakeFirst();
if (!art) throw new CapabilityError("not_found", "整理计划不存在");
if (art.content_hash !== input.payloadHash) throw new CapabilityError("conflict", "payloadHash 与计划不一致");
const content = art.content as {
request: { downloadTaskId: string; action: "copy" | "move"; targetLibraryRootId?: string };
};
const req = content.request;
// 已执行过(同计划)→ 返回已有记录
const taskKey = `organize-plan:${art.id}`;
const existing = await db
.selectFrom("task")
.select(["id"])
.where("kind", "=", "verifyAndImport")
.where("idempotency_key", "=", taskKey)
.executeTakeFirst();
if (existing) {
const rec = await db
.selectFrom("organization_record")
.selectAll()
.where("task_id", "=", existing.id)
.executeTakeFirst();
return { taskId: existing.id, planId: art.id, record: rec ? (toApi(rec) as never) : null };
}
// 执行前复核:重新冻结一次,与审批/授权时看到的计划一致才执行
const again = await planImport(d, {
...req,
trigger: "agent",
actorType: "agent",
actorId: ctx.actorId,
dryRun: true,
});
if (!again.plan || planHash(req, again.plan) !== art.content_hash)
throw new CapabilityError("conflict", "源文件或目标已变化,计划失效,请重新生成整理计划");
const taskId = await ensureTaskRow(db, {
kind: "verifyAndImport",
idempotencyKey: taskKey,
input: { ...req, planId: art.id },
subjectType: "download_task",
subjectId: req.downloadTaskId,
triggeredBy: "agent",
});
let res: Awaited<ReturnType<typeof planImport>>;
try {
res = await planImport(d, { ...req, trigger: "agent", actorType: "agent", actorId: ctx.actorId, taskId });
} catch (err) {
await markTask(db, taskId, "failed", { error: errMessage(err) });
throw new CapabilityError("precondition", errMessage(err));
}
if (res.recordId && (res.status === "running" || res.status === "queued")) {
const { runId } = await d.ctx.engine.enqueue(
"verifyAndImport",
{ recordId: res.recordId, downloadTaskId: req.downloadTaskId, taskId },
{ idempotencyKey: `verifyAndImport:${taskKey}`, traceId: taskId },
);
await db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
await db
.updateTable("organization_record")
.set({ task_id: taskId })
.where("id", "=", res.recordId)
.where("task_id", "is", null)
.execute();
} else {
await markTask(db, taskId, "failed", { error: res.reason ?? res.status });
}
const rec = res.recordId
? await db.selectFrom("organization_record").selectAll().where("id", "=", res.recordId).executeTakeFirst()
: undefined;
return { taskId, planId: art.id, record: rec ? (toApi(rec) as never) : null };
});
registry.register(capabilitySpec("policy.preview"), async (input) => {
const d = deps();
const cur = await d.ctx.db
.selectFrom("policy_revision")
.selectAll()
.where("id", "=", input.policyRevisionId)
.executeTakeFirst();
if (!cur) throw new CapabilityError("not_found", "策略版本不存在");
const draft = await previewPolicy(d, cur.rules);
const published = (await publishedAcquisitionPolicies(d.ctx.db)).find(
(p) => p.row.policy_key === cur.policy_key && p.row.id !== cur.id,
);
const base = published ? await previewPolicy(d, published.row.rules) : null;
const before = new Map((base?.samples ?? []).map((s) => [`${s.sourceReleaseId}`, s.decision]));
return {
newlyMatched: Math.max(0, draft.matched - (base?.matched ?? 0)),
newlyExcluded: Math.max(0, draft.excluded - (base?.excluded ?? 0)),
samples: draft.samples.slice(0, input.sampleLimit).map((s) => ({
sourceReleaseId: s.sourceReleaseId,
before: before.get(s.sourceReleaseId) ?? "none",
after: s.decision,
reason: `template:${s.template}`,
})),
};
});
registry.register(capabilitySpec("policy.apply"), async (input, ctx) => {
const d = deps();
const row = await d.ctx.db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("policy_revision")
.selectAll()
.where("id", "=", input.policyRevisionId)
.forUpdate()
.executeTakeFirst();
if (!cur) throw new CapabilityError("not_found", "策略版本不存在");
if (cur.status === "published") return cur; // 幂等:已发布
if (cur.revision !== input.expectedRevision)
throw new CapabilityError("conflict", `revision 不匹配(当前 ${cur.revision})`);
if (cur.status !== "draft") throw new CapabilityError("precondition", `只能发布草案(当前 ${cur.status})`);
return publishRevision(trx, cur.id, cur.policy_key, ctx.actorId, "superseded");
});
return { policy: toApi(row) as never };
});
}
/** 整理计划指纹:请求 + 冻结的文件动作/目标/前置条件(源大小变化、目标出现都会改变指纹) */
function planHash(request: object, frozen: FrozenPlan): string {
const o = frozen.organize;
return hashJson({
request,
entityId: frozen.entityId,
root: frozen.libraryRootId,
files: o.files.map((f) => [f.src, f.dst, f.size, f.action, f.skipReason ?? null]),
conflicts: o.conflicts.map((c) => [c.kind, c.src ?? null, c.dst ?? null, c.blocking]),
preconditions: o.preconditions,
});
}
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);
@@ -1,5 +1,6 @@
import { type EventPayload, EventType } from "@umt/contracts";
import type { ServerContext, ServerModule } from "../types.ts";
import { registerAoCapabilities } from "./capabilities.ts";
import { registerAoTasks } from "./handlers.ts";
import { type AoOptions, type AoPorts, createPorts } from "./ports.ts";
import { registerDashboardRoutes } from "./routes/dashboard.ts";
@@ -70,6 +71,9 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
registerTasks(registry, ctx) {
registerAoTasks(registry, lazy(ctx));
},
registerCapabilities(registry, ctx) {
registerAoCapabilities(registry, () => lazy(ctx));
},
outboxRoutes: () => ({
[EventType.releaseResolved]: (ev) => {
const p = ev.payload as EventPayload<"release.resolved">;
@@ -639,7 +639,7 @@ async function suppress(trx: Transaction<DB>, tagId: string, fingerprint: string
}
/** 发布:旧 published → superseded/rolled_back;吊销旧授权;新版本生成持续授权 automation_grant */
async function publishRevision(
export async function publishRevision(
trx: Transaction<DB>,
id: string,
policyKey: string,
@@ -684,7 +684,7 @@ async function publishRevision(
}
/** 预览:用规则里的 Slot 模板评估最近已识别的发布(确定性,不产生副作用) */
async function previewPolicy(d: AoDeps, rules: unknown) {
export async function previewPolicy(d: AoDeps, rules: unknown) {
const r = AcquisitionPolicyRules.safeParse(rules);
const templates = r.success ? [...(r.data.defaultSlots ?? []), ...(r.data.newTitle?.slots ?? [])] : [];
const rels = await d.ctx.db
@@ -34,6 +34,11 @@ export interface EvaluateInput {
acquisitionSlotId?: string | undefined;
/** release.resolved 带来的内容单元 */
contentUnitIds?: string[] | undefined;
/**
* 只生成草案计划(download.plan 能力):status=draft,不预留 Slot、不计入预算、不入队提交;
* 由 download.submit 在授权通过后转为 reserved 再提交。
*/
draft?: boolean | undefined;
}
export interface EvaluateOutcome {
@@ -216,6 +221,7 @@ interface Ctx {
d: AoDeps;
now: Date;
out: EvaluateOutcome;
draft: boolean;
}
async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
@@ -223,7 +229,7 @@ async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
}
export async function evaluateAcquisition(d: AoDeps, input: EvaluateInput): Promise<EvaluateOutcome> {
const c: Ctx = { d, now: d.ports.now(), out: { evaluated: 0, plans: [], skipped: [] } };
const c: Ctx = { d, now: d.ports.now(), out: { evaluated: 0, plans: [], skipped: [] }, draft: input.draft === true };
const db = d.ctx.db;
if (input.sourceReleaseId) {
const rel = await db
@@ -488,6 +494,7 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
rejected: [],
reasons: flat.reasons,
});
if (c.draft) continue;
await enqueueTracked(d, {
kind: "submitDownload",
idempotencyKey: `plan:${planId}`,
@@ -593,6 +600,54 @@ async function reserve(
savePath: p.savePath,
mode: p.mode,
};
const evaluation = jsonb({
...toContractSlotEvaluation(p.ev),
mode: p.mode,
instanceId: p.instanceId,
volumeKey: p.volumeKey,
savePath: p.savePath,
});
if (c.draft) {
// 草案:同一 Slot+发布复用未提交的草案;Slot 状态与预算不动,expected_revision 记下当前 Slot 版本 +1
const key = `draft:${p.slot.id}:${p.rel.id}`;
const hit = await d.ctx.db
.selectFrom("acquisition_plan")
.select(["id", "status"])
.where("idempotency_key", "=", key)
.executeTakeFirst();
if (hit) {
if (hit.status !== "draft") return null;
await d.ctx.db
.updateTable("acquisition_plan")
.set({ evaluation, payload_hash: hashJson(payload), expected_revision: p.slot.revision + 1, priority })
.where("id", "=", hit.id)
.execute();
return hit.id;
}
const row = await d.ctx.db
.insertInto("acquisition_plan")
.values({
slot_id: p.slot.id,
source_release_id: p.rel.id,
purpose: p.mode === "upgrade" ? "upgrade" : "fill",
status: "draft",
priority,
evaluation,
reserved_bytes: p.rel.size_bytes ?? 0,
budget_reservation: jsonb({
bytes: p.rel.size_bytes,
countsAsNewTitle: p.mode === "new_title",
day: startOfDay(now).toISOString().slice(0, 10),
}),
payload_hash: hashJson(payload),
expected_revision: p.slot.revision + 1,
idempotency_key: key,
})
.onConflict((oc) => oc.column("idempotency_key").doNothing())
.returning("id")
.executeTakeFirst();
return row?.id ?? null;
}
try {
return await d.ctx.db.transaction().execute(async (trx) => {
const slot = await trx
@@ -613,13 +668,7 @@ async function reserve(
purpose: p.mode === "upgrade" ? "upgrade" : "fill",
status: "reserved",
priority,
evaluation: jsonb({
...toContractSlotEvaluation(p.ev),
mode: p.mode,
instanceId: p.instanceId,
volumeKey: p.volumeKey,
savePath: p.savePath,
}),
evaluation,
reserved_bytes: p.rel.size_bytes ?? 0,
budget_reservation: jsonb({
bytes: p.rel.size_bytes,
+210
View File
@@ -0,0 +1,210 @@
import { createHash } from "node:crypto";
import type { AuthorizationChecker, AuthorizationDecision } from "@umt/agent";
import type { CapabilitySpec } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import type { SettingsReader } from "../types.ts";
// 服务端授权(REQ §10.2、§10.3):propose/approve/execute 类能力必须命中
// - 单次审批 approval:status=approved、绑定 plan 类型 + plan id + payload hash + revision、未过期(用后 consumed);或
// - 持续授权 automation_grant:action 覆盖本能力、active 未过期、绑定的策略版本仍是 published、
// 对象范围(mediaEntityIds / mediaKinds)包含目标、预算(每日次数 / 字节)未用尽。
// 模型参数里的"用户已同意"不会被读取;输入里的 approvalId 也只是指针,必须在库里核对。
// 控制台管理员从 Web 手动调用(source=web 且 actor=user:*)本身就是人工授权;API Key / Agent / 后台任务一律要授权记录。
/** 能力 → 可覆盖它的授权动作(策略发布生成 policy.<kind> 持续授权;"*" 全部) */
export const GRANT_ACTIONS: Record<string, readonly string[]> = {
"identity.resolve": ["identity.resolve", "policy.identity"],
"download.submit": ["download.submit", "policy.acquisition"],
"organize.execute": ["organize.execute", "policy.acquisition", "policy.organize"],
"task.retry": ["task.retry", "task.*"],
"task.cancel": ["task.cancel", "task.*"],
"policy.apply": ["policy.apply"],
};
/** 单次审批绑定的计划类型与输入里的计划 ID 字段 */
const APPROVAL_PLAN: Record<string, { planType: string; field: string }> = {
"download.submit": { planType: "acquisition_plan", field: "planId" },
"organize.execute": { planType: "organize_plan", field: "planId" },
"policy.apply": { planType: "policy_revision", field: "policyRevisionId" },
};
export interface PlanBinding {
payloadHash: string;
revision: number;
mediaEntityId: string | null;
mediaKind: string | null;
bytes: number | null;
}
const sha = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
/** 读计划当前的 payload hash / revision / 目标作品(审批绑定与范围校验共用) */
export async function planBinding(db: Kysely<DB>, planType: string, planId: string): Promise<PlanBinding | null> {
if (planType === "acquisition_plan") {
const r = await db
.selectFrom("acquisition_plan as p")
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
.select(["p.payload_hash", "p.expected_revision", "e.id as entity_id", "e.kind", "p.reserved_bytes"])
.where("p.id", "=", planId)
.executeTakeFirst();
return r
? {
payloadHash: r.payload_hash,
revision: r.expected_revision,
mediaEntityId: r.entity_id,
mediaKind: r.kind,
bytes: Number(r.reserved_bytes),
}
: null;
}
if (planType === "organize_plan") {
const r = await db
.selectFrom("artifact")
.select(["content_hash", "content"])
.where("id", "=", planId)
.where("kind", "=", "plan")
.executeTakeFirst();
if (!r) return null;
const c = (r.content ?? {}) as { mediaEntityId?: string | null; mediaKind?: string | null };
return {
payloadHash: r.content_hash,
revision: 1,
mediaEntityId: c.mediaEntityId ?? null,
mediaKind: c.mediaKind ?? null,
bytes: null,
};
}
if (planType === "policy_revision") {
const r = await db
.selectFrom("policy_revision")
.select(["revision", "rules", "scope"])
.where("id", "=", planId)
.executeTakeFirst();
return r
? {
payloadHash: sha(JSON.stringify({ rules: r.rules, scope: r.scope })),
revision: r.revision,
mediaEntityId: null,
mediaKind: null,
bytes: null,
}
: null;
}
return null;
}
export function createAuthorizer(deps: {
db: Kysely<DB>;
settings: SettingsReader;
now?: (() => Date) | undefined;
}): AuthorizationChecker {
const { db } = deps;
const now = () => deps.now?.() ?? new Date();
return async ({ descriptor, input, ctx }): Promise<AuthorizationDecision> => {
const d = descriptor as CapabilitySpec;
const inp = (input ?? {}) as Record<string, unknown>;
// 自动化总开关 / 只读对账:自动来源(agent / 后台任务)一律不执行写操作
if (ctx.source !== "web" && d.kind === "execute") {
const a = await deps.settings.get("automation");
if (!a.enabled) return { allowed: false, reason: "自动化总开关已关闭,自动执行被拒绝" };
if (a.readOnlyReconcile) return { allowed: false, reason: "只读对账模式,自动执行被拒绝" };
}
if (ctx.source === "web" && /^(user|admin):/.test(ctx.actorId)) return { allowed: true };
// 1) 单次审批
const ap = APPROVAL_PLAN[d.id];
const approvalId = typeof inp.approvalId === "string" ? inp.approvalId : null;
let target: PlanBinding | null = null;
if (ap) {
const planId = typeof inp[ap.field] === "string" ? (inp[ap.field] as string) : null;
if (planId) target = await planBinding(db, ap.planType, planId);
if (approvalId) {
if (!planId || !target) return { allowed: false, reason: `审批 ${approvalId} 绑定的计划不存在` };
const a = await db.selectFrom("approval").selectAll().where("id", "=", approvalId).executeTakeFirst();
if (!a) return { allowed: false, reason: `审批 ${approvalId} 不存在` };
if (a.status !== "approved") return { allowed: false, reason: `审批 ${approvalId} 状态为 ${a.status}` };
if (a.plan_type !== ap.planType || a.plan_id !== planId)
return { allowed: false, reason: "审批绑定的计划与本次调用不一致" };
if (a.payload_hash !== target.payloadHash) return { allowed: false, reason: "计划内容已变化,旧审批失效" };
if (a.expected_revision !== target.revision)
return { allowed: false, reason: "计划 revision 已变化,旧审批失效" };
if (a.expires_at && a.expires_at.getTime() <= now().getTime()) return { allowed: false, reason: "审批已过期" };
return { allowed: true, approvalId: a.id };
}
}
// 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 };
}
return {
allowed: false,
reason:
reasons.length > 0
? `没有可用的授权:${reasons.join(";")}`
: `缺少 ${d.id} 的持续授权(automation_grant)或有效的单次审批(approval)`,
};
};
}
/** 策略范围用订阅口径(movie / series / variety),作品用 media_entity.kind(movie / tv / variety …) */
function kindCovered(kinds: readonly string[], entityKind: string): boolean {
if (kinds.includes(entityKind)) return true;
if (entityKind !== "movie" && entityKind !== "variety" && kinds.includes("series")) return true;
return false;
}
@@ -0,0 +1,213 @@
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { capabilitySpec, type TaskStatus } from "@umt/contracts";
import { sql, type TaskRow } from "@umt/db";
import type { ServerContext } from "../types.ts";
// 任务类能力:task.inspect / task.retry / task.cancel。业务任务行(task 表)是时间线投影,
// 引擎只管推进;重试 = 按原输入重新入队(副作用由各处理器的操作账本去重),取消 = 写取消标志 + 引擎取消。
/** task.kind → 引擎任务名(agentRun 行对应 runAgent) */
export const engineTaskName = (kind: string) => (kind === "agentRun" ? "runAgent" : kind);
const toIso = (d: Date | null) => (d ? d.toISOString() : null);
export function taskToApi(t: TaskRow) {
return {
id: t.id,
kind: t.kind,
handlerVersion: t.handler_version,
idempotencyKey: t.idempotency_key,
status: t.status,
priority: t.priority,
input: t.input,
output: t.output,
subjectType: t.subject_type,
subjectId: t.subject_id,
parentTaskId: t.parent_task_id,
engineRunId: t.engine_run_id,
attempts: t.attempts,
maxAttempts: t.max_attempts,
nextRunAt: t.next_run_at.toISOString(),
waitReason: t.wait_reason,
cancelRequestedAt: toIso(t.cancel_requested_at),
cancelReason: t.cancel_reason,
lastError: t.last_error,
startedAt: toIso(t.started_at),
finishedAt: toIso(t.finished_at),
triggeredBy: t.triggered_by,
createdAt: t.created_at.toISOString(),
updatedAt: t.updated_at.toISOString(),
};
}
/** 业务时间线:任务自身 + 关联业务对象(计划/下载/整理记录/agent 运行)的当前状态 */
export async function taskTimeline(ctx: ServerContext, t: TaskRow) {
const db = ctx.db;
const out: { at: string; type: string; summary: string; data?: Record<string, unknown> }[] = [];
out.push({ at: t.created_at.toISOString(), type: "task.created", summary: `${t.kind} 已创建` });
if (t.started_at)
out.push({ at: t.started_at.toISOString(), type: "task.started", summary: `第 ${t.attempts} 次执行` });
if (t.cancel_requested_at)
out.push({
at: t.cancel_requested_at.toISOString(),
type: "task.cancel_requested",
summary: t.cancel_reason ?? "请求取消",
});
if (t.finished_at)
out.push({
at: t.finished_at.toISOString(),
type: `task.${t.status}`,
summary: t.status === "failed" ? `失败:${JSON.stringify(t.last_error ?? {}).slice(0, 200)}` : t.status,
});
if (t.subject_type === "acquisition_plan" && t.subject_id) {
const p = await db
.selectFrom("acquisition_plan")
.select(["status", "status_reason", "updated_at"])
.where("id", "=", t.subject_id)
.executeTakeFirst();
if (p)
out.push({
at: p.updated_at.toISOString(),
type: "plan.status",
summary: `下载计划 ${p.status}${p.status_reason ? `(${p.status_reason})` : ""}`,
data: { planId: t.subject_id, status: p.status },
});
const dts = await db
.selectFrom("download_task")
.select(["id", "status", "progress", "updated_at"])
.where("acquisition_plan_id", "=", t.subject_id)
.execute();
for (const dt of dts)
out.push({
at: dt.updated_at.toISOString(),
type: "download.status",
summary: `下载任务 ${dt.status}(进度 ${Math.round(Number(dt.progress ?? 0) * 100)}%)`,
data: { downloadTaskId: dt.id, status: dt.status },
});
const recs = dts.length
? await db
.selectFrom("organization_record")
.select(["id", "status", "updated_at"])
.where(
"download_task_id",
"in",
dts.map((d) => d.id),
)
.execute()
: [];
for (const r of recs)
out.push({
at: r.updated_at.toISOString(),
type: "organization.status",
summary: `整理记录 ${r.status}`,
data: { recordId: r.id, status: r.status },
});
}
if (t.kind === "verifyAndImport") {
const recs = await db
.selectFrom("organization_record")
.select(["id", "status", "updated_at"])
.where("task_id", "=", t.id)
.execute();
for (const r of recs)
out.push({
at: r.updated_at.toISOString(),
type: "organization.status",
summary: `整理记录 ${r.status}`,
data: { recordId: r.id, status: r.status },
});
}
return out.sort((a, b) => a.at.localeCompare(b.at));
}
export async function retryTask(ctx: ServerContext, taskId: string, by: string, reason?: string) {
const t = await ctx.db.selectFrom("task").selectAll().where("id", "=", taskId).executeTakeFirst();
if (!t) throw new CapabilityError("not_found", "任务不存在");
if (!["failed", "dead", "cancelled"].includes(t.status))
throw new CapabilityError("precondition", `只能重试失败/死信/已取消的任务(当前 ${t.status})`);
const input =
t.kind === "agentRun" ? { agentRunId: t.subject_id } : { ...((t.input ?? {}) as Record<string, unknown>), taskId };
const { runId } = await ctx.engine.enqueue(engineTaskName(t.kind), input, {
idempotencyKey: `${t.kind}:${t.idempotency_key}:retry:${t.attempts}:${t.updated_at.getTime()}`,
traceId: t.id,
});
const row = await ctx.db
.updateTable("task")
.set({
status: "pending",
engine_run_id: runId,
cancel_requested_at: null,
cancel_reason: null,
finished_at: null,
next_run_at: new Date(),
triggered_by: t.triggered_by ?? by,
last_error: sql`coalesce(last_error, '{}'::jsonb) || ${JSON.stringify({ retriedBy: by, retryReason: reason ?? null })}::jsonb`,
})
.where("id", "=", taskId)
.returningAll()
.executeTakeFirstOrThrow();
if (t.kind === "agentRun" && t.subject_id)
await ctx.db
.updateTable("agent_run")
.set({ status: "queued", cancel_requested_at: null, finished_at: null })
.where("id", "=", t.subject_id)
.execute();
return row;
}
export async function cancelTask(ctx: ServerContext, taskId: string, reason: string) {
const t = await ctx.db.selectFrom("task").selectAll().where("id", "=", taskId).executeTakeFirst();
if (!t) throw new CapabilityError("not_found", "任务不存在");
if (["succeeded", "failed", "cancelled", "dead"].includes(t.status)) return { row: t, cancelRequested: false };
const now = new Date();
await ctx.db
.updateTable("task")
.set({ cancel_requested_at: now, cancel_reason: reason })
.where("id", "=", taskId)
.execute();
if (t.kind === "agentRun" && t.subject_id)
await ctx.db
.updateTable("agent_run")
.set({ cancel_requested_at: now })
.where("id", "=", t.subject_id)
.where("cancel_requested_at", "is", null)
.execute();
let cancelled = false;
if (t.engine_run_id) cancelled = await ctx.engine.cancel(t.engine_run_id, reason).catch(() => false);
const row = await ctx.db
.updateTable("task")
.set(cancelled || !t.engine_run_id ? { status: "cancelled", finished_at: now } : {})
.where("id", "=", taskId)
.returningAll()
.executeTakeFirstOrThrow();
return { row, cancelRequested: true };
}
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", "任务不存在");
const children = await ctx.db
.selectFrom("task")
.selectAll()
.where("parent_task_id", "=", t.id)
.orderBy("created_at")
.limit(100)
.execute();
return {
task: taskToApi(t) as never,
children: children.map(taskToApi) as never,
timeline: await taskTimeline(ctx, t),
};
});
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 };
});
registry.register(capabilitySpec("task.cancel"), async (input) => {
const r = await cancelTask(ctx, input.taskId, input.reason);
return { taskId: r.row.id, status: r.row.status as TaskStatus, cancelRequested: r.cancelRequested };
});
}
@@ -1,13 +1,200 @@
import { CapabilityRegistry } from "@umt/agent";
import { createHash } from "node:crypto";
import {
type AuthorizationChecker,
type CapabilityAuditRecord,
type CapabilityHandler,
CapabilityRegistry,
type IdempotencyKeyGenerator,
stableStringify,
} from "@umt/agent";
import type { ActorType, CapabilityKind, CapabilitySpec } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import type { Logger } from "pino";
import type { z } from "zod";
import type { SettingsReader } from "../types.ts";
import { createAuthorizer } from "./authorize.ts";
// 能力注册中心工厂:authorize(automation_grant / approval + 自动化总开关)、audit(audit_event)、
// 服务端幂等键、有副作用能力的操作账本(capability_invocation)。Web / Pi / CLI / MCP 共用这一实例。
const SIDE_EFFECT: readonly CapabilityKind[] = ["propose", "approve", "execute"];
/** 首批能力总数:启动时断言注册数与此一致(漏挂处理器即启动失败) */
export const EXPECTED_CAPABILITY_COUNT = 14;
/**
* 能力注册中心工厂(W2-D 负责接 authorize:automation_grant / approval + 自动化总开关;audit:写 audit_event)。
* 当前未注入 authorize:read/preview 可调用,propose/approve/execute 一律拒绝(安全默认)。
* 幂等键:能力 ID + 版本 + 所属运行(agent run / 任务)+ 规范化输入的 sha256。
* 同一运行里同参数重复调用 → 同一个键 → 账本直接返回已记录的结果(崩溃恢复后的对账也走这里)。
*/
export function createCapabilityRegistry(deps: { logger: Logger }): CapabilityRegistry {
export const serverIdempotencyKey: IdempotencyKeyGenerator = (d, input, ctx) =>
`${d.id}:v${d.version}:${ctx.agentRunId ?? ctx.taskId}:${createHash("sha256")
.update(stableStringify(input))
.digest("hex")
.slice(0, 32)}`;
export interface CapabilityRegistryDeps {
logger: Logger;
/** 不给 db 时退化为 W1 安全默认(无授权回调 → propose/approve/execute 一律拒绝;审计只写日志) */
db?: Kysely<DB>;
settings?: SettingsReader;
now?: () => Date;
}
/** 注册时给有副作用的处理器套上操作账本 */
class LedgeredCapabilityRegistry extends CapabilityRegistry {
constructor(
private readonly db: Kysely<DB> | undefined,
options: ConstructorParameters<typeof CapabilityRegistry>[0],
) {
super(options);
}
override register<I extends z.ZodType, O extends z.ZodType>(
descriptor: CapabilitySpec<I, O>,
handler: CapabilityHandler<I, O>,
): this {
if (!this.db || !SIDE_EFFECT.includes(descriptor.kind)) return super.register(descriptor, handler);
return super.register(descriptor, ledgered(this.db, descriptor as unknown as CapabilitySpec, handler));
}
}
function ledgered<I extends z.ZodType, O extends z.ZodType>(
db: Kysely<DB>,
d: CapabilitySpec,
handler: CapabilityHandler<I, O>,
): CapabilityHandler<I, O> {
return async (input, ctx) => {
const key = ctx.idempotencyKey;
if (!key) return handler(input, ctx);
const prev = await db
.selectFrom("capability_invocation")
.select(["id", "status", "output"])
.where("idempotency_key", "=", key)
.executeTakeFirst();
if (prev?.status === "succeeded") return prev.output as z.input<O>;
const auth = ctx.authorization;
if (prev) {
await db
.updateTable("capability_invocation")
.set({ status: "running", attempts: sql`attempts + 1`, error: null })
.where("id", "=", prev.id)
.execute();
} else {
await db
.insertInto("capability_invocation")
.values({
idempotency_key: key,
capability_id: d.id,
capability_version: d.version,
agent_run_id: ctx.agentRunId && isUuid(ctx.agentRunId) ? ctx.agentRunId : null,
actor_id: ctx.actorId,
source: ctx.source ?? "unknown",
input: JSON.stringify(input ?? {}),
grant_id: auth?.grantId ?? null,
approval_id: auth?.approvalId ?? null,
})
.onConflict((oc) =>
oc.column("idempotency_key").doUpdateSet({ attempts: sql`capability_invocation.attempts + 1` }),
)
.execute();
}
try {
const out = await handler(input, ctx);
await db
.updateTable("capability_invocation")
.set({ status: "succeeded", output: JSON.stringify(out ?? {}), finished_at: new Date() })
.where("idempotency_key", "=", key)
.execute();
return out;
} catch (err) {
const e = err as { code?: string; message?: string };
await db
.updateTable("capability_invocation")
.set({
status: "failed",
error: JSON.stringify({ code: e.code ?? "internal", message: String(e.message ?? err).slice(0, 500) }),
finished_at: new Date(),
})
.where("idempotency_key", "=", key)
.execute()
.catch(() => {});
throw err;
}
};
}
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);
function actorTypeOf(r: CapabilityAuditRecord): ActorType {
if (r.source === "agent") return "agent";
if (r.actorId.startsWith("api_key:")) return "api_key";
if (r.actorId.startsWith("user:") || r.actorId.startsWith("admin:")) return "user";
return "system";
}
export function createCapabilityRegistry(deps: CapabilityRegistryDeps): CapabilityRegistry {
const log = deps.logger.child({ component: "capabilities" });
return new CapabilityRegistry({
audit: (r) => log.info({ capability: r.capabilityId, outcome: r.outcome, actor: r.actorId }, "capability.invoke"),
const db = deps.db;
let authorize: AuthorizationChecker | undefined;
if (db && deps.settings) authorize = createAuthorizer({ db, settings: deps.settings, now: deps.now });
return new LedgeredCapabilityRegistry(db, {
...(authorize ? { authorize } : {}),
idempotencyKey: serverIdempotencyKey,
...(deps.now ? { now: deps.now } : {}),
audit: async (r) => {
log.info(
{ capability: r.capabilityId, outcome: r.outcome, actor: r.actorId, source: r.source },
"capability.invoke",
);
if (!db) return;
await db
.insertInto("audit_event")
.values({
actor_type: actorTypeOf(r),
actor_id: r.actorId,
action: r.outcome === "denied" ? "capability.denied" : "capability.invoke",
target_type: "capability",
target_id: r.capabilityId,
payload: JSON.stringify({
outcome: r.outcome,
version: r.version,
kind: r.kind,
riskLevel: r.riskLevel,
source: r.source ?? null,
agentRunId: r.agentRunId ?? null,
invocationId: r.invocationId ?? null,
idempotencyKey: r.idempotencyKey ?? null,
grantId: r.grantId ?? null,
approvalId: r.approvalId ?? null,
errorCode: r.errorCode ?? null,
errorMessage: r.errorMessage ?? null,
inputSummary: r.inputSummary,
durationMs: r.durationMs,
}),
trace_id: r.agentRunId ?? r.taskId,
})
.execute();
// 单次审批只用一次:成功执行后标记 consumed
if (r.outcome === "ok" && r.approvalId) {
await db
.updateTable("approval")
.set({ status: "consumed" })
.where("id", "=", r.approvalId)
.where("status", "=", "approved")
.execute();
}
},
});
}
/** 启动断言:首批 14 个能力全部挂上处理器 */
export function assertCapabilityCount(registry: CapabilityRegistry): void {
const n = registry.list().length;
if (n !== EXPECTED_CAPABILITY_COUNT) {
const ids = registry
.list()
.map((c) => c.descriptor.id)
.sort();
throw new Error(`能力注册数 ${n} ≠ ${EXPECTED_CAPABILITY_COUNT}:${ids.join(", ")}`);
}
}
@@ -0,0 +1,85 @@
import type { FakeScript, FakeStep, FakeToolResult } from "@umt/agent";
import { z } from "zod";
// fake 模式脚本(pi.provider = "fake"):测试直接注入 FakeScript;设置里的 agent.fakeScript 是可序列化子集(JSON):
// { "match": "收进来" | { "regex": "..." }, "steps": [{ "text"?, "toolCalls": [{ "capability", "input" }] }], "finalText": "..." }
// 或它们的数组。input 里的字符串可以写 "{{download.plan.plan.id}}":取该能力最近一次成功结果里的路径值。
const JsonStep = z.object({
thinking: z.string().optional(),
text: z.string().optional(),
toolCalls: z
.array(z.object({ capability: z.string().min(3), input: z.record(z.string(), z.unknown()).default({}) }))
.optional(),
});
const JsonScript = z.object({
name: z.string().optional(),
match: z.union([z.string(), z.object({ regex: z.string() })]),
steps: z.array(JsonStep),
finalText: z.string(),
});
const JsonScripts = z.union([JsonScript, z.array(JsonScript)]);
const TEMPLATE = /^\{\{\s*([a-z][a-z0-9]*\.[a-z][a-z0-9_]*)((?:\.[A-Za-z0-9_]+)*)\s*\}\}$/;
function lookup(results: FakeToolResult[], capability: string, path: string[]): unknown {
const hit = [...results].reverse().find((r) => r.capabilityId === capability && !r.isError);
let cur: unknown = hit?.json;
for (const k of path) {
if (cur === null || cur === undefined) return undefined;
cur = Array.isArray(cur) ? cur[Number(k)] : (cur as Record<string, unknown>)[k];
}
return cur;
}
function render(v: unknown, results: FakeToolResult[]): unknown {
if (typeof v === "string") {
const m = TEMPLATE.exec(v);
if (!m) return v;
return lookup(results, m[1]!, (m[2] ?? "").split(".").filter(Boolean)) ?? null;
}
if (Array.isArray(v)) return v.map((x) => render(x, results));
if (v && typeof v === "object") {
return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, render(x, results)]));
}
return v;
}
/** 解析 settings.agent.fakeScript;不合法时抛错(运行失败并给出原因) */
export function parseFakeScripts(json: string): FakeScript[] {
const parsed = JsonScripts.parse(JSON.parse(json));
const list = Array.isArray(parsed) ? parsed : [parsed];
return list.map((s) => ({
...(s.name ? { name: s.name } : {}),
match: typeof s.match === "string" ? s.match : new RegExp(s.match.regex),
steps: s.steps.map(
(st): FakeStep => ({
...(st.thinking ? { thinking: st.thinking } : {}),
...(st.text ? { text: st.text } : {}),
...(st.toolCalls
? {
toolCalls: st.toolCalls.map((c) => ({
capability: c.capability,
input: (results: FakeToolResult[]) => render(c.input, results) as never,
})),
}
: {}),
}),
),
finalText: s.finalText,
}));
}
/** 内置演示脚本:任何目标都先查库存再如实汇报(不做写操作) */
export const DEMO_SCRIPTS: FakeScript[] = [
{
name: "demo",
match: () => true,
steps: [{ text: "[fake] 先查本地库存。", toolCalls: [{ capability: "inventory.query", input: { limit: 5 } }] }],
finalText: (results) => {
const r = results.find((x) => x.capabilityId === "inventory.query");
const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0;
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不会提交下载;需要真实执行请在设置里配置 Pi 上游。`;
},
},
];
+68
View File
@@ -0,0 +1,68 @@
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import { type Actor, type ApiScope, apiResponse, type EndpointDef } from "@umt/contracts";
import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import type { ModuleApp } from "../types.ts";
// 本模块路由封装:吃 contracts 的 EndpointDef(Api.xxx),createRoute 进 OpenAPI + requireScope + 入参校验,
// 出参按合同 parse(剥掉多余列)。
// crud() 生成的端点 schema 类型被擦成 z.ZodType(输出 unknown):这时退化为 any,处理器按合同自行使用
// biome-ignore lint/suspicious/noExplicitAny: 见上
type Loose<O> = unknown extends O ? any : O;
type Infer<T> = T extends z.ZodType ? Loose<z.infer<T>> : Record<string, never>;
export interface Input<D extends EndpointDef> {
params: Infer<D["params"]>;
query: Infer<D["query"]>;
body: Infer<D["body"]>;
actor: Actor;
/** 能力/审计用的调用者 ID:管理员会话 user:<id>,API Key api_key:<id> */
actorId: string;
c: Context<AuthEnv>;
}
export function actorIdOf(a: Actor | undefined): string {
if (!a) return "system:unknown";
return `${a.type === "api_key" ? "api_key" : "user"}:${a.id ?? a.name ?? "?"}`;
}
export function route<D extends EndpointDef>(
app: ModuleApp,
def: D,
handler: (input: Input<D>) => Promise<unknown>,
opts: { tag?: string; status?: 200 | 201 } = {},
) {
const request: RouteConfig["request"] = {};
if (def.params) request.params = def.params as never;
if (def.query) request.query = def.query as never;
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
const status = opts.status ?? 200;
const r = createRoute({
method: def.method,
path: def.path,
summary: def.summary,
tags: [opts.tag ?? "agent"],
middleware: [requireScope(def.scope as ApiScope)] as never,
request,
responses: {
[status]: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(r, (async (c: any) => {
const actor = c.get("actor") as Actor | undefined;
const input = {
params: def.params ? c.req.valid("param") : {},
query: def.query ? c.req.valid("query") : {},
body: def.body ? c.req.valid("json") : {},
actor,
actorId: actorIdOf(actor),
c,
} as Input<D>;
const data = def.response.parse(await handler(input));
return c.json({ ok: true, data }, status);
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
}
+38 -8
View File
@@ -1,11 +1,41 @@
import type { ServerModule } from "../types.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { registerTaskCapabilities } from "./capabilities.ts";
import { registerAgentRoutes } from "./routes.ts";
import type { AgentEnv, AgentModuleOptions } from "./runner.ts";
import { agentOutboxRoutes, registerAgentTasks } from "./tasks.ts";
export type { AgentModuleOptions } from "./runner.ts";
/**
* W2-D Agent 装配:14 个能力的 authorize/audit 注入(./capability-registry.ts)、Agent 会话 API + SSE、
* agent_run_event 落库、resolveDecision 处理器、后台触发的有界 agent 任务。
* W2-D Agent 装配:能力注册中心的 authorize/audit/账本(./capability-registry.ts,在 context.ts 创建)、
* task.* 能力、Agent 会话 API(/agent/runs)、持续授权 / 单次审批 / 能力调用 / 业务任务 API、
* runAgent(有界会话 + 等目标落定 + 业务表汇报)与 resolveDecision(自动调查 / 执行处理结果)。
* 本模块可以 import 其它模块的 service 导出,反向不行。
*/
export const agentModule: ServerModule = {
name: "agent",
registerRoutes() {},
registerTasks() {},
};
export function createAgentModule(opts: AgentModuleOptions = {}): ServerModule & { env(ctx: ServerContext): AgentEnv } {
const envs = new WeakMap<ServerContext, AgentEnv>();
const env = (ctx: ServerContext): AgentEnv => {
let e = envs.get(ctx);
if (!e) {
e = { ctx, opts };
envs.set(ctx, e);
}
return e;
};
return {
name: "agent",
env,
registerRoutes(app, ctx) {
registerAgentRoutes(app, ctx);
},
registerTasks(registry, ctx) {
registerAgentTasks(registry, () => env(ctx));
},
outboxRoutes: () => agentOutboxRoutes,
registerCapabilities(registry, ctx) {
registerTaskCapabilities(registry, ctx);
},
};
}
export const agentModule = createAgentModule();
+142
View File
@@ -0,0 +1,142 @@
import type { AgentGoalReport } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db";
// 目标完成度汇报(REQ §10.7 "完成标准以可核查结果为准"):从业务表汇总本次运行触达的
// 下载计划 → 下载任务 → 整理记录。"已提交"只代表进入下载器,只有整理记录 succeeded 才算入库;
// 模型文本只作为参考附在 modelText,不参与判定。
const DL_FAILED = ["failed", "removed", "missing"];
const REC_DONE = ["succeeded", "partial", "failed", "cancelled"];
const PLAN_FAILED = ["failed", "cancelled", "expired"];
export async function buildGoalReport(
db: Kysely<DB>,
runId: string,
modelText: string | null,
now = new Date(),
): Promise<AgentGoalReport> {
const inv = await db
.selectFrom("capability_invocation")
.select(["capability_id", "input", "output"])
.where("agent_run_id", "=", runId)
.where("status", "=", "succeeded")
.where("capability_id", "in", ["download.submit", "organize.execute"])
.orderBy("created_at")
.execute();
const planIds = new Set<string>();
const recordIds = new Set<string>();
for (const i of inv) {
const input = (i.input ?? {}) as { planId?: string };
const output = (i.output ?? {}) as { record?: { id?: string } | null };
if (i.capability_id === "download.submit" && input.planId) planIds.add(input.planId);
if (i.capability_id === "organize.execute" && output.record?.id) recordIds.add(output.record.id);
}
const plans = planIds.size
? await db
.selectFrom("acquisition_plan")
.select(["id", "status", "slot_id", "source_release_id"])
.where("id", "in", [...planIds])
.execute()
: [];
const downloads = planIds.size
? await db
.selectFrom("download_task")
.select(["id", "acquisition_plan_id", "status", "name"])
.where("acquisition_plan_id", "in", [...planIds])
.execute()
: [];
const dtIds = downloads.map((d) => d.id);
const records =
dtIds.length || recordIds.size
? await db
.selectFrom("organization_record")
.select(["id", "status", "download_task_id", "media_entity_id", "target_path", "created_at"])
.where((eb) =>
eb.or([
...(dtIds.length ? [eb("download_task_id", "in", dtIds)] : []),
...(recordIds.size ? [eb("id", "in", [...recordIds])] : []),
]),
)
.orderBy("created_at")
.execute()
: [];
let imported = 0;
let failed = 0;
let inProgress = 0;
const submitted = downloads.length;
// 按下载任务判定;最后一条整理记录为准
for (const dt of downloads) {
const recs = records.filter((r) => r.download_task_id === dt.id);
const last = recs.at(-1);
if (last?.status === "succeeded" || last?.status === "partial") imported++;
else if (last && REC_DONE.includes(last.status)) failed++;
else if (DL_FAILED.includes(dt.status)) failed++;
else inProgress++;
}
// 还没进入下载器的计划
for (const p of plans) {
if (downloads.some((d) => d.acquisition_plan_id === p.id)) continue;
if (PLAN_FAILED.includes(p.status)) failed++;
else inProgress++;
}
// 直接执行的整理(不经下载)
for (const r of records) {
if (r.download_task_id && dtIds.includes(r.download_task_id)) continue;
if (r.status === "succeeded" || r.status === "partial") imported++;
else if (REC_DONE.includes(r.status)) failed++;
else inProgress++;
}
const touched = plans.length + records.length;
const state: AgentGoalReport["state"] =
touched === 0
? "no_actions"
: inProgress > 0
? "waiting"
: failed === 0
? "completed"
: imported > 0
? "partial"
: "failed";
const done = records.filter((r) => r.status === "succeeded" || r.status === "partial");
const summary =
state === "no_actions"
? "本次运行没有提交下载或整理;结论见模型汇报(未产生可核查的业务变更)。"
: [
`已提交下载 ${submitted} 个,已入库 ${imported} 个,进行中 ${inProgress} 个,失败 ${failed} 个。`,
state === "waiting" ? "已提交≠已入库:仍在等待下载完成与核验入库。" : "",
done.length ? `整理记录:${done.map((r) => `${r.id}(${r.status})`).join("、")}。` : "",
]
.filter(Boolean)
.join("");
return {
state,
summary,
submitted,
imported,
inProgress,
failed,
plans: plans.map((p) => ({
planId: p.id,
status: p.status,
slotId: p.slot_id,
sourceReleaseId: p.source_release_id,
})),
downloads: downloads.map((d) => ({
downloadTaskId: d.id,
planId: d.acquisition_plan_id,
status: d.status,
name: d.name,
})),
organization: records.map((r) => ({
recordId: r.id,
status: r.status,
downloadTaskId: r.download_task_id,
mediaEntityId: r.media_entity_id,
targetPath: r.target_path,
})),
modelText,
generatedAt: now.toISOString(),
};
}
+428
View File
@@ -0,0 +1,428 @@
import { isCapabilityError } from "@umt/agent";
import {
type AgentGoalReport,
Api,
CAPABILITY_CATALOG,
type CapabilityDescriptor,
type KnownCapabilityId,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
import { hasScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { planBinding } from "./authorize.ts";
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
import { route } from "./http.ts";
import { AgentRunError, abortRun, appendMessage, startRun } from "./service.ts";
// Agent 会话 API、持续授权 / 单次审批、能力目录与调用、业务任务。
/** crud() 生成的列表 query 类型被擦除,这里按合同收窄 */
type ListQuery = {
cursor?: string;
limit: number;
status?: string;
planId?: string;
kind?: string;
subjectId?: string;
};
const toIso = (d: Date | null) => (d ? d.toISOString() : null);
export function runToApi(r: AgentRunRow) {
return {
id: r.id,
taskId: r.task_id,
parentRunId: r.parent_run_id,
trigger: r.trigger,
goal: r.goal,
status: r.status,
provider: r.provider,
model: r.model,
thinkingLevel: r.thinking_level,
scope: r.scope,
budget: r.budget,
usage: r.usage,
evidenceVersion: r.evidence_version,
sessionState: null,
summary: r.summary,
toolCallCount: r.tool_call_count,
lastEventSeq: Number(r.last_event_seq),
error: r.error,
cancelRequestedAt: toIso(r.cancel_requested_at),
startedAt: toIso(r.started_at),
finishedAt: toIso(r.finished_at),
createdBy: r.created_by,
mode: r.mode,
dedupeKey: r.dedupe_key,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
};
}
/** snake_case 行 → camelCase(Date → ISO) */
function camel(row: object): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(row))
out[k.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase())] = v instanceof Date ? v.toISOString() : v;
return out;
}
function mapErr(err: unknown): never {
if (err instanceof AgentRunError)
throw new ApiException(
err.code === "budget" ? "rate_limited" : err.code === "bad_request" ? "validation_failed" : err.code,
err.message,
);
throw err;
}
const encode = (at: Date, id: string) => Buffer.from(`${at.toISOString()}|${id}`).toString("base64url");
const decode = (c?: string) => {
if (!c) return null;
const [at, id] = Buffer.from(c, "base64url").toString().split("|");
return at && id ? { at, id } : null;
};
export function registerAgentRoutes(app: ModuleApp, ctx: ServerContext) {
const db = () => ctx.db;
// ── Agent 会话 ──
route(app, Api.agentRuns.list, async ({ query }) => {
let q = db().selectFrom("agent_run").selectAll();
if (query.status) q = q.where("status", "=", query.status);
if (query.trigger) q = q.where("trigger", "=", query.trigger);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(runToApi),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.agentRuns.get, async ({ params, query }) => {
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!run) throw new ApiException("not_found", "Agent 运行不存在");
const events = await db()
.selectFrom("agent_run_event")
.selectAll()
.where("run_id", "=", params.id)
.where("seq", ">", query.afterSeq)
.orderBy("seq")
.limit(query.limit + 1)
.execute();
const page = events.slice(0, query.limit);
const messages = await db()
.selectFrom("agent_run_message")
.selectAll()
.where("run_id", "=", params.id)
.orderBy("created_at")
.execute();
const report = await db()
.selectFrom("artifact")
.select("content")
.where("agent_run_id", "=", params.id)
.where("kind", "=", "report")
.orderBy("created_at", "desc")
.executeTakeFirst();
return {
run: runToApi(run),
events: page.map((e) => ({
runId: e.run_id,
seq: Number(e.seq),
type: e.type,
at: e.at.toISOString(),
payload: e.payload,
})),
nextAfterSeq: events.length > query.limit ? Number(page.at(-1)!.seq) : null,
messages: messages.map(camel),
report: (report?.content as AgentGoalReport | undefined) ?? null,
};
});
route(
app,
Api.agentRuns.create,
async ({ body, actorId }) => {
const r = await startRun(ctx, {
goal: body.goal,
mode: body.mode,
capabilityAllowlist: body.capabilityAllowlist,
trigger: "user",
createdBy: actorId,
}).catch(mapErr);
return runToApi(r.run);
},
{ status: 201 },
);
route(app, Api.agentRuns.message, async ({ params, body, actorId }) =>
runToApi(await appendMessage(ctx, params.id, body.text, actorId).catch(mapErr)),
);
route(app, Api.agentRuns.abort, async ({ params, body, actorId }) =>
runToApi(await abortRun(ctx, params.id, body.reason, actorId).catch(mapErr)),
);
// ── 持续授权 ──
route(app, Api.automationGrants.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("automation_grant").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(camel),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.automationGrants.get, async ({ params }) => {
const r = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "授权不存在");
return camel(r);
});
route(
app,
Api.automationGrants.create!,
async ({ body, actorId }) => {
const r = await db()
.insertInto("automation_grant")
.values({
action: body.action,
policy_revision_id: body.policyRevisionId ?? null,
scope: JSON.stringify(body.scope ?? {}),
budget: JSON.stringify(body.budget ?? {}),
granted_by: actorId,
reason: body.reason ?? null,
expires_at: body.expiresAt ? new Date(body.expiresAt) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
await audit(ctx, actorId, "automation_grant.create", r.id, { action: r.action });
return camel(r);
},
{ status: 201 },
);
route(app, Api.automationGrants.update!, async ({ params, body, actorId }) => {
const cur = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "授权不存在");
if (body.expiresAt && cur.expires_at && new Date(body.expiresAt) > cur.expires_at)
throw new ApiException("validation_failed", "只能缩短有效期;扩大范围请新建授权");
const r = await db()
.updateTable("automation_grant")
.set({
...(body.status === "revoked" ? { status: "revoked", revoked_at: new Date(), revoked_by: actorId } : {}),
...(body.expiresAt !== undefined ? { expires_at: body.expiresAt ? new Date(body.expiresAt) : null } : {}),
...(body.reason !== undefined ? { reason: body.reason } : {}),
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
await audit(ctx, actorId, "automation_grant.update", r.id, { status: r.status });
return camel(r);
});
// ── 单次审批 ──
route(app, Api.approvals.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("approval").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
if (query.planId) q = q.where("plan_id", "=", query.planId);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(camel),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.approvals.get, async ({ params }) => {
const r = await db().selectFrom("approval").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "审批不存在");
return camel(r);
});
route(
app,
Api.approvals.create,
async ({ body, actorId }) => {
const b = await planBinding(db(), body.planType, body.planId);
if (!b) throw new ApiException("not_found", "计划不存在");
const r = await db()
.insertInto("approval")
.values({
plan_type: body.planType,
plan_id: body.planId,
payload_hash: b.payloadHash,
expected_revision: b.revision,
requested_by: actorId,
expires_at: new Date(Date.now() + body.expiresInSec * 1000),
reason: body.reason ?? null,
})
.returningAll()
.executeTakeFirstOrThrow();
return camel(r);
},
{ status: 201 },
);
route(app, Api.approvals.update!, async ({ params, body, actorId }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("approval")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur) throw new ApiException("not_found", "审批不存在");
if (cur.payload_hash !== body.payloadHash)
throw new ApiException("conflict", "payloadHash 不一致:计划已变化,请重新申请审批");
const allowed = body.status === "revoked" ? ["pending", "approved"] : ["pending"];
if (!allowed.includes(cur.status)) throw new ApiException("conflict", `审批已是 ${cur.status}`);
return trx
.updateTable("approval")
.set({ status: body.status, decided_by: actorId, decided_at: new Date(), reason: body.reason ?? cur.reason })
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
});
await audit(ctx, actorId, `approval.${body.status}`, r.id, { planType: r.plan_type, planId: r.plan_id });
return camel(r);
});
// ── 能力目录与调用(Web / MCP 与 Agent 同一条 invoke 路径)──
route(app, Api.capabilities.list, async () => ({
items: ctx.capabilities.list().map(
(c): CapabilityDescriptor => ({
...CAPABILITY_CATALOG[c.descriptor.id as KnownCapabilityId],
}),
),
}));
route(app, Api.capabilities.invoke, async ({ params, body, actor, actorId, c }) => {
const entry = ctx.capabilities.get(params.capabilityId);
if (!entry) throw new ApiException("not_found", `能力未注册:${params.capabilityId}`);
const scopes = (c.get("principal")?.scopes ?? actor?.scopes ?? []) as string[];
if (!hasScope(scopes, entry.descriptor.scope))
throw new ApiException("forbidden", `调用 ${entry.descriptor.id} 需要 scope: ${entry.descriptor.scope}`);
if (body.preview && !entry.descriptor.supportsPreview)
throw new ApiException("bad_request", `${entry.descriptor.id} 不支持预览`);
try {
const output = await ctx.capabilities.invoke(params.capabilityId, body.input, {
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
scopeIds: [],
signal: c.req.raw.signal,
source: "web",
});
const o = (output ?? {}) as { taskId?: unknown; planId?: unknown; plan?: { id?: unknown } | null };
return {
output,
taskId: typeof o.taskId === "string" ? o.taskId : null,
planId: typeof o.planId === "string" ? o.planId : typeof o.plan?.id === "string" ? o.plan.id : null,
};
} catch (err) {
if (!isCapabilityError(err)) throw err;
const code =
err.code === "unauthorized"
? "forbidden"
: err.code === "not_found"
? "not_found"
: err.code === "conflict"
? "conflict"
: err.code === "precondition" || err.code === "budget"
? "precondition_failed"
: err.code === "invalid_input"
? "validation_failed"
: "internal";
throw new ApiException(code, err.message);
}
});
// ── 业务任务 ──
route(app, Api.tasks.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("task").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
if (query.kind) q = q.where("kind", "=", query.kind);
if (query.subjectId) q = q.where("subject_id", "=", query.subjectId);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(taskToApi),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.tasks.get, async ({ params }) => {
const r = await db().selectFrom("task").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "任务不存在");
return taskToApi(r);
});
route(app, Api.tasks.operations, async ({ params }) => {
const rows = await db()
.selectFrom("asset_operation")
.selectAll()
.where("task_id", "=", params.id)
.orderBy("created_at")
.execute();
return { items: rows.map(camel) };
});
route(app, Api.tasks.cancel, async ({ params, body }) => {
try {
return taskToApi((await cancelTask(ctx, params.id, body.reason)).row);
} catch (err) {
if (isCapabilityError(err))
throw new ApiException(err.code === "not_found" ? "not_found" : "conflict", err.message);
throw err;
}
});
route(app, Api.tasks.retry, async ({ params, actorId }) => {
try {
return taskToApi(await retryTask(ctx, params.id, actorId));
} catch (err) {
if (isCapabilityError(err))
throw new ApiException(err.code === "not_found" ? "not_found" : "conflict", err.message);
throw err;
}
});
}
async function audit(ctx: ServerContext, actorId: string, action: string, targetId: string, payload: object) {
await ctx.db
.insertInto("audit_event")
.values({
actor_type: actorId.startsWith("api_key:") ? "api_key" : "user",
actor_id: actorId,
action,
target_type: action.split(".")[0]!,
target_id: targetId,
payload: JSON.stringify(payload),
})
.execute();
}
+434
View File
@@ -0,0 +1,434 @@
import { join, resolve } from "node:path";
import {
type AgentRunHandle,
type AgentSettings,
buildSystemPrompt,
type CapabilityRegistry,
createFakeRuntime,
type FakeScript,
PiAgentRuntime,
type RunOutcome,
} from "@umt/agent";
import {
type AgentBudget,
type AgentGoalReport,
type AgentRunEvent,
type AgentRunStatus,
agentRunEventToBusEvent,
agentRunEventToRow,
CAPABILITY_CATALOG,
type CapabilityKind,
type GlobalSettings,
KnownCapabilityId,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { DEMO_SCRIPTS, parseFakeScripts } from "./fake-scripts.ts";
import { buildGoalReport } from "./report.ts";
// runAgent 任务的执行体:从设置装配 Pi(或 fake)运行时 → 事件先写 agent_run_event 再推 SSE →
// 崩溃恢复(pendingOperations>0 时先按幂等键对账未知结果再 resume)→ 消费追加消息 → 汇报以业务表为准。
export interface AgentModuleOptions {
/** Pi JSONL 会话目录(崩溃恢复点;新 worker 需能读到同一目录) */
sessionDir?: string;
/** fake 模式脚本(测试注入);缺省读 settings.agent.fakeScript,再缺省用内置演示脚本 */
fakeScripts?: FakeScript[];
/** 覆盖 settings.agent.goalPollSec(测试用小值) */
goalPollSec?: number;
}
export interface AgentEnv {
ctx: ServerContext;
opts: AgentModuleOptions;
}
/** "先给方案"与后台调查只允许这些类别 */
export const PLAN_ONLY_KINDS: CapabilityKind[] = ["read", "preview", "propose"];
/** 后台自动调查的能力白名单:只读/预览 + 身份提案 */
export const INVESTIGATE_CAPABILITIES = [
"catalog.lookup",
"release.search",
"release.inspect",
"inventory.query",
"task.inspect",
"identity.resolve",
] as const;
export interface RunScope {
capabilities: string[];
allowedKinds: CapabilityKind[] | null;
decisionId?: string | null;
}
/** 按模式确定能力白名单与类别上限;plan_only 下给了 execute 类能力直接报错 */
export function scopeFor(mode: "execute" | "plan_only", allowlist?: readonly string[]): RunScope {
const all = Object.values(CAPABILITY_CATALOG);
if (mode === "plan_only") {
const bad = (allowlist ?? []).filter((id) => {
const d = CAPABILITY_CATALOG[id as KnownCapabilityId];
return !d || !PLAN_ONLY_KINDS.includes(d.kind);
});
if (bad.length) throw new Error(`plan_only 模式只允许 read/preview/propose 类能力,不允许:${bad.join(", ")}`);
return {
capabilities: allowlist?.length
? [...allowlist]
: all.filter((d) => PLAN_ONLY_KINDS.includes(d.kind)).map((d) => d.id),
allowedKinds: PLAN_ONLY_KINDS,
};
}
for (const id of allowlist ?? []) KnownCapabilityId.parse(id);
return { capabilities: allowlist?.length ? [...allowlist] : all.map((d) => d.id), allowedKinds: null };
}
export function sessionDirOf(env: AgentEnv): string {
return resolve(
env.opts.sessionDir ?? Deno.env.get("AGENT_SESSION_DIR") ?? join(Deno.cwd(), "data", "agent-sessions"),
);
}
export interface DriveResult {
status: "completed" | "failed" | "cancelled";
reason: "budget" | "aborted" | "error" | null;
budget: string | null;
message: string | null;
finalText: string;
}
/** 设置 → @umt/agent 的运行参数(apiKey 现场经 secrets 解密,不落日志) */
async function agentSettings(
env: AgentEnv,
s: GlobalSettings,
budget: AgentBudget,
fake: boolean,
): Promise<AgentSettings> {
const pi = s.pi;
let apiKey = "fake-key-not-used";
if (!fake) {
if (!pi.apiKeySecretRef) throw new Error("未配置 Pi 上游密钥(设置 → pi.apiKey)");
apiKey = await env.ctx.secrets.resolve(pi.apiKeySecretRef);
if (!pi.model) throw new Error("未配置模型(设置 → pi.model)");
}
const maxTokens = budget.maxTokens ?? s.agent.maxTokensPerRun ?? undefined;
const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined;
return {
provider: pi.provider === "openai-compatible" ? "openai-compatible" : "anthropic",
...(pi.baseUrl && !fake ? { baseUrl: pi.baseUrl } : {}),
apiKey,
model: pi.model ?? "fake-model",
thinkingLevel: pi.thinkingLevel,
contextBudgetTokens: pi.contextBudget,
compaction: {
mode: pi.compaction.enabled ? "threshold" : "manual",
threshold: Math.min(0.95, Math.max(0.5, pi.compaction.thresholdRatio)),
},
subagentsEnabled: pi.subagentsEnabled,
maxToolCalls: budget.maxToolCalls ?? s.agent.maxToolCalls,
maxDurationMs: (budget.maxDurationSec ?? s.agent.maxDurationSec) * 1000,
...(maxTokens !== undefined || maxCost !== undefined
? {
costBudget: {
...(maxTokens !== undefined ? { maxTotalTokens: maxTokens } : {}),
...(maxCost !== undefined && maxCost > 0 ? { maxCostUsd: maxCost } : {}),
},
}
: {}),
};
}
/** 事件:先落库(run_id, seq 唯一)再推全局 SSE;未授权的工具失败在总线上标为 denied */
function eventSink(env: AgentEnv) {
const { ctx } = env;
return async (e: AgentRunEvent) => {
const row = agentRunEventToRow(e);
await ctx.db
.insertInto("agent_run_event")
.values({
run_id: row.runId,
seq: row.seq,
type: row.type,
at: new Date(row.at),
payload: JSON.stringify(row.payload),
})
.onConflict((oc) => oc.columns(["run_id", "seq"]).doNothing())
.execute();
await ctx.db
.updateTable("agent_run")
.set({
last_event_seq: sql`greatest(last_event_seq, ${e.seq})`,
...(e.type === "tool_call_start" && !e.recovery ? { tool_call_count: sql`tool_call_count + 1` } : {}),
...(e.type === "run_completed" || e.type === "run_failed" ? { usage: JSON.stringify(e.usage) } : {}),
})
.where("id", "=", e.runId)
.execute();
const bus = agentRunEventToBusEvent(e);
if (!bus) return;
// run_completed/run_failed 只代表模型本轮结束;agent_run 的最终状态由 finalize 按业务结果发布
if (bus.type === "agent.run_status" && (e.type === "run_completed" || e.type === "run_failed")) return;
if (
bus.type === "agent.tool_call" &&
e.type === "tool_call_end" &&
e.isError &&
/"code":"unauthorized"/.test(e.outputSummary)
) {
bus.payload.phase = "denied";
}
void ctx.events.publish(bus.type, bus.payload);
};
}
export async function publishStatus(ctx: ServerContext, runId: string, status: AgentRunStatus, summary: string | null) {
void ctx.events.publish("agent.run_status", { runId, status, summary });
}
/** 取下一条待消费的追加消息(原子认领,两个驱动者不会拿到同一条) */
async function claimMessage(ctx: ServerContext, runId: string): Promise<string | null> {
const r = await sql<{ text: string }>`
update agent_run_message set status = 'consumed', consumed_at = now()
where id = (select id from agent_run_message where run_id = ${runId} and status = 'pending'
order by created_at, id limit 1 for update skip locked)
returning text`.execute(ctx.db);
return r.rows[0]?.text ?? null;
}
/**
* 崩溃恢复对账:本 run 在账本里停在 running 的有副作用调用,按同一幂等键重跑处理器
* (处理器自身幂等:下载按计划状态 + qB hash,整理按记录状态),让账本落到真实结果。
* 之后 resume 时模型拿到"结果未知",再次调用同参数能力会直接得到账本里的结果。
*/
export async function reconcilePending(
env: AgentEnv,
registry: CapabilityRegistry,
run: AgentRunRow,
signal: AbortSignal,
) {
const rows = await env.ctx.db
.selectFrom("capability_invocation")
.selectAll()
.where("agent_run_id", "=", run.id)
.where("status", "=", "running")
.orderBy("created_at")
.execute();
for (const r of rows) {
try {
await registry.invoke(r.capability_id, r.input, {
actorId: r.actor_id,
taskId: run.task_id ?? run.id,
scopeIds: [],
signal,
source: "task",
agentRunId: run.id,
invocationId: `reconcile:${r.id}`,
});
} catch (err) {
env.ctx.logger.warn(
{ runId: run.id, capability: r.capability_id, err: { message: (err as Error).message } },
"agent 崩溃恢复对账失败",
);
}
}
}
function systemPromptFor(run: AgentRunRow, scope: RunScope): string {
const constraints: string[] = [
`可用能力:${scope.capabilities.join(", ")}`,
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒绝时如实说明缺少哪项授权。",
];
if (run.mode === "plan_only")
constraints.push("本次为“先给方案”:只调查与规划,不提交下载、不做文件变更、不发布策略。");
if (run.trigger === "event")
constraints.push("本任务由后台事件触发,是有界的只读调查:收集证据、给出结论与建议,不要反复重试同一操作。");
return buildSystemPrompt({
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化助手",
goal: run.goal,
constraints,
});
}
/** 读 agent_run_event 里最后一轮的结束事件(崩溃发生在"模型已结束、业务状态未写"之间时复用) */
async function lastOutcomeEvent(env: AgentEnv, runId: string) {
const r = await env.ctx.db
.selectFrom("agent_run_event")
.select(["type", "payload", "seq"])
.where("run_id", "=", runId)
.where("type", "in", ["run_started", "run_completed", "run_failed"])
.orderBy("seq", "desc")
.executeTakeFirst();
return r ?? null;
}
async function dailyBudgetBlock(env: AgentEnv, s: GlobalSettings, run: AgentRunRow): Promise<string | null> {
if (run.started_at) return null; // 续跑不重复计数
const day = new Date();
day.setHours(0, 0, 0, 0);
const r = await sql<{ n: number; cost: number | null }>`
select count(*)::int as n, sum(coalesce((usage->>'costUsd')::float8, 0))::float8 as cost
from agent_run where started_at >= ${day} and id <> ${run.id}`.execute(env.ctx.db);
const n = r.rows[0]?.n ?? 0;
const cost = r.rows[0]?.cost ?? 0;
if (n >= s.budget.agentMaxRunsPerDay) return `今日 Agent 运行次数已达上限 ${s.budget.agentMaxRunsPerDay}`;
if (cost >= s.budget.agentMaxCostUsdPerDay) return `今日 Agent 费用已达上限 $${s.budget.agentMaxCostUsdPerDay}`;
return null;
}
/**
* 驱动一次模型会话直到本轮结束(含追加消息)。整个函数在一个持久步骤里执行:worker 崩溃后新 worker 从头重跑本函数,
* 打开同一 JSONL 会话,pendingOperations>0 时对账 + resume,已完成的工具调用不会重放。
*/
export async function drive(
env: AgentEnv,
registry: CapabilityRegistry,
runId: string,
signal: AbortSignal,
): Promise<DriveResult> {
const { ctx } = env;
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow();
if (run.cancel_requested_at || signal.aborted)
return { status: "cancelled", reason: "aborted", budget: null, message: "运行已被取消", finalText: "" };
const s = await ctx.settings.all();
const blocked = await dailyBudgetBlock(env, s, run);
if (blocked) return { status: "failed", reason: "budget", budget: "daily", message: blocked, finalText: "" };
const fake = s.pi.provider === "fake";
const scope = (run.scope ?? {}) as unknown as RunScope;
let settings: AgentSettings;
try {
settings = await agentSettings(env, s, (run.budget ?? {}) as AgentBudget, fake);
} catch (err) {
return { status: "failed", reason: "error", budget: null, message: (err as Error).message, finalText: "" };
}
await ctx.db
.updateTable("agent_run")
.set({
status: "running",
started_at: run.started_at ?? new Date(),
provider: fake ? "fake" : s.pi.provider,
model: fake ? "fake-model" : s.pi.model,
thinking_level: s.pi.thinkingLevel,
})
.where("id", "=", runId)
.execute();
void publishStatus(ctx, runId, "running", null);
const onEvent = eventSink(env);
const onEventError = (err: unknown, e: AgentRunEvent) =>
ctx.logger.warn({ runId, seq: e.seq, err: { message: (err as Error).message } }, "agent 事件落库失败");
let scripts: FakeScript[] = DEMO_SCRIPTS;
if (fake) {
try {
scripts = env.opts.fakeScripts ?? (s.agent.fakeScript ? parseFakeScripts(s.agent.fakeScript) : DEMO_SCRIPTS);
} catch (err) {
return {
status: "failed",
reason: "error",
budget: null,
message: `fakeScript 不合法:${(err as Error).message}`,
finalText: "",
};
}
}
const runtime = fake
? createFakeRuntime({ registry, scripts, onEvent, onEventError }).runtime
: new PiAgentRuntime({ registry, onEvent, onEventError });
const maxSeq = await ctx.db
.selectFrom("agent_run_event")
.select(sql<number | null>`max(seq)`.as("m"))
.where("run_id", "=", runId)
.executeTakeFirst();
const actor = {
actorId: run.created_by ?? `agent:${runId}`,
taskId: run.task_id ?? runId,
scopeIds: [] as string[],
...(scope.allowedKinds ? { allowedKinds: scope.allowedKinds } : {}),
};
let handle: AgentRunHandle;
try {
handle = await runtime.createRun({
runId,
goal: run.goal,
systemPrompt: systemPromptFor(run, scope),
capabilities: scope.capabilities?.length ? scope.capabilities : Object.keys(CAPABILITY_CATALOG),
settings,
sessionDir: sessionDirOf(env),
actor,
firstSeq: maxSeq?.m === null || maxSeq?.m === undefined ? 0 : Number(maxSeq.m) + 1,
});
} catch (err) {
return { status: "failed", reason: "error", budget: null, message: (err as Error).message, finalText: "" };
}
// 取消传播:任务取消信号或 agent_run.cancel_requested_at → 中止会话
let aborting = false;
const abortOnce = () => {
if (aborting) return;
aborting = true;
void handle.abort("用户取消");
};
signal.addEventListener("abort", abortOnce, { once: true });
const watch = setInterval(async () => {
const r = await ctx.db
.selectFrom("agent_run")
.select("cancel_requested_at")
.where("id", "=", runId)
.executeTakeFirst()
.catch(() => null);
if (r?.cancel_requested_at) abortOnce();
}, 1000);
try {
let outcome: RunOutcome | null = null;
if (handle.pendingOperations > 0) {
await reconcilePending(env, registry, run, signal);
outcome = await handle.resume();
} else {
const last = await lastOutcomeEvent(env, runId);
if (!last) outcome = await handle.prompt();
else if (last.type === "run_started") outcome = await handle.resume();
else {
const p = last.payload as { finalText?: string; reason?: string; message?: string; budget?: string };
outcome =
last.type === "run_completed"
? { status: "completed", finalText: p.finalText ?? "", usage: {} as never }
: {
status: "failed",
reason: (p.reason ?? "error") as RunOutcome["reason"] & string,
message: p.message ?? "",
finalText: "",
usage: {} as never,
...(p.budget ? { budget: p.budget as never } : {}),
};
}
}
while (outcome.status === "completed" && !aborting) {
const text = await claimMessage(ctx, runId);
if (text === null) break;
outcome = await handle.prompt(text);
}
if (outcome.status === "completed")
return { status: "completed", reason: null, budget: null, message: null, finalText: outcome.finalText };
return {
status: outcome.reason === "aborted" ? "cancelled" : "failed",
reason: outcome.reason ?? "error",
budget: outcome.budget ?? null,
message: outcome.message ?? null,
finalText: outcome.finalText,
};
} finally {
clearInterval(watch);
signal.removeEventListener("abort", abortOnce);
await handle.close();
}
}
/** 等待目标关联的下载/入库落定:返回报告(state≠waiting)或 null(继续等) */
export async function goalSettled(env: AgentEnv, runId: string, modelText: string): Promise<AgentGoalReport | null> {
const r = await buildGoalReport(env.ctx.db, runId, modelText);
if (r.state !== "waiting") return r;
await env.ctx.db
.updateTable("agent_run")
.set({ status: "waiting", summary: r.summary })
.where("id", "=", runId)
.where("status", "in", ["running", "waiting"])
.execute();
return null;
}
+353
View File
@@ -0,0 +1,353 @@
import { createHash } from "node:crypto";
import type {
AgentBudget,
AgentGoalReport,
AgentRunMode,
AgentRunStatus,
AgentTrigger,
DecisionKind,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { buildGoalReport } from "./report.ts";
import { type AgentEnv, type DriveResult, INVESTIGATE_CAPABILITIES, publishStatus, scopeFor } from "./runner.ts";
// agent_run 生命周期:建行 + 业务任务行 + 入队 runAgent;追加消息 / 中止;终态汇报(业务表核查)与后台调查去重。
const TERMINAL: AgentRunStatus[] = ["succeeded", "failed", "cancelled", "budget_exceeded"];
export class AgentRunError extends Error {
constructor(
readonly code: "not_found" | "conflict" | "bad_request" | "budget",
message: string,
) {
super(message);
}
}
export interface StartRunInput {
goal: string;
mode: AgentRunMode;
capabilityAllowlist?: readonly string[] | undefined;
trigger: AgentTrigger;
createdBy: string;
/** 后台触发按根因去重;同键已存在则返回已有运行 */
dedupeKey?: string | null;
budget?: AgentBudget;
allowedKinds?: ("read" | "preview" | "propose")[];
}
const sha = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
/** 建 agent_run + task 行,入队 runAgent(不在调用方进程里跑模型) */
export async function startRun(ctx: ServerContext, p: StartRunInput): Promise<{ run: AgentRunRow; created: boolean }> {
let scope: ReturnType<typeof scopeFor>;
try {
scope = scopeFor(p.mode, p.capabilityAllowlist);
} catch (err) {
throw new AgentRunError("bad_request", (err as Error).message);
}
if (p.allowedKinds) scope = { ...scope, allowedKinds: p.allowedKinds };
const ins = await ctx.db.transaction().execute(async (trx) => {
const row = await sql<AgentRunRow>`
insert into agent_run (trigger, goal, status, scope, budget, mode, dedupe_key, created_by)
values (${p.trigger}, ${p.goal}, 'queued', ${JSON.stringify(scope)}::jsonb, ${JSON.stringify(p.budget ?? {})}::jsonb,
${p.mode}, ${p.dedupeKey ?? null}, ${p.createdBy})
on conflict (dedupe_key) where dedupe_key is not null do nothing
returning *`.execute(trx);
const run = row.rows[0];
if (!run) return null;
const task = await trx
.insertInto("task")
.values({
kind: "agentRun",
idempotency_key: `agent_run:${run.id}`,
input: JSON.stringify({ agentRunId: run.id }),
subject_type: "agent_run",
subject_id: run.id,
triggered_by: p.createdBy,
})
.returning("id")
.executeTakeFirstOrThrow();
return trx
.updateTable("agent_run")
.set({ task_id: task.id })
.where("id", "=", run.id)
.returningAll()
.executeTakeFirstOrThrow();
});
if (!ins) {
const existing = await ctx.db
.selectFrom("agent_run")
.selectAll()
.where("dedupe_key", "=", p.dedupeKey!)
.executeTakeFirstOrThrow();
return { run: existing, created: false };
}
await enqueueRun(ctx, ins, `runAgent:${ins.id}`);
void publishStatus(ctx, ins.id, "queued", null);
return { run: ins, created: true };
}
async function enqueueRun(ctx: ServerContext, run: AgentRunRow, key: string) {
try {
const { runId } = await ctx.engine.enqueue(
"runAgent",
{ agentRunId: run.id },
{
idempotencyKey: key,
traceId: run.task_id ?? run.id,
},
);
if (run.task_id)
await ctx.db
.updateTable("task")
.set({ engine_run_id: runId, status: "pending" })
.where("id", "=", run.task_id)
.execute();
} catch (err) {
await ctx.db
.updateTable("agent_run")
.set({ status: "failed", error: JSON.stringify({ message: `入队失败:${(err as Error).message}` }) })
.where("id", "=", run.id)
.execute();
throw err;
}
}
/** 追加用户消息:运行中由当前会话在本轮结束后消费;已结束/等待中则唤醒续跑(新的 runAgent run) */
export async function appendMessage(ctx: ServerContext, runId: string, text: string, by: string): Promise<AgentRunRow> {
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirst();
if (!run) throw new AgentRunError("not_found", "Agent 运行不存在");
const msg = await ctx.db
.insertInto("agent_run_message")
.values({ run_id: runId, text, created_by: by })
.returning("id")
.executeTakeFirstOrThrow();
if (run.status === "queued" || run.status === "running") return run;
const woke = await ctx.db
.updateTable("agent_run")
.set({ status: "queued", cancel_requested_at: null, finished_at: null })
.where("id", "=", runId)
.returningAll()
.executeTakeFirstOrThrow();
await enqueueRun(ctx, woke, `runAgent:${runId}:msg:${msg.id}`);
void publishStatus(ctx, runId, "queued", null);
return woke;
}
/** 中止:只写取消标志,由会话监视器中止模型并走 finalize(不取消引擎 run,保证终态落库) */
export async function abortRun(ctx: ServerContext, runId: string, reason: string | undefined, by: string) {
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirst();
if (!run) throw new AgentRunError("not_found", "Agent 运行不存在");
if (TERMINAL.includes(run.status)) return run;
const now = new Date();
const row = await ctx.db
.updateTable("agent_run")
.set({
cancel_requested_at: run.cancel_requested_at ?? now,
error: JSON.stringify({ abortReason: reason ?? "用户中止", by }),
})
.where("id", "=", runId)
.returningAll()
.executeTakeFirstOrThrow();
if (run.task_id)
await ctx.db
.updateTable("task")
.set({ cancel_requested_at: now, cancel_reason: reason ?? "用户中止" })
.where("id", "=", run.task_id)
.execute();
return row;
}
async function writeDecision(
ctx: ServerContext,
d: { kind: DecisionKind; title: string; summary: string; subjectId: string; dedupeKey: string; evidence: object },
) {
await ctx.db.transaction().execute(async (trx) => {
const r = await sql<{ id: string; inserted: boolean }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, agent_run_id)
values (${d.kind}, 'warning', ${d.title}, ${d.summary}, 'agent_run', ${d.subjectId}, ${d.dedupeKey},
${JSON.stringify(d.evidence)}::jsonb, ${d.subjectId})
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, updated_at = now()
returning id, (xmax = 0) as inserted`.execute(trx);
const row = r.rows[0]!;
if (row.inserted)
await ctx.outbox.emit(
trx,
"decision.created",
{ decisionId: row.id, kind: d.kind, severity: "warning", title: d.title },
{ idempotencyKey: `decision.created:${row.id}` },
);
});
}
/** 终态:按业务表汇总的报告决定状态;预算超限 → budget_exceeded + 待处理;调查运行回写 decision */
export async function finalize(
env: AgentEnv,
runId: string,
res: DriveResult,
settled: AgentGoalReport | null,
): Promise<{ status: AgentRunStatus; summary: string }> {
const { ctx } = env;
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow();
const report = settled ?? (await buildGoalReport(ctx.db, runId, res.finalText || null));
let status: AgentRunStatus;
let summary: string;
if (res.status === "cancelled" || run.cancel_requested_at) {
status = "cancelled";
summary = `已取消(停止未来步骤,不撤销已完成动作)。${report.state === "no_actions" ? "" : report.summary}`;
} else if (res.reason === "budget") {
status = "budget_exceeded";
summary = `超出预算:${res.message ?? ""}。${report.state === "no_actions" ? "" : report.summary}`;
} else if (res.status === "failed") {
status = "failed";
summary = `运行失败:${res.message ?? ""}`;
} else if (report.state === "waiting") {
status = "waiting";
summary = report.summary;
} else {
status = report.state === "failed" ? "failed" : "succeeded";
summary = report.summary;
}
// 模型文本只作参考:附在业务核查结论之后,明确标注未经核查
if (res.finalText && res.status !== "failed") summary += `\n模型汇报(未经核查):${res.finalText}`;
await ctx.db
.insertInto("artifact")
.values({
kind: "report",
agent_run_id: runId,
task_id: run.task_id,
subject_type: "agent_run",
subject_id: runId,
title: "goal_report",
content: JSON.stringify(report),
content_hash: sha(JSON.stringify({ runId, report })),
})
.execute();
await ctx.db
.updateTable("agent_run")
.set({
status,
summary: summary.slice(0, 4000),
...(status === "waiting" ? {} : { finished_at: new Date() }),
...(res.status === "failed"
? { error: JSON.stringify({ reason: res.reason, budget: res.budget, message: res.message }) }
: {}),
})
.where("id", "=", runId)
.execute();
if (run.task_id) {
const taskStatus =
status === "cancelled"
? "cancelled"
: status === "waiting"
? "waiting"
: status === "succeeded"
? "succeeded"
: "failed";
await ctx.db
.updateTable("task")
.set({
status: taskStatus,
output: JSON.stringify({
status,
report: { state: report.state, imported: report.imported, submitted: report.submitted },
}),
...(taskStatus === "waiting" ? {} : { finished_at: new Date() }),
})
.where("id", "=", run.task_id)
.execute();
}
if (status === "budget_exceeded") {
await writeDecision(ctx, {
kind: "other",
title: "Agent 运行超出预算",
summary: res.message ?? "超出预算",
subjectId: runId,
dedupeKey: `agent_budget:${runId}`,
evidence: { runId, budget: res.budget, message: res.message, goal: run.goal },
});
}
// 后台调查:结论回写待处理事项,交还人工(仍可被新证据重新唤醒)
if (run.dedupe_key?.startsWith("investigate:")) {
const decisionId = run.dedupe_key.slice("investigate:".length);
await ctx.db.transaction().execute(async (trx) => {
const r = await trx
.updateTable("decision")
.set({
status: "awaiting_user",
evidence: sql`evidence || ${JSON.stringify({ agentInvestigation: { runId, status, summary: summary.slice(0, 2000) } })}::jsonb`,
})
.where("id", "=", decisionId)
.where("status", "=", "investigating")
.returning("id")
.executeTakeFirst();
if (r)
await ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId, status: "awaiting_user" },
{ idempotencyKey: `decision.updated:${decisionId}:investigated:${runId}` },
);
});
}
void publishStatus(ctx, runId, status, summary.slice(0, 2000));
// 结束前刚到的追加消息:再唤醒一次
const pending = await ctx.db
.selectFrom("agent_run_message")
.select("id")
.where("run_id", "=", runId)
.where("status", "=", "pending")
.executeTakeFirst();
if (pending && status !== "cancelled") {
const woke = await ctx.db
.updateTable("agent_run")
.set({ status: "queued", finished_at: null })
.where("id", "=", runId)
.returningAll()
.executeTakeFirstOrThrow();
await enqueueRun(ctx, woke, `runAgent:${runId}:msg:${pending.id}`);
}
return { status, summary };
}
/** 后台触发:识别歧义 / 下载失败诊断 → 有界只读调查(同一 decision 只一个 run) */
export async function autoInvestigate(
ctx: ServerContext,
decisionId: string,
): Promise<{ runId: string | null; created: boolean }> {
const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
if (d?.status !== "open") return { runId: null, created: false };
if (!["identity_ambiguous", "download_failed", "download_stalled"].includes(d.kind))
return { runId: null, created: false };
// Agent 自己引起的事项不再触发 Agent(防止递归自触发)
if (d.subject_type === "agent_run") return { runId: null, created: false };
const s = await ctx.settings.get("agent");
if (!s.autoInvestigate) return { runId: null, created: false };
const goal = [
`调查待处理事项 ${d.id}(${d.kind}):${d.title}。`,
d.summary ? `摘要:${d.summary}。` : "",
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
"只做有界的只读调查:查证据、给出结论与处理建议;不要提交下载或变更文件。",
].join("");
const { run, created } = await startRun(ctx, {
goal,
mode: "plan_only",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
trigger: "event",
createdBy: "system:auto-investigate",
dedupeKey: `investigate:${d.id}`,
budget: { maxToolCalls: s.investigateMaxToolCalls, maxDurationSec: Math.min(600, s.maxDurationSec) },
});
if (created) {
await ctx.db
.updateTable("decision")
.set({ status: "investigating", agent_run_id: run.id })
.where("id", "=", d.id)
.where("status", "=", "open")
.execute();
}
return { runId: run.id, created };
}
+162
View File
@@ -0,0 +1,162 @@
import type { AgentGoalReport, DecisionOption, EventPayload } from "@umt/contracts";
import { sql } from "@umt/db";
import { defineTask, type OutboxRouteFn, type TaskRegistry } from "@umt/tasks";
import { z } from "zod";
import type { ServerContext } from "../types.ts";
import { retryTask } from "./capabilities.ts";
import { buildGoalReport } from "./report.ts";
import { type AgentEnv, type DriveResult, drive, goalSettled } from "./runner.ts";
import { autoInvestigate, finalize } from "./service.ts";
// 持久任务:runAgent(驱动一次有界 Agent 会话 → 等目标关联的下载/入库落定 → 以业务表汇报)
// 与 resolveDecision(decision.created → 自动调查;decision.updated(resolved)→ 执行所选处理)。
const id = z.string().uuid();
const DB_RETRY = { maximumAttempts: 3, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "30s" } as const;
export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv) {
registry.register(
defineTask({
name: "runAgent",
version: 1,
inputSchema: z.object({ agentRunId: id }),
retryPolicy: DB_RETRY,
handler: async (t) => {
const e = env();
const runId = t.input.agentRunId;
await t.step.run("start", async () => {
await e.ctx.db
.updateTable("task")
.set({ status: "running", started_at: new Date(), attempts: sql`attempts + 1` })
.where("subject_type", "=", "agent_run")
.where("subject_id", "=", runId)
.execute();
return null;
});
const res: DriveResult = await t.step.run("drive", () => drive(e, e.ctx.capabilities, runId, t.abortSignal));
let settled: AgentGoalReport | null = null;
const run = await e.ctx.db
.selectFrom("agent_run")
.select(["mode"])
.where("id", "=", runId)
.executeTakeFirstOrThrow();
if (res.status === "completed" && run.mode === "execute") {
const s = await e.ctx.settings.get("agent");
const pollSec = e.opts.goalPollSec ?? s.goalPollSec;
const rounds = Math.max(1, Math.min(900, Math.ceil((s.goalWaitHours * 3600) / pollSec)));
settled = await t.step.waitForFact<AgentGoalReport>("goal-settled", {
check: async () => {
const c = await e.ctx.db
.selectFrom("agent_run")
.select("cancel_requested_at")
.where("id", "=", runId)
.executeTakeFirst();
// 已请求取消:不再等待,finalize 按取消收尾
if (c?.cancel_requested_at) return buildGoalReport(e.ctx.db, runId, res.finalText || null);
return goalSettled(e, runId, res.finalText);
},
timeout: `${pollSec}s`,
maxRounds: rounds,
});
}
return t.step.run("finalize", () => finalize(e, runId, res, settled));
},
}),
defineTask({
name: "resolveDecision",
version: 1,
inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated"]).default("updated") }),
retryPolicy: DB_RETRY,
handler: async (t) => {
const e = env();
if (t.input.trigger === "created")
return t.step.run("investigate", () => autoInvestigate(e.ctx, t.input.decisionId));
return t.step.run("execute", () => executeResolution(e.ctx, t.input.decisionId));
},
}),
);
}
/**
* 执行 decision 的处理结果(身份歧义由 sources-identity 在处理端点里直接执行,这里跳过):
* - 选项带 capabilityId → 经注册中心调用(仍走授权/审计/账本);
* - retry → 重试关联任务;reevaluate / accept → 按对象重发 evaluateAcquisition;dismiss / ignore → 无动作。
*/
export async function executeResolution(ctx: ServerContext, decisionId: string) {
const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
if (!d) return { executed: false, reason: "not_found" };
if (d.status !== "resolved") return { executed: false, reason: `status_${d.status}` };
if (d.kind === "identity_ambiguous") return { executed: false, reason: "handled_by_sources_identity" };
const res = (d.resolution ?? {}) as { optionKey?: string | null; by?: string; execution?: unknown };
if (res.execution) return { executed: false, reason: "already_executed" };
const key = res.optionKey ?? null;
const opt = ((d.options ?? []) as DecisionOption[]).find((o) => o.key === key);
const by = d.resolved_by ?? res.by ?? "system";
let execution: Record<string, unknown>;
try {
if (opt?.capabilityId) {
const output = await ctx.capabilities.invoke(opt.capabilityId, opt.input ?? {}, {
actorId: by.replace(/^admin:/, "user:"),
taskId: `decision:${d.id}`,
scopeIds: [],
signal: new AbortController().signal,
// 人工在待处理中心选中的处理 = 控制台人工授权;系统/Agent 关闭的事项仍需授权记录
source: /^(admin|user):/.test(by) ? "web" : "task",
invocationId: `decision:${d.id}`,
});
execution = { kind: "capability", capabilityId: opt.capabilityId, output };
} else if (key === "retry" && d.task_id) {
const row = await retryTask(ctx, d.task_id, by, `decision ${d.id}`);
execution = { kind: "retry", taskId: row.id, status: row.status };
} else if ((key === "reevaluate" || key === "accept") && d.subject_id) {
const input =
d.subject_type === "acquisition_slot"
? { acquisitionSlotId: d.subject_id }
: d.subject_type === "source_release"
? { sourceReleaseId: d.subject_id }
: d.subject_type === "acquisition_plan"
? {
acquisitionSlotId: (
await ctx.db
.selectFrom("acquisition_plan")
.select("slot_id")
.where("id", "=", d.subject_id)
.executeTakeFirst()
)?.slot_id,
}
: null;
if (!input || Object.values(input).some((v) => !v)) execution = { kind: "none", reason: "subject_not_evaluable" };
else {
const { runId } = await ctx.engine.enqueue("evaluateAcquisition", input, {
idempotencyKey: `decision:${d.id}:reevaluate`,
traceId: d.id,
});
execution = { kind: "reevaluate", engineRunId: runId, input };
}
} else {
execution = { kind: "none", reason: key ? `option_${key}` : "no_option" };
}
} catch (err) {
execution = { kind: "error", message: (err as Error).message.slice(0, 500) };
}
await ctx.db
.updateTable("decision")
.set({ resolution: sql`coalesce(resolution, '{}'::jsonb) || ${JSON.stringify({ execution })}::jsonb` })
.where("id", "=", d.id)
.execute();
return { executed: execution.kind !== "none" && execution.kind !== "error", execution };
}
export const agentOutboxRoutes: Record<string, OutboxRouteFn> = {
"decision.created": (ev) => {
const p = ev.payload as EventPayload<"decision.created">;
if (!["identity_ambiguous", "download_failed", "download_stalled"].includes(p.kind)) return null;
return { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "created" } };
},
"decision.updated": (ev) => {
const p = ev.payload as EventPayload<"decision.updated">;
return p.status === "resolved"
? { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "updated" } }
: null;
},
};
@@ -29,6 +29,15 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
merged.pi.apiKeySecretRef = null;
dropRef = oldRef;
}
// TMDB 密钥同样只写:存 secret(metadata_api_key),设置里只留 tmdbApiKeySecretRef
const oldTmdbRef = current.metadata.tmdbApiKeySecretRef;
const tmdbKey = patch.metadata?.tmdbApiKey;
let dropTmdbRef: string | null = null;
let newTmdbRef: string | null = null;
if (tmdbKey === null) {
merged.metadata.tmdbApiKeySecretRef = null;
dropTmdbRef = oldTmdbRef;
}
const parsed = GlobalSettings.safeParse(merged);
if (!parsed.success) {
const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
@@ -44,6 +53,15 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
next.pi.apiKeySecretRef = newRef;
}
}
if (typeof tmdbKey === "string") {
if (oldTmdbRef) {
await ctx.secrets.replace(oldTmdbRef, tmdbKey);
next.metadata.tmdbApiKeySecretRef = oldTmdbRef;
} else {
newTmdbRef = await ctx.secrets.create({ purpose: "metadata_api_key", plaintext: tmdbKey, label: "tmdb" });
next.metadata.tmdbApiKeySecretRef = newTmdbRef;
}
}
const keys = (Object.keys(patch) as SettingsKey[]).filter((k) => patch[k] !== undefined);
try {
await ctx.db.transaction().execute(async (trx) => {
@@ -59,14 +77,18 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
});
} catch (err) {
if (newRef) await ctx.secrets.remove(newRef).catch(() => {});
if (newTmdbRef) await ctx.secrets.remove(newTmdbRef).catch(() => {});
throw err;
}
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
if (dropTmdbRef) await ctx.secrets.remove(dropTmdbRef).catch(() => {});
if (isCached(ctx.settings)) ctx.settings.invalidate();
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
// 审计里不留明文密钥
const logged: Record<string, unknown> = { ...patch };
if (patch.pi && "apiKey" in patch.pi) logged.pi = { ...patch.pi, apiKey: apiKey === null ? "(cleared)" : "(set)" };
if (patch.metadata && "tmdbApiKey" in patch.metadata)
logged.metadata = { ...patch.metadata, tmdbApiKey: tmdbKey === null ? "(cleared)" : "(set)" };
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
return next;
});
@@ -52,7 +52,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
title: string;
year: number | null;
}[] = [];
const tmdb = rt().tmdb();
const tmdb = await rt().tmdb();
if (input.includeRemote && input.query && tmdb) {
for (const m of (await tmdb.searchAll(input.query, input.year ? { year: input.year } : {})).slice(
0,
@@ -40,6 +40,7 @@ export class SourcesRuntime {
readonly rss: RssFetcher;
private readonly adapters = new Map<string, CachedAdapter>();
private tmdbClient: TmdbClient | null | undefined;
private tmdbSig: string | null = null;
constructor(
readonly ctx: ServerContext,
@@ -53,17 +54,41 @@ export class SourcesRuntime {
});
}
/** TMDB:优先注入;否则读环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN(W2-A 设置页接管前的过渡) */
tmdb(): TmdbClient | null {
if (this.tmdbClient !== undefined) return this.tmdbClient;
/**
* TMDB 客户端:优先注入(测试);否则读设置 metadata.tmdbApiKeySecretRef(secret 现场解密)与 tmdbBaseUrl,
* 未配置时回退环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。设置变化(引用/地址)时重建。
*/
async tmdb(): Promise<TmdbClient | null> {
if (this.overrides.tmdb) {
this.tmdbClient = this.overrides.tmdb();
if (this.tmdbClient === undefined) this.tmdbClient = this.overrides.tmdb();
return this.tmdbClient;
}
const meta = await this.ctx.settings.get("metadata").catch(() => null);
const env = (k: string) => Deno.env.get(k);
const apiKey = env("TMDB_API_KEY");
const accessToken = env("TMDB_ACCESS_TOKEN");
const baseUrl = env("TMDB_BASE_URL");
const ref = meta?.tmdbApiKeySecretRef ?? null;
const baseUrl = meta?.tmdbBaseUrl ?? env("TMDB_BASE_URL");
const sig = JSON.stringify([
ref,
baseUrl ?? null,
ref ? null : [!!env("TMDB_API_KEY"), !!env("TMDB_ACCESS_TOKEN")],
]);
if (this.tmdbClient !== undefined && this.tmdbSig === sig) return this.tmdbClient;
let apiKey: string | undefined;
let accessToken: string | undefined;
if (ref) {
try {
const secret = (await this.ctx.secrets.resolve(ref)).trim();
// v3 API Key 是 32 位十六进制;其余(v4 read access token,JWT)走 Bearer
if (/^[0-9a-f]{32}$/i.test(secret)) apiKey = secret;
else accessToken = secret;
} catch (err) {
this.ctx.logger.warn({ err: { message: (err as Error).message } }, "TMDB 密钥解密失败,按未配置处理");
}
} else {
apiKey = env("TMDB_API_KEY");
accessToken = env("TMDB_ACCESS_TOKEN");
}
this.tmdbSig = sig;
this.tmdbClient =
apiKey || accessToken
? createTmdbClient({
@@ -35,7 +35,7 @@ async function entityForExternal(rt: SourcesRuntime, exec: Exec, ref: ExternalRe
if (hit) return hit;
if (ref.provider !== "tmdb")
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
const tmdb = rt.tmdb();
const tmdb = await rt.tmdb();
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
let detail: Awaited<ReturnType<typeof tmdb.details>>;
try {
@@ -47,7 +47,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
.where("provider", "=", "tmdb")
.where("status", "=", "accepted")
.executeTakeFirst();
const tmdb = rt.tmdb();
const tmdb = await rt.tmdb();
if (!ident || !tmdb || (ident.entity_type !== "movie" && ident.entity_type !== "tv")) {
return {
status: "skipped",
@@ -156,7 +156,7 @@ async function localCandidates(
}
async function tmdbCandidates(rt: SourcesRuntime, parsed: ParsedRelease): Promise<MetadataCandidate[]> {
const tmdb = rt.tmdb();
const tmdb = await rt.tmdb();
if (!tmdb) return [];
const queries = [parsed.titleCn, parsed.titleEn, parsed.title].filter((t): t is string => !!t);
const seen = new Map<string, MetadataCandidate>();
@@ -282,7 +282,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
});
if (!existing) {
try {
detailed = await rt.tmdb()!.details(remoteBest.entityType, remoteBest.externalId);
detailed = await (await rt.tmdb())!.details(remoteBest.entityType, remoteBest.externalId);
} catch (e) {
if (!(e instanceof TmdbError)) throw e;
detailed = remoteBest;