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:
1 parent
0dec0854df
commit
6280d52ec3
46 files changed
+5133
-111
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 上游。`;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user