Archived
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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:
+781
-12
@@ -3,18 +3,20 @@
|
||||
*
|
||||
* 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, ViewBlock } from '@contracts';
|
||||
import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts';
|
||||
import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
answerMessageRoute,
|
||||
chatCardReplySchema,
|
||||
confirmTriggerPendingRoute,
|
||||
listMessagesRoute,
|
||||
markMessageReadRoute,
|
||||
listTasksRoute,
|
||||
markCommReadRoute,
|
||||
replyChatCardRoute,
|
||||
replyWorkOrderRoute,
|
||||
saveTableRoute,
|
||||
sayRoute,
|
||||
taskDetailRoute,
|
||||
staffIdSchema,
|
||||
@@ -23,8 +25,525 @@ import {
|
||||
} from '@contracts';
|
||||
import { ReplyRejectedError } from '@modules/human-tool';
|
||||
import { advanceOrder } from '@modules/process';
|
||||
import { displayLog } from './log-lines.ts';
|
||||
import { pulse } from '../workers/pulse.ts';
|
||||
import type { Container } from '../container.ts';
|
||||
|
||||
/** drizzle 的 execute 出参在不同驱动下形状不一 —— 收在一处判 */
|
||||
function rowsOf<T>(result: unknown): T[] {
|
||||
if (Array.isArray(result)) return result as T[];
|
||||
const rows = (result as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { coerceToColumn, missingReadings } from '../wiring/engines.ts';
|
||||
|
||||
|
||||
/** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */
|
||||
type EditedRow = Readonly<Record<string, string>>;
|
||||
|
||||
/** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */
|
||||
type WriteBack = { rows: number; added: number; dropped: string[]; bad: string[] };
|
||||
|
||||
const nothing = (): WriteBack => ({ rows: 0, added: 0, dropped: [], bad: [] });
|
||||
const merge = (into: WriteBack, one: WriteBack): void => {
|
||||
into.rows += one.rows;
|
||||
into.added += one.added;
|
||||
into.dropped.push(...one.dropped);
|
||||
into.bad.push(...one.bad);
|
||||
};
|
||||
|
||||
/**
|
||||
* **把人改过的那几行落进本单草稿层** —— 两条路(件上按保存 / 卡上按提交)共用这一段。
|
||||
*
|
||||
* 主键列取台账声明的主键 —— 人改主键那一格等于换了一行,所以**主键不参与写回**:
|
||||
* 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。
|
||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||
*/
|
||||
async function writeEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean }> },
|
||||
lines: readonly EditedRow[],
|
||||
): Promise<WriteBack> {
|
||||
const { domain, object } = declared;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const out = nothing();
|
||||
// 这一单现有的行 —— 三处要用:补主键(件上没摆全的那几列)、认出哪几行真改了、认出哪几行是新增
|
||||
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
||||
/*
|
||||
* **件上压根没摆的那几列主键**(A1 的 `rfq_ref` 就是:一单一个单号,不占版面)——
|
||||
* 本单现有行里那一列若全表一致,就是这一单的那个值,新增行照它填。
|
||||
* 不一致(一单跨了两个单号)就取不出来:那时新增行认不出自己是谁,如实跳过,不猜。
|
||||
*/
|
||||
const constant = (column: string): string | undefined => {
|
||||
const seen = new Set(drafts.map((d) => String(d.key[column] ?? '')));
|
||||
const only = [...seen][0];
|
||||
return seen.size === 1 && only !== undefined && only !== '' ? only : undefined;
|
||||
};
|
||||
|
||||
for (const cells of lines) {
|
||||
/*
|
||||
* 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行,
|
||||
* 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。
|
||||
* 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。
|
||||
*/
|
||||
const key: Record<string, string> = {};
|
||||
let complete = true;
|
||||
for (const k of keys) {
|
||||
const v = cells[k];
|
||||
if (v === undefined || v.trim() === '') complete = false;
|
||||
else key[k] = v;
|
||||
}
|
||||
/*
|
||||
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
|
||||
*
|
||||
* 不过这道门的代价实测过:夹具往「目标价」那一格填了「客户已确认」,
|
||||
* 基座原样落进草稿,到 `releaseWrite` 才被 PG 掀桌
|
||||
* (`invalid input syntax for type double precision`)—— 而那是**人签完字之后**,
|
||||
* 整单落 `blocked`。最坏的失败点就是这里:前面全对,最后一步炸。
|
||||
* 归得了就归、空值不写、归不了**跳过那一格**(别的格照落):
|
||||
* 人填错一格不该把他改对的另外八格一起丢掉。
|
||||
*/
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [name, value] of Object.entries(cells)) {
|
||||
if (keys.includes(name)) continue;
|
||||
const type = declared.fields.find((f) => f.name === name)?.type ?? 'text';
|
||||
const coerced = coerceToColumn(value, type);
|
||||
if (coerced.kind === 'value') patch[name] = coerced.value;
|
||||
else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`);
|
||||
}
|
||||
if (!complete) {
|
||||
/*
|
||||
* 主键没摆全 —— 两种情形,先按行认,认不出再按列补:
|
||||
* · 老行:件上给了 PN,单号那一列没摆 → 拿 PN 去本单现有行里找,取它的单号;
|
||||
* · **新增行**:PN 是新的,现有行里一个都对不上 → 那几列取全表一致的那个值。
|
||||
* 少了后一半,人在件上加的行会**静默丢掉**:主键凑不齐,这里一 continue 就没了,
|
||||
* 而屏上那一行还在 —— 界面说加上了,台账里没有。
|
||||
*/
|
||||
const known = drafts.find((d) => keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]));
|
||||
for (const k of keys) {
|
||||
if (key[k] !== undefined) continue;
|
||||
const v = known === undefined ? constant(k) : String(known.key[k] ?? '');
|
||||
if (v !== undefined && v !== '') key[k] = v;
|
||||
}
|
||||
}
|
||||
if (Object.keys(key).length !== keys.length) continue;
|
||||
const was = drafts.find((d) => keys.every((k) => String(d.key[k] ?? '') === key[k]));
|
||||
if (!rowChanged(was === undefined ? undefined : { ...was.key, ...was.patch }, patch)) continue;
|
||||
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
|
||||
if (was === undefined) out.added += 1;
|
||||
else out.rows += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **把人在件上删掉的那几行撤出本单草稿层**。
|
||||
*
|
||||
* 撤的只是这一单还没过门的那一笔(`draftDrop`):正式版一个字都动不了,也不抛事件 ——
|
||||
* 与 Agent 的 `drop` 同一条路子。人手删行是**兜 AI 的错**的一手:
|
||||
* 客户点名本轮跳过的料被写进了有效清单,只给他改格子是改不掉的
|
||||
* (改数又与另一个数打架 —— 实测那一单 13 次拒回、234k token 才熔断)。
|
||||
*
|
||||
* 主键摆不全的一律不撤:**宁可少撤一行,不要撤错一行**。
|
||||
*/
|
||||
async function dropEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> },
|
||||
lines: readonly EditedRow[],
|
||||
): Promise<WriteBack> {
|
||||
const { domain, object } = declared;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const out = nothing();
|
||||
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
||||
|
||||
for (const cells of lines) {
|
||||
// 按件上给出的那几列主键找本单的行 —— 找不到(或不止一行)就不动
|
||||
const hit = drafts.filter((d) =>
|
||||
keys.every((k) => cells[k] === undefined || cells[k]?.trim() === '' || String(d.key[k] ?? '') === cells[k]),
|
||||
);
|
||||
const one = hit.length === 1 ? hit[0] : undefined;
|
||||
if (one === undefined) continue;
|
||||
const gone = await container.ledger.draftDrop({ taskId, address: { domain, object }, key: one.key });
|
||||
if (gone) out.dropped.push(Object.values(one.key).map(String).join('/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。
|
||||
*
|
||||
* 照单全写会留一条「就地修订成果:4 行」的痕,而人只改了一格 —— 日志是给人看的,
|
||||
* 那句话就成了假的(用户实测点出来的:改一格,日志说改了四行)。
|
||||
* 比的是**摊平后的整行**:主键在里面也无妨(它按定义不参与改行)。
|
||||
* 值一律转字符串再比 —— 台账里躺的是数字 30000,人手填回来的是 "30000",
|
||||
* 归型之后两者相等,可类型不同;拿类型去比会把「没改」判成「改了」。
|
||||
*/
|
||||
export function rowChanged(
|
||||
existing: Readonly<Record<string, unknown>> | undefined,
|
||||
patch: Readonly<Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (existing === undefined) return true; // 台账里还没有这一行 —— 那是新增,当然算改
|
||||
return Object.entries(patch).some(([k, v]) => String(existing[k] ?? '') !== String(v ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
|
||||
*
|
||||
* 声明里标了 `auto` 的主键列不进卡面(见 `entryGroup`),值在写入这一刻补上:
|
||||
* **取同组其余主键的值作前缀,后面跟两位序号** —— `CU-9001-01`、`-02`……
|
||||
* 序号数的是同组已有的行(正式版 + 本单草稿),同一单里加两位联系人也不撞。
|
||||
* 没有其余主键(它自己就是唯一那把键)时退回纯序号。
|
||||
*
|
||||
* 为什么不让人填:他哪知道这家客户已经有几位联系人。让模型编更糟 ——
|
||||
* 编重了就是覆盖别人那一行,而主数据覆盖错了后面每一单都跟着错。
|
||||
*/
|
||||
async function autoKeys(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: LedgerDeclaration | undefined,
|
||||
given: Readonly<Record<string, string>>,
|
||||
): Promise<Record<string, string>> {
|
||||
const auto = (declared?.fields ?? []).filter((f) => f.primaryKey && f.auto === true).map((f) => f.name);
|
||||
if (declared === undefined || auto.length === 0) return {};
|
||||
const others = declared.fields.filter((f) => f.primaryKey && f.auto !== true).map((f) => f.name);
|
||||
const prefix = others
|
||||
.map((k) => given[k] ?? '')
|
||||
.filter((v) => v !== '')
|
||||
.join('-');
|
||||
/*
|
||||
* 同组已有几行 —— 正式版与本单草稿都要数。只数正式版的话,
|
||||
* 同一单里连加两位联系人会拿到同一个号,后一位把前一位覆盖掉。
|
||||
*/
|
||||
const page = await container.ledger
|
||||
.taskView(taskId, {
|
||||
address: { domain: declared.domain, object: declared.object },
|
||||
where: {},
|
||||
orderBy: [],
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
})
|
||||
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
||||
const same = page.rows.filter((r) => others.every((k) => String(r[k] ?? '') === (given[k] ?? '')));
|
||||
const out: Record<string, string> = {};
|
||||
for (const [i, name] of auto.entries()) {
|
||||
const seq = String(same.length + 1 + i).padStart(2, '0');
|
||||
out[name] = prefix === '' ? seq : `${prefix}-${seq}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **办结之后:这一签下去,世界上发生了什么**(2026-08-02 用户定)。
|
||||
*
|
||||
* 放行卡上摆的是签字前要核的东西(判据);签完之后人关心的换成了另一件事 ——
|
||||
* 落了几行、进了哪几张表、转去哪儿了。从前两件事共用一屏判据表,
|
||||
* 签完再回看,那一摞对勾说的还是签之前的话。
|
||||
*
|
||||
* 事实取自放行那一刻的**结构化留痕**(`ledger-write` / `release-verdict` 的 detail),
|
||||
* 不是把日志那句话再解析一遍 —— 措辞一改解析就失灵,而没人会发现。
|
||||
*/
|
||||
async function landingOf(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
activityId: string,
|
||||
): Promise<
|
||||
| { rows: number; simulated: number; tables: Array<{ label: string; rows: number }>; ports: string[]; carried: string[] }
|
||||
| undefined
|
||||
> {
|
||||
const entries = await container.memory.history(taskId);
|
||||
const wrote = entries.filter((e) => e.kind === 'ledger-write' && e.detail?.['tables'] !== undefined).at(-1);
|
||||
const fired = entries.filter((e) => e.kind === 'release-verdict' && e.detail?.['ports'] !== undefined).at(-1);
|
||||
if (wrote === undefined && fired === undefined) return undefined;
|
||||
|
||||
const objects = await container.ledger.listObjects().catch(() => []);
|
||||
const manifest = await container.registry.manifestOf(activityId).catch(() => undefined);
|
||||
/*
|
||||
* **按交付物点名,不按表点名**(2026-08-02 用户当场指出:「料号分类表和受理结论
|
||||
* 不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)。
|
||||
*
|
||||
* 一份成果常常不止一张表:A1 交出的是一份询价受理记录,而它由受理记录、
|
||||
* 料号分类表、受理结论三张表承载 —— 人拿到的是**一份**,不是三份。
|
||||
* 清单里 `ledgers` 说了哪几张表归哪份成果,这里照它并;
|
||||
* 不归任何一份成果的(客户档案这类主数据)按自己的表名摆。
|
||||
*/
|
||||
const owner = new Map<string, string>();
|
||||
for (const d of manifest?.contract.requiredDeliverables ?? []) {
|
||||
for (const at of d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger])) owner.set(at, d.label);
|
||||
}
|
||||
const merged = new Map<string, number>();
|
||||
for (const raw of Array.isArray(wrote?.detail?.['tables']) ? (wrote.detail['tables'] as unknown[]) : []) {
|
||||
const t = (typeof raw === 'object' && raw !== null ? raw : {}) as { address?: unknown; rows?: unknown };
|
||||
const address = String(t.address ?? '');
|
||||
const [domain, object] = address.split('.');
|
||||
// 摆的是人读名:先看它归哪份成果,再退回表的登记名,最后才退回地址
|
||||
const label = owner.get(address) ?? objects.find((o) => o.domain === domain && o.object === object)?.label ?? address;
|
||||
if (label === '') continue;
|
||||
merged.set(label, (merged.get(label) ?? 0) + Number(t.rows ?? 0));
|
||||
}
|
||||
const tables = [...merged].map(([label, rows]) => ({ label, rows }));
|
||||
|
||||
/* 出口也摆人读名:契约里每个口都写了 `description`,那才是给人看的那一句 */
|
||||
const named = new Map<string, string>();
|
||||
for (const p of manifest?.contract.output.mandatory ?? []) named.set(p.name, p.description ?? p.name);
|
||||
for (const g of manifest?.contract.output.selective ?? []) {
|
||||
for (const p of g.ports) named.set(p.name, p.description ?? p.name);
|
||||
}
|
||||
const ports = (Array.isArray(fired?.detail?.['ports']) ? (fired.detail['ports'] as unknown[]) : [])
|
||||
.map((p) => named.get(String(p)) ?? String(p))
|
||||
.filter((p) => p !== '');
|
||||
/*
|
||||
* **带过去了什么**(2026-08-02 用户定:「流向下一个活动带了什么数据」)。
|
||||
*
|
||||
* 发射的载荷就是**已落地的那些事实**(见 `release-service` 的 `landedFacts`),
|
||||
* 摆的是列的中文名 —— 下游活动读到的正是这几个字段。
|
||||
* 只摆名字不摆值:值在成果件里,这一屏答的是「带了哪些东西过去」。
|
||||
*/
|
||||
const colLabel = new Map<string, string>();
|
||||
for (const d of manifest?.declarations.ledger ?? []) {
|
||||
for (const f of d.fields) if (f.label !== undefined && f.label !== '') colLabel.set(f.name, f.label);
|
||||
}
|
||||
const carried = (Array.isArray(fired?.detail?.['carried']) ? (fired.detail['carried'] as unknown[]) : [])
|
||||
.map((k) => colLabel.get(String(k)) ?? String(k))
|
||||
.filter((k) => k !== '');
|
||||
|
||||
return {
|
||||
rows: Number(wrote?.detail?.['rows'] ?? 0),
|
||||
simulated: Number(wrote?.detail?.['simulated'] ?? 0),
|
||||
tables,
|
||||
ports,
|
||||
carried,
|
||||
};
|
||||
}
|
||||
|
||||
/** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */
|
||||
async function declarationsOf(container: Container, taskId: TaskId) {
|
||||
const task = await container.task.get(taskId).catch(() => undefined);
|
||||
if (task === undefined) return undefined;
|
||||
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
||||
return manifest?.declarations.ledger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。
|
||||
*
|
||||
* **撤掉的那几行要点名**:那一条不只是给人看的账,也是**下一拍模型的行动指南** ——
|
||||
* 它看不见这一屏,只看得见轨迹。不说清「这一行是人撤的」,它会照着
|
||||
* 「条目数 9 而表里 8 行」判成「少写了一行」,转头把人刚删掉的那行写回去。
|
||||
*/
|
||||
async function noteEdits(container: Container, taskId: TaskId, got: WriteBack): Promise<number> {
|
||||
const touched = got.rows + got.added + got.dropped.length;
|
||||
if (touched === 0 && got.bad.length === 0) return touched;
|
||||
const parts: string[] = [];
|
||||
if (got.rows > 0) parts.push(`改了 ${got.rows} 行`);
|
||||
if (got.added > 0) parts.push(`新增 ${got.added} 行`);
|
||||
if (got.dropped.length > 0) parts.push(`撤掉 ${got.dropped.length} 行(${got.dropped.slice(0, 5).join('、')})`);
|
||||
if (got.bad.length > 0) parts.push(`${got.bad.length} 格填的值与列型对不上,已跳过(${got.bad.slice(0, 3).join('、')})`);
|
||||
await container.memory.append({
|
||||
taskId,
|
||||
kind: 'human-input',
|
||||
title:
|
||||
`就地修订成果:${parts.join(';')}` +
|
||||
(got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对'),
|
||||
detail: {
|
||||
rows: got.rows,
|
||||
...(got.added === 0 ? {} : { added: got.added }),
|
||||
...(got.dropped.length === 0 ? {} : { dropped: got.dropped }),
|
||||
...(got.bad.length === 0 ? {} : { rejected: got.bad }),
|
||||
},
|
||||
// 版本戳:这条不由活动产出,与引擎那几条同一口径
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
});
|
||||
return touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* **件上按「保存」** —— 按**地址**写回,不经过「当下那张卡的第几块」(2026-08-02)。
|
||||
*
|
||||
* 件里每张可改的表挂着 `data-edit="域.对象"`、每个格子挂着 `data-col="台账列名"`,
|
||||
* 地址与列名本来就在件里 —— 前端照原样收上来即可。
|
||||
*
|
||||
* 从前这条路借道工单的 `blocks[i]`:列名由那张卡给出,于是**「这份件能不能改」
|
||||
* 被绑在了「此刻开着的那张卡长什么样」上**。实测的下场:一单在放行门连拒五拍熔断,
|
||||
* 引擎发的是一张没有 `blocks` 的熔断卡,而屏上摆着的正是受理记录那份件 ——
|
||||
* 闸在、编辑进得去、保存按得下,前端却找不到匹配的块,**一格都没往下送**。
|
||||
* 人要补的恰恰是把这一单卡在那儿的那个必填格,而屏上什么反应都没有。
|
||||
*
|
||||
* 授权面改由**活动清单的台账声明**把关:不在声明里的地址一概不写 ——
|
||||
* 比「当下那张卡碰巧带了哪一块」既严格又稳定。
|
||||
*/
|
||||
async function applyViewTableEdits(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
payload: {
|
||||
tables: Readonly<Record<string, readonly EditedRow[]>>;
|
||||
drops?: Readonly<Record<string, readonly EditedRow[]>> | undefined;
|
||||
},
|
||||
): Promise<number> {
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return 0;
|
||||
const got = nothing();
|
||||
/*
|
||||
* **先撤后写** —— 顺序有意义:人可能把一行删掉、又在同一次保存里加回一行同主键的。
|
||||
* 反过来做的话,新写的那一行会被紧接着的撤掉抹平,屏上有而台账里没有。
|
||||
*/
|
||||
for (const { declared, lines } of writesOf(declarations, payload.drops ?? {})) {
|
||||
merge(got, await dropEditedRows(container, taskId, declared, lines));
|
||||
}
|
||||
for (const { declared, lines } of writesOf(declarations, payload.tables)) {
|
||||
merge(got, await writeEditedRows(container, taskId, declared, lines));
|
||||
}
|
||||
return noteEdits(container, taskId, got);
|
||||
}
|
||||
|
||||
/** 台账声明的最小形状 —— 这一段只用到这几样 */
|
||||
type LedgerDeclaration = {
|
||||
domain: string;
|
||||
object: string;
|
||||
fields: ReadonlyArray<{
|
||||
name: string;
|
||||
type: string;
|
||||
primaryKey: boolean;
|
||||
label?: string | undefined;
|
||||
required?: boolean | undefined;
|
||||
/** 基座生成的内部流水号 —— 不问人、不让模型编(见 `autoKeys`) */
|
||||
auto?: boolean | undefined;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* **哪几张表准写** —— 授权面是活动清单的台账声明,不是当下那张卡带了哪一块。
|
||||
*
|
||||
* 地址对不上号(不是「域.对象」)、活动没声明过的地址,一概不写:
|
||||
* 载荷是从浏览器来的,门得在这儿,不能靠件里写着什么就信什么。
|
||||
*/
|
||||
export function writesOf(
|
||||
declarations: readonly LedgerDeclaration[],
|
||||
tables: Readonly<Record<string, readonly EditedRow[]>>,
|
||||
): Array<{ declared: LedgerDeclaration; lines: readonly EditedRow[] }> {
|
||||
const out: Array<{ declared: LedgerDeclaration; lines: readonly EditedRow[] }> = [];
|
||||
for (const [address, lines] of Object.entries(tables)) {
|
||||
const [domain, object, extra] = address.split('.');
|
||||
if (domain === undefined || object === undefined || extra !== undefined) continue;
|
||||
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
||||
if (declared === undefined) continue; // 活动没声明这张表 —— 不是它的地盘,不写
|
||||
out.push({ declared, lines });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **卡上按「提交」时带着的那张表** —— 回传里第一行是列名,其后一行一条。
|
||||
*
|
||||
* 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。
|
||||
* 从前这张表的回传只进 AI 的轨迹、由它自己 write 回去 —— 中间隔一道,
|
||||
* 它可以抄错、可以顺手改一笔(实测:人填 30000 它写回 30;人留空表示「客户不给」,
|
||||
* 它填了隔壁那条料的价)。验收页上更要紧:那一改本来就是**人对成果的修订**,
|
||||
* 过一遍 AI 就不叫修订了。
|
||||
*
|
||||
* 只认**绑了 `ledger` + `fields`** 的表:这条路上列名确实由卡给出(卡上摆的是
|
||||
* `ViewBlocks`,格子里没有 `data-col`),所以位置对应仍留在这儿。
|
||||
*/
|
||||
async function applyBoundTableEdits(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
order: WorkOrder,
|
||||
reply: { values: Readonly<Record<string, string>> },
|
||||
): Promise<number> {
|
||||
const blocks = (order as { blocks?: ReadonlyArray<Record<string, unknown>> }).blocks;
|
||||
if (blocks === undefined || blocks.length === 0) return 0;
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return 0;
|
||||
|
||||
const got = nothing();
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
if (block['t'] !== 'table' || typeof block['ledger'] !== 'string' || !Array.isArray(block['fields'])) continue;
|
||||
const raw = reply.values[`table:${index}`];
|
||||
if (raw === undefined || raw.trim() === '') continue;
|
||||
let table: unknown;
|
||||
try {
|
||||
table = JSON.parse(raw);
|
||||
} catch {
|
||||
continue; // 解析不了就当没改 —— 宁可少写一次,不要把一份坏数据落进草稿
|
||||
}
|
||||
if (!Array.isArray(table) || table.length < 2) continue;
|
||||
const [domain, object] = String(block['ledger']).split('.');
|
||||
if (domain === undefined || object === undefined) continue;
|
||||
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
||||
if (declared === undefined) continue;
|
||||
const fields = (block['fields'] as unknown[]).map((f) => String(f));
|
||||
|
||||
const lines: EditedRow[] = [];
|
||||
for (const line of (table as unknown[]).slice(1)) {
|
||||
if (!Array.isArray(line)) continue;
|
||||
const cells: Record<string, string> = {};
|
||||
fields.forEach((f, i) => {
|
||||
cells[f] = String((line as unknown[])[i] ?? '');
|
||||
});
|
||||
lines.push(cells);
|
||||
}
|
||||
merge(got, await writeEditedRows(container, taskId, declared, lines));
|
||||
}
|
||||
return noteEdits(container, taskId, got);
|
||||
}
|
||||
|
||||
/**
|
||||
* **提交闸:必填还空着就交不了**(2026-08-02 用户定)。
|
||||
*
|
||||
* 「必填」由**台账声明**说了算(`fields[].required`),缺几格由基座数(草稿层现读)——
|
||||
* 与放行门那条 L1 机检 `域_对象_missing = 0` **同一个数、同一套算法**
|
||||
* (都走 `missingReadings`)。同源是要紧的:两处各算各的,就会出现
|
||||
* 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。
|
||||
*
|
||||
* 只对**绑了台账表**的执行卡出闸:算得出才拦,算不出(卡上没绑表)就不给闸,
|
||||
* 按钮照常能按 —— **没有依据的时候不拦人**。
|
||||
*/
|
||||
async function submitGate(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
order: WorkOrder,
|
||||
): Promise<{ ready: boolean; why: string; blanks: string[] } | undefined> {
|
||||
if (order.mode !== 'execution' || order.done !== undefined) return undefined;
|
||||
const addresses = (order.blocks ?? [])
|
||||
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
||||
.map((b) => b.ledger as string);
|
||||
if (addresses.length === 0) return undefined;
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return undefined;
|
||||
const watched = declarations.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
|
||||
if (watched.length === 0) return undefined;
|
||||
|
||||
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
for (const draft of await container.ledger.taskDrafts(taskId)) {
|
||||
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
||||
}
|
||||
return gateOf(missingReadings(mine, watched), watched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。
|
||||
*
|
||||
* 一格不缺就是 `ready: true`(闸不出现);缺了就把「几条 / 哪几行」原样摆出来 ——
|
||||
* 只给一个按不下的灰按钮,人只能猜自己哪儿做错了。
|
||||
*/
|
||||
export function gateOf(
|
||||
readings: Readonly<Record<string, string | number>>,
|
||||
watched: ReadonlyArray<{ domain: string; object: string }>,
|
||||
): { ready: boolean; why: string; blanks: string[] } {
|
||||
let short = 0;
|
||||
const blanks: string[] = [];
|
||||
for (const { domain, object } of watched) {
|
||||
short += Number(readings[`${domain}_${object}_missing`] ?? 0);
|
||||
const keys = readings[`${domain}_${object}_missing_keys`];
|
||||
if (typeof keys === 'string' && keys !== '') blanks.push(...keys.split(';'));
|
||||
}
|
||||
return short === 0
|
||||
? { ready: true, why: '', blanks: [] }
|
||||
: { ready: false, why: `还有 ${short} 条必填没填 —— 在工作区的件上补齐才能提交`, blanks };
|
||||
}
|
||||
|
||||
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/** 当前登录者;缺会话即 401 */
|
||||
@@ -53,6 +572,13 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 阶段进度 —— 一行运行时读出来(阶段声明与计划项状态同在那一行)。
|
||||
* 每张卡一次读:任务清单是「我手上开着的单」,量级是个位数,与上面
|
||||
* 已有的按卡取工作流/流程包同一个形状。活动没声明阶段就没有这一格。
|
||||
*/
|
||||
const phase = await container.loop.phaseOf(task.id, task.terminal !== undefined).catch(() => undefined);
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
activity: task.title,
|
||||
@@ -61,6 +587,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
...(task.terminal === undefined ? {} : { terminal: task.terminal }),
|
||||
position,
|
||||
total,
|
||||
...(phase === undefined ? {} : { phase }),
|
||||
workflowSubject: subject,
|
||||
workflowState,
|
||||
startedAt: task.startedAt,
|
||||
@@ -149,6 +676,78 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
container.interaction.viewOf(taskId),
|
||||
]);
|
||||
const cards = await container.humanTool.chatCards(taskId);
|
||||
/*
|
||||
* **闸与判据都现算** —— 人在件上补一格、撤一行,按下保存,这一屏跟着重取,
|
||||
* 闸该跟着开、判据该跟着变。发卡那一刻算死是不行的:
|
||||
* 那时缺的还缺着,而他补的动作正是为了消掉它们。
|
||||
*
|
||||
* 验收卡那一摞判据从前是发卡时钉死的:人在成果件上把目标价填好,
|
||||
* 卡上照旧写着「缺目标价」—— 用户实测点出来的(「这个价格是我填写的,但是它判错了」)。
|
||||
* 现在与提交门读同一段(`criterionCheck`),两处不会各说各话。
|
||||
*/
|
||||
const live = workOrders.some((o) => o.mode === 'acceptance' && o.done === undefined)
|
||||
? await container.checks.of(taskId).catch(() => undefined)
|
||||
: undefined;
|
||||
const landed = workOrders.some((o) => o.mode === 'acceptance' && o.done !== undefined)
|
||||
? await landingOf(container, taskId, task.activityId).catch(() => undefined)
|
||||
: undefined;
|
||||
const gated = await Promise.all(
|
||||
workOrders.map(async (order) => {
|
||||
if (order.mode === 'acceptance' && order.done === undefined && live !== undefined) {
|
||||
// L5 照旧不上卡:那张卡本身就是那道关,列进去等于摆一条恒为「未达标」的项
|
||||
return { ...order, criteria: live.filter((c) => c.level !== 'L5') };
|
||||
}
|
||||
// 办结的验收卡:判据换成落地结果 —— 签完之后人关心的是「发生了什么」
|
||||
if (order.mode === 'acceptance' && order.done !== undefined && landed !== undefined) {
|
||||
return { ...order, landing: landed };
|
||||
}
|
||||
const gate = await submitGate(container, taskId, order);
|
||||
return gate === undefined ? order : { ...order, gate };
|
||||
}),
|
||||
);
|
||||
/*
|
||||
* 沟通记录 —— 一单往来的原件。附件那一格照抄基座读出来的 `note`(读出多少字 /
|
||||
* 为什么读不出),**不改写**:界面上说「已上传 ✓」而活动其实看不见里面,是撒谎。
|
||||
*/
|
||||
const commRows = rowsOf<{
|
||||
id: string;
|
||||
at: Date | string;
|
||||
direction: string;
|
||||
channel: string;
|
||||
party: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachments: unknown;
|
||||
note: string;
|
||||
read_at: unknown;
|
||||
}>(
|
||||
await container.db.execute(sql`
|
||||
select id, at, direction, channel, party, subject, body, attachments, note, read_at
|
||||
from sys.task_comm where task_id = ${taskId} order by at`),
|
||||
);
|
||||
const comms = commRows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.at instanceof Date ? r.at.toISOString() : String(r.at),
|
||||
direction: r.direction === 'out' ? ('out' as const) : ('in' as const),
|
||||
channel: r.channel,
|
||||
party: r.party,
|
||||
subject: r.subject,
|
||||
body: r.body,
|
||||
// read_at 为空 = 未读;只由人按按钮改,不随打开页签自动清
|
||||
unread: r.read_at === null || r.read_at === undefined,
|
||||
attachments: (Array.isArray(r.attachments) ? r.attachments : []).map((a) => {
|
||||
const one = (typeof a === 'object' && a !== null ? a : {}) as Record<string, unknown>;
|
||||
const text = one['text'];
|
||||
return {
|
||||
name: String(one['name'] ?? '未命名'),
|
||||
...(typeof one['size'] === 'number' ? { size: one['size'] } : {}),
|
||||
...(typeof one['ref'] === 'string' ? { ref: one['ref'] } : {}),
|
||||
...(typeof one['note'] === 'string' ? { note: one['note'] } : {}),
|
||||
readable: typeof text === 'string' && text !== '',
|
||||
};
|
||||
}),
|
||||
note: r.note,
|
||||
}));
|
||||
const chain = await toChain(task);
|
||||
|
||||
/*
|
||||
@@ -218,21 +817,60 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
stages: [],
|
||||
deliverables,
|
||||
results,
|
||||
workOrders,
|
||||
comms,
|
||||
workOrders: gated,
|
||||
chat: merged,
|
||||
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
|
||||
...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }),
|
||||
log: lines.map((l) => ({
|
||||
id: l.id,
|
||||
at: l.at,
|
||||
side: l.side,
|
||||
sideLabel: ATTRIBUTION_SIDE_LABEL[l.side],
|
||||
title: l.title,
|
||||
...(l.summary === undefined ? {} : { summary: l.summary }),
|
||||
})),
|
||||
log: await displayLog(lines, container.org),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **就地改完按下「保存」** —— 写回本单草稿层,并**按新数据把件重渲一遍**。
|
||||
*
|
||||
* 两步缺一不可:
|
||||
* ① 落库 —— 不落,人改的东西只活在浏览器里,提交前一刷新就没了;
|
||||
* ② 重渲 —— 件是**服务端投影**:只读那几格是他自己敲的,而由数据算出来的东西
|
||||
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在上一次渲染那一刻。
|
||||
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」——
|
||||
* 一半新一半旧,那不是没更新,是界面在撒谎。
|
||||
*
|
||||
* **工单不办结**:保存只是保存。放行门的位置一点没变 ——
|
||||
* 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。
|
||||
*/
|
||||
app.post(saveTableRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||
const parsed = saveTableRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '保存载荷不合规' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const task = await container.task.get(taskId);
|
||||
if (task.ownerId !== staff.id) {
|
||||
const body: ApiError = { code: 'forbidden', message: '这一单不归你办' };
|
||||
return c.json(body, 403);
|
||||
}
|
||||
/*
|
||||
* **这一单还在办**才收得下改动 —— 终态之后草稿层已经并进正式版,再改就是改历史。
|
||||
* 门只有这一道:改哪张表由**活动声明**把关(见 `applyViewTableEdits`),
|
||||
* 不再问「此刻开着的是哪张卡」—— 那道门实测把人真正要补的那一格挡在了外面。
|
||||
*/
|
||||
if (task.terminal !== undefined) {
|
||||
const body: ApiError = { code: 'conflict', message: '这一单已收尾,改不动了' };
|
||||
return c.json(body, 409);
|
||||
}
|
||||
const rows = await applyViewTableEdits(container, taskId, parsed.data);
|
||||
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
|
||||
if (rows > 0) await container.views.repush(taskId);
|
||||
return c.json({ ok: true, rows });
|
||||
});
|
||||
|
||||
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
||||
app.post(replyWorkOrderRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
@@ -247,6 +885,20 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
try {
|
||||
const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id);
|
||||
/*
|
||||
* **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(2026-08-01)。
|
||||
*
|
||||
* 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。
|
||||
* 从前这张表的回传只进 AI 的轨迹,由它自己 write 回去 —— 中间隔一道,
|
||||
* 它可以抄错、可以顺手改一笔(实测:人填 30000,它写回 30;
|
||||
* 人留空表示「客户不给」,它填了隔壁那条料的价)。
|
||||
* 验收页上更要紧:那一改本来就是**人对成果的修订**,过一遍 AI 就不叫修订了。
|
||||
*
|
||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||
*/
|
||||
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
|
||||
// 人答完了就接着跑,别让他对着不动的屏幕等两轮轮询(见 workers/pulse.ts)
|
||||
void pulse();
|
||||
return c.json({ ok: true, producesCompletion: outcome.producesCompletion });
|
||||
} catch (error) {
|
||||
if (error instanceof ReplyRejectedError) {
|
||||
@@ -274,7 +926,58 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
return c.json(body, 400);
|
||||
}
|
||||
try {
|
||||
await container.humanTool.replyChatCard(parsed.data, staff.id);
|
||||
const { card, taskId: cardTaskId } = await container.humanTool.replyChatCard(parsed.data, staff.id);
|
||||
/*
|
||||
* **数据录入卡:确认那一下就是写入**(2026-07-31 用户定)。
|
||||
*
|
||||
* 别的卡是「把值交给 AI,AI 再决定写什么」——中间隔一道传话,
|
||||
* 它可以抄错、可以顺手改一笔,而人不知道自己填的东西最后落成了什么。
|
||||
* 这一类不同:卡上写明落哪张表、哪个主键,人按下确认,基座**当场**落进草稿层,
|
||||
* AI 不经手。建客户档案这类事就该这样 —— 主数据建错一次,后面每一单都跟着错。
|
||||
*
|
||||
* 仍守铁律②:写的是**草稿层**,放行那一刻才并进正式版 —— 人的确认不越过放行门。
|
||||
*/
|
||||
if (parsed.data.k === 'entry' && card.k === 'entry') {
|
||||
/*
|
||||
* **一张卡可以落好几张表**(2026-08-02:建一位新客户 = 公司档 + 联系人档)。
|
||||
* 值按段隔开(`段号.列名`,主段 `0.`)—— 两张表都有 `name` 这一列,
|
||||
* 平铺就会互相盖掉,而盖掉之后没人看得出来。
|
||||
*/
|
||||
const groups = [
|
||||
{ ledger: card.ledger, key: card.key },
|
||||
...(card.also ?? []).map((g) => ({ ledger: g.ledger, key: g.key })),
|
||||
];
|
||||
const declarations = (await declarationsOf(container, cardTaskId)) ?? [];
|
||||
for (const [at, group] of groups.entries()) {
|
||||
const [domain, object] = group.ledger.split('.');
|
||||
if (domain === undefined || object === undefined) continue;
|
||||
const patch: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parsed.data.values)) {
|
||||
const mark = `${at}.`;
|
||||
if (k.startsWith(mark)) patch[k.slice(mark.length)] = v;
|
||||
// 老形状(没段号的平键)只喂主段 —— 一张卡只有一段时它仍旧管用
|
||||
else if (at === 0 && !/^\d+\./.test(k)) patch[k] = v;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) continue;
|
||||
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
||||
const key = { ...group.key, ...(await autoKeys(container, cardTaskId, declared, group.key)) };
|
||||
await container.ledger.draftWrite({ taskId: cardTaskId, address: { domain, object }, key, patch });
|
||||
}
|
||||
{
|
||||
/*
|
||||
* 这里**不再另记一条** —— `replyChatCard` 已经把这一下记全了
|
||||
* (谁、写入哪张表、几个格子各是什么)。从前两处各记一条,
|
||||
* 同一次点击在日志里出现两行:「柴浩 确认录入 customer.profile」
|
||||
* 紧跟着「entry 卡回传」,读起来像人点了两次。
|
||||
*/
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 人答完了就接着跑 —— **录入卡尤其要紧**:它是人按下确认那一刻,
|
||||
* 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动,
|
||||
* 人以为没点上,会再点一次。见 `workers/pulse.ts`。
|
||||
*/
|
||||
void pulse();
|
||||
return c.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ReplyRejectedError) {
|
||||
@@ -287,6 +990,72 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
});
|
||||
|
||||
/** 自由输入 —— 人事件的第二形态:写入任务记忆,loop 作为观察;不驱动 flow 步 */
|
||||
/**
|
||||
* 标记沟通记录已读 / 未读 —— **只认按钮**。
|
||||
*
|
||||
* 不做「打开页签即已读」:那等于替人宣布他看过了,而他可能只是路过。
|
||||
* 反向也留着(已读改回未读):看了一半想留个记号是常事。
|
||||
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
|
||||
*/
|
||||
app.post(markCommReadRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
const parsed = markCommReadRoute.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 commId = c.req.param('commId');
|
||||
const rows = rowsOf<{ task_id: string; subject: string; read_at: unknown }>(
|
||||
await container.db.execute(sql`select task_id, subject, read_at from sys.task_comm where id = ${commId}`),
|
||||
);
|
||||
const taskId = rows[0]?.task_id;
|
||||
if (taskId === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: '沟通记录不存在' };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
const task = await container.task.get(taskIdSchema.parse(taskId));
|
||||
if (task === undefined || task.ownerId !== staff.id) {
|
||||
const body: ApiError = { code: 'forbidden', message: '这一单不归你,标不了它的沟通记录' };
|
||||
return c.json(body, 403);
|
||||
}
|
||||
/*
|
||||
* **第一次看过要留痕**(2026-07-31 用户定)——「谁在什么时候看了这封信」
|
||||
* 是回看这一单时最想知道的事之一:客户抱怨没人理他,日志里得答得出
|
||||
* 「07-31 13:34 柴浩看过了」。只记**第一次**:反复标来标去不是事实的变化,
|
||||
* 是人在做记号,记满一屏反而把真事实淹了。
|
||||
*/
|
||||
const firstRead = parsed.data.read && (rows[0]?.read_at === null || rows[0]?.read_at === undefined);
|
||||
await container.db.execute(
|
||||
parsed.data.read
|
||||
? sql`update sys.task_comm set read_at = now() where id = ${commId}`
|
||||
: sql`update sys.task_comm set read_at = null where id = ${commId}`,
|
||||
);
|
||||
if (firstRead) {
|
||||
await container.memory.append({
|
||||
taskId: taskIdSchema.parse(taskId),
|
||||
kind: 'human-input',
|
||||
// 名字**不拼进标题**:主语由日志统一按 `by` 换成名字 —— 拼死在这里,
|
||||
// 改个显示口径就得改留痕,而留痕是只增不改的
|
||||
title: '看过了邮件',
|
||||
/*
|
||||
* 主题落 `summary` 而不是塞进标题 —— 日志一行是「谁干了什么」,
|
||||
* 副行才是「哪一件」。塞进标题会让这一行长到把别的条目挤扁,
|
||||
* 而人扫日志时先看的是动作,确认是哪封信是第二眼的事。
|
||||
*/
|
||||
detail: { commId, by: staff.id, act: 'read-comm', summary: rows[0]?.subject ?? '' },
|
||||
// 版本戳:这条不由活动产出,与引擎那几条同一口径
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
});
|
||||
}
|
||||
// 气泡计数长在任务投影上 —— 标完要让那一屏自己动,不能等人再点一次
|
||||
container.broadcaster.publish({ kind: 'task', id: taskId, reason: 'comm-read' });
|
||||
return c.json({ ok: true, unread: !parsed.data.read });
|
||||
});
|
||||
|
||||
app.post(sayRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
|
||||
@@ -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() });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 日志行的呈现口径 —— **主语进内容**(2026-07-31 用户定)。
|
||||
*
|
||||
* 日志一行读起来该是「谁 · 做了什么 · 什么内容」:
|
||||
*
|
||||
* 16:25 [触发事件] 系统:一封邮件触发了这条流程,系统指定你负责「A1 · 询价受理」。
|
||||
* 16:26 [阶段结论] 任务助手:这封邮件来自青岛海洋电子,是新客户…
|
||||
* 16:30 [员工确认] 柴浩:写入 customer.profile
|
||||
*
|
||||
* 四字动作与归侧由 `memory` 的域函数算出(纯函数,可重算);
|
||||
* **只有人名算不出来** —— 域里零 IO,不认识人。所以域交出 `actorId`,
|
||||
* 在这一层换成名字:换不到就退回「员工」,不编名字(界面不撒谎)。
|
||||
*
|
||||
* 放在 routes 而不是 memory 模块里:这是**呈现**口径,不是事实口径。
|
||||
* 事实库里存的是 id,谁改了名、谁离职了,回看旧单都该按当时的 id 找当下的名字。
|
||||
*/
|
||||
import type { LogLine } from '@modules/memory';
|
||||
import { staffIdSchema } from '@contracts';
|
||||
import type { OrgService } from '@modules/org';
|
||||
|
||||
export type DisplayLogLine = {
|
||||
readonly id: string;
|
||||
readonly at: string;
|
||||
readonly side: LogLine['side'];
|
||||
readonly sideLabel: string;
|
||||
/** 谁干的 —— 日志只认三档:负责人(绿)· 智能体(蓝)· 系统(灰) */
|
||||
readonly who: LogLine['who'];
|
||||
readonly action: string;
|
||||
readonly title: string;
|
||||
readonly summary?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 `actorId` 换成名字并拼进内容。
|
||||
*
|
||||
* 一次任务里出现的人极少(通常 0–2 个),所以按**出现过的 id** 逐个查,
|
||||
* 不整表捞:整表捞在任务详情这条热路径上是白花的一次全表查询。
|
||||
*/
|
||||
export async function displayLog(lines: readonly LogLine[], org: OrgService): Promise<DisplayLogLine[]> {
|
||||
const ids = [...new Set(lines.flatMap((l) => (l.actorId === undefined ? [] : [l.actorId])))];
|
||||
const names = new Map<string, string>();
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const parsed = staffIdSchema.safeParse(id);
|
||||
if (!parsed.success) return;
|
||||
const staff = await org.getStaff(parsed.data).catch(() => undefined);
|
||||
if (staff !== undefined) names.set(id, staff.name);
|
||||
}),
|
||||
);
|
||||
|
||||
return lines.map((l) => {
|
||||
// 人做的那些:换得到名字就用名字,换不到就写「员工」—— 不编,也不留空
|
||||
const actor = l.actor !== '' ? l.actor : (l.actorId === undefined ? '员工' : (names.get(l.actorId) ?? '员工'));
|
||||
return {
|
||||
id: l.id,
|
||||
at: l.at,
|
||||
side: l.side,
|
||||
sideLabel: l.sideLabel,
|
||||
who: l.who,
|
||||
action: l.action,
|
||||
title: `${actor}:${l.title}`,
|
||||
...(l.summary === undefined ? {} : { summary: l.summary }),
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user