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
@@ -7,3 +7,5 @@ LOG_LEVEL=info
|
||||
NODE_ENV=development
|
||||
# 开发模式下可直接用此 Key 以 admin scope 访问 API;生产环境忽略
|
||||
DEV_ADMIN_KEY=umt-dev-admin-key
|
||||
# Agent(Pi)JSONL 会话目录:api 与 worker 进程需共享同一目录(崩溃恢复点);缺省 ./data/agent-sessions
|
||||
# AGENT_SESSION_DIR=./data/agent-sessions
|
||||
@@ -12,3 +12,4 @@ coverage/
|
||||
.vite/
|
||||
types/deno.d.ts
|
||||
apps/web/test/screenshots/
|
||||
data/agent-sessions/
|
||||
@@ -16,7 +16,8 @@ src/
|
||||
auth-settings/ W2-A(provided.ts 提供 secrets / settings 实现)
|
||||
sources-identity/ W2-B
|
||||
acquisition-organize/ W2-C
|
||||
agent/ W2-D(capability-registry.ts 负责 authorize / audit 注入)
|
||||
agent/ W2-D:capability-registry.ts(authorize / audit / 幂等键 / 操作账本)、会话 API、runAgent、resolveDecision
|
||||
(见 docs/runbook/agent.md;agent 模块可以 import 其它模块的 service 导出,反向不行)
|
||||
```
|
||||
|
||||
**分工规则:各 W2 子任务只改自己的 `modules/<area>/` 目录,以及 `packages/contracts`、`packages/db` 中本域的文件**(新迁移按编号顺延,开工前在群里认领编号)。`modules/types.ts`、`modules/index.ts`、`context.ts`、`http/app.ts`、`worker/index.ts`、`main.ts` 是共享装配,确需改动时单独提交并通知其他子任务;`http/auth.ts`、`http/sse.ts` 归 W2-A。跨模块只通过 DB 表 + `outbox_event`(事件类型在 contracts/events.ts)+ contracts 类型耦合,不互相 import 对方模块。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { API_PREFIX, listEndpoints } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApp } from "../src/http/app.ts";
|
||||
import { EventBus } from "../src/http/sse.ts";
|
||||
import { SERVER_MODULES } from "../src/modules/index.ts";
|
||||
import type { ServerContext } from "../src/modules/types.ts";
|
||||
import { DEV_KEY, json, memoryLogger } from "./helpers.ts";
|
||||
|
||||
// OpenAPI 里出现的每条 /api/v1 路由都必须登记在 @umt/contracts 的 Api 注册表(web 与测试都从那里取路径/schema)。
|
||||
|
||||
describe("Api 注册表覆盖", () => {
|
||||
it("OpenAPI 文档中的每条路由都在 listEndpoints() 里", async () => {
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger: memoryLogger().logger,
|
||||
bus: new EventBus(5),
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
modules: SERVER_MODULES,
|
||||
ctx: { role: "all" } as unknown as ServerContext,
|
||||
});
|
||||
const res = await app.request("/api/openapi.json");
|
||||
expect(res.status).toBe(200);
|
||||
const doc = (await json(res)) as { paths: Record<string, Record<string, unknown>> };
|
||||
|
||||
const served: string[] = [];
|
||||
for (const [path, ops] of Object.entries(doc.paths)) {
|
||||
if (!path.startsWith(`${API_PREFIX}/`)) continue; // /api/openapi.json 等非业务路由
|
||||
const rel = path.slice(API_PREFIX.length);
|
||||
for (const method of Object.keys(ops)) served.push(`${method.toUpperCase()} ${rel}`);
|
||||
}
|
||||
expect(served.length).toBeGreaterThan(0);
|
||||
|
||||
const registered = new Set(listEndpoints().map((e) => `${e.method.toUpperCase()} ${e.path}`));
|
||||
const missing = served.filter((k) => !registered.has(k)).sort();
|
||||
expect(missing, `以下路由未登记到 packages/contracts/src/api.ts:\n${missing.join("\n")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createMockRssServer, type MockRssServer } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { completeDownload, createE2E, type E2E, pgAvailable, seedBase, waitFor } from "./harness.ts";
|
||||
|
||||
// E1 RSS → 入库(全部经真实任务引擎 + outbox 中继):
|
||||
// mock RSS(观众风格条目)→ ingestFeed → release.observed → resolveObservation(mock TMDB,密钥来自设置)
|
||||
// → release.resolved → evaluateAcquisition(已发布策略:新作品 1080p Slot,发布即生成持续授权)→ 计划 + 提交 mock qB
|
||||
// → 完成(落盘)→ download.status_changed(completed)→ verifyAndImport(Copy)→ /organization-records/changes 文件级记录
|
||||
// → 媒体库详情 Slot=satisfied。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const BASE = "https://audiences.example";
|
||||
const TITLE = "Dune.Part.Two.2024.1080p.BluRay.x264-GRP";
|
||||
|
||||
describe.skipIf(!hasPg)("E1 RSS → 入库", () => {
|
||||
let e: E2E;
|
||||
let rss: MockRssServer;
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
e = await createE2E("e1");
|
||||
base = await seedBase(e, { baseUrl: `${BASE}/` });
|
||||
rss = await createMockRssServer({
|
||||
passkey: "E2EPASSKEY",
|
||||
items: [
|
||||
{
|
||||
guid: "aud-812345",
|
||||
title: TITLE,
|
||||
link: `${BASE}/details.php?id=812345&hit=1`,
|
||||
enclosureUrl: `${BASE}/download.php?id=812345&passkey=E2EPASSKEY`,
|
||||
size: 300_000,
|
||||
pubDate: new Date(),
|
||||
categories: ["电影"],
|
||||
},
|
||||
{
|
||||
guid: "aud-812346",
|
||||
title: "Dune.Part.Two.2024.720p.WEB-DL.x264-AAA",
|
||||
link: `${BASE}/details.php?id=812346&hit=1`,
|
||||
enclosureUrl: `${BASE}/download.php?id=812346&passkey=E2EPASSKEY`,
|
||||
size: 300_000,
|
||||
pubDate: new Date(),
|
||||
categories: ["电影"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await rss?.close();
|
||||
await e?.close();
|
||||
}, 60_000);
|
||||
|
||||
it("发布 1080p 收录策略 → 持续授权", async () => {
|
||||
const created = await e.api("POST", "/policy-revisions", {
|
||||
policyKey: "default",
|
||||
kind: "acquisition",
|
||||
title: "新片 1080p",
|
||||
scope: { mediaKinds: ["movie"] },
|
||||
rules: {
|
||||
newTitle: {
|
||||
enabled: true,
|
||||
windowDays: 3650,
|
||||
mediaKinds: ["movie"],
|
||||
slots: [{ name: "1080p", hardConditions: { resolutions: ["1080p"] } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const pub = await e.api("POST", `/policy-revisions/${created.body.data.id}/publish`, {
|
||||
expectedRevision: created.body.data.revision,
|
||||
});
|
||||
expect(pub.status).toBe(200);
|
||||
expect(pub.body.data.status).toBe("published");
|
||||
const grants = await e.api("GET", "/automation-grants");
|
||||
expect(grants.body.data.items.map((g: { action: string }) => g.action)).toContain("policy.acquisition");
|
||||
});
|
||||
|
||||
it("RSS 抓取 → 识别 → 计划 → 提交 → 完成 → Copy 入库 → 整理记录增量可读 → Slot satisfied", async () => {
|
||||
const urlRef = await e.booted.ctx.secrets.create({ purpose: "feed_url", plaintext: rss.feedUrl });
|
||||
const feed = await e.db
|
||||
.insertInto("feed_source")
|
||||
.values({
|
||||
site_id: base.siteId,
|
||||
name: "观众-电影",
|
||||
purpose: "movie",
|
||||
url_secret_ref: urlRef,
|
||||
url_display: rss.feedUrl.replace(/passkey=[^&]+/, "passkey=[REDACTED]"),
|
||||
poll_interval_sec: 3600,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const fetched = await e.api("POST", `/feed-sources/${feed.id}/fetch`);
|
||||
expect(fetched.status).toBe(200);
|
||||
|
||||
// 识别:TMDB 客户端来自设置(secret_ref),建本地作品
|
||||
const rel = await waitFor(
|
||||
async () =>
|
||||
e.db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("title", "=", TITLE)
|
||||
.where("resolution_status", "=", "resolved")
|
||||
.executeTakeFirst(),
|
||||
60_000,
|
||||
"1080p 资源识别完成",
|
||||
);
|
||||
const entity = await e.db
|
||||
.selectFrom("media_entity")
|
||||
.selectAll()
|
||||
.where("id", "=", rel.media_entity_id!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(entity).toMatchObject({ kind: "movie", year: 2024 });
|
||||
|
||||
// 计划与提交:只有 1080p 被选中
|
||||
await completeDownload(e, TITLE);
|
||||
expect(e.qb.callsTo("torrents/add")).toHaveLength(1);
|
||||
|
||||
const rec = await waitFor(
|
||||
async () =>
|
||||
e.db
|
||||
.selectFrom("organization_record")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", entity.id)
|
||||
.where("status", "=", "succeeded")
|
||||
.executeTakeFirst(),
|
||||
90_000,
|
||||
"整理记录 succeeded",
|
||||
);
|
||||
expect(rec).toMatchObject({ action: "copy", trigger: "auto" });
|
||||
|
||||
// 公开整理记录增量 API:文件级结果(只返回早于所有活跃事务的变更,共享库上其它测试的长事务会让它稍晚可见)
|
||||
const item = await waitFor(
|
||||
async () => {
|
||||
const changes = await e.api("GET", "/organization-records/changes?after=0&includeFiles=true");
|
||||
expect(changes.status).toBe(200);
|
||||
return changes.body.data.items.find(
|
||||
(x: { id: string; status: string }) => x.id === rec.id && x.status === "succeeded",
|
||||
);
|
||||
},
|
||||
60_000,
|
||||
"整理记录出现在 changes 增量里",
|
||||
500,
|
||||
);
|
||||
expect(item.files[0]).toMatchObject({ role: "main", status: "succeeded" });
|
||||
expect(item.files[0].target.path).toMatch(/1080p\.mkv$/);
|
||||
|
||||
// 媒体库详情:Slot satisfied,资产登记
|
||||
const lib = await e.api("GET", `/library/${entity.id}`);
|
||||
expect(lib.status).toBe(200);
|
||||
expect(lib.body.data.slots.map((s: { name: string; status: string }) => `${s.name}:${s.status}`)).toEqual([
|
||||
"1080p:satisfied",
|
||||
]);
|
||||
expect(lib.body.data.assets.length).toBeGreaterThanOrEqual(1);
|
||||
// 凭证不落业务表明文
|
||||
const dump = JSON.stringify(await e.db.selectFrom("observation").selectAll().execute());
|
||||
expect(dump).not.toContain("E2EPASSKEY");
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { delegateScript } from "./fixtures/scripts.ts";
|
||||
import {
|
||||
completeDownload,
|
||||
createE2E,
|
||||
type E2E,
|
||||
pgAvailable,
|
||||
runEvents,
|
||||
runRow,
|
||||
seedBase,
|
||||
seedMovieTarget,
|
||||
waitFor,
|
||||
} from "./harness.ts";
|
||||
|
||||
// E2 Agent 委托闭环:fake 模型按"把《沙丘2》1080p 收进来"依次调用
|
||||
// inventory.query → release.search → download.plan → download.submit → task.inspect。
|
||||
// - 没有持续授权时 download.submit 被服务端拒绝(模型文本"用户已同意"无效),SSE 上是 denied;
|
||||
// - 发了 automation_grant 后同一目标成功提交;模型汇报"已入库",但 run 的汇报来自整理记录:
|
||||
// 下载完成前是 waiting(已提交≠已入库),核验入库后才 succeeded 并带整理记录 ID。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const GOAL = "把《沙丘2》1080p 收进来";
|
||||
|
||||
describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权/任务)", () => {
|
||||
let e: E2E;
|
||||
let target: Awaited<ReturnType<typeof seedMovieTarget>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
e = await createE2E("e2", { fakeScripts: [delegateScript] });
|
||||
const base = await seedBase(e);
|
||||
target = await seedMovieTarget(e, base.siteId);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await e?.close();
|
||||
}, 60_000);
|
||||
|
||||
const createRun = async () => {
|
||||
const r = await e.api("POST", "/agent/runs", { goal: GOAL, mode: "execute" });
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.body.data).toMatchObject({ status: "queued", trigger: "user", mode: "execute" });
|
||||
return r.body.data.id as string;
|
||||
};
|
||||
|
||||
it("启动断言:14 个能力全部注册,并在能力目录里可见", async () => {
|
||||
expect(e.booted.ctx.capabilities.list()).toHaveLength(14);
|
||||
const r = await e.api("GET", "/capabilities");
|
||||
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
|
||||
e.booted.ctx.capabilities
|
||||
.list()
|
||||
.map((c) => c.descriptor.id)
|
||||
.sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("没有授权:download.submit 被拒(模型声称已同意无效),不产生下载", async () => {
|
||||
const seen: { type: string; payload: { phase?: string; capabilityId?: string } }[] = [];
|
||||
const off = e.bus.subscribe((ev) => {
|
||||
if (ev.type.startsWith("agent.")) seen.push(ev as never);
|
||||
});
|
||||
const runId = await createRun();
|
||||
const run = await waitFor(
|
||||
async () => {
|
||||
const r = await runRow(e, runId);
|
||||
return ["succeeded", "failed", "waiting"].includes(r.status) ? r : null;
|
||||
},
|
||||
60_000,
|
||||
"run 结束",
|
||||
);
|
||||
off();
|
||||
expect(run.status).toBe("succeeded");
|
||||
const events = await runEvents(e, runId);
|
||||
const submitEnd = events.find(
|
||||
(x) => x.type === "tool_call_end" && (x.payload as { capabilityId: string }).capabilityId === "download.submit",
|
||||
);
|
||||
expect(submitEnd?.payload).toMatchObject({ isError: true });
|
||||
expect(JSON.stringify(submitEnd?.payload)).toContain("unauthorized");
|
||||
// SSE:未授权的调用标为 denied
|
||||
expect(
|
||||
seen.some(
|
||||
(x) =>
|
||||
x.type === "agent.tool_call" && x.payload.capabilityId === "download.submit" && x.payload.phase === "denied",
|
||||
),
|
||||
).toBe(true);
|
||||
// 审计:capability.denied
|
||||
const denied = await e.db
|
||||
.selectFrom("audit_event")
|
||||
.selectAll()
|
||||
.where("action", "=", "capability.denied")
|
||||
.where("target_id", "=", "download.submit")
|
||||
.execute();
|
||||
expect(denied.length).toBeGreaterThanOrEqual(1);
|
||||
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(0);
|
||||
// 草案计划存在,但没有预留 Slot
|
||||
const plans = await e.db.selectFrom("acquisition_plan").selectAll().execute();
|
||||
expect(plans.map((p) => p.status)).toEqual(["draft"]);
|
||||
const slot = await e.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("status")
|
||||
.where("id", "=", target.slotId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(slot.status).toBe("empty");
|
||||
// 汇报不采信模型文本:没有可核查的业务变更
|
||||
expect(run.summary).toContain("没有提交下载");
|
||||
expect(run.summary).toContain("模型汇报(未经核查)");
|
||||
}, 90_000);
|
||||
|
||||
it("有持续授权:提交成功 → 已提交≠已入库(waiting)→ 下载完成、核验入库后 succeeded,汇报来自整理记录", async () => {
|
||||
const g = await e.api("POST", "/automation-grants", {
|
||||
action: "download.submit",
|
||||
scope: { mediaEntityIds: [target.entityId] },
|
||||
reason: "e2e 授权",
|
||||
});
|
||||
expect(g.status).toBe(201);
|
||||
|
||||
const runId = await createRun();
|
||||
// 模型已经说完"已入库",但业务上只是已提交:run 进入 waiting
|
||||
const waiting = await waitFor(
|
||||
async () => {
|
||||
const r = await runRow(e, runId);
|
||||
return r.status === "waiting" ? r : null;
|
||||
},
|
||||
60_000,
|
||||
"run waiting",
|
||||
);
|
||||
expect(waiting.summary).toContain("已提交≠已入库");
|
||||
const events = await runEvents(e, runId);
|
||||
const completed = events.find((x) => x.type === "run_completed");
|
||||
expect((completed!.payload as { finalText: string }).finalText).toContain("已入库");
|
||||
const calls = events
|
||||
.filter((x) => x.type === "tool_call_start")
|
||||
.map((x) => (x.payload as { capabilityId: string }).capabilityId);
|
||||
expect(calls).toEqual(["inventory.query", "release.search", "download.plan", "download.submit", "task.inspect"]);
|
||||
const submitEnd = events.find(
|
||||
(x) => x.type === "tool_call_end" && (x.payload as { capabilityId: string }).capabilityId === "download.submit",
|
||||
);
|
||||
expect(submitEnd?.payload).toMatchObject({ isError: false });
|
||||
// 账本:download.submit 记在本 run 名下,带授权 ID
|
||||
const inv = await e.db
|
||||
.selectFrom("capability_invocation")
|
||||
.selectAll()
|
||||
.where("agent_run_id", "=", runId)
|
||||
.where("capability_id", "=", "download.submit")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(inv).toMatchObject({ status: "succeeded", grant_id: g.body.data.id });
|
||||
// 草案被转为预留并提交
|
||||
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
|
||||
|
||||
const done = await waitFor(
|
||||
async () => {
|
||||
const r = await runRow(e, runId);
|
||||
return r.status === "succeeded" ? r : null;
|
||||
},
|
||||
90_000,
|
||||
"run succeeded",
|
||||
);
|
||||
const rec = await e.db
|
||||
.selectFrom("organization_record")
|
||||
.selectAll()
|
||||
.where("status", "=", "succeeded")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(done.summary).toContain("已入库 1 个");
|
||||
expect(done.summary).toContain(rec.id);
|
||||
const detail = await e.api("GET", `/agent/runs/${runId}?afterSeq=-1&limit=500`);
|
||||
expect(detail.body.data.report).toMatchObject({ state: "completed", imported: 1, submitted: 1 });
|
||||
expect(detail.body.data.report.organization[0]).toMatchObject({ recordId: rec.id, status: "succeeded" });
|
||||
// 断线补读:afterSeq 之后的事件
|
||||
const tail = await e.api("GET", `/agent/runs/${runId}?afterSeq=3&limit=2`);
|
||||
expect(tail.body.data.events.map((x: { seq: number }) => x.seq)).toEqual([4, 5]);
|
||||
expect(tail.body.data.nextAfterSeq).toBe(5);
|
||||
// 入库成功:Slot 满足
|
||||
const slot = await e.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("status")
|
||||
.where("id", "=", target.slotId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(slot.status).toBe("satisfied");
|
||||
expect(e.qb.callsTo("torrents/add")).toHaveLength(1);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { delegateScript } from "./fixtures/scripts.ts";
|
||||
import {
|
||||
completeDownload,
|
||||
createE2E,
|
||||
type E2E,
|
||||
pgAvailable,
|
||||
runRow,
|
||||
seedBase,
|
||||
seedMovieTarget,
|
||||
waitFor,
|
||||
} from "./harness.ts";
|
||||
|
||||
// E3 关闭聊天不影响后台:只发一次 POST /agent/runs(HTTP 请求里不跑模型,立即返回 queued),之后不再有任何 API 请求
|
||||
// 也不订阅 SSE;持久任务独立推进:模型会话 → 提交 → (外部下载器完成)→ 核验入库 → run succeeded。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("E3 关闭聊天不影响后台", () => {
|
||||
let e: E2E;
|
||||
let target: Awaited<ReturnType<typeof seedMovieTarget>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
e = await createE2E("e3", { fakeScripts: [delegateScript] });
|
||||
const base = await seedBase(e);
|
||||
target = await seedMovieTarget(e, base.siteId);
|
||||
await e.db
|
||||
.insertInto("automation_grant")
|
||||
.values({ action: "download.submit", scope: JSON.stringify({}), granted_by: "user:e2e" })
|
||||
.execute();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await e?.close();
|
||||
}, 60_000);
|
||||
|
||||
it("创建后不再请求,任务系统独立推进到 completed", async () => {
|
||||
const t0 = Date.now();
|
||||
const r = await e.api("POST", "/agent/runs", { goal: "把《沙丘2》1080p 收进来", mode: "execute" });
|
||||
// HTTP 不驱动模型:立即返回 queued(模型会话在 worker 的持久任务里跑)
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.body.data.status).toBe("queued");
|
||||
expect(Date.now() - t0).toBeLessThan(5_000);
|
||||
const runId = r.body.data.id as string;
|
||||
const task = await e.db
|
||||
.selectFrom("task")
|
||||
.selectAll()
|
||||
.where("id", "=", r.body.data.taskId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(task).toMatchObject({ kind: "agentRun", subject_type: "agent_run", subject_id: runId });
|
||||
expect(task.engine_run_id).toBeTruthy();
|
||||
|
||||
// "聊天已关闭":从这里开始只有外部世界(下载器)在变化
|
||||
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
|
||||
const run = await waitFor(
|
||||
async () => {
|
||||
const x = await runRow(e, runId);
|
||||
return x.status === "succeeded" ? x : null;
|
||||
},
|
||||
120_000,
|
||||
"run succeeded",
|
||||
);
|
||||
expect(run.finished_at).not.toBeNull();
|
||||
expect(run.summary).toContain("已入库 1 个");
|
||||
const engineRun = await e.booted.ctx.engine.get(task.engine_run_id!);
|
||||
expect(engineRun?.status).toBe("completed");
|
||||
const t = await e.db.selectFrom("task").select("status").where("id", "=", task.id).executeTakeFirstOrThrow();
|
||||
expect(t.status).toBe("succeeded");
|
||||
const slot = await e.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("status")
|
||||
.where("id", "=", target.slotId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(slot.status).toBe("satisfied");
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { torrentFor } from "./fixtures/modules.ts";
|
||||
import { crashScript } from "./fixtures/scripts.ts";
|
||||
import {
|
||||
createE2E,
|
||||
type E2E,
|
||||
PG_URL,
|
||||
pgAvailable,
|
||||
runEvents,
|
||||
runRow,
|
||||
seedBase,
|
||||
seedMovieTarget,
|
||||
waitFor,
|
||||
} from "./harness.ts";
|
||||
|
||||
// E4 崩溃恢复:worker 子进程在 Agent 调用 download.submit 时被 kill -9;新 worker 在租约到期(约 30s)后接手:
|
||||
// runAgent 的 drive 步骤重跑 → 打开同一 JSONL 会话(pendingOperations>0)→ 按幂等键对账账本里停在 running 的调用
|
||||
// → resume(模型拿到"结果未知")→ 同参数再调 download.submit 得到账本结果;mock qB 只收到一次 add。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const WORKER = fileURLToPath(new URL("./fixtures/worker.ts", import.meta.url));
|
||||
const SERVER_DIR = fileURLToPath(new URL("../..", import.meta.url));
|
||||
const TITLE = "Dune.Part.Two.2024.1080p.BluRay.x264-GRP";
|
||||
|
||||
describe.skipIf(!hasPg)("E4 崩溃恢复(worker 子进程 kill -9)", () => {
|
||||
let e: E2E;
|
||||
const children: ChildProcess[] = [];
|
||||
|
||||
const spawnWorker = (): Promise<ChildProcess> => {
|
||||
const child = spawn(process.execPath, ["run", "-A", WORKER], {
|
||||
cwd: SERVER_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: PG_URL,
|
||||
BIZ_SCHEMA: e.biz,
|
||||
OW_SCHEMA: e.ow,
|
||||
SESSION_DIR: e.sessionDir,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
children.push(child);
|
||||
let err = "";
|
||||
child.stderr!.on("data", (d) => {
|
||||
err += d;
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`worker 启动超时: ${err}`)), 60_000);
|
||||
child.stdout!.on("data", (d) => {
|
||||
if (String(d).includes("worker ready")) {
|
||||
clearTimeout(timer);
|
||||
resolve(child);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`worker 提前退出 code=${code}: ${err}`));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
e = await createE2E("e4", { worker: false, fakeScripts: [crashScript] });
|
||||
const base = await seedBase(e);
|
||||
await seedMovieTarget(e, base.siteId);
|
||||
await e.db
|
||||
.insertInto("automation_grant")
|
||||
.values({ action: "download.submit", scope: JSON.stringify({}), granted_by: "user:e2e" })
|
||||
.execute();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
for (const c of children) if (c.exitCode === null && c.signalCode === null) c.kill("SIGKILL");
|
||||
await e?.close();
|
||||
}, 60_000);
|
||||
|
||||
it("download.submit 途中 kill worker → 新 worker resume,不重复提交,最终入库", async () => {
|
||||
const w1 = await spawnWorker();
|
||||
const r = await e.api("POST", "/agent/runs", { goal: "把《沙丘2》1080p 收进来", mode: "execute" });
|
||||
expect(r.status).toBe(201);
|
||||
const runId = r.body.data.id as string;
|
||||
|
||||
// Agent 调到 download.submit(账本行出现)的瞬间 kill -9
|
||||
await waitFor(
|
||||
async () =>
|
||||
e.db
|
||||
.selectFrom("capability_invocation")
|
||||
.select("id")
|
||||
.where("agent_run_id", "=", runId)
|
||||
.where("capability_id", "=", "download.submit")
|
||||
.executeTakeFirst(),
|
||||
60_000,
|
||||
"download.submit 开始",
|
||||
10,
|
||||
);
|
||||
w1.kill("SIGKILL");
|
||||
await new Promise((res) => w1.once("exit", res));
|
||||
const before = await runRow(e, runId);
|
||||
expect(["running", "queued"]).toContain(before.status);
|
||||
|
||||
await spawnWorker();
|
||||
// 新 worker 接手(租约约 30s 到期)后提交进入 qB,外部下载器完成
|
||||
const hash = torrentFor(TITLE).hash;
|
||||
await waitFor(async () => e.qb.get(hash), 120_000, "qB 收到种子", 500);
|
||||
await e.qb.complete(hash, { writeFiles: true });
|
||||
const done = await waitFor(
|
||||
async () => {
|
||||
const x = await runRow(e, runId);
|
||||
return x.status === "succeeded" ? x : null;
|
||||
},
|
||||
120_000,
|
||||
"run succeeded",
|
||||
500,
|
||||
);
|
||||
expect(done.summary).toContain("已入库 1 个");
|
||||
|
||||
// 不重复提交:qB 只收到一次 add;download.submit 账本只有一条(同幂等键)
|
||||
expect(e.qb.callsTo("torrents/add")).toHaveLength(1);
|
||||
const inv = await e.db
|
||||
.selectFrom("capability_invocation")
|
||||
.selectAll()
|
||||
.where("agent_run_id", "=", runId)
|
||||
.where("capability_id", "=", "download.submit")
|
||||
.execute();
|
||||
expect(inv).toHaveLength(1);
|
||||
expect(inv[0]!.status).toBe("succeeded");
|
||||
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(1);
|
||||
// 事件:续跑标记 + 恢复的工具调用
|
||||
const events = await runEvents(e, runId);
|
||||
expect(events.some((x) => x.type === "run_started" && (x.payload as { resumed: boolean }).resumed)).toBe(true);
|
||||
const recovered = events.filter((x) => x.type === "tool_call_end" && (x.payload as { recovery: boolean }).recovery);
|
||||
expect(recovered.map((x) => (x.payload as { capabilityId: string }).capabilityId)).toContain("download.submit");
|
||||
// (run_id, seq) 连续不冲突
|
||||
const seqs = events.map((x) => Number(x.seq));
|
||||
expect(new Set(seqs).size).toBe(seqs.length);
|
||||
const starts = events
|
||||
.filter((x) => x.type === "tool_call_start" && !(x.payload as { recovery: boolean }).recovery)
|
||||
.map((x) => (x.payload as { capabilityId: string }).capabilityId);
|
||||
expect(starts.slice(-2)).toEqual(["download.submit", "task.inspect"]);
|
||||
}, 300_000);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { INVESTIGATE_CAPABILITIES } from "../../src/modules/agent/runner.ts";
|
||||
import { investigateScript } from "./fixtures/scripts.ts";
|
||||
import { createE2E, type E2E, pgAvailable, runEvents, seedBase, waitFor } from "./harness.ts";
|
||||
|
||||
// E5 后台唤醒:识别歧义产生 decision.created → outbox → resolveDecision → autoInvestigate 开启时入队有界 runAgent
|
||||
// (plan_only、白名单只读 + identity.resolve、小预算、trigger=event);同一 decision 只一个 run(dedupe_key)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
const twin = (id: number): TmdbFixture => ({
|
||||
type: "movie",
|
||||
id,
|
||||
original_title: "Twins",
|
||||
original_language: "en",
|
||||
origin_country: ["US"],
|
||||
date: "1988-12-09",
|
||||
titles: { "zh-CN": "龙兄鼠弟", "en-US": "Twins" },
|
||||
popularity: 10 + id / 1000,
|
||||
alternative_titles: [],
|
||||
external_ids: {},
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("E5 后台唤醒有界调查", () => {
|
||||
let e: E2E;
|
||||
let siteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
e = await createE2E("e5", {
|
||||
fakeScripts: [investigateScript],
|
||||
tmdbFixtures: [...TMDB_FIXTURES, twin(111), twin(112)],
|
||||
});
|
||||
siteId = (await seedBase(e)).siteId;
|
||||
await e.settings({ agent: { autoInvestigate: true, investigateMaxToolCalls: 6 } });
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await e?.close();
|
||||
}, 60_000);
|
||||
|
||||
it("识别歧义 → 自动发起只读调查 → 结论回写待处理事项;重复事件只一个 run", async () => {
|
||||
const rel = await e.db
|
||||
.insertInto("source_release")
|
||||
.values({ site_id: siteId, site_release_id: "990001", title: "Twins.1988.1080p.BluRay.x264-GRP" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await e.booted.ctx.engine.enqueue(
|
||||
"resolveObservation",
|
||||
{ sourceReleaseId: rel.id },
|
||||
{ idempotencyKey: `e5:resolve:${rel.id}`, traceId: rel.id },
|
||||
);
|
||||
const decision = await waitFor(
|
||||
async () =>
|
||||
e.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("kind", "=", "identity_ambiguous")
|
||||
.where("subject_id", "=", rel.id)
|
||||
.executeTakeFirst(),
|
||||
60_000,
|
||||
"识别歧义 decision",
|
||||
);
|
||||
const run = await waitFor(
|
||||
async () =>
|
||||
e.db
|
||||
.selectFrom("agent_run")
|
||||
.selectAll()
|
||||
.where("dedupe_key", "=", `investigate:${decision.id}`)
|
||||
.executeTakeFirst(),
|
||||
60_000,
|
||||
"自动调查 run",
|
||||
);
|
||||
expect(run).toMatchObject({ trigger: "event", mode: "plan_only", created_by: "system:auto-investigate" });
|
||||
expect(run.goal).toContain(decision.id);
|
||||
expect(run.scope).toMatchObject({
|
||||
capabilities: [...INVESTIGATE_CAPABILITIES],
|
||||
allowedKinds: ["read", "preview", "propose"],
|
||||
});
|
||||
expect(run.budget).toMatchObject({ maxToolCalls: 6 });
|
||||
|
||||
const finished = await waitFor(
|
||||
async () => {
|
||||
const r = await e.db.selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow();
|
||||
return ["succeeded", "failed", "budget_exceeded"].includes(r.status) ? r : null;
|
||||
},
|
||||
60_000,
|
||||
"调查 run 结束",
|
||||
);
|
||||
expect(finished.status).toBe("succeeded");
|
||||
const events = await runEvents(e, run.id);
|
||||
const invoked = events
|
||||
.filter((x) => x.type === "tool_call_start")
|
||||
.map((x) => (x.payload as { capabilityId: string }).capabilityId);
|
||||
expect(invoked).toEqual(["catalog.lookup", "inventory.query"]);
|
||||
// 白名单外的写能力:被拒绝,没有执行
|
||||
const rejected = events.filter((x) => x.type === "tool_call_rejected");
|
||||
expect(rejected.map((x) => (x.payload as { toolName: string }).toolName)).toEqual(["download__submit"]);
|
||||
expect(
|
||||
await e.db.selectFrom("capability_invocation").select("id").where("agent_run_id", "=", run.id).execute(),
|
||||
).toHaveLength(0);
|
||||
// 结论回写:待处理事项交还人工并带调查摘要
|
||||
const d2 = await e.db.selectFrom("decision").selectAll().where("id", "=", decision.id).executeTakeFirstOrThrow();
|
||||
expect(d2.status).toBe("awaiting_user");
|
||||
expect(d2.agent_run_id).toBe(run.id);
|
||||
expect((d2.evidence as { agentInvestigation?: { runId: string } }).agentInvestigation?.runId).toBe(run.id);
|
||||
|
||||
// 同一根因的重复事件(不同 outbox 键)与事项重新打开后的再次触发:仍只有一个 run
|
||||
await e.db.updateTable("decision").set({ status: "open" }).where("id", "=", decision.id).execute();
|
||||
for (const k of ["dup-1", "dup-2"]) {
|
||||
await e.booted.ctx.outbox.emit(
|
||||
e.db,
|
||||
"decision.created",
|
||||
{ decisionId: decision.id, kind: "identity_ambiguous", severity: "warning", title: decision.title },
|
||||
{ idempotencyKey: `e5:${k}` },
|
||||
);
|
||||
}
|
||||
await waitFor(
|
||||
async () => {
|
||||
const rows = await e.db
|
||||
.selectFrom("outbox_event")
|
||||
.select("status")
|
||||
.where("idempotency_key", "in", ["e5:dup-1", "e5:dup-2"])
|
||||
.execute();
|
||||
return rows.length === 2 && rows.every((r) => r.status === "dispatched");
|
||||
},
|
||||
30_000,
|
||||
"重复事件已投递",
|
||||
);
|
||||
// 等 resolveDecision 跑完
|
||||
await waitFor(
|
||||
async () => {
|
||||
const runs = await e.booted.ctx.engine.list({ taskName: "resolveDecision" });
|
||||
return runs.length >= 3 && runs.every((r) => r.status === "completed") ? runs : null;
|
||||
},
|
||||
30_000,
|
||||
"resolveDecision 完成",
|
||||
);
|
||||
const runs = await e.db
|
||||
.selectFrom("agent_run")
|
||||
.select("id")
|
||||
.where("dedupe_key", "=", `investigate:${decision.id}`)
|
||||
.execute();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(await e.db.selectFrom("agent_run").select("id").execute()).toHaveLength(1);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createQbClient } from "@umt/adapters";
|
||||
import type { FakeScript } from "@umt/agent";
|
||||
import { makeTorrent } from "@umt/testkit";
|
||||
import { createAcquisitionOrganizeModule } from "../../../src/modules/acquisition-organize/index.ts";
|
||||
import { createAgentModule } from "../../../src/modules/agent/index.ts";
|
||||
import { authSettingsModule } from "../../../src/modules/auth-settings/index.ts";
|
||||
import { createSourcesIdentityModule } from "../../../src/modules/sources-identity/index.ts";
|
||||
import type { ServerModule } from "../../../src/modules/types.ts";
|
||||
|
||||
// e2e 模块装配:真实四个模块,外部依赖指向 testkit mock(qB 账号 admin/adminadmin、固定磁盘用量、按标题生成的种子)。
|
||||
// harness(API/worker 同进程)与崩溃恢复用的 worker 子进程共用这一份,保证两边行为一致。
|
||||
|
||||
export const TORRENT_SIZE = 300_000;
|
||||
|
||||
/** 按发布标题确定性地生成种子(同标题 → 同 infohash,父子进程一致) */
|
||||
export function torrentFor(title: string) {
|
||||
const seed = createHash("sha256").update(title).digest().readUInt32BE(0);
|
||||
return makeTorrent({ name: `${title}.mkv`, length: TORRENT_SIZE, pieceSeed: seed });
|
||||
}
|
||||
|
||||
export interface E2EModuleOptions {
|
||||
sessionDir: string;
|
||||
fakeScripts?: FakeScript[];
|
||||
/** 下载同步周期(毫秒) */
|
||||
syncIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function buildModules(o: E2EModuleOptions): ServerModule[] {
|
||||
return [
|
||||
authSettingsModule,
|
||||
// TMDB 不注入:走设置 metadata.tmdbApiKeySecretRef / tmdbBaseUrl(W2 收口 b)
|
||||
createSourcesIdentityModule({ schedulerIntervalMs: 3_600_000 }),
|
||||
createAcquisitionOrganizeModule({
|
||||
downloader: async (inst) =>
|
||||
createQbClient({
|
||||
instanceId: inst.id,
|
||||
baseUrl: inst.base_url,
|
||||
username: "admin",
|
||||
password: "adminadmin",
|
||||
lookup: { attempts: 5, intervalMs: 50 },
|
||||
}),
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrentFor(release.title).bytes }),
|
||||
diskUsage: async (path) => ({
|
||||
status: "ok",
|
||||
path,
|
||||
totalBytes: 1e12,
|
||||
freeBytes: 6e11,
|
||||
availableBytes: 6e11,
|
||||
usedBytes: 4e11,
|
||||
usedRatio: 0.4,
|
||||
}),
|
||||
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
|
||||
options: {
|
||||
backgroundLoops: true,
|
||||
syncIntervalMs: o.syncIntervalMs ?? 300,
|
||||
minVideoBytes: 100_000,
|
||||
verifyHash: "sha256",
|
||||
reconcileIntervalMs: 0,
|
||||
},
|
||||
}),
|
||||
createAgentModule({
|
||||
sessionDir: o.sessionDir,
|
||||
...(o.fakeScripts ? { fakeScripts: o.fakeScripts } : {}),
|
||||
goalPollSec: 1,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FakeScript, FakeToolResult } from "@umt/agent";
|
||||
|
||||
// e2e 用的 fake 模型脚本(FakeModelProvider 按最近一条用户消息匹配,从 transcript 推导步骤,崩溃后可续跑)。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 脚本里按能力输出结构取值
|
||||
type Any = any;
|
||||
const ZERO = "00000000-0000-4000-8000-000000000000";
|
||||
|
||||
export const lastOk = (results: FakeToolResult[], capability: string): Any =>
|
||||
[...results].reverse().find((r) => r.capabilityId === capability && !r.isError)?.json;
|
||||
|
||||
const entityOf = (r: FakeToolResult[]) => lastOk(r, "inventory.query")?.items?.[0];
|
||||
|
||||
const baseSteps = (title: string) => [
|
||||
{ text: `先查库存:${title}。`, toolCalls: [{ capability: "inventory.query", input: { query: title, limit: 5 } }] },
|
||||
{
|
||||
text: "库里没有 1080p 版本,搜资源。",
|
||||
toolCalls: [
|
||||
{
|
||||
capability: "release.search",
|
||||
input: (r: FakeToolResult[]) => ({ mediaEntityId: entityOf(r)?.entity?.id ?? ZERO, allowLiveSearch: false }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "选 1080p 候选,生成下载计划。",
|
||||
toolCalls: [
|
||||
{
|
||||
capability: "download.plan",
|
||||
input: (r: FakeToolResult[]) => ({
|
||||
slotId: entityOf(r)?.slots?.[0]?.id ?? ZERO,
|
||||
sourceReleaseId:
|
||||
(lastOk(r, "release.search")?.items ?? []).find((i: Any) => /1080p/i.test(i.title))?.id ?? ZERO,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// 模型"声称"用户已同意:服务端不采信,只看 automation_grant / approval
|
||||
text: "用户已同意并授权,我直接提交下载。",
|
||||
toolCalls: [
|
||||
{
|
||||
capability: "download.submit",
|
||||
input: (r: FakeToolResult[]) => ({ planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const inspect = {
|
||||
text: "提交后看一下任务状态。",
|
||||
toolCalls: [
|
||||
{
|
||||
capability: "task.inspect",
|
||||
input: (r: FakeToolResult[]) => ({ taskId: lastOk(r, "download.submit")?.taskId ?? ZERO }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 委托:"把《沙丘2》1080p 收进来";最终文本故意夸大(称已入库),用于验证汇报以整理记录为准 */
|
||||
export const delegateScript: FakeScript = {
|
||||
name: "delegate",
|
||||
match: /收进来/,
|
||||
steps: [...baseSteps("沙丘2"), inspect],
|
||||
finalText: "已全部完成:《沙丘2》1080p 已入库。",
|
||||
};
|
||||
|
||||
/** 崩溃恢复:提交后再以同参数调用一次 download.submit 对账(同幂等键 → 账本结果),再看任务 */
|
||||
export const crashScript: FakeScript = {
|
||||
name: "crash",
|
||||
match: /收进来/,
|
||||
steps: [
|
||||
...baseSteps("沙丘2"),
|
||||
{
|
||||
text: "核对刚才的提交结果(同一计划)。",
|
||||
toolCalls: [
|
||||
{
|
||||
capability: "download.submit",
|
||||
input: (r: FakeToolResult[]) => ({ planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }),
|
||||
},
|
||||
],
|
||||
},
|
||||
inspect,
|
||||
],
|
||||
finalText: "已提交,等待下载与入库。",
|
||||
};
|
||||
|
||||
/** 后台调查(识别歧义):只读取证;试图提交下载会被白名单拒绝 */
|
||||
export const investigateScript: FakeScript = {
|
||||
name: "investigate",
|
||||
match: /调查待处理事项/,
|
||||
steps: [
|
||||
{
|
||||
text: "查本地作品与远端候选。",
|
||||
toolCalls: [{ capability: "catalog.lookup", input: { query: "Twins", limit: 5 } }],
|
||||
},
|
||||
{ text: "查库存。", toolCalls: [{ capability: "inventory.query", input: { query: "Twins", limit: 5 } }] },
|
||||
{ text: "顺手提交下载。", toolCalls: [{ capability: "download.submit", input: { planId: ZERO } }] },
|
||||
],
|
||||
finalText: "两个同名候选(1988)证据相当,无法自动确定;建议人工在待处理中心选择。",
|
||||
};
|
||||
|
||||
export const ALL_SCRIPTS: FakeScript[] = [delegateScript, investigateScript];
|
||||
@@ -0,0 +1,45 @@
|
||||
// 崩溃恢复 e2e 的 worker 子进程:与 harness 同一套模块装配(fixtures/modules.ts),连同一业务/引擎 schema。
|
||||
// 环境变量:BIZ_SCHEMA / OW_SCHEMA / SESSION_DIR(可选 DATABASE_URL)。就绪后输出 "worker ready";SIGTERM 优雅退出。
|
||||
import type { DB } from "@umt/db";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { ConfigSchema } from "../../../src/config.ts";
|
||||
import { bootstrap } from "../../../src/context.ts";
|
||||
import { EventBus } from "../../../src/http/sse.ts";
|
||||
import { createLogger } from "../../../src/logger.ts";
|
||||
import { startWorker } from "../../../src/worker/index.ts";
|
||||
import { buildModules } from "./modules.ts";
|
||||
import { crashScript } from "./scripts.ts";
|
||||
|
||||
const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
const biz = Deno.env.get("BIZ_SCHEMA")!;
|
||||
const ow = Deno.env.get("OW_SCHEMA")!;
|
||||
const sessionDir = Deno.env.get("SESSION_DIR")!;
|
||||
await import("@umt/db"); // 注册 int8 解析器
|
||||
const db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: url, max: 8, options: `-c search_path=${biz},public` }),
|
||||
}),
|
||||
});
|
||||
const logger = createLogger({ level: Deno.env.get("E2E_LOG_LEVEL") === "debug" ? "debug" : "warn" });
|
||||
const config = ConfigSchema.parse({
|
||||
DATABASE_URL: url,
|
||||
SECRET_MASTER_KEY: "e2e-master-key-0123456789",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
const bus = new EventBus(1000);
|
||||
const modules = buildModules({ sessionDir, fakeScripts: [crashScript] });
|
||||
const booted = await bootstrap({ role: "worker", config, logger, db, events: bus, modules, engineSchema: ow });
|
||||
const worker = await startWorker({
|
||||
logger,
|
||||
heartbeatMs: 60_000,
|
||||
bus,
|
||||
runtime: { ctx: booted.ctx, modules, setRelay: booted.outbox.setRelay, outboxIntervalMs: 100, concurrency: 4 },
|
||||
});
|
||||
console.log(`worker ready pid=${Deno.pid}`);
|
||||
Deno.addSignalListener("SIGTERM", async () => {
|
||||
await worker.stop().catch(() => {});
|
||||
await booted.engine.stop().catch(() => {});
|
||||
await db.destroy().catch(() => {});
|
||||
Deno.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { FakeScript } from "@umt/agent";
|
||||
import { type DB, migrateToLatest } from "@umt/db";
|
||||
import {
|
||||
createMockQb,
|
||||
createMockTmdb,
|
||||
createTempTree,
|
||||
type MockQb,
|
||||
type MockTmdb,
|
||||
type TempTree,
|
||||
TMDB_FIXTURES,
|
||||
type TmdbFixture,
|
||||
} from "@umt/testkit";
|
||||
import { Kysely, PostgresDialect, sql } from "kysely";
|
||||
import pg from "pg";
|
||||
import { ConfigSchema } from "../../src/config.ts";
|
||||
import { type Bootstrapped, bootstrap } from "../../src/context.ts";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
import type { ServerModule } from "../../src/modules/types.ts";
|
||||
import { type RunningWorker, startWorker } from "../../src/worker/index.ts";
|
||||
import { withSchemaLock } from "../auth-settings/harness.ts";
|
||||
import { buildModules } from "./fixtures/modules.ts";
|
||||
|
||||
// 端到端夹具:真实 PG(每个文件独立业务 schema + 独立引擎 schema)+ 真实四个模块 + OpenWorkflow 引擎/worker/outbox 中继
|
||||
// + testkit mock(qB / TMDB / RSS)+ 临时目录(缓存卷、媒体库、Agent 会话)。HTTP 走 createApp(DEV key = admin)。
|
||||
|
||||
export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
export const DEV_KEY = "dev-admin-e2e-key";
|
||||
export const MASTER_KEY = "e2e-master-key-0123456789";
|
||||
/** mock TMDB 的 v3 key(32 位十六进制,经设置 metadata.tmdbApiKey 录入) */
|
||||
export const TMDB_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
export async function pgAvailable(): Promise<boolean> {
|
||||
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
|
||||
const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 });
|
||||
try {
|
||||
await c.connect();
|
||||
await c.end();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
|
||||
type Loose = any;
|
||||
|
||||
export interface E2E {
|
||||
name: string;
|
||||
biz: string;
|
||||
ow: string;
|
||||
db: Kysely<DB>;
|
||||
booted: Bootstrapped;
|
||||
modules: ServerModule[];
|
||||
bus: EventBus;
|
||||
worker: RunningWorker | null;
|
||||
qb: MockQb;
|
||||
tmdb: MockTmdb;
|
||||
lib: TempTree;
|
||||
cache: TempTree;
|
||||
sessionDir: string;
|
||||
api(method: string, path: string, body?: unknown): Promise<{ status: number; body: Loose }>;
|
||||
/** PATCH /settings 并断言成功 */
|
||||
settings(patch: Record<string, unknown>): Promise<Loose>;
|
||||
startWorker(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface E2EOptions {
|
||||
fakeScripts?: FakeScript[];
|
||||
/** false:只做 API/引擎客户端(崩溃恢复用例由子进程跑 worker) */
|
||||
worker?: boolean;
|
||||
tmdbFixtures?: TmdbFixture[];
|
||||
}
|
||||
|
||||
export async function waitFor<T>(
|
||||
fn: () => Promise<T | null | undefined | false>,
|
||||
ms: number,
|
||||
what: string,
|
||||
every = 200,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + ms;
|
||||
let last: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const v = await fn();
|
||||
if (v) return v as T;
|
||||
} catch (err) {
|
||||
last = err;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, every));
|
||||
}
|
||||
throw new Error(`等待超时:${what}${last ? `(${(last as Error).message})` : ""}`);
|
||||
}
|
||||
|
||||
export async function createE2E(name: string, opts: E2EOptions = {}): Promise<E2E> {
|
||||
const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const biz = `e2e_${name}_${uniq}`;
|
||||
const ow = `${biz}_ow`;
|
||||
const admin = new pg.Client({ connectionString: PG_URL });
|
||||
await admin.connect();
|
||||
await admin.query(`create schema ${biz}`);
|
||||
await admin.end();
|
||||
const db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 10, options: `-c search_path=${biz},public` }),
|
||||
}),
|
||||
});
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: biz }));
|
||||
|
||||
const qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
const tmdb = await createMockTmdb({ apiKey: TMDB_KEY, fixtures: opts.tmdbFixtures ?? TMDB_FIXTURES });
|
||||
const lib = await createTempTree({}, { sentinel: true, prefix: "umt-e2e-lib-" });
|
||||
const cache = await createTempTree({}, { prefix: "umt-e2e-cache-" });
|
||||
const sessionDir = await mkdtemp(join(tmpdir(), "umt-e2e-sessions-"));
|
||||
|
||||
const logger = createLogger({ level: process.env.E2E_LOG_LEVEL === "debug" ? "debug" : "silent" });
|
||||
const config = ConfigSchema.parse({
|
||||
DATABASE_URL: PG_URL,
|
||||
SECRET_MASTER_KEY: MASTER_KEY,
|
||||
DEV_ADMIN_KEY: DEV_KEY,
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
const bus = new EventBus(5000);
|
||||
const modules = buildModules({ sessionDir, ...(opts.fakeScripts ? { fakeScripts: opts.fakeScripts } : {}) });
|
||||
const booted = await bootstrap({
|
||||
role: "all",
|
||||
config,
|
||||
logger,
|
||||
db,
|
||||
events: bus,
|
||||
modules,
|
||||
engineSchema: ow,
|
||||
engineMigrations: true,
|
||||
});
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger,
|
||||
bus,
|
||||
role: "all",
|
||||
version: "e2e",
|
||||
db,
|
||||
modules,
|
||||
ctx: booted.ctx,
|
||||
});
|
||||
|
||||
let worker: RunningWorker | null = null;
|
||||
const e: E2E = {
|
||||
name,
|
||||
biz,
|
||||
ow,
|
||||
db,
|
||||
booted,
|
||||
modules,
|
||||
bus,
|
||||
worker,
|
||||
qb,
|
||||
tmdb,
|
||||
lib,
|
||||
cache,
|
||||
sessionDir,
|
||||
async api(method, path, body) {
|
||||
const res = await app.request(`/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${DEV_KEY}`,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
},
|
||||
async settings(patch) {
|
||||
const r = await e.api("PATCH", "/settings", patch);
|
||||
if (r.status !== 200) throw new Error(`PATCH /settings 失败:${JSON.stringify(r.body)}`);
|
||||
return r.body.data;
|
||||
},
|
||||
async startWorker() {
|
||||
worker = await startWorker({
|
||||
logger,
|
||||
heartbeatMs: 60_000,
|
||||
bus,
|
||||
runtime: {
|
||||
ctx: booted.ctx,
|
||||
modules,
|
||||
setRelay: booted.outbox.setRelay,
|
||||
outboxIntervalMs: 100,
|
||||
concurrency: 4,
|
||||
},
|
||||
});
|
||||
e.worker = worker;
|
||||
},
|
||||
async close() {
|
||||
await worker?.stop().catch(() => {});
|
||||
await booted.engine.stop().catch(() => {});
|
||||
for (const m of [...modules].reverse()) await m.stop?.();
|
||||
await db.destroy();
|
||||
await qb.close();
|
||||
await tmdb.close();
|
||||
await lib.cleanup();
|
||||
await cache.cleanup();
|
||||
await rm(sessionDir, { recursive: true, force: true });
|
||||
await withSchemaLock(async () => {
|
||||
const c = new pg.Client({ connectionString: PG_URL });
|
||||
await c.connect();
|
||||
await c.query(`drop schema if exists ${biz} cascade`);
|
||||
await c.query(`drop schema if exists ${ow} cascade`);
|
||||
await c.end();
|
||||
});
|
||||
},
|
||||
};
|
||||
// 通用设置:fake 模型、TMDB 走设置里的 secret、结算快一点
|
||||
await e.settings({
|
||||
pi: { provider: "fake", model: "fake-model" },
|
||||
metadata: { tmdbApiKey: TMDB_KEY, tmdbBaseUrl: `${tmdb.url}/3` },
|
||||
retention: { seedRetentionDays: 0 },
|
||||
});
|
||||
if (opts.worker !== false) await e.startWorker();
|
||||
return e;
|
||||
}
|
||||
|
||||
/** 媒体库根 + qB 实例(保存路径 = 缓存临时目录)+ 站点 */
|
||||
export async function seedBase(e: E2E, site: { key?: string; baseUrl?: string } = {}) {
|
||||
const root = await e.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: `lib-${e.name}`, media_kind: "mixed", local_path: e.lib.root, management_mode: "managed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const inst = await e.db
|
||||
.insertInto("downloader_instance")
|
||||
.values({
|
||||
name: `qb-${e.name}`,
|
||||
base_url: e.qb.url,
|
||||
username: "admin",
|
||||
settings: JSON.stringify({ savePath: e.cache.root }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const s = await e.db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: site.key ?? "audiences",
|
||||
name: "观众",
|
||||
adapter: "nexusphp",
|
||||
base_url: site.baseUrl ?? "https://audiences.example/",
|
||||
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return { rootId: root.id, instanceId: inst.id, siteId: s.id };
|
||||
}
|
||||
|
||||
/** 作品 + 订阅 + 1080p Slot + 两个已识别的站点资源(720p / 1080p);不发 release.resolved,只有 Agent 会去规划 */
|
||||
export async function seedMovieTarget(e: E2E, siteId: string) {
|
||||
const entity = await e.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const sub = await e.db
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: entity.id, media_kind: "movie", mode: "once" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const slot = await e.db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entity.id,
|
||||
subscription_id: sub.id,
|
||||
name: "1080p",
|
||||
hard_conditions: JSON.stringify({ resolutions: ["1080p"] }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const rel = async (title: string, n: number) =>
|
||||
(
|
||||
await e.db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
site_release_id: String(880000 + n),
|
||||
title,
|
||||
size_bytes: 300_000,
|
||||
media_entity_id: entity.id,
|
||||
resolution_status: "resolved",
|
||||
raw: JSON.stringify({ _site: { hr: false } }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const r720 = await rel("Dune.Part.Two.2024.720p.WEB-DL.x264-AAA", 1);
|
||||
const r1080 = await rel("Dune.Part.Two.2024.1080p.BluRay.x264-GRP", 2);
|
||||
return { entityId: entity.id, slotId: slot.id, r720, r1080 };
|
||||
}
|
||||
|
||||
export async function runRow(e: E2E, runId: string) {
|
||||
return e.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
export async function runEvents(e: E2E, runId: string) {
|
||||
return e.db.selectFrom("agent_run_event").selectAll().where("run_id", "=", runId).orderBy("seq").execute();
|
||||
}
|
||||
|
||||
/** 等 mock qB 收到某个种子后置为完成并落盘 */
|
||||
export async function completeDownload(e: E2E, title: string, ms = 60_000) {
|
||||
const { torrentFor } = await import("./fixtures/modules.ts");
|
||||
const hash = torrentFor(title).hash;
|
||||
await waitFor(async () => e.qb.get(hash), ms, `qB 收到 ${title}`);
|
||||
await e.qb.complete(hash, { writeFiles: true });
|
||||
return hash;
|
||||
}
|
||||
|
||||
export { sql };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// 真实 PG 的集成/e2e 测试共用开发库并行跑:建/删 schema 串行在一把咨询锁上(withSchemaLock),
|
||||
// 负载高时 beforeAll/afterAll 排队会超过缺省 10s,这里放宽钩子超时。
|
||||
export default defineConfig({
|
||||
test: {
|
||||
hookTimeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
# Agent 运行手册(W2-D)
|
||||
|
||||
实现:`apps/server/src/modules/agent/`(会话 API、runAgent / resolveDecision 任务、授权与账本)、
|
||||
`apps/server/src/modules/acquisition-organize/capabilities.ts`、`apps/server/src/modules/sources-identity/capabilities.ts`、
|
||||
`packages/agent`(Pi 接入、FakeModelProvider)、迁移 `0021_agent_runtime`。
|
||||
|
||||
## 能力
|
||||
|
||||
启动时装配 14 个能力(`catalog.lookup`、`release.search`、`release.inspect`、`identity.resolve`、`download.plan`、`download.submit`、`organize.plan`、`organize.execute`、`task.inspect`、`task.retry`、`task.cancel`、`policy.preview`、`policy.apply`、`inventory.query`)。注册数不等于 14 时进程启动失败。Web(`POST /capabilities/{id}/invoke`)、Pi 工具和后台任务都走 `ctx.capabilities.invoke` 这一条路径。
|
||||
|
||||
- **授权**(`agent/authorize.ts`):propose / approve / execute 类必须满足以下之一:
|
||||
- 单次审批 `approval`:状态为 approved,绑定计划类型 + 计划 ID + payload hash + revision,且未过期。执行成功后审批标记为 consumed。
|
||||
- 持续授权 `automation_grant`:`action` 覆盖该能力(比如 `download.submit`,或策略发布自动生成的 `policy.acquisition`,或 `*`),状态 active 且未过期,绑定的策略版本仍是 published。`scope.mediaEntityIds`、`scope.mediaKinds`、`scope.capabilities` 要包含目标;`budget.maxInvocationsPerDay`、`budget.maxBytesPerDay` 还有余量。
|
||||
|
||||
模型参数里写"用户已同意"不起作用。输入里的 `approvalId` 只是一个指针,授权结果以库里的审批记录为准。控制台管理员从 Web 手动调用(actor=`user:*`)算人工授权,API Key、Agent 和后台任务都必须有授权记录。自动化总开关关闭或处于只读对账时,自动来源的 execute 一律拒绝。
|
||||
- **审计**:每次调用都写一条 `audit_event`,action 为 `capability.invoke` 或 `capability.denied`,payload 包含 outcome、授权 ID、幂等键和脱敏后的输入摘要。
|
||||
- **幂等键与操作账本**:有副作用的调用由服务端生成幂等键,格式为 `id:v版本:<agentRunId|taskId>:sha256(规范化输入)`,并记入 `capability_invocation`。状态为 succeeded 时直接返回记录的结果。状态为 running(进程在调用途中崩溃)时,用同一个键重跑处理器来对账,处理器本身是幂等的。
|
||||
- `download.plan` 只生成 **draft** 计划:不预留 Slot,不计入预算,也不会被后台巡检补提交。`download.submit` 在授权通过后先把 draft 转成 reserved(Slot revision 必须与生成计划时一致),再入队 `submitDownload`,和自动路径共用幂等键 `plan:<planId>`。
|
||||
- `organize.plan` 把冻结的整理计划存成 artifact(kind=plan),`payloadHash` 就是计划指纹。`organize.execute` 执行前会重新冻结一次,指纹不一致就返回 conflict。
|
||||
|
||||
## 会话 API
|
||||
|
||||
| 端点 | 说明 |
|
||||
|---|---|
|
||||
| `POST /agent/runs {goal, mode: execute\|plan_only, capabilityAllowlist?}` | 建 `agent_run` 和业务任务行(kind=agentRun),并入队持久任务 `runAgent`。**HTTP 请求里不跑模型**,返回时状态为 `queued`。`plan_only` 只允许 read / preview / propose 类能力,白名单里放了 execute 类会返回 422 |
|
||||
| `GET /agent/runs` / `GET /agent/runs/{id}?afterSeq&limit` | 详情包括:`agent_run_event` 按 seq 分页、追加的消息、服务端核查过的目标汇报(`report`) |
|
||||
| `POST /agent/runs/{id}/messages {text}` | 追加用户消息。运行中时,当前会话在本轮结束后消费;已结束或等待中时,会唤醒一次新的 runAgent 续跑 |
|
||||
| `POST /agent/runs/{id}/abort {reason?}` | 写取消标志,会话监视器会中止模型并收尾为 cancelled。只停止后续步骤,不撤销已经完成的外部动作 |
|
||||
| `GET/POST /automation-grants`、`PATCH /automation-grants/{id}` | 持续授权。PATCH 只能撤销或缩短有效期,扩大范围要新建一条 |
|
||||
| `POST /approvals {planType, planId}`、`PATCH /approvals/{id} {status, payloadHash}` | 单次审批。批准时要回传 payloadHash,计划变化后旧审批失效 |
|
||||
| `GET /capabilities`、`POST /capabilities/{id}/invoke` | 能力目录与调用,HTTP scope 按能力描述符再校验一次 |
|
||||
| `GET /tasks[/{id}]`、`POST /tasks/{id}/cancel`、`POST /tasks/{id}/retry` | 业务任务 |
|
||||
|
||||
**增量推送**:事件先写 `agent_run_event`(run_id + seq 唯一),再经 `ctx.events` 推到全局 SSE(`GET /events`)。推送的类型有 `agent.run_status`、`agent.text_delta`、`agent.tool_call`;未授权导致的工具失败,`phase` 为 `denied`。断线后,先用全局 `?after=<seq>` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。
|
||||
|
||||
## runAgent 做什么
|
||||
|
||||
1. `drive` 步骤:
|
||||
- 从设置读 Pi 参数(provider / baseUrl / model / 思考档位 / 上下文预算 / 压缩)和预算。`pi.apiKeySecretRef` 现场解密后传给 SDK,不落日志。
|
||||
- 检查今日次数和费用预算(`budget.agentMaxRunsPerDay` / `agentMaxCostUsdPerDay`)。
|
||||
- 打开 `AGENT_SESSION_DIR/<runId>.jsonl`。`pendingOperations>0` 说明上次崩溃:先按幂等键对账,再 `resume()`;否则就 `prompt(goal)`。最后逐条消费追加的消息。
|
||||
2. `goal-settled` 步骤:只在 execute 模式且会话正常结束时执行。每隔 `agent.goalPollSec` 检查一次,直到本 run 触达的计划、下载和整理记录全部落定,最长等 `agent.goalWaitHours`。等待期间 run 状态为 `waiting`,汇报写的是"已提交≠已入库"。
|
||||
3. `finalize` 步骤:从业务表汇总计划、下载任务和整理记录,生成 `AgentGoalReport`(artifact kind=report),并据此决定终态:
|
||||
- 入库完成 → `succeeded`,汇报带整理记录 ID;
|
||||
- 仍在等待 → `waiting`;
|
||||
- 失败 → `failed`;
|
||||
- 超预算 → `budget_exceeded`,同时开一条待处理事项 `agent_budget:<runId>`;
|
||||
- 取消 → `cancelled`。
|
||||
|
||||
模型的最终文本只附在汇报后面,标注"模型汇报(未经核查)"。
|
||||
|
||||
worker 崩溃后,新 worker 在引擎租约到期后(约 30s)接手,`drive` 步骤会从头重跑。已经完成的工具调用不会重放。副作用调用的结果模型会看到"结果未知",模型需要重新核对;同参数再调用一次,会直接拿到账本里的结果。
|
||||
|
||||
## 后台触发与 resolveDecision
|
||||
|
||||
- `decision.created` 路由到 `resolveDecision(trigger=created)`。满足以下全部条件时,会发起一次有界调查 run:`agent.autoInvestigate=true`、kind 为 `identity_ambiguous` / `download_failed` / `download_stalled`、事项仍为 open、事项不是由 Agent 自己引起的。这个 run 的参数:`trigger=event`、`mode=plan_only`,白名单为只读能力加 `identity.resolve`,工具调用上限为 `agent.investigateMaxToolCalls`,`dedupe_key=investigate:<decisionId>`。同一事项只会有一个 run,重复事件或重新打开都不会再建。调查结束后,事项转为 `awaiting_user`,`evidence.agentInvestigation` 里带上结论。
|
||||
- `decision.updated(status=resolved)` 路由到 `resolveDecision(trigger=updated)`,执行用户选中的处理:
|
||||
- 选项带 `capabilityId` 时经注册中心调用(照样校验授权);
|
||||
- `retry` 重试关联任务;
|
||||
- `reevaluate` / `accept` 按对象重新发起 evaluateAcquisition;
|
||||
- `dismiss` / `ignore` 不做任何动作。
|
||||
|
||||
执行结果写进 `decision.resolution.execution`。身份歧义类由 sources-identity 在处理端点里直接执行。
|
||||
|
||||
## 配置上游
|
||||
|
||||
在设置页或 `PATCH /settings` 里配置:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"pi": {
|
||||
"provider": "anthropic", // 或 "openai-compatible"(baseUrl 要带 /v1);"fake" = 演示假模型
|
||||
"baseUrl": null, // Anthropic 写根地址,不带 /v1
|
||||
"model": "<模型 ID>",
|
||||
"thinkingLevel": "medium",
|
||||
"contextBudget": 128000,
|
||||
"compaction": { "enabled": true, "thresholdRatio": 0.8 },
|
||||
"apiKey": "<只写,存为 secret>"
|
||||
},
|
||||
"agent": {
|
||||
"maxToolCalls": 40, "maxDurationSec": 900, // 单次运行预算
|
||||
"maxTokensPerRun": null, "maxCostUsdPerRun": null,
|
||||
"autoInvestigate": false, "investigateMaxToolCalls": 8,
|
||||
"goalPollSec": 60, "goalWaitHours": 72
|
||||
},
|
||||
"budget": { "agentMaxRunsPerDay": 50, "agentMaxCostUsdPerDay": 5 }
|
||||
}
|
||||
```
|
||||
|
||||
- 会话目录由环境变量 `AGENT_SESSION_DIR` 指定,缺省为 `<cwd>/data/agent-sessions`。api 进程和 worker 进程要能读到同一个目录,这是崩溃恢复的前提。
|
||||
- TMDB 密钥放在 `metadata.tmdbApiKey`(只写,存为 secret `metadata_api_key`),地址放在 `metadata.tmdbBaseUrl`(可选)。设置里没有时,回退到环境变量 `TMDB_API_KEY` / `TMDB_ACCESS_TOKEN` / `TMDB_BASE_URL`。
|
||||
|
||||
## fake 模式演示
|
||||
|
||||
```sh
|
||||
curl -X PATCH localhost:4380/api/v1/settings -H "Authorization: Bearer umt-dev-admin-key" \
|
||||
-H 'content-type: application/json' -d '{"pi":{"provider":"fake","model":"fake-model"}}'
|
||||
curl -X POST localhost:4380/api/v1/agent/runs -H "Authorization: Bearer umt-dev-admin-key" \
|
||||
-H 'content-type: application/json' -d '{"goal":"看看库里有什么","mode":"plan_only"}'
|
||||
```
|
||||
|
||||
没有配置 `agent.fakeScript` 时,使用内置演示脚本:查一次库存,然后如实汇报,不做写操作。自定义脚本用 JSON 写,可以是单个对象或数组:
|
||||
|
||||
```json
|
||||
{
|
||||
"match": "收进来",
|
||||
"steps": [
|
||||
{ "text": "查库存", "toolCalls": [{ "capability": "inventory.query", "input": { "query": "沙丘2" } }] },
|
||||
{ "toolCalls": [{ "capability": "download.plan",
|
||||
"input": { "slotId": "{{inventory.query.items.0.slots.0.id}}", "sourceReleaseId": "<id>" } }] },
|
||||
{ "toolCalls": [{ "capability": "download.submit", "input": { "planId": "{{download.plan.plan.id}}" } }] }
|
||||
],
|
||||
"finalText": "已提交"
|
||||
}
|
||||
```
|
||||
|
||||
`{{能力ID.路径}}` 取该能力最近一次成功调用结果里对应路径的值。测试直接给 `createAgentModule({ fakeScripts })` 注入 `FakeScript`,输入可以写成函数。
|
||||
|
||||
## 事件约定
|
||||
|
||||
没有单独的 `download.completed` / `organization.completed` 事件,用现有事件表达:
|
||||
- 下载完成 = `download.status_changed` 且 `to=completed`(只发一次);
|
||||
- 入库完成 = `organization.changed` 且 `status=succeeded`。
|
||||
|
||||
## e2e
|
||||
|
||||
`apps/server/test/e2e/`:每个文件使用独立的业务 schema 和引擎 schema,跑真实的四个模块、OpenWorkflow worker 和 outbox 中继,外部依赖用 testkit 的 mock(qB、TMDB、RSS),文件放在临时目录。
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `e1-rss-to-library` | RSS → 识别 → 策略 → 下载 → Copy 入库 → 整理记录增量 → Slot satisfied |
|
||||
| `e2-agent-delegate` | 委托闭环:无授权时被拒,有授权时成功;汇报以整理记录为准 |
|
||||
| `e3-background-continues` | 关闭聊天后台照样推进 |
|
||||
| `e4-crash-recovery` | worker 子进程在 download.submit 时 kill -9,新 worker resume,qB 只收到一次 add |
|
||||
| `e5-auto-investigate` | 识别歧义 → 有界调查,去重 |
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 编译后的二进制(`deno compile`)只授予 `--allow-read=/app`,没有写权限。Agent 会话目录和入库 Copy/Move 在容器里都需要写权限,需要在 W4 统一补 `--allow-write=<数据目录,库根>`,在此之前生产容器里的 runAgent 会失败。
|
||||
- 子代理(`subagentsEnabled`)只做记录,没有实现。
|
||||
- 追加消息与正在收尾的 run 存在很小的竞态窗口。`finalize` 会检查一次未消费的消息并补唤醒一次 run。
|
||||
@@ -68,7 +68,7 @@ update admin_session set revoked_at = now(), revoke_reason = 'reset' where revok
|
||||
- 库根登记/改路径时写哨兵文件 `.uhd-root`(`writeRootSentinel`),目录不存在或不可写 → 412;`POST /library-roots/{id}/test` 查哨兵在线。
|
||||
- 实例凭证 `credential` 只写 → secret;`POST /downloader-instances/{id}/test`(qB:有用户名走登录,否则当 WebUI API Key)、`POST /media-server-instances/{id}/test`(Emby/Jellyfin ping),结果写回 health。
|
||||
- `POST /path-mappings/{id}/verify`:本地前缀是否为可达目录。
|
||||
- `PUT /sites/{id}/credentials {label?, authKind, credential, username?}`:按 (site, label) 建或更新 site_account,凭证换新 secret(旧的删除),响应不回显。
|
||||
- `PUT /sites/{id}/credentials {label?, authKind, credential, username?}`(兼容别名):等价于按 (site, label) 新建或更新一条 site_account,凭证换新 secret(旧的删除),响应不回显。规范接口是 `/site-accounts` 资源 CRUD,新客户端请用 `POST /site-accounts` / `PATCH /site-accounts/{id}`。
|
||||
- `GET /audit-events?actorType&actorId&action&targetType&targetId&cursor&limit`:新→旧,`action=admin.*` 前缀匹配。动作枚举见 contracts `AuditAction`。
|
||||
|
||||
## 引擎面板(仅 admin,只读直查 `openworkflow` schema)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { AgentRunEventRow } from "./agent-events.ts";
|
||||
import { KnownCapabilityId } from "./capability.ts";
|
||||
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts";
|
||||
|
||||
// 人工/AI 域:Agent 运行、证据/计划产物、待处理事项、审批、持续授权(REQ §10、§11.3、§11.4)
|
||||
@@ -15,6 +17,9 @@ export const AgentRunStatus = z.enum([
|
||||
"budget_exceeded",
|
||||
]);
|
||||
export type AgentRunStatus = z.infer<typeof AgentRunStatus>;
|
||||
/** execute:授权范围内边执行边汇报;plan_only:"先给方案",只允许 read/preview/propose 类能力 */
|
||||
export const AgentRunMode = z.enum(["execute", "plan_only"]);
|
||||
export type AgentRunMode = z.infer<typeof AgentRunMode>;
|
||||
|
||||
/** 单次运行预算(调用次数/token/时长/费用,REQ §10.5) */
|
||||
export const AgentBudget = z.object({
|
||||
@@ -60,6 +65,9 @@ export const AgentRun = EntityBase.extend({
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
createdBy: z.string().nullable(),
|
||||
mode: AgentRunMode,
|
||||
/** 后台触发按根因去重(如 investigate:<decisionId>);用户委托为 null */
|
||||
dedupeKey: z.string().nullable(),
|
||||
});
|
||||
export type AgentRun = z.infer<typeof AgentRun>;
|
||||
export const AgentRunCreate = AgentRun.pick({
|
||||
@@ -283,3 +291,125 @@ export const AutomationGrantUpdate = z.object({
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
export type AutomationGrantUpdate = z.infer<typeof AutomationGrantUpdate>;
|
||||
|
||||
// ── Agent 会话 API(W2-D) ──
|
||||
|
||||
/** POST /agent/runs:建 agent_run + 入队持久任务 runAgent(不在 HTTP 请求里跑模型) */
|
||||
export const AgentRunStartInput = z.object({
|
||||
goal: z.string().trim().min(1).max(8000),
|
||||
mode: AgentRunMode.default("execute"),
|
||||
/** 能力白名单(缺省按模式给全部可用能力);plan_only 下超出 read/preview/propose 的会被拒绝 */
|
||||
capabilityAllowlist: z.array(KnownCapabilityId).min(1).max(50).optional(),
|
||||
});
|
||||
export type AgentRunStartInput = z.infer<typeof AgentRunStartInput>;
|
||||
|
||||
export const AgentRunMessageInput = z.object({ text: z.string().trim().min(1).max(8000) });
|
||||
export type AgentRunMessageInput = z.infer<typeof AgentRunMessageInput>;
|
||||
|
||||
export const AgentRunAbortInput = z.object({ reason: z.string().max(500).optional() });
|
||||
export type AgentRunAbortInput = z.infer<typeof AgentRunAbortInput>;
|
||||
|
||||
export const AgentRunListQuery = z.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
status: AgentRunStatus.optional(),
|
||||
trigger: AgentTrigger.optional(),
|
||||
});
|
||||
export type AgentRunListQuery = z.infer<typeof AgentRunListQuery>;
|
||||
|
||||
/** 详情里的事件分页:afterSeq(不含)之后按 seq 升序;断线后按最后收到的 runSeq 补读 */
|
||||
export const AgentRunDetailQuery = z.object({
|
||||
afterSeq: z.coerce.number().int().min(-1).default(-1),
|
||||
limit: z.coerce.number().int().min(1).max(1000).default(200),
|
||||
});
|
||||
export type AgentRunDetailQuery = z.infer<typeof AgentRunDetailQuery>;
|
||||
|
||||
/**
|
||||
* 目标完成度汇报:由服务端从业务表汇总(下载计划 → 下载任务 → 整理记录),不采信模型文本。
|
||||
* "已提交"只代表进入下载器;只有整理记录 succeeded 才算入库。
|
||||
*/
|
||||
export const AgentGoalReport = z.object({
|
||||
state: z.enum(["no_actions", "waiting", "completed", "partial", "failed"]),
|
||||
summary: z.string(),
|
||||
submitted: NonNegInt,
|
||||
imported: NonNegInt,
|
||||
inProgress: NonNegInt,
|
||||
failed: NonNegInt,
|
||||
plans: z.array(z.object({ planId: Id, status: z.string(), slotId: Id.nullable(), sourceReleaseId: Id.nullable() })),
|
||||
downloads: z.array(
|
||||
z.object({ downloadTaskId: Id, planId: Id.nullable(), status: z.string(), name: z.string().nullable() }),
|
||||
),
|
||||
organization: z.array(
|
||||
z.object({
|
||||
recordId: Id,
|
||||
status: z.string(),
|
||||
downloadTaskId: Id.nullable(),
|
||||
mediaEntityId: Id.nullable(),
|
||||
targetPath: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
/** 模型最终文本(仅供参考,不作为完成依据) */
|
||||
modelText: z.string().nullable(),
|
||||
generatedAt: Timestamp,
|
||||
});
|
||||
export type AgentGoalReport = z.infer<typeof AgentGoalReport>;
|
||||
|
||||
export const AgentRunMessageStatus = z.enum(["pending", "consumed"]);
|
||||
export type AgentRunMessageStatus = z.infer<typeof AgentRunMessageStatus>;
|
||||
/** 追加到会话的用户消息(纠正/补充);runAgent 任务按创建顺序消费 */
|
||||
export const AgentRunMessage = EntityBase.extend({
|
||||
runId: Id,
|
||||
text: z.string(),
|
||||
status: AgentRunMessageStatus,
|
||||
createdBy: z.string().nullable(),
|
||||
consumedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type AgentRunMessage = z.infer<typeof AgentRunMessage>;
|
||||
|
||||
export const AgentRunDetail = z.object({
|
||||
run: AgentRun,
|
||||
events: z.array(AgentRunEventRow),
|
||||
/** 下一页起点(传给 afterSeq);null 表示已到最新 */
|
||||
nextAfterSeq: z.number().int().nullable(),
|
||||
messages: z.array(AgentRunMessage),
|
||||
report: AgentGoalReport.nullable(),
|
||||
});
|
||||
export type AgentRunDetail = z.infer<typeof AgentRunDetail>;
|
||||
|
||||
/** 单次审批的创建:服务端从计划读取 payloadHash / expectedRevision,调用方只给计划 */
|
||||
export const ApprovalRequest = z.object({
|
||||
planType: z.enum(["acquisition_plan", "organize_plan", "policy_revision"]),
|
||||
planId: Id,
|
||||
expiresInSec: z
|
||||
.number()
|
||||
.int()
|
||||
.min(60)
|
||||
.max(30 * 86400)
|
||||
.default(86400),
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
export type ApprovalRequest = z.infer<typeof ApprovalRequest>;
|
||||
|
||||
export const CapabilityInvocationStatus = z.enum(["running", "succeeded", "failed"]);
|
||||
export type CapabilityInvocationStatus = z.infer<typeof CapabilityInvocationStatus>;
|
||||
/**
|
||||
* 有副作用能力的操作账本(capability_invocation):幂等键由服务端生成且唯一;succeeded 直接返回记录的输出,
|
||||
* running(调用中崩溃)按同一幂等键重跑处理器对账。
|
||||
*/
|
||||
export const CapabilityInvocation = EntityBase.extend({
|
||||
idempotencyKey: z.string().min(1),
|
||||
capabilityId: z.string().min(1),
|
||||
capabilityVersion: z.number().int().min(1),
|
||||
agentRunId: Id.nullable(),
|
||||
actorId: z.string(),
|
||||
source: z.string(),
|
||||
status: CapabilityInvocationStatus,
|
||||
input: JsonObject,
|
||||
output: JsonObject.nullable(),
|
||||
error: JsonObject.nullable(),
|
||||
grantId: Id.nullable(),
|
||||
approvalId: Id.nullable(),
|
||||
attempts: NonNegInt,
|
||||
finishedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type CapabilityInvocation = z.infer<typeof CapabilityInvocation>;
|
||||
+512
-69
@@ -1,8 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import * as agent from "./agent.ts";
|
||||
import * as assets from "./assets.ts";
|
||||
import * as auth from "./auth.ts";
|
||||
import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts";
|
||||
import { type ApiScope, CursorQuery, cursorPage, Id, IdParam } from "./common.ts";
|
||||
import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts";
|
||||
import * as content from "./content.ts";
|
||||
import * as execution from "./execution.ts";
|
||||
import * as identity from "./identity.ts";
|
||||
@@ -105,6 +106,37 @@ const R = { read: "read", write: "admin" } as const;
|
||||
const del = (path: string, summary: string) =>
|
||||
ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult });
|
||||
|
||||
/** 实例连通性测试 */
|
||||
const instanceTest = (path: string, summary: string) =>
|
||||
ep({ method: "post", path, scope: "admin", summary, params: IdParam, response: instances.InstanceTestResult });
|
||||
|
||||
/** 入队后台任务的受理结果(taskId = 引擎 run id) */
|
||||
const Accepted = z.object({ taskId: Id });
|
||||
|
||||
/** 人工指定来源资源身份的结果 */
|
||||
const ReleaseIdentityResult = z.object({ mediaEntityId: Id, contentUnitIds: z.array(Id) });
|
||||
|
||||
/** 字段覆盖/取消覆盖的结果 */
|
||||
const FieldOverrideResult = z.object({
|
||||
mediaEntityId: Id,
|
||||
changedFields: z.array(z.string()),
|
||||
lockedFields: z.array(z.string()),
|
||||
});
|
||||
const FieldParam = z.object({ id: Id, field: content.EntityMetadataField });
|
||||
|
||||
/** 引擎 run id 不是 uuid(openworkflow 自有 id) */
|
||||
const EngineRunIdParam = z.object({ id: z.string().min(1).max(200) });
|
||||
|
||||
/** 待处理事项聚合分组 */
|
||||
const DecisionSummaryGroup = z.object({
|
||||
kind: agent.DecisionKind,
|
||||
mediaEntityId: Id.nullable(),
|
||||
mediaEntityTitle: z.string().nullable(),
|
||||
count: z.number().int(),
|
||||
maxSeverity: z.enum(["info", "warning", "critical"]),
|
||||
latestAt: z.string(),
|
||||
});
|
||||
|
||||
export const Api = {
|
||||
// ── 内容 / 身份 ──
|
||||
mediaEntities: {
|
||||
@@ -132,6 +164,53 @@ export const Api = {
|
||||
body: z.object({ intoId: Id, reason: z.string().max(2000) }),
|
||||
response: content.MediaEntity,
|
||||
}),
|
||||
detail: ep({
|
||||
method: "get",
|
||||
path: "/media-entities/{id}/detail",
|
||||
scope: "read",
|
||||
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
|
||||
params: IdParam,
|
||||
response: content.MediaEntityDetail,
|
||||
}),
|
||||
setIdentity: ep({
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/identities",
|
||||
scope: "organize.execute",
|
||||
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
|
||||
params: IdParam,
|
||||
body: identity.EntityIdentitySet,
|
||||
response: z.object({
|
||||
mediaEntityId: Id,
|
||||
changed: z.boolean(),
|
||||
identityId: Id.optional(),
|
||||
retired: z.array(Id).optional(),
|
||||
}),
|
||||
}),
|
||||
refresh: ep({
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/refresh",
|
||||
scope: "organize.execute",
|
||||
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
}),
|
||||
setField: ep({
|
||||
method: "put",
|
||||
path: "/media-entities/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
|
||||
params: FieldParam,
|
||||
body: content.FieldOverrideInput,
|
||||
response: FieldOverrideResult,
|
||||
}),
|
||||
clearField: ep({
|
||||
method: "delete",
|
||||
path: "/media-entities/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "取消字段覆盖与锁定",
|
||||
params: FieldParam,
|
||||
response: FieldOverrideResult,
|
||||
}),
|
||||
},
|
||||
contentUnits: crud(
|
||||
"/content-units",
|
||||
@@ -229,16 +308,20 @@ export const Api = {
|
||||
),
|
||||
|
||||
// ── 实例 ──
|
||||
libraryRoots: crud(
|
||||
"/library-roots",
|
||||
"媒体库根",
|
||||
{
|
||||
row: instances.LibraryRoot,
|
||||
create: instances.LibraryRootCreate,
|
||||
update: instances.LibraryRootUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
libraryRoots: {
|
||||
...crud(
|
||||
"/library-roots",
|
||||
"媒体库根",
|
||||
{
|
||||
row: instances.LibraryRoot,
|
||||
create: instances.LibraryRootCreate,
|
||||
update: instances.LibraryRootUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
test: instanceTest("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)"),
|
||||
remove: del("/library-roots/{id}", "删除媒体库根"),
|
||||
},
|
||||
downloaderInstances: {
|
||||
...crud(
|
||||
"/downloader-instances",
|
||||
@@ -258,17 +341,22 @@ export const Api = {
|
||||
params: IdParam,
|
||||
response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }),
|
||||
}),
|
||||
remove: del("/downloader-instances/{id}", "删除下载器实例"),
|
||||
},
|
||||
mediaServerInstances: {
|
||||
...crud(
|
||||
"/media-server-instances",
|
||||
"媒体服务器实例",
|
||||
{
|
||||
row: instances.MediaServerInstance,
|
||||
create: instances.MediaServerInstanceCreate,
|
||||
update: instances.MediaServerInstanceUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"),
|
||||
remove: del("/media-server-instances/{id}", "删除媒体服务器实例"),
|
||||
},
|
||||
mediaServerInstances: crud(
|
||||
"/media-server-instances",
|
||||
"媒体服务器实例",
|
||||
{
|
||||
row: instances.MediaServerInstance,
|
||||
create: instances.MediaServerInstanceCreate,
|
||||
update: instances.MediaServerInstanceUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
pathMappings: {
|
||||
...crud(
|
||||
"/path-mappings",
|
||||
@@ -292,7 +380,84 @@ export const Api = {
|
||||
},
|
||||
|
||||
// ── 来源 ──
|
||||
sites: crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R),
|
||||
sites: {
|
||||
...crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R),
|
||||
enable: ep({
|
||||
method: "post",
|
||||
path: "/sites/{id}/enable",
|
||||
scope: "admin",
|
||||
summary: "开启站点",
|
||||
params: IdParam,
|
||||
response: sources.Site,
|
||||
}),
|
||||
disable: ep({
|
||||
method: "post",
|
||||
path: "/sites/{id}/disable",
|
||||
scope: "admin",
|
||||
summary: "关闭站点(停止网络访问与新候选,历史资源仍可查)",
|
||||
params: IdParam,
|
||||
response: sources.Site,
|
||||
}),
|
||||
snapshot: ep({
|
||||
method: "post",
|
||||
path: "/sites/{id}/snapshot",
|
||||
scope: "admin",
|
||||
summary: "立即同步站点快照(受站点限流)",
|
||||
params: IdParam,
|
||||
body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }),
|
||||
response: Accepted,
|
||||
}),
|
||||
snapshots: ep({
|
||||
method: "get",
|
||||
path: "/sites/{id}/snapshots",
|
||||
scope: "read",
|
||||
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
|
||||
params: IdParam,
|
||||
query: CursorQuery,
|
||||
response: cursorPage(sources.SiteSnapshot),
|
||||
}),
|
||||
/** 兼容别名:规范接口是 /site-accounts 资源 CRUD;这里按 (site,label) 新建或更新 site_account 的凭证 */
|
||||
credentials: ep({
|
||||
method: "put",
|
||||
path: "/sites/{id}/credentials",
|
||||
scope: "admin",
|
||||
summary:
|
||||
"兼容别名:按 (站点, label) 新建或更新 site_account 凭证(→ secret_ref,不回显);新客户端请用 POST/PATCH /site-accounts",
|
||||
params: IdParam,
|
||||
body: instances.SiteCredentialsPut,
|
||||
response: instances.SiteCredentialsResult,
|
||||
}),
|
||||
},
|
||||
siteDefinitions: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/site-definitions",
|
||||
scope: "read",
|
||||
summary: "随产品发布的站点适配器定义(含是否已登记)",
|
||||
response: z.object({ items: z.array(sources.SiteDefinitionView) }),
|
||||
}),
|
||||
},
|
||||
/** 站点工作台(整理视图):库存叠加走 inventory.overlay */
|
||||
siteWorkbench: {
|
||||
releases: ep({
|
||||
method: "get",
|
||||
path: "/sites/{id}/releases",
|
||||
scope: "read",
|
||||
summary: "站点资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
|
||||
params: IdParam,
|
||||
query: sources.WorkbenchReleaseQuery,
|
||||
response: sources.WorkbenchReleasePage,
|
||||
}),
|
||||
release: ep({
|
||||
method: "get",
|
||||
path: "/sites/{id}/releases/{rid}",
|
||||
scope: "read",
|
||||
summary: "站点资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
|
||||
params: z.object({ id: Id, rid: Id }),
|
||||
query: z.object({ refresh: z.enum(["true", "false"]).optional() }),
|
||||
response: sources.WorkbenchReleaseDetail,
|
||||
}),
|
||||
},
|
||||
siteAccounts: crud(
|
||||
"/site-accounts",
|
||||
"站点账号",
|
||||
@@ -324,21 +489,64 @@ export const Api = {
|
||||
params: IdParam,
|
||||
response: z.object({ taskId: Id }),
|
||||
}),
|
||||
enable: ep({
|
||||
method: "post",
|
||||
path: "/feed-sources/{id}/enable",
|
||||
scope: "admin",
|
||||
summary: "启用 Feed",
|
||||
params: IdParam,
|
||||
response: sources.FeedSource,
|
||||
}),
|
||||
disable: ep({
|
||||
method: "post",
|
||||
path: "/feed-sources/{id}/disable",
|
||||
scope: "admin",
|
||||
summary: "停用 Feed",
|
||||
params: IdParam,
|
||||
response: sources.FeedSource,
|
||||
}),
|
||||
reset: ep({
|
||||
method: "post",
|
||||
path: "/feed-sources/{id}/reset",
|
||||
scope: "admin",
|
||||
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
|
||||
params: IdParam,
|
||||
response: sources.FeedSource,
|
||||
}),
|
||||
},
|
||||
sourceReleases: crud(
|
||||
"/source-releases",
|
||||
"站点资源",
|
||||
{
|
||||
row: sources.SourceRelease,
|
||||
filters: {
|
||||
siteId: Id.optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
resolutionStatus: sources.ResolutionStatus.optional(),
|
||||
q: z.string().max(200).optional(),
|
||||
sourceReleases: {
|
||||
...crud(
|
||||
"/source-releases",
|
||||
"站点资源",
|
||||
{
|
||||
row: sources.SourceRelease,
|
||||
filters: {
|
||||
siteId: Id.optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
resolutionStatus: sources.ResolutionStatus.optional(),
|
||||
q: z.string().max(200).optional(),
|
||||
},
|
||||
},
|
||||
},
|
||||
R,
|
||||
),
|
||||
R,
|
||||
),
|
||||
assignIdentity: ep({
|
||||
method: "post",
|
||||
path: "/source-releases/{id}/identity",
|
||||
scope: "organize.execute",
|
||||
summary: "人工指定来源资源的作品/内容单元(加人工锁,自动识别不再改写)",
|
||||
params: IdParam,
|
||||
body: identity.ReleaseIdentityAssign,
|
||||
response: ReleaseIdentityResult,
|
||||
}),
|
||||
unlock: ep({
|
||||
method: "post",
|
||||
path: "/source-releases/{id}/unlock",
|
||||
scope: "organize.execute",
|
||||
summary: "解除来源资源的人工锁并重新识别",
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
}),
|
||||
},
|
||||
observations: crud(
|
||||
"/observations",
|
||||
"观察",
|
||||
@@ -539,17 +747,35 @@ export const Api = {
|
||||
body: agent.DecisionResolveInput,
|
||||
response: agent.Decision,
|
||||
}),
|
||||
summary: ep({
|
||||
method: "get",
|
||||
path: "/decisions/summary",
|
||||
scope: "read",
|
||||
summary: "未关闭待处理事项按作品与问题类型聚合",
|
||||
query: z.object({ status: agent.DecisionStatus.optional() }),
|
||||
response: z.object({ total: z.number().int(), groups: z.array(DecisionSummaryGroup) }),
|
||||
}),
|
||||
},
|
||||
approvals: {
|
||||
...crud(
|
||||
"/approvals",
|
||||
"审批",
|
||||
{
|
||||
row: agent.Approval,
|
||||
update: agent.ApprovalUpdate,
|
||||
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
|
||||
},
|
||||
{ read: "read", write: "organize.execute" },
|
||||
),
|
||||
create: ep({
|
||||
method: "post",
|
||||
path: "/approvals",
|
||||
scope: "organize.execute",
|
||||
summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期)",
|
||||
body: agent.ApprovalRequest,
|
||||
response: agent.Approval,
|
||||
}),
|
||||
},
|
||||
approvals: crud(
|
||||
"/approvals",
|
||||
"审批",
|
||||
{
|
||||
row: agent.Approval,
|
||||
update: agent.ApprovalUpdate,
|
||||
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
|
||||
},
|
||||
{ read: "read", write: "organize.execute" },
|
||||
),
|
||||
automationGrants: crud(
|
||||
"/automation-grants",
|
||||
"持续授权",
|
||||
@@ -561,32 +787,49 @@ export const Api = {
|
||||
},
|
||||
R,
|
||||
),
|
||||
/** Agent 会话(W2-D):建 run 只入队持久任务 runAgent;增量经全局 SSE(agent.*),断线按 runSeq 用 afterSeq 补读 */
|
||||
agentRuns: {
|
||||
...crud(
|
||||
"/agent-runs",
|
||||
"Agent 运行",
|
||||
{
|
||||
row: agent.AgentRun,
|
||||
create: agent.AgentRunCreate,
|
||||
filters: { status: agent.AgentRunStatus.optional(), taskId: Id.optional() },
|
||||
},
|
||||
{ read: "read", write: "organize.execute" },
|
||||
),
|
||||
cancel: ep({
|
||||
method: "post",
|
||||
path: "/agent-runs/{id}/cancel",
|
||||
scope: "organize.execute",
|
||||
summary: "取消 Agent 运行",
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/agent/runs",
|
||||
scope: "read",
|
||||
summary: "Agent 运行列表(新→旧)",
|
||||
query: agent.AgentRunListQuery,
|
||||
response: cursorPage(agent.AgentRun),
|
||||
}),
|
||||
get: ep({
|
||||
method: "get",
|
||||
path: "/agent/runs/{id}",
|
||||
scope: "read",
|
||||
summary: "Agent 运行详情:事件按 seq 分页、追加消息、服务端核查的目标汇报",
|
||||
params: IdParam,
|
||||
query: agent.AgentRunDetailQuery,
|
||||
response: agent.AgentRunDetail,
|
||||
}),
|
||||
create: ep({
|
||||
method: "post",
|
||||
path: "/agent/runs",
|
||||
scope: "organize.execute",
|
||||
summary: "委托目标:建 agent_run 并入队 runAgent(execute | plan_only)",
|
||||
body: agent.AgentRunStartInput,
|
||||
response: agent.AgentRun,
|
||||
}),
|
||||
message: ep({
|
||||
method: "post",
|
||||
path: "/agent-runs/{id}/messages",
|
||||
path: "/agent/runs/{id}/messages",
|
||||
scope: "organize.execute",
|
||||
summary: "向运行中的会话追加用户消息(纠正/补充)",
|
||||
summary: "追加用户消息(纠正/补充);运行已结束时唤醒续跑",
|
||||
params: IdParam,
|
||||
body: z.object({ text: z.string().min(1).max(8000) }),
|
||||
body: agent.AgentRunMessageInput,
|
||||
response: agent.AgentRun,
|
||||
}),
|
||||
abort: ep({
|
||||
method: "post",
|
||||
path: "/agent/runs/{id}/abort",
|
||||
scope: "organize.execute",
|
||||
summary: "中止运行(停止未来步骤,不撤销已完成的外部动作)",
|
||||
params: IdParam,
|
||||
body: agent.AgentRunAbortInput,
|
||||
response: agent.AgentRun,
|
||||
}),
|
||||
},
|
||||
@@ -651,18 +894,15 @@ export const Api = {
|
||||
body: ops.ApiKeyUpdate,
|
||||
response: ops.ApiKey,
|
||||
}),
|
||||
revoke: del("/api-keys/{id}", "撤销 API Key"),
|
||||
},
|
||||
auditEvents: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/audit-events",
|
||||
scope: "admin",
|
||||
summary: "审计事件",
|
||||
query: listQuery({
|
||||
targetType: z.string().optional(),
|
||||
targetId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
}),
|
||||
summary: "审计事件(按 actor/action/target 过滤,新→旧)",
|
||||
query: ops.AuditEventQuery,
|
||||
response: cursorPage(ops.AuditEvent),
|
||||
}),
|
||||
},
|
||||
@@ -692,6 +932,7 @@ export const Api = {
|
||||
body: ops.SecretUpdate,
|
||||
response: ops.Secret,
|
||||
}),
|
||||
remove: del("/secrets/{id}", "删除凭证(被引用时 409)"),
|
||||
},
|
||||
|
||||
// ── 整理(公开合同) ──
|
||||
@@ -762,6 +1003,208 @@ export const Api = {
|
||||
response: GlobalSettings,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 登录门禁 ──
|
||||
/** /auth/setup、/auth/login、/auth/session 免鉴权;改密与 TOTP 只接受控制台 cookie 会话 */
|
||||
auth: {
|
||||
setup: ep({
|
||||
method: "post",
|
||||
path: "/auth/setup",
|
||||
scope: "read",
|
||||
summary: "首次初始化管理员",
|
||||
body: auth.AdminSetupRequest,
|
||||
response: auth.SessionInfo,
|
||||
}),
|
||||
login: ep({
|
||||
method: "post",
|
||||
path: "/auth/login",
|
||||
scope: "read",
|
||||
summary: "管理员登录",
|
||||
body: auth.LoginRequest,
|
||||
response: auth.LoginResult,
|
||||
}),
|
||||
logout: ep({ method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult }),
|
||||
session: ep({
|
||||
method: "get",
|
||||
path: "/auth/session",
|
||||
scope: "read",
|
||||
summary: "当前登录态(免鉴权)",
|
||||
response: auth.SessionInfo,
|
||||
}),
|
||||
password: ep({
|
||||
method: "post",
|
||||
path: "/auth/password",
|
||||
scope: "admin",
|
||||
summary: "修改密码(吊销其它会话)",
|
||||
body: auth.PasswordChangeRequest,
|
||||
response: auth.PasswordChangeResult,
|
||||
}),
|
||||
totpStatus: ep({
|
||||
method: "get",
|
||||
path: "/auth/totp",
|
||||
scope: "admin",
|
||||
summary: "TOTP 状态",
|
||||
response: auth.TotpStatus,
|
||||
}),
|
||||
totp: ep({
|
||||
method: "post",
|
||||
path: "/auth/totp",
|
||||
scope: "admin",
|
||||
summary: "TOTP 绑定/启用/停用",
|
||||
body: auth.TotpAction,
|
||||
response: auth.TotpActionResult,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 系统 ──
|
||||
system: {
|
||||
health: ep({
|
||||
method: "get",
|
||||
path: "/health",
|
||||
scope: "read",
|
||||
summary: "服务健康状态(免鉴权)",
|
||||
response: HealthStatus,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 引擎面板(仅 admin) ──
|
||||
engine: {
|
||||
runs: ep({
|
||||
method: "get",
|
||||
path: "/engine/runs",
|
||||
scope: "admin",
|
||||
summary: "引擎 runs(状态/名称/时间筛选,新→旧)",
|
||||
query: ops.EngineRunQuery,
|
||||
response: cursorPage(ops.EngineRun),
|
||||
}),
|
||||
run: ep({
|
||||
method: "get",
|
||||
path: "/engine/runs/{id}",
|
||||
scope: "admin",
|
||||
summary: "run 详情(step attempts + signals)",
|
||||
params: EngineRunIdParam,
|
||||
response: ops.EngineRunDetail,
|
||||
}),
|
||||
stats: ep({
|
||||
method: "get",
|
||||
path: "/engine/stats",
|
||||
scope: "admin",
|
||||
summary: "按状态计数 / 最老待处理 / 活跃 worker 快照",
|
||||
response: ops.EngineStats,
|
||||
}),
|
||||
cancel: ep({
|
||||
method: "post",
|
||||
path: "/engine/runs/{id}/cancel",
|
||||
scope: "admin",
|
||||
summary: "取消 run",
|
||||
params: EngineRunIdParam,
|
||||
body: ops.EngineCancelRequest,
|
||||
response: ops.EngineCancelResult,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 待识别池 ──
|
||||
identityPool: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/identity-pool",
|
||||
scope: "read",
|
||||
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
|
||||
query: identity.IdentityPoolQuery,
|
||||
response: cursorPage(identity.IdentityPoolItem),
|
||||
}),
|
||||
assign: ep({
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/assign",
|
||||
scope: "organize.execute",
|
||||
summary: "为待识别资源手动指定候选 / 本地作品 / 外部身份",
|
||||
params: IdParam,
|
||||
body: identity.ReleaseIdentityAssign,
|
||||
response: ReleaseIdentityResult,
|
||||
}),
|
||||
dismiss: ep({
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/dismiss",
|
||||
scope: "organize.execute",
|
||||
summary: "忽略待识别资源(不再自动复查)",
|
||||
params: IdParam,
|
||||
body: z.object({ note: z.string().max(2000).optional() }),
|
||||
response: identity.IdentityCandidate,
|
||||
}),
|
||||
retry: ep({
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/retry",
|
||||
scope: "organize.execute",
|
||||
summary: "立即重新识别",
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 媒体库 / 库存 / 首页 ──
|
||||
library: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/library",
|
||||
scope: "read",
|
||||
summary: "媒体库列表(已登记资产或正在追踪的作品)",
|
||||
query: organization.LibraryListQuery,
|
||||
response: cursorPage(organization.LibraryItem),
|
||||
}),
|
||||
get: ep({
|
||||
method: "get",
|
||||
path: "/library/{id}",
|
||||
scope: "read",
|
||||
summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果",
|
||||
params: IdParam,
|
||||
response: organization.LibraryDetail,
|
||||
}),
|
||||
reconcile: ep({
|
||||
method: "post",
|
||||
path: "/library/reconcile",
|
||||
scope: "admin",
|
||||
summary: "立即做一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载)",
|
||||
body: z.object({ libraryRootId: z.uuid().optional() }),
|
||||
response: z.object({ runId: z.uuid(), stats: z.record(z.string(), z.unknown()) }),
|
||||
}),
|
||||
},
|
||||
inventory: {
|
||||
overlay: ep({
|
||||
method: "get",
|
||||
path: "/inventory",
|
||||
scope: "read",
|
||||
summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中",
|
||||
query: organization.InventoryQuery,
|
||||
response: organization.InventoryResponse,
|
||||
}),
|
||||
mediaServers: ep({
|
||||
method: "get",
|
||||
path: "/inventory/media-servers",
|
||||
scope: "read",
|
||||
summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)",
|
||||
query: organization.MediaServerInventoryQuery,
|
||||
response: organization.MediaServerInventory,
|
||||
}),
|
||||
},
|
||||
dashboard: {
|
||||
get: ep({
|
||||
method: "get",
|
||||
path: "/dashboard",
|
||||
scope: "read",
|
||||
summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出",
|
||||
response: organization.Dashboard,
|
||||
}),
|
||||
},
|
||||
downloadCache: {
|
||||
cleanup: ep({
|
||||
method: "post",
|
||||
path: "/download-cache/cleanup",
|
||||
scope: "admin",
|
||||
summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)",
|
||||
body: z.object({ downloaderInstanceId: z.uuid().optional() }),
|
||||
response: z.object({ report: z.record(z.string(), z.unknown()) }),
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */
|
||||
|
||||
@@ -20,7 +20,14 @@ export type EventEnvelope<P = unknown> = {
|
||||
payload: P;
|
||||
};
|
||||
|
||||
/** 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema */
|
||||
/**
|
||||
* 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema。
|
||||
*
|
||||
* 没有单独的 download.completed / organization.completed 事件,约定用现有事件表达(W2 收口):
|
||||
* - "下载完成" = `download.status_changed` 且 `to === "completed"`(outbox 幂等键 `download.completed:<id>`,只发一次;
|
||||
* acquisition-organize 据此入队 verifyAndImport);
|
||||
* - "入库完成" = `organization.changed` 且 `status === "succeeded"`(或 partial);`changeSeq` 与公开增量 API 游标一致。
|
||||
*/
|
||||
export const EventType = {
|
||||
heartbeat: "system.heartbeat",
|
||||
resync: "system.resync",
|
||||
|
||||
@@ -68,6 +68,47 @@ export const PiSettings = z.object({
|
||||
});
|
||||
export type PiSettings = z.infer<typeof PiSettings>;
|
||||
|
||||
/**
|
||||
* Agent 运行参数(REQ §10.5、§10.7):单次运行预算、后台自动调查、fake 演示脚本。
|
||||
* 上游/模型/思考档位在 pi 分组;pi.provider = "fake" 时使用脚本化假模型(不联网,演示与 e2e 用)。
|
||||
*/
|
||||
export const AgentRuntimeSettings = z.object({
|
||||
/** 识别歧义 / 下载失败诊断等待处理事项创建时,自动发起有界只读调查 */
|
||||
autoInvestigate: z.boolean(),
|
||||
/** 单次运行:工具调用次数上限 */
|
||||
maxToolCalls: z.number().int().min(1).max(500),
|
||||
/** 单次运行:时长上限(秒) */
|
||||
maxDurationSec: z
|
||||
.number()
|
||||
.int()
|
||||
.min(10)
|
||||
.max(24 * 3600),
|
||||
/** 单次运行:token 上限(null = 不限) */
|
||||
maxTokensPerRun: z.number().int().positive().nullable(),
|
||||
/** 单次运行:费用上限(美元,null = 不限) */
|
||||
maxCostUsdPerRun: z.number().positive().nullable(),
|
||||
/** 后台自动调查的工具调用上限(比用户委托更小) */
|
||||
investigateMaxToolCalls: z.number().int().min(1).max(100),
|
||||
/** 等待目标关联的下载/入库结束时的轮询间隔(秒) */
|
||||
goalPollSec: z.number().int().min(1).max(3600),
|
||||
/** 等待目标完成的最长时间(小时),超时后以"仍在等待"汇报结束 */
|
||||
goalWaitHours: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(24 * 30),
|
||||
/** fake 模式脚本(JSON,FakeScript 的可序列化子集);null 用内置演示脚本 */
|
||||
fakeScript: z.string().max(100_000).nullable(),
|
||||
});
|
||||
export type AgentRuntimeSettings = z.infer<typeof AgentRuntimeSettings>;
|
||||
|
||||
/** 元数据源(TMDB):密钥只存 secret 引用;未配置时回退环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN */
|
||||
export const MetadataSettings = z.object({
|
||||
tmdbApiKeySecretRef: Id.nullable(),
|
||||
/** 自建反代或测试 mock;null 用官方地址 */
|
||||
tmdbBaseUrl: z.url().nullable(),
|
||||
});
|
||||
export type MetadataSettings = z.infer<typeof MetadataSettings>;
|
||||
|
||||
/** 每日预算与并发(REQ §6.4、§10.5) */
|
||||
export const BudgetSettings = z.object({
|
||||
maxNewTitlesPerDay: z.number().int().min(0),
|
||||
@@ -85,6 +126,8 @@ export const GlobalSettings = z.object({
|
||||
retention: RetentionSettings,
|
||||
pi: PiSettings,
|
||||
budget: BudgetSettings,
|
||||
agent: AgentRuntimeSettings,
|
||||
metadata: MetadataSettings,
|
||||
});
|
||||
export type GlobalSettings = z.infer<typeof GlobalSettings>;
|
||||
|
||||
@@ -115,6 +158,18 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
agentMaxRunsPerDay: 50,
|
||||
agentMaxCostUsdPerDay: 5,
|
||||
},
|
||||
agent: {
|
||||
autoInvestigate: false,
|
||||
maxToolCalls: 40,
|
||||
maxDurationSec: 900,
|
||||
maxTokensPerRun: null,
|
||||
maxCostUsdPerRun: null,
|
||||
investigateMaxToolCalls: 8,
|
||||
goalPollSec: 60,
|
||||
goalWaitHours: 72,
|
||||
fakeScript: null,
|
||||
},
|
||||
metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null },
|
||||
};
|
||||
|
||||
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
|
||||
@@ -132,12 +187,21 @@ export const GlobalSettingsUpdate = z.object({
|
||||
})
|
||||
.optional(),
|
||||
budget: BudgetSettings.partial().optional(),
|
||||
agent: AgentRuntimeSettings.partial().optional(),
|
||||
metadata: MetadataSettings.omit({ tmdbApiKeySecretRef: true })
|
||||
.partial()
|
||||
.extend({
|
||||
/** 只写:TMDB API Key(v3 key 或 v4 read token),服务端存为 secret 并回填 tmdbApiKeySecretRef;null 清除 */
|
||||
tmdbApiKey: z.string().min(1).max(4096).nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
|
||||
|
||||
/** 合并部分更新(纯函数,web 乐观更新与 server 共用) */
|
||||
export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings {
|
||||
const { apiKey: _apiKey, compaction, ...pi } = patch.pi ?? {};
|
||||
const { tmdbApiKey: _tmdbApiKey, ...metadata } = patch.metadata ?? {};
|
||||
return {
|
||||
automation: { ...current.automation, ...patch.automation },
|
||||
cache: { ...current.cache, ...patch.cache },
|
||||
@@ -145,6 +209,8 @@ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpda
|
||||
retention: { ...current.retention, ...patch.retention },
|
||||
pi: { ...current.pi, ...pi, compaction: { ...current.pi.compaction, ...compaction } },
|
||||
budget: { ...current.budget, ...patch.budget },
|
||||
agent: { ...current.agent, ...patch.agent },
|
||||
metadata: { ...current.metadata, ...metadata },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
||||
AcquisitionSlot: { row: C.AcquisitionSlot },
|
||||
AgentRun: { row: C.AgentRun },
|
||||
AgentRunEvent: { row: C.AgentRunEventRow },
|
||||
AgentRunMessage: { row: C.AgentRunMessage },
|
||||
CapabilityInvocation: { row: C.CapabilityInvocation },
|
||||
ApiKey: { row: C.ApiKey, dbOnly: ["keyHash"] },
|
||||
Approval: { row: C.Approval },
|
||||
Artifact: { row: C.Artifact },
|
||||
@@ -110,6 +112,9 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
||||
AgentRunTrigger: C.AgentTrigger,
|
||||
AgentRunStatus: C.AgentRunStatus,
|
||||
AgentRunEventType: C.AgentRunEventType,
|
||||
AgentRunMode: C.AgentRunMode,
|
||||
AgentRunMessageStatus: C.AgentRunMessageStatus,
|
||||
CapabilityInvocationStatus: C.CapabilityInvocationStatus,
|
||||
ApprovalStatus: C.ApprovalStatus,
|
||||
ArtifactKind: C.ArtifactKind,
|
||||
AssetLocationLocationKind: C.LocationKind,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// W2-D Agent 装配(REQ §10.3、§10.6、§10.7、§11):
|
||||
// - capability_invocation:有副作用能力的操作账本。幂等键由服务端按能力 ID/版本/运行/规范化输入生成(唯一);
|
||||
// succeeded 行直接返回已记录的输出,running 行(进程在调用中崩溃)按同一幂等键重跑处理器对账——
|
||||
// 处理器自身必须幂等(下载提交按计划状态 + qB hash、整理按记录状态)。
|
||||
// - agent_run_message:会话追加的用户消息(纠正/补充),runAgent 任务按顺序消费;
|
||||
// - agent_run 增加 mode(execute | plan_only)与 dedupe_key(后台触发按根因去重)。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
alter table agent_run add column mode text not null default 'execute' ${oneOf("mode", ["execute", "plan_only"])};
|
||||
alter table agent_run add column dedupe_key text;
|
||||
create unique index agent_run_dedupe_key on agent_run (dedupe_key) where dedupe_key is not null;
|
||||
|
||||
${entityTable(
|
||||
"capability_invocation",
|
||||
`
|
||||
idempotency_key text not null,
|
||||
capability_id text not null,
|
||||
capability_version integer not null,
|
||||
agent_run_id uuid references agent_run (id) on delete set null,
|
||||
actor_id text not null,
|
||||
source text not null,
|
||||
status text not null default 'running' ${oneOf("status", ["running", "succeeded", "failed"])},
|
||||
input jsonb not null default '{}'::jsonb,
|
||||
output jsonb,
|
||||
error jsonb,
|
||||
grant_id uuid,
|
||||
approval_id uuid,
|
||||
attempts integer not null default 1,
|
||||
finished_at timestamptz,
|
||||
constraint capability_invocation_idempotency_key unique (idempotency_key)`,
|
||||
)}
|
||||
create index capability_invocation_run_idx on capability_invocation (agent_run_id, created_at) where agent_run_id is not null;
|
||||
create index capability_invocation_running_idx on capability_invocation (created_at) where status = 'running';
|
||||
|
||||
${entityTable(
|
||||
"agent_run_message",
|
||||
`
|
||||
run_id uuid not null references agent_run (id) on delete cascade,
|
||||
text text not null,
|
||||
status text not null default 'pending' ${oneOf("status", ["pending", "consumed"])},
|
||||
created_by text,
|
||||
consumed_at timestamptz`,
|
||||
)}
|
||||
create index agent_run_message_pending_idx on agent_run_message (run_id, created_at) where status = 'pending';
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`${dropTables(["agent_run_message", "capability_invocation"])}
|
||||
drop index if exists agent_run_dedupe_key;
|
||||
alter table agent_run drop column if exists dedupe_key;
|
||||
alter table agent_run drop column if exists mode;`,
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export type AgentRunStatus =
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "budget_exceeded";
|
||||
export type AgentRunMode = "execute" | "plan_only";
|
||||
export type AgentRunEventType =
|
||||
| "run_started"
|
||||
| "text_delta"
|
||||
@@ -47,6 +48,7 @@ export type AgentRunEventType =
|
||||
| "compaction"
|
||||
| "run_completed"
|
||||
| "run_failed";
|
||||
export type AgentRunMessageStatus = "pending" | "consumed";
|
||||
export type ApprovalStatus = "pending" | "approved" | "rejected" | "revoked" | "expired" | "consumed";
|
||||
export type ArtifactKind = "evidence" | "plan" | "proposal" | "report" | "snapshot" | "log";
|
||||
export type AssetLocationLocationKind = "library" | "download_cache" | "hardlink" | "external";
|
||||
@@ -71,6 +73,7 @@ export type AssetOperationStatus = "planned" | "started" | "succeeded" | "failed
|
||||
export type AssetUnitCoverageCoverage = "full" | "partial" | "segment" | "unknown";
|
||||
export type AssetUnitCoverageSegment = "full" | "upper" | "middle" | "lower" | "unknown";
|
||||
export type AutomationGrantStatus = "active" | "revoked" | "expired";
|
||||
export type CapabilityInvocationStatus = "running" | "succeeded" | "failed";
|
||||
export type ContentUnitKind = "season" | "episode" | "special" | "part";
|
||||
export type ContentUnitContentType =
|
||||
| "main"
|
||||
@@ -305,6 +308,8 @@ export interface AgentRunTable {
|
||||
created_by: string | null;
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
mode: Generated<AgentRunMode>;
|
||||
dedupe_key: string | null;
|
||||
}
|
||||
|
||||
export interface AgentRunEventTable {
|
||||
@@ -315,6 +320,17 @@ export interface AgentRunEventTable {
|
||||
payload: Generated<Json>;
|
||||
}
|
||||
|
||||
export interface AgentRunMessageTable {
|
||||
id: Generated<string>;
|
||||
run_id: string;
|
||||
text: string;
|
||||
status: Generated<AgentRunMessageStatus>;
|
||||
created_by: string | null;
|
||||
consumed_at: Timestamptz | null;
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
}
|
||||
|
||||
export interface ApiKeyTable {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
@@ -444,6 +460,26 @@ export interface AutomationGrantTable {
|
||||
updated_at: GeneratedTimestamptz;
|
||||
}
|
||||
|
||||
export interface CapabilityInvocationTable {
|
||||
id: Generated<string>;
|
||||
idempotency_key: string;
|
||||
capability_id: string;
|
||||
capability_version: number;
|
||||
agent_run_id: string | null;
|
||||
actor_id: string;
|
||||
source: string;
|
||||
status: Generated<CapabilityInvocationStatus>;
|
||||
input: Generated<Json>;
|
||||
output: Json | null;
|
||||
error: Json | null;
|
||||
grant_id: string | null;
|
||||
approval_id: string | null;
|
||||
attempts: Generated<number>;
|
||||
finished_at: Timestamptz | null;
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
}
|
||||
|
||||
export interface ContentUnitTable {
|
||||
id: Generated<string>;
|
||||
media_entity_id: string;
|
||||
@@ -1180,6 +1216,7 @@ export interface DB {
|
||||
admin_user: AdminUserTable;
|
||||
agent_run: AgentRunTable;
|
||||
agent_run_event: AgentRunEventTable;
|
||||
agent_run_message: AgentRunMessageTable;
|
||||
api_key: ApiKeyTable;
|
||||
approval: ApprovalTable;
|
||||
artifact: ArtifactTable;
|
||||
@@ -1188,6 +1225,7 @@ export interface DB {
|
||||
asset_unit_coverage: AssetUnitCoverageTable;
|
||||
audit_event: AuditEventTable;
|
||||
automation_grant: AutomationGrantTable;
|
||||
capability_invocation: CapabilityInvocationTable;
|
||||
content_unit: ContentUnitTable;
|
||||
decision: DecisionTable;
|
||||
download_task: DownloadTaskTable;
|
||||
@@ -1249,6 +1287,9 @@ export type AgentRunUpdate = Updateable<AgentRunTable>;
|
||||
export type AgentRunEventRow = Selectable<AgentRunEventTable>;
|
||||
export type NewAgentRunEvent = Insertable<AgentRunEventTable>;
|
||||
export type AgentRunEventUpdate = Updateable<AgentRunEventTable>;
|
||||
export type AgentRunMessageRow = Selectable<AgentRunMessageTable>;
|
||||
export type NewAgentRunMessage = Insertable<AgentRunMessageTable>;
|
||||
export type AgentRunMessageUpdate = Updateable<AgentRunMessageTable>;
|
||||
export type ApiKeyRow = Selectable<ApiKeyTable>;
|
||||
export type NewApiKey = Insertable<ApiKeyTable>;
|
||||
export type ApiKeyUpdate = Updateable<ApiKeyTable>;
|
||||
@@ -1273,6 +1314,9 @@ export type AuditEventUpdate = Updateable<AuditEventTable>;
|
||||
export type AutomationGrantRow = Selectable<AutomationGrantTable>;
|
||||
export type NewAutomationGrant = Insertable<AutomationGrantTable>;
|
||||
export type AutomationGrantUpdate = Updateable<AutomationGrantTable>;
|
||||
export type CapabilityInvocationRow = Selectable<CapabilityInvocationTable>;
|
||||
export type NewCapabilityInvocation = Insertable<CapabilityInvocationTable>;
|
||||
export type CapabilityInvocationUpdate = Updateable<CapabilityInvocationTable>;
|
||||
export type ContentUnitRow = Selectable<ContentUnitTable>;
|
||||
export type NewContentUnit = Insertable<ContentUnitTable>;
|
||||
export type ContentUnitUpdate = Updateable<ContentUnitTable>;
|
||||
|
||||
@@ -75,6 +75,9 @@ const EXPECTED_TABLES = [
|
||||
"identity_redirect",
|
||||
"metadata_field",
|
||||
"field_override",
|
||||
// W2-D Agent 装配(0021)
|
||||
"capability_invocation",
|
||||
"agent_run_message",
|
||||
// 登录门禁(0015)与 SSE 多进程总线(0016)
|
||||
"admin_user",
|
||||
"admin_session",
|
||||
|
||||
Reference in new issue
Block a user