- 抽屉和会话页渲染同一套 GoalComposer / AgentRunPanel;抽屉标题即最近会话切换, 头部只有新对话、在页面打开、关闭;去掉右下角悬浮按钮和最小化小窗 - 会话列表合一:对话与巡检混排带类型小标签,搜索 + 一个状态下拉,只列顶层会话 (GET /agent/runs?roots=true,新增可选参数) - 子会话卡片:由镜像的 subagent_* 事件折成,显示任务、状态、当前步骤、用时、调用次数、费用, 可展开看过程、可打开;顶部「N 个子会话运行中」跳到卡片;subagent_completed 加可选 durationMs - 回答去内部字段:汇报规则禁止照抄键值与字段表;展示层 stripInternalFields 把字段行 / 字段表移进技术信息 - 开场建议按页面给 2 条完整问法、点了直接发;指令只留 /先给方案,/重跑 改成「重新运行」按钮 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
612 lines
24 KiB
TypeScript
612 lines
24 KiB
TypeScript
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 { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts";
|
|
import { InternalDispatcher } from "../search/dispatch.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 { afterApprovalDecided, answerOptions } from "./interaction.ts";
|
|
import { type InvokeLimiter, sharedInvokeLimiter, UpstreamBusyError, UpstreamTimeoutError } from "./invoke-limits.ts";
|
|
import { AgentRunError, abortRun, appendMessage, startRun } from "./service.ts";
|
|
import { descendants, descendantUsage } from "./subagents.ts";
|
|
import { agentUsage } from "./usage.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,
|
|
dedupeKey: r.dedupe_key,
|
|
depth: r.depth,
|
|
spawnReason: r.spawn_reason,
|
|
createdAt: r.created_at.toISOString(),
|
|
updatedAt: r.updated_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
/** 会话树节点(父会话 / 子孙会话) */
|
|
export function runToNode(r: AgentRunRow) {
|
|
const scope = (r.scope ?? {}) as { capabilities?: string[] };
|
|
return {
|
|
id: r.id,
|
|
parentRunId: r.parent_run_id,
|
|
goal: r.goal,
|
|
status: r.status,
|
|
depth: r.depth,
|
|
spawnReason: r.spawn_reason,
|
|
toolCallCount: r.tool_call_count,
|
|
usage: r.usage as Record<string, number>,
|
|
summary: r.summary,
|
|
capabilities: scope.capabilities ?? [],
|
|
budget: r.budget as Record<string, number>,
|
|
createdAt: r.created_at.toISOString(),
|
|
finishedAt: toIso(r.finished_at),
|
|
};
|
|
}
|
|
|
|
/** 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,
|
|
limiter: InvokeLimiter = sharedInvokeLimiter(),
|
|
) {
|
|
const db = () => ctx.db;
|
|
const dispatch = new InternalDispatcher(app);
|
|
|
|
// ── Agent 会话 ──
|
|
route(app, Api.agentRuns.usage, async () => agentUsage(db()));
|
|
|
|
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);
|
|
if (query.parentRunId) q = q.where("parent_run_id", "=", query.parentRunId);
|
|
if (query.roots === "true") q = q.where("parent_run_id", "is", null);
|
|
if (query.group) {
|
|
// 自动 = 定时 / 事件触发,子会话按父会话归类
|
|
const auto = sql<boolean>`(trigger in ('schedule', 'event') or (trigger = 'subagent' and parent_run_id in
|
|
(select p.id from agent_run p where p.trigger in ('schedule', 'event'))))`;
|
|
q = q.where(query.group === "auto" ? auto : sql<boolean>`not ${auto}`);
|
|
}
|
|
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", "智能体运行不存在");
|
|
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();
|
|
// 会话树:父会话 + 全部子孙会话;合计用量 = 本会话 + 子孙会话
|
|
const parent = run.parent_run_id
|
|
? await db().selectFrom("agent_run").selectAll().where("id", "=", run.parent_run_id).executeTakeFirst()
|
|
: undefined;
|
|
const kids = await descendants(ctx, run.id);
|
|
const own = (run.usage ?? {}) as { totalTokens?: number; costUsd?: number };
|
|
const ext = await descendantUsage(ctx, run.id);
|
|
return {
|
|
run: runToApi(run),
|
|
parent: parent ? runToNode(parent) : null,
|
|
children: kids.map(runToNode),
|
|
familyUsage: {
|
|
runs: kids.length + 1,
|
|
toolCalls: run.tool_call_count + ext.toolCalls,
|
|
totalTokens: (own.totalTokens ?? 0) + ext.totalTokens,
|
|
costUsd: (own.costUsd ?? 0) + ext.costUsd,
|
|
},
|
|
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, c }) => {
|
|
// 运行模式已取消:用户会话一律按授权执行;旧客户端传 mode=plan_only 按"先看方案再执行"处理
|
|
const proposeFirst = body.proposeFirst || body.mode === "plan_only";
|
|
if (body.mode) c.header("Deprecation", "true");
|
|
const r = await startRun(ctx, {
|
|
goal: body.goal,
|
|
mode: "execute",
|
|
capabilityAllowlist: body.capabilityAllowlist,
|
|
trigger: "user",
|
|
createdBy: actorId,
|
|
session: {
|
|
// 控制台管理员会话发起:需审批的动作在步骤处出审批卡(只有管理员会话能批准)
|
|
interactive: c.get("principal")?.kind === "admin_session",
|
|
proposeFirst,
|
|
},
|
|
}).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.choose, async ({ params, body, actorId, c }) => {
|
|
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
|
|
if (!run) throw new ApiException("not_found", "智能体运行不存在");
|
|
if (body.optionId === null && !body.reason?.trim())
|
|
throw new ApiException("validation_failed", "都不要时写一句原因,智能体按它调整方案");
|
|
// 选定即批准只认控制台管理员会话(与 PATCH /approvals 同一口径);API Key 作答只选方案
|
|
const r = await answerOptions(ctx, run.id, body, actorId, {
|
|
approver: c.get("principal")?.kind === "admin_session",
|
|
});
|
|
if (r.status === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
|
|
if (r.status === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
|
|
if (r.status === "closed") throw new ApiException("conflict", "这项方案选择已处理");
|
|
if (r.status === "ok" && r.approvalId)
|
|
await audit(ctx, actorId, "approval.approved", r.approvalId, {
|
|
via: "option",
|
|
runId: run.id,
|
|
decisionId: body.decisionId,
|
|
optionId: body.optionId,
|
|
});
|
|
const fresh = await db().selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow();
|
|
return runToApi(fresh);
|
|
});
|
|
|
|
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 });
|
|
// 会话内审批:写关联待处理事项的结果(拒绝带理由),唤醒挂起在这份审批上的会话
|
|
await afterApprovalDecided(ctx, r, actorId);
|
|
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 {
|
|
// 外部 IO 型能力(站点实时搜索/详情刷新、TMDB):15s 超时 + 按上游限并发
|
|
const output = await limiter.run(params.capabilityId, body.input, c.req.raw.signal, (signal) =>
|
|
ctx.capabilities.invoke(params.capabilityId, body.input, {
|
|
actorId,
|
|
taskId: `http:${c.get("requestId") ?? "req"}`,
|
|
scopeIds: [],
|
|
signal: 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) {
|
|
capabilityHttpError(err);
|
|
}
|
|
});
|
|
|
|
// 手动资源选择不依赖订阅,提交后仍走共同下载及整理任务。
|
|
route(app, Api.acquisitionPlans.create, async ({ body: input, actorId, c }) => {
|
|
let body = input;
|
|
// 下载前指定了作品:走资源指定身份的同一端点(建 / 绑作品、人工锁、审计),再按该作品下载
|
|
if (body.identity) {
|
|
const { identity, ...rest } = body;
|
|
const assigned = await dispatch.call<{ mediaEntityId: string }>(
|
|
c,
|
|
"POST",
|
|
`/source-releases/${body.sourceReleaseId}/identity`,
|
|
{ ...identity, reason: "手动下载时指定" },
|
|
);
|
|
body = { ...rest, entityId: assigned.mediaEntityId };
|
|
}
|
|
const rel = await db()
|
|
.selectFrom("source_release")
|
|
.select("media_entity_id")
|
|
.where("id", "=", body.sourceReleaseId)
|
|
.executeTakeFirst();
|
|
if (
|
|
rel &&
|
|
!rel.media_entity_id &&
|
|
!body.entityId &&
|
|
!body.allowUnrecognized &&
|
|
ctx.capabilities.get("identity.resolve")
|
|
) {
|
|
try {
|
|
await ctx.capabilities.invoke(
|
|
"identity.resolve",
|
|
{ sourceReleaseId: body.sourceReleaseId },
|
|
{
|
|
actorId,
|
|
taskId: `http:${c.get("requestId") ?? "req"}`,
|
|
scopeIds: [],
|
|
signal: c.req.raw.signal,
|
|
source: "web",
|
|
},
|
|
);
|
|
} catch (err) {
|
|
ctx.logger.warn(
|
|
{ sourceReleaseId: body.sourceReleaseId, error: err instanceof Error ? err.message : String(err) },
|
|
"手动下载识别未完成,允许确认后下载",
|
|
);
|
|
}
|
|
}
|
|
const result = await planManualAcquisition(ctx, body);
|
|
if (result.plan)
|
|
await audit(ctx, actorId, "acquisition_plan.manual_submit", result.plan.id, {
|
|
sourceReleaseId: body.sourceReleaseId,
|
|
taskId: result.taskId,
|
|
importMode: body.importMode,
|
|
note: body.note ?? null,
|
|
});
|
|
return result;
|
|
});
|
|
|
|
// ── 产物(整理计划 / 汇报 / 证据):只读 ──
|
|
route(app, Api.artifacts.list, async ({ query: raw }) => {
|
|
const query = raw as ListQuery & { agentRunId?: string; taskId?: string };
|
|
let q = db().selectFrom("artifact").selectAll();
|
|
if (query.agentRunId) q = q.where("agent_run_id", "=", query.agentRunId);
|
|
if (query.taskId) q = q.where("task_id", "=", query.taskId);
|
|
if (query.kind) q = q.where("kind", "=", query.kind 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.artifacts.get, async ({ params }) => {
|
|
const r = await db().selectFrom("artifact").selectAll().where("id", "=", params.id).executeTakeFirst();
|
|
if (!r) throw new ApiException("not_found", "产物不存在");
|
|
return camel(r);
|
|
});
|
|
|
|
// ── 业务任务 ──
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** 能力错误 → HTTP 错误码 */
|
|
function capabilityHttpError(err: unknown): never {
|
|
if (err instanceof UpstreamBusyError) throw new ApiException("rate_limited", err.message);
|
|
if (err instanceof UpstreamTimeoutError) throw new ApiException("upstream_unavailable", err.message);
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|