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

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

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

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
import type { AgentGoalReport, DecisionOption, EventPayload } from "@umt/contracts";
import { sql } from "@umt/db";
import { defineTask, type OutboxRouteFn, type TaskRegistry } from "@umt/tasks";
import { z } from "zod";
import type { ServerContext } from "../types.ts";
import { retryTask } from "./capabilities.ts";
import { buildGoalReport } from "./report.ts";
import { type AgentEnv, type DriveResult, drive, goalSettled } from "./runner.ts";
import { autoInvestigate, finalize } from "./service.ts";
// 持久任务:runAgent(驱动一次有界 Agent 会话 → 等目标关联的下载/入库落定 → 以业务表汇报)
// 与 resolveDecision(decision.created → 自动调查;decision.updated(resolved)→ 执行所选处理)。
const id = z.string().uuid();
const DB_RETRY = { maximumAttempts: 3, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "30s" } as const;
export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv) {
registry.register(
defineTask({
name: "runAgent",
version: 1,
inputSchema: z.object({ agentRunId: id }),
retryPolicy: DB_RETRY,
handler: async (t) => {
const e = env();
const runId = t.input.agentRunId;
await t.step.run("start", async () => {
await e.ctx.db
.updateTable("task")
.set({ status: "running", started_at: new Date(), attempts: sql`attempts + 1` })
.where("subject_type", "=", "agent_run")
.where("subject_id", "=", runId)
.execute();
return null;
});
const res: DriveResult = await t.step.run("drive", () => drive(e, e.ctx.capabilities, runId, t.abortSignal));
let settled: AgentGoalReport | null = null;
const run = await e.ctx.db
.selectFrom("agent_run")
.select(["mode"])
.where("id", "=", runId)
.executeTakeFirstOrThrow();
if (res.status === "completed" && run.mode === "execute") {
const s = await e.ctx.settings.get("agent");
const pollSec = e.opts.goalPollSec ?? s.goalPollSec;
const rounds = Math.max(1, Math.min(900, Math.ceil((s.goalWaitHours * 3600) / pollSec)));
settled = await t.step.waitForFact<AgentGoalReport>("goal-settled", {
check: async () => {
const c = await e.ctx.db
.selectFrom("agent_run")
.select("cancel_requested_at")
.where("id", "=", runId)
.executeTakeFirst();
// 已请求取消:不再等待,finalize 按取消收尾
if (c?.cancel_requested_at) return buildGoalReport(e.ctx.db, runId, res.finalText || null);
return goalSettled(e, runId, res.finalText);
},
timeout: `${pollSec}s`,
maxRounds: rounds,
});
}
return t.step.run("finalize", () => finalize(e, runId, res, settled));
},
}),
defineTask({
name: "resolveDecision",
version: 1,
inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated"]).default("updated") }),
retryPolicy: DB_RETRY,
handler: async (t) => {
const e = env();
if (t.input.trigger === "created")
return t.step.run("investigate", () => autoInvestigate(e.ctx, t.input.decisionId));
return t.step.run("execute", () => executeResolution(e.ctx, t.input.decisionId));
},
}),
);
}
/**
* 执行 decision 的处理结果(身份歧义由 sources-identity 在处理端点里直接执行,这里跳过):
* - 选项带 capabilityId → 经注册中心调用(仍走授权/审计/账本);
* - retry → 重试关联任务;reevaluate / accept → 按对象重发 evaluateAcquisition;dismiss / ignore → 无动作。
*/
export async function executeResolution(ctx: ServerContext, decisionId: string) {
const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
if (!d) return { executed: false, reason: "not_found" };
if (d.status !== "resolved") return { executed: false, reason: `status_${d.status}` };
if (d.kind === "identity_ambiguous") return { executed: false, reason: "handled_by_sources_identity" };
const res = (d.resolution ?? {}) as { optionKey?: string | null; by?: string; execution?: unknown };
if (res.execution) return { executed: false, reason: "already_executed" };
const key = res.optionKey ?? null;
const opt = ((d.options ?? []) as DecisionOption[]).find((o) => o.key === key);
const by = d.resolved_by ?? res.by ?? "system";
let execution: Record<string, unknown>;
try {
if (opt?.capabilityId) {
const output = await ctx.capabilities.invoke(opt.capabilityId, opt.input ?? {}, {
actorId: by.replace(/^admin:/, "user:"),
taskId: `decision:${d.id}`,
scopeIds: [],
signal: new AbortController().signal,
// 人工在待处理中心选中的处理 = 控制台人工授权;系统/Agent 关闭的事项仍需授权记录
source: /^(admin|user):/.test(by) ? "web" : "task",
invocationId: `decision:${d.id}`,
});
execution = { kind: "capability", capabilityId: opt.capabilityId, output };
} else if (key === "retry" && d.task_id) {
const row = await retryTask(ctx, d.task_id, by, `decision ${d.id}`);
execution = { kind: "retry", taskId: row.id, status: row.status };
} else if ((key === "reevaluate" || key === "accept") && d.subject_id) {
const input =
d.subject_type === "acquisition_slot"
? { acquisitionSlotId: d.subject_id }
: d.subject_type === "source_release"
? { sourceReleaseId: d.subject_id }
: d.subject_type === "acquisition_plan"
? {
acquisitionSlotId: (
await ctx.db
.selectFrom("acquisition_plan")
.select("slot_id")
.where("id", "=", d.subject_id)
.executeTakeFirst()
)?.slot_id,
}
: null;
if (!input || Object.values(input).some((v) => !v)) execution = { kind: "none", reason: "subject_not_evaluable" };
else {
const { runId } = await ctx.engine.enqueue("evaluateAcquisition", input, {
idempotencyKey: `decision:${d.id}:reevaluate`,
traceId: d.id,
});
execution = { kind: "reevaluate", engineRunId: runId, input };
}
} else {
execution = { kind: "none", reason: key ? `option_${key}` : "no_option" };
}
} catch (err) {
execution = { kind: "error", message: (err as Error).message.slice(0, 500) };
}
await ctx.db
.updateTable("decision")
.set({ resolution: sql`coalesce(resolution, '{}'::jsonb) || ${JSON.stringify({ execution })}::jsonb` })
.where("id", "=", d.id)
.execute();
return { executed: execution.kind !== "none" && execution.kind !== "error", execution };
}
export const agentOutboxRoutes: Record<string, OutboxRouteFn> = {
"decision.created": (ev) => {
const p = ev.payload as EventPayload<"decision.created">;
if (!["identity_ambiguous", "download_failed", "download_stalled"].includes(p.kind)) return null;
return { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "created" } };
},
"decision.updated": (ev) => {
const p = ev.payload as EventPayload<"decision.updated">;
return p.status === "resolved"
? { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "updated" } }
: null;
},
};