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

+428
View File
@@ -0,0 +1,428 @@
import { isCapabilityError } from "@umt/agent";
import {
type AgentGoalReport,
Api,
CAPABILITY_CATALOG,
type CapabilityDescriptor,
type KnownCapabilityId,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
import { hasScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { planBinding } from "./authorize.ts";
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
import { route } from "./http.ts";
import { AgentRunError, abortRun, appendMessage, startRun } from "./service.ts";
// Agent 会话 API、持续授权 / 单次审批、能力目录与调用、业务任务。
/** crud() 生成的列表 query 类型被擦除,这里按合同收窄 */
type ListQuery = {
cursor?: string;
limit: number;
status?: string;
planId?: string;
kind?: string;
subjectId?: string;
};
const toIso = (d: Date | null) => (d ? d.toISOString() : null);
export function runToApi(r: AgentRunRow) {
return {
id: r.id,
taskId: r.task_id,
parentRunId: r.parent_run_id,
trigger: r.trigger,
goal: r.goal,
status: r.status,
provider: r.provider,
model: r.model,
thinkingLevel: r.thinking_level,
scope: r.scope,
budget: r.budget,
usage: r.usage,
evidenceVersion: r.evidence_version,
sessionState: null,
summary: r.summary,
toolCallCount: r.tool_call_count,
lastEventSeq: Number(r.last_event_seq),
error: r.error,
cancelRequestedAt: toIso(r.cancel_requested_at),
startedAt: toIso(r.started_at),
finishedAt: toIso(r.finished_at),
createdBy: r.created_by,
mode: r.mode,
dedupeKey: r.dedupe_key,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
};
}
/** snake_case 行 → camelCase(Date → ISO) */
function camel(row: object): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(row))
out[k.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase())] = v instanceof Date ? v.toISOString() : v;
return out;
}
function mapErr(err: unknown): never {
if (err instanceof AgentRunError)
throw new ApiException(
err.code === "budget" ? "rate_limited" : err.code === "bad_request" ? "validation_failed" : err.code,
err.message,
);
throw err;
}
const encode = (at: Date, id: string) => Buffer.from(`${at.toISOString()}|${id}`).toString("base64url");
const decode = (c?: string) => {
if (!c) return null;
const [at, id] = Buffer.from(c, "base64url").toString().split("|");
return at && id ? { at, id } : null;
};
export function registerAgentRoutes(app: ModuleApp, ctx: ServerContext) {
const db = () => ctx.db;
// ── Agent 会话 ──
route(app, Api.agentRuns.list, async ({ query }) => {
let q = db().selectFrom("agent_run").selectAll();
if (query.status) q = q.where("status", "=", query.status);
if (query.trigger) q = q.where("trigger", "=", query.trigger);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(runToApi),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.agentRuns.get, async ({ params, query }) => {
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!run) throw new ApiException("not_found", "Agent 运行不存在");
const events = await db()
.selectFrom("agent_run_event")
.selectAll()
.where("run_id", "=", params.id)
.where("seq", ">", query.afterSeq)
.orderBy("seq")
.limit(query.limit + 1)
.execute();
const page = events.slice(0, query.limit);
const messages = await db()
.selectFrom("agent_run_message")
.selectAll()
.where("run_id", "=", params.id)
.orderBy("created_at")
.execute();
const report = await db()
.selectFrom("artifact")
.select("content")
.where("agent_run_id", "=", params.id)
.where("kind", "=", "report")
.orderBy("created_at", "desc")
.executeTakeFirst();
return {
run: runToApi(run),
events: page.map((e) => ({
runId: e.run_id,
seq: Number(e.seq),
type: e.type,
at: e.at.toISOString(),
payload: e.payload,
})),
nextAfterSeq: events.length > query.limit ? Number(page.at(-1)!.seq) : null,
messages: messages.map(camel),
report: (report?.content as AgentGoalReport | undefined) ?? null,
};
});
route(
app,
Api.agentRuns.create,
async ({ body, actorId }) => {
const r = await startRun(ctx, {
goal: body.goal,
mode: body.mode,
capabilityAllowlist: body.capabilityAllowlist,
trigger: "user",
createdBy: actorId,
}).catch(mapErr);
return runToApi(r.run);
},
{ status: 201 },
);
route(app, Api.agentRuns.message, async ({ params, body, actorId }) =>
runToApi(await appendMessage(ctx, params.id, body.text, actorId).catch(mapErr)),
);
route(app, Api.agentRuns.abort, async ({ params, body, actorId }) =>
runToApi(await abortRun(ctx, params.id, body.reason, actorId).catch(mapErr)),
);
// ── 持续授权 ──
route(app, Api.automationGrants.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("automation_grant").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(camel),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.automationGrants.get, async ({ params }) => {
const r = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "授权不存在");
return camel(r);
});
route(
app,
Api.automationGrants.create!,
async ({ body, actorId }) => {
const r = await db()
.insertInto("automation_grant")
.values({
action: body.action,
policy_revision_id: body.policyRevisionId ?? null,
scope: JSON.stringify(body.scope ?? {}),
budget: JSON.stringify(body.budget ?? {}),
granted_by: actorId,
reason: body.reason ?? null,
expires_at: body.expiresAt ? new Date(body.expiresAt) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
await audit(ctx, actorId, "automation_grant.create", r.id, { action: r.action });
return camel(r);
},
{ status: 201 },
);
route(app, Api.automationGrants.update!, async ({ params, body, actorId }) => {
const cur = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "授权不存在");
if (body.expiresAt && cur.expires_at && new Date(body.expiresAt) > cur.expires_at)
throw new ApiException("validation_failed", "只能缩短有效期;扩大范围请新建授权");
const r = await db()
.updateTable("automation_grant")
.set({
...(body.status === "revoked" ? { status: "revoked", revoked_at: new Date(), revoked_by: actorId } : {}),
...(body.expiresAt !== undefined ? { expires_at: body.expiresAt ? new Date(body.expiresAt) : null } : {}),
...(body.reason !== undefined ? { reason: body.reason } : {}),
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
await audit(ctx, actorId, "automation_grant.update", r.id, { status: r.status });
return camel(r);
});
// ── 单次审批 ──
route(app, Api.approvals.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("approval").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
if (query.planId) q = q.where("plan_id", "=", query.planId);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(camel),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.approvals.get, async ({ params }) => {
const r = await db().selectFrom("approval").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "审批不存在");
return camel(r);
});
route(
app,
Api.approvals.create,
async ({ body, actorId }) => {
const b = await planBinding(db(), body.planType, body.planId);
if (!b) throw new ApiException("not_found", "计划不存在");
const r = await db()
.insertInto("approval")
.values({
plan_type: body.planType,
plan_id: body.planId,
payload_hash: b.payloadHash,
expected_revision: b.revision,
requested_by: actorId,
expires_at: new Date(Date.now() + body.expiresInSec * 1000),
reason: body.reason ?? null,
})
.returningAll()
.executeTakeFirstOrThrow();
return camel(r);
},
{ status: 201 },
);
route(app, Api.approvals.update!, async ({ params, body, actorId }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("approval")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur) throw new ApiException("not_found", "审批不存在");
if (cur.payload_hash !== body.payloadHash)
throw new ApiException("conflict", "payloadHash 不一致:计划已变化,请重新申请审批");
const allowed = body.status === "revoked" ? ["pending", "approved"] : ["pending"];
if (!allowed.includes(cur.status)) throw new ApiException("conflict", `审批已是 ${cur.status}`);
return trx
.updateTable("approval")
.set({ status: body.status, decided_by: actorId, decided_at: new Date(), reason: body.reason ?? cur.reason })
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
});
await audit(ctx, actorId, `approval.${body.status}`, r.id, { planType: r.plan_type, planId: r.plan_id });
return camel(r);
});
// ── 能力目录与调用(Web / MCP 与 Agent 同一条 invoke 路径)──
route(app, Api.capabilities.list, async () => ({
items: ctx.capabilities.list().map(
(c): CapabilityDescriptor => ({
...CAPABILITY_CATALOG[c.descriptor.id as KnownCapabilityId],
}),
),
}));
route(app, Api.capabilities.invoke, async ({ params, body, actor, actorId, c }) => {
const entry = ctx.capabilities.get(params.capabilityId);
if (!entry) throw new ApiException("not_found", `能力未注册:${params.capabilityId}`);
const scopes = (c.get("principal")?.scopes ?? actor?.scopes ?? []) as string[];
if (!hasScope(scopes, entry.descriptor.scope))
throw new ApiException("forbidden", `调用 ${entry.descriptor.id} 需要 scope: ${entry.descriptor.scope}`);
if (body.preview && !entry.descriptor.supportsPreview)
throw new ApiException("bad_request", `${entry.descriptor.id} 不支持预览`);
try {
const output = await ctx.capabilities.invoke(params.capabilityId, body.input, {
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
scopeIds: [],
signal: c.req.raw.signal,
source: "web",
});
const o = (output ?? {}) as { taskId?: unknown; planId?: unknown; plan?: { id?: unknown } | null };
return {
output,
taskId: typeof o.taskId === "string" ? o.taskId : null,
planId: typeof o.planId === "string" ? o.planId : typeof o.plan?.id === "string" ? o.plan.id : null,
};
} catch (err) {
if (!isCapabilityError(err)) throw err;
const code =
err.code === "unauthorized"
? "forbidden"
: err.code === "not_found"
? "not_found"
: err.code === "conflict"
? "conflict"
: err.code === "precondition" || err.code === "budget"
? "precondition_failed"
: err.code === "invalid_input"
? "validation_failed"
: "internal";
throw new ApiException(code, err.message);
}
});
// ── 业务任务 ──
route(app, Api.tasks.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("task").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
if (query.kind) q = q.where("kind", "=", query.kind);
if (query.subjectId) q = q.where("subject_id", "=", query.subjectId);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(taskToApi),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.tasks.get, async ({ params }) => {
const r = await db().selectFrom("task").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "任务不存在");
return taskToApi(r);
});
route(app, Api.tasks.operations, async ({ params }) => {
const rows = await db()
.selectFrom("asset_operation")
.selectAll()
.where("task_id", "=", params.id)
.orderBy("created_at")
.execute();
return { items: rows.map(camel) };
});
route(app, Api.tasks.cancel, async ({ params, body }) => {
try {
return taskToApi((await cancelTask(ctx, params.id, body.reason)).row);
} catch (err) {
if (isCapabilityError(err))
throw new ApiException(err.code === "not_found" ? "not_found" : "conflict", err.message);
throw err;
}
});
route(app, Api.tasks.retry, async ({ params, actorId }) => {
try {
return taskToApi(await retryTask(ctx, params.id, actorId));
} catch (err) {
if (isCapabilityError(err))
throw new ApiException(err.code === "not_found" ? "not_found" : "conflict", err.message);
throw err;
}
});
}
async function audit(ctx: ServerContext, actorId: string, action: string, targetId: string, payload: object) {
await ctx.db
.insertInto("audit_event")
.values({
actor_type: actorId.startsWith("api_key:") ? "api_key" : "user",
actor_id: actorId,
action,
target_type: action.split(".")[0]!,
target_id: targetId,
payload: JSON.stringify(payload),
})
.execute();
}