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

@@ -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,