人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
+482 -10
View File
@@ -5,10 +5,10 @@
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
*/
import { sql } from 'drizzle-orm';
import { extractText } from '../upload-extract.ts';
import type { Hono } from 'hono';
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
import {
ATTRIBUTION_SIDE_LABEL,
browseLedgerRoute,
controlTaskRoute,
govMetricsRoute,
@@ -26,9 +26,17 @@ import {
listTriggersRoute,
registerToolRoute,
saveProcessRoute,
testActivityStateRoute,
testCurrentRoute,
testLogDetailRoute,
testRunActivityRoute,
testRunRoute,
testStopRoute,
listTestLogsRoute,
deleteTestLogRoute,
clearTestLogsRoute,
downloadFileRoute,
uploadFileRoute,
traceOfWorkflowRoute,
setProcessStateRoute,
reassignTaskRoute,
@@ -63,6 +71,7 @@ import {
} from '@modules/process';
import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts';
import { displayLog } from './log-lines.ts';
/** `db.execute` 的行读取 —— Drizzle 的返回型随驱动变,窄化收在这一处 */
function rowsOf<T>(result: unknown): T[] {
@@ -78,8 +87,180 @@ function rowsOf<T>(result: unknown): T[] {
* 用 `source->>'ref' = 'testbed'` 认门:**只动测试台自己起的单**,
* 生产单一个字都不碰(它们的来源是触发器或纪要登记)。
*/
/**
* 归档一次试跑 —— **销毁之前拍下快照**(见 `0023_testbed_log.sql` 的表注)。
*
* 拔掉运行数据这条不变;拔之前把观测面存下来:逐拍轨迹、台账草稿、发过的卡、
* 预算读数与终态。开发活动靠的就是「上一版跑了几拍、卡在哪一步」的比对。
*
* 归档失败**不挡销毁**:清场是人明确要的动作,不能因为存不下账就把仪器占着。
* 失败如实写进 stderr —— 静默吞掉会让人以为存了,回头找不着。
*/
async function archiveRun(
container: Container,
input: {
kind: 'activity' | 'package';
taskIds: readonly string[];
workflowId?: string;
subject?: string;
target?: string;
/** 流程包一次试跑的终态取流自己的,不是头一个节点任务的 */
terminal?: string;
},
): Promise<void> {
if (input.taskIds.length === 0) return;
try {
const trail: { at: string; kind: string; title: string; detail?: Record<string, unknown> }[] = [];
const rows: { address: string; key: Record<string, unknown>; patch: Record<string, unknown> }[] = [];
const cards: { id: string; mode: string; title: string; done: boolean; result?: string }[] = [];
const used = { steps: 0, tokens: 0, humanCalls: 0 };
const caps = { steps: 0, tokens: 0, humanCalls: 0 };
let head: { subject: string; target: string; terminal?: string; reason?: string; startedAt: string } | undefined;
for (const taskId of input.taskIds) {
const task = await container.task.get(taskId as never);
const [memory, drafts, orders] = await Promise.all([
container.memory.window(taskId as never, 400),
container.ledger.taskDrafts(taskId as never),
container.humanTool.allWorkOrders(taskId as never),
]);
// 多节点合成一条:每条痕带上是哪一节点的,不然合起来读不出先后归属
const tag = input.taskIds.length > 1 ? `${task.title} · ` : '';
for (const m of memory) {
trail.push({
at: m.at,
kind: m.kind,
title: `${tag}${m.title}`,
...(m.detail === undefined ? {} : { detail: m.detail }),
});
}
for (const d of drafts) rows.push({ address: `${d.domain}.${d.object}`, key: d.key, patch: d.patch });
for (const o of orders) {
cards.push({
id: o.id,
mode: o.mode,
title: `${tag}${o.title}`,
done: o.done !== undefined,
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
});
}
used.steps += task.used.steps;
used.tokens += task.used.tokens;
used.humanCalls += task.used.humanCalls;
caps.steps += task.caps.steps;
caps.tokens += task.caps.tokens;
caps.humanCalls += task.caps.humanCalls;
head ??= {
subject: input.subject ?? task.title,
target: input.target ?? task.activityId,
...(input.terminal ?? task.terminal) === undefined ? {} : { terminal: input.terminal ?? task.terminal },
...(task.reason === undefined ? {} : { reason: task.reason }),
startedAt: task.startedAt,
};
}
if (head === undefined) return;
trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
await container.db.execute(sql`
insert into sys.testbed_run
(id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, trail, drafts, cards, started_at)
values (
${crypto.randomUUID()}, ${input.kind}, ${head.subject}, ${head.target},
${input.taskIds[0] ?? null}, ${input.workflowId ?? null}, ${head.terminal ?? null}, ${head.reason ?? null},
${JSON.stringify(used)}::jsonb, ${JSON.stringify(caps)}::jsonb,
${JSON.stringify(trail)}::jsonb, ${JSON.stringify(rows)}::jsonb, ${JSON.stringify(cards)}::jsonb,
${head.startedAt}
)`);
} catch (error) {
process.stderr.write(`[testbed] 归档失败(照旧销毁):${error instanceof Error ? error.message : String(error)}\n`);
}
}
/** 试跑日志的库行 —— snake 出库、camel 出网,映射收在一处 */
type TestLogDbRow = {
id: string;
kind: string;
subject: string;
target: string;
task_id: string | null;
workflow_id: string | null;
terminal: string | null;
reason: string | null;
used: Record<string, number>;
caps: Record<string, number>;
trail_count: number;
draft_count: number;
card_count: number;
started_at: Date | string | null;
archived_at: Date | string;
};
const iso = (v: Date | string | null): string | undefined =>
v === null ? undefined : v instanceof Date ? v.toISOString() : String(v);
function toTestLogRow(r: TestLogDbRow): Record<string, unknown> {
return {
id: r.id,
kind: r.kind === 'package' ? 'package' : 'activity',
subject: r.subject,
target: r.target,
...(r.task_id === null ? {} : { taskId: r.task_id }),
...(r.workflow_id === null ? {} : { workflowId: r.workflow_id }),
...(r.terminal === null ? {} : { terminal: r.terminal }),
...(r.reason === null ? {} : { reason: r.reason }),
used: r.used ?? {},
caps: r.caps ?? {},
trailCount: Number(r.trail_count ?? 0),
draftCount: Number(r.draft_count ?? 0),
cardCount: Number(r.card_count ?? 0),
...(iso(r.started_at) === undefined ? {} : { startedAt: iso(r.started_at) }),
archivedAt: iso(r.archived_at) ?? new Date(0).toISOString(),
};
}
/** 一单任务的全部运行痕 —— 拔试跑单要拔干净:留在别人工作台上的卡最讨嫌 */
async function destroyTask(container: Container, taskId: string): Promise<void> {
for (const table of [
'work_order',
'chat_card',
'chat_message',
'task_memory',
'task_memory_seal',
'task_view',
'flow_state',
'loop_state',
'action_intent',
'listener',
'tool_handoff',
]) {
await container.db.execute(sql`delete from sys.${sql.raw(table)} where task_id = ${taskId}`);
}
// 草稿层是任务级的:试跑写下的草稿不该留在库里冒充别单的读己之写
await container.db.execute(sql`delete from ledger.__draft where task_id = ${taskId}`);
await container.db.execute(sql`delete from sys.trace_span where task_id = ${taskId}`);
await container.db.execute(sql`delete from sys.task where id = ${taskId}`);
}
/**
* 拔掉**单活动试跑**的残留 —— 认的是 `origin = 'testbed'` 且不挂工作流的那些单。
* 不挂工作流是关键:挂着的那批归 `destroySimulations` 按流拔,两边不重不漏。
*/
async function destroyActivityRuns(container: Container, taskId?: string): Promise<number> {
const scope = taskId === undefined || taskId === '' ? sql`` : sql` and id = ${taskId}`;
const rows = await container.db.execute<{ id: string }>(
sql`select id from sys.task where origin = 'testbed' and workflow_id is null${scope}`,
);
const ids = rowsOf<{ id: string }>(rows).map((r) => r.id);
for (const id of ids) {
await archiveRun(container, { kind: 'activity', taskIds: [id] });
await destroyTask(container, id);
}
return ids.length;
}
async function destroySimulations(container: Container, workflowId?: string): Promise<number> {
const scope = workflowId === undefined ? sql`` : sql` and id = ${workflowId}`;
const scope = workflowId === undefined || workflowId === '' ? sql`` : sql` and id = ${workflowId}`;
const wfs = await container.db.execute<{ id: string }>(
sql`select id from sys.workflow where source->>'ref' = 'testbed'${scope}`,
);
@@ -91,6 +272,18 @@ async function destroySimulations(container: Container, workflowId?: string): Pr
const tasks = await container.db.execute<{ id: string }>(sql`select id from sys.task where workflow_id = ${id}`);
const taskIds = rowsOf<{ id: string }>(tasks).map((r) => r.id);
taskCount += taskIds.length;
// 一次试跑 = 一条账:名下节点任务合成一条,先归档再拔
const head = rowsOf<{ subject: string; package_id: string; state: string }>(
await container.db.execute<{ subject: string; package_id: string; state: string }>(
sql`select subject, package_id, state from sys.workflow where id = ${id}`,
),
)[0];
await archiveRun(container, {
kind: 'package',
taskIds,
workflowId: id,
...(head === undefined ? {} : { subject: head.subject, target: head.package_id, terminal: head.state }),
});
for (const taskId of taskIds) {
await container.db.execute(sql`delete from sys.work_order where task_id = ${taskId}`);
await container.db.execute(sql`delete from sys.chat_card where task_id = ${taskId}`);
@@ -292,7 +485,14 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
if (denied) return c.json(denied, denyStatus(denied));
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
return c.json({
log: lines.map((l) => ({ id: l.id, at: l.at, sideLabel: ATTRIBUTION_SIDE_LABEL[l.side], title: l.title })),
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
log: (await displayLog(lines, container.org)).map((l) => ({
id: l.id,
at: l.at,
sideLabel: l.sideLabel,
action: l.action,
title: l.title,
})),
});
});
@@ -371,6 +571,13 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
data,
...(Object.keys(assignments).length === 0 ? {} : { assignments }),
});
/*
* 起单要说一声 —— 试跑起出来的是**真任务**:它这一刻就落到了负责人的工作台上、
* 进了运营台的在途口径。不广播,那两处要等到下一次自己重取才看得见
* (而工作台没有轮询,等于不刷新)。`system` 一并发:测试台自己的当前态也变了。
*/
container.broadcaster.publish({ kind: 'workflow', id: record.id, reason: 'testbed-start' });
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
return c.json({ workflowId: record.id, state: record.state });
} catch (error) {
// 起不来的原因要如实说(不合调用接口 / 无生效版 / 岗位空缺),别让人对着空白猜
@@ -391,19 +598,272 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
return c.json(body, 400);
}
const tasks = await destroySimulations(container, parsed.data.workflowId);
return c.json({ destroyed: true, tasks });
const { workflowId, taskId } = parsed.data;
/*
* 两种试法各有其拔法。都不给 = 拔掉所有试跑残留(换一种试法之前先清场):
* 一次只跑一条,不然读数屏上是哪一单的数就说不清了。
*/
const byFlow = taskId === '' ? await destroySimulations(container, workflowId) : 0;
const byTask = workflowId === '' ? await destroyActivityRuns(container, taskId) : 0;
/*
* **销毁尤其要说一声**:被拔掉的任务此刻还挂在负责人的工作台列表上、还占着运营台的
* 在途口径。不广播,那些面上会留着一张点进去就 404 的鬼卡片,直到人自己刷新。
* 用 `system`:销毁是跨面的全局变更(任务没了、工作流没了、trace 也没了),
* 按单个 id 广播反而说不清 —— 说没就是全没了。
*/
if (byFlow + byTask > 0 || workflowId !== '' || taskId !== '') {
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-destroyed' });
}
return c.json({ destroyed: true, tasks: byFlow + byTask });
});
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
app.get(testCurrentRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<{ id: string; subject: string }>(
sql`select id, subject from sys.workflow where source->>'ref' = 'testbed' order by started_at desc limit 1`,
/*
* **在跑与跑完要分得开**(`live`)。
*
* 从前只答「最近一条试跑单是哪个」,不问它有没有收尾 —— 于是跑完之后
* 这台仪器还认为自己被占着:旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。
* 现在照旧把最近那一单交出去(观测面要摆结果给人看),但如实说明它还在不在跑。
*/
const flows = await container.db.execute<{ id: string; subject: string; state: string }>(
sql`select id, subject, state from sys.workflow
where source->>'ref' = 'testbed' order by started_at desc limit 1`,
);
const hit = rowsOf<{ id: string; subject: string }>(rows)[0];
return c.json(hit === undefined ? {} : { workflowId: hit.id, subject: hit.subject });
const flow = rowsOf<{ id: string; subject: string; state: string }>(flows)[0];
if (flow !== undefined) {
const liveFlow = flow.state === 'running' || flow.state === 'waiting';
return c.json({
workflowId: flow.id,
subject: flow.subject,
live: liveFlow,
...(liveFlow ? {} : { terminal: flow.state }),
});
}
// 没有试跑的流,再看单活动试跑(它不挂工作流,认的是 origin)
const solo = await container.db.execute<{ id: string; activity_id: string; title: string; terminal: string | null }>(
sql`select id, activity_id, title, terminal from sys.task
where origin = 'testbed' and workflow_id is null order by started_at desc limit 1`,
);
const one = rowsOf<{ id: string; activity_id: string; title: string; terminal: string | null }>(solo)[0];
if (one === undefined) return c.json({ live: false });
return c.json({
taskId: one.id,
activityId: one.activity_id,
subject: one.title,
live: one.terminal === null,
...(one.terminal === null ? {} : { terminal: one.terminal }),
});
});
/**
* 仿真测试台 · 试跑**一个活动** —— 起一单不挂工作流的真任务。
*
* 真任务:照旧过装载两审、Harness 预算、案例模型推进、放行门。
* 「试跑不等于假跑」—— 假跑试不出任何东西;它与生产单的区别只有一个
* `origin='testbed'`,好让人随时连根拔掉。
*/
app.post(testRunActivityRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = testRunActivityRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
return c.json(body, 400);
}
const me = await container.org.findByAccount(c.get('account'));
const { activityId, data, message, ownerId } = parsed.data;
/*
* 派给谁:显式指定优先,否则**派给操作者自己**。
* 绝不默认派到别人工作台上 —— 试跑单是自己要看的,不是给同事添的活。
*/
const owner = ownerId !== '' ? ownerId : me?.id;
if (owner === undefined) {
const body: ApiError = { code: 'validation-failed', message: '定不了负责人:既没指派,也认不出操作者' };
return c.json(body, 400);
}
try {
// 一次只跑一条:先清场(流与单活动两种残留都拔)
await destroySimulations(container);
await destroyActivityRuns(container);
const manifest = await container.registry.manifestOf(activityId);
const task = await container.task.start({
activityId,
ownerId: staffIdSchema.parse(owner),
title: manifest.identity.name,
origin: 'testbed',
data,
...(message === '' ? {} : { message }),
});
container.broadcaster.publish({ kind: 'task', id: task.id, reason: 'testbed-start' });
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
return c.json({ taskId: task.id, title: task.title });
} catch (error) {
// 起不来的原因要如实说(活动未装 / 入参不合契约 / 模型未绑),别让人对着空白猜
const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 400);
}
});
/**
* 单活动试跑的观测面 —— 一屏看穿这一单。
*
* 流程包那侧看 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**:
* 每一拍干了什么、拿回了什么、被拒的理由、成果落到台账哪一行、卡问了谁什么。
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
*/
app.get(testActivityStateRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const taskId = taskIdSchema.parse(c.req.param('taskId'));
const task = await container.task.get(taskId);
const memory = await container.memory.window(taskId, 200);
const drafts = await container.ledger.taskDrafts(taskId);
const orders = await container.humanTool.allWorkOrders(taskId);
return c.json({
taskId: task.id,
activityId: task.activityId,
title: task.title,
runtime: task.runtime,
...(task.terminal === undefined ? {} : { terminal: task.terminal }),
...(task.reason === undefined ? {} : { reason: task.reason }),
used: { steps: task.used.steps, tokens: task.used.tokens, humanCalls: task.used.humanCalls },
caps: { steps: task.caps.steps, tokens: task.caps.tokens, humanCalls: task.caps.humanCalls },
trail: memory.map((m) => ({
at: m.at,
kind: m.kind,
title: m.title,
...(m.detail === undefined ? {} : { detail: m.detail }),
})),
drafts: drafts.map((d) => ({ address: `${d.domain}.${d.object}`, key: d.key, patch: d.patch })),
cards: orders.map((o) => ({
id: o.id,
mode: o.mode,
title: o.title,
done: o.done !== undefined,
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
})),
});
});
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
const UPLOAD_CAP = 8 * 1024 * 1024;
app.post(uploadFileRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
return c.json(body, 400);
}
const { name, contentType, dataBase64 } = parsed.data;
const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64'));
if (bytes.length === 0) {
const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` };
return c.json(body, 400);
}
if (bytes.length > UPLOAD_CAP) {
const body: ApiError = {
code: 'validation-failed',
message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`,
};
return c.json(body, 400);
}
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
const got = extractText(name, contentType, bytes);
const me = await container.org.findByAccount(c.get('account'));
const ref = crypto.randomUUID();
await container.db.execute(sql`
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
values (${ref}, ${name}, ${contentType}, ${bytes.length}, ${Buffer.from(bytes)},
${got.text ?? null}, ${got.note}, ${me?.id ?? null})`);
return c.json({
ref,
name,
contentType,
size: bytes.length,
...(got.text === undefined ? {} : { text: got.text }),
note: got.note,
});
});
app.get(downloadFileRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<{ name: string; content_type: string; bytes: Buffer }>(
sql`select name, content_type, bytes from sys.upload where id = ${c.req.param('id')}`,
);
const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0];
if (hit === undefined) {
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
return c.json(body, 404);
}
return c.json({
name: hit.name,
contentType: hit.content_type,
dataBase64: Buffer.from(hit.bytes).toString('base64'),
});
});
/* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */
/** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */
app.get(listTestLogsRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<TestLogDbRow>(sql`
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
jsonb_array_length(trail) as trail_count,
jsonb_array_length(drafts) as draft_count,
jsonb_array_length(cards) as card_count,
started_at, archived_at
from sys.testbed_run order by archived_at desc limit 200`);
return c.json({ runs: rowsOf<TestLogDbRow>(rows).map(toTestLogRow) });
});
app.get(testLogDetailRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(sql`
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
jsonb_array_length(trail) as trail_count,
jsonb_array_length(drafts) as draft_count,
jsonb_array_length(cards) as card_count,
trail, drafts, cards, started_at, archived_at
from sys.testbed_run where id = ${c.req.param('id')}`);
const hit = rowsOf<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(rows)[0];
if (hit === undefined) {
const body: ApiError = { code: 'not-found', message: '这条试跑日志不存在(可能已被清掉)' };
return c.json(body, 404);
}
return c.json({ ...toTestLogRow(hit), trail: hit.trail ?? [], drafts: hit.drafts ?? [], cards: hit.cards ?? [] });
});
app.delete(deleteTestLogRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<{ id: string }>(
sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`,
);
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
});
app.delete(clearTestLogsRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<{ id: string }>(sql`delete from sys.testbed_run returning id`);
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
});
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
@@ -786,7 +1246,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
const faces = await Promise.all(
cards.map(async (card) => {
const manifest = await container.registry.manifestOf(card.activityId);
type FieldSpec = { type?: string; items?: { type?: string } };
type FieldSpec = { type?: string; items?: { type?: string }; title?: string; format?: string };
const data = manifest.contract.input.data as {
required?: string[];
properties?: Record<string, FieldSpec>;
@@ -806,7 +1266,13 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
name,
type: typeOf(spec),
required: required.includes(name),
// 清单里写了中文名就用中文名 —— 表单上摆 `customerEmail` 是给机器看的
...(spec?.title === undefined ? {} : { label: spec.title }),
// `format: 'file'` = 这一格收的是**真文件**,界面该给文件选择器而不是文本框
...(spec?.format === undefined ? {} : { format: spec.format }),
})),
// 包自带的样例集:试跑一个活动时照它预填(对着空表单编,编出来的多半跑不通)
samples: manifest.samples.map((sample) => ({ name: sample.name, input: sample.input })),
// 必发口与选择组一并列出:调用方只凭口名与 schema 就能衔接下游
outputPorts: [
...manifest.contract.output.mandatory,
@@ -897,6 +1363,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
app.get(listLedgerObjectsRoute.path, async (c) => {
const denied = await readData(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
/*
* 性质随 `listObjects` **一次查询带回**(0026_object_meta)——
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
* 拉该活动全部版本的清单加产物再整份 zod parse,光 quote-intake 就 798kB、26 版,
* 一次请求约 1.5MB JSON。列表口不该做这种事。
*/
return c.json({ objects: await container.ledger.listObjects() });
});