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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user