/** * 工作台路由 —— 五要素是**同一份过程事实的五种投影**(编码规范 §3;语义出处 OS 2.1)。 * * 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。 */ import { sql } from 'drizzle-orm'; import type { Hono } from 'hono'; import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts'; import { answerMessageRoute, chatCardReplySchema, confirmTriggerPendingRoute, listMessagesRoute, markMessageReadRoute, listTasksRoute, markCommReadRoute, replyChatCardRoute, replyWorkOrderRoute, saveTableRoute, sayRoute, taskDetailRoute, staffIdSchema, taskIdSchema, workOrderReplySchema, } 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(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>; /** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */ 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 { 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 = {}; 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 = {}; 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 { 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> | undefined, patch: Readonly>, ): 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>, ): Promise> { 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> })); const same = page.rows.filter((r) => others.every((k) => String(r[k] ?? '') === (given[k] ?? ''))); const out: Record = {}; 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(); 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(); 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(); 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(); 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 { 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>; drops?: Readonly> | undefined; }, ): Promise { 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>, ): 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> }, ): Promise { const blocks = (order as { blocks?: ReadonlyArray> }).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 = {}; 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 => 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 = {}; 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>, 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, container: Container): void { /** 当前登录者;缺会话即 401 */ async function me(account: string) { const staff = await container.org.findByAccount(account); return staff; } /** 任务卡 —— 一条 = 一个活动的一次运行;序位取自工作流的节点实例 */ async function toCard(task: Awaited>): Promise { let position = 1; let total = 1; let subject = task.title; let workflowState: TaskCard['workflowState'] = 'running'; if (task.workflowId !== undefined) { const workflow = await container.process.getWorkflow(task.workflowId).catch(() => undefined); if (workflow !== undefined) { subject = workflow.subject; workflowState = workflow.state; const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); const activityNodes = (pkg?.nodes ?? []).filter((n) => n.kind === 'activity' || n.kind === 'approval'); total = Math.max(activityNodes.length, 1); const index = activityNodes.findIndex((n) => n.id === task.nodeId); position = index < 0 ? 1 : index + 1; } } /* * 阶段进度 —— 一行运行时读出来(阶段声明与计划项状态同在那一行)。 * 每张卡一次读:任务清单是「我手上开着的单」,量级是个位数,与上面 * 已有的按卡取工作流/流程包同一个形状。活动没声明阶段就没有这一格。 */ const phase = await container.loop.phaseOf(task.id, task.terminal !== undefined).catch(() => undefined); return { id: task.id, activity: task.title, title: task.title, runtime: task.runtime, ...(task.terminal === undefined ? {} : { terminal: task.terminal }), position, total, ...(phase === undefined ? {} : { phase }), workflowSubject: subject, workflowState, startedAt: task.startedAt, }; } /** * 流程链 —— 人要看见自己在整条流程的哪一环,以及前后是谁。 * 别人负责的节点在链上只读呈现,不进任务清单(清单只装我负责的)。 */ async function toChain(task: Awaited>): Promise { if (task.workflowId === undefined) return []; const workflow = await container.process.getWorkflow(task.workflowId).catch(() => undefined); if (workflow === undefined) return []; const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); /* * 链的呈现顺序:**按边的拓扑序**(`advanceOrder`),不吃 `nodes` 数组的书写顺序, * 也不看图纸坐标 —— 图纸一回形走线,列位就跟推进次序对不上了。 */ const nodes = pkg === undefined ? [] : advanceOrder(pkg).filter((n) => n.kind === 'activity' || n.kind === 'approval'); /* * 状态取**真实的节点实例**,不按下标推。 * 按下标推(「排在当前之前的就算做完」)在并行支上会说谎:人在 A3 时 A2 可能还在跑, * 却被标成已完成。宁可把在跑的并行支报作待办(少报),也不能把没做完的说成做完了。 */ const instances = await container.process.instancesOf(workflow.id).catch(() => []); const finished = new Set(instances.filter((x) => x.terminal !== undefined).map((x) => x.nodeId)); return await Promise.all( nodes.map(async (n) => { /* * 报谁,要和**真正派单时的口径一致**:负责人是运营时分配的(员工组轮值 / * 全员轮值),分派结果记在实例 assignee 上 —— 起了单的报实际承接人, * 还没起的如实报「轮值待派」,不编造具体承接人。活动点与审批点同一口径。 */ const inst = [...instances].reverse().find((x) => x.nodeId === n.id && x.assigneeId !== undefined); const who = inst?.assigneeId === undefined ? undefined : await container.org.getStaff(staffIdSchema.parse(inst.assigneeId)).catch(() => undefined); return { nodeId: n.id, label: n.label, kind: n.kind === 'approval' ? ('approval' as const) : ('activity' as const), ownerName: who?.name ?? '轮值待派', ownerRole: who?.position.name ?? '运营分派', state: n.id === task.nodeId ? ('current' as const) : finished.has(n.id) ? ('done' as const) : ('todo' as const), }; }), ); } app.get(listTasksRoute.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 tasks = await container.task.listByOwner(staff.id); return c.json({ tasks: await Promise.all(tasks.map(toCard)) }); }); app.get(taskDetailRoute.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 task = await container.task.get(taskId); const [workOrders, chat, lines, pushed] = await Promise.all([ // 全部工单:待处理要翻页办,已办结要回看定稿报告 —— 两摞同源 container.humanTool.allWorkOrders(taskId), container.interaction.timeline(taskId), container.memory.taskLog(taskId), 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; 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); /* * 工作成果 = 一组**文件**,大部分是 HTML 汇报报告。 * 两处来源: * ① 活动推的那一屏 —— 它就是这单当下的汇报报告(AM 07); * ② 过程成果里声明的 `files` 块 —— 活动按需生成的目标输出(报价单 / 台账 / 档案)。 * 活动尚未产出文件时给空列表,界面落空态,不编造条目。 */ /* * 过程成果尚无来源:活动还不发布 deliverable(一个个活动开发时逐步补上)。 * 先声明出来并接进成果文件的装配 —— 活动一开始产出,文件列表就自动有了。 */ const deliverables: Deliverable[] = []; const results: Array<{ label: string; desc: string; at?: string; file: FileDoc }> = []; if (pushed?.html !== undefined) { results.push({ label: `${task.title} · 汇报报告`, desc: 'HTML · 活动按当下局面组织的一屏', file: { kind: 'html', name: `${task.activityId}-report.html`, html: pushed.html }, }); } for (const deliverable of deliverables) { for (const block of deliverable.blocks) { if (block.t !== 'files') continue; for (const item of block.items) { results.push({ label: item.label, desc: item.desc, ...(deliverable.at === undefined ? {} : { at: deliverable.at }), file: item.file, }); } } } // 对话流 = 时间线 + 对话卡族,按时间全序 —— 与任务记忆同源、不同筛选 const merged: ChatMessage[] = [...chat, ...cards].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); // 视图区:活动推的 HTML 优先(它是活动按局面组织的完整一屏); // 尚无推送时给一份进度概览,不留空屏 const view: ViewBlock[] = [ { t: 'kv', title: '任务概览', items: [ { k: '活动', v: task.activityId }, { k: '负责人', v: staff.name }, { k: '状态', v: task.runtime }, { k: '已用步数', v: `${task.used.steps} / ${task.caps.steps}` }, ], }, ]; return c.json({ card: await toCard(task), goal: task.title, owner: { name: staff.name, role: staff.position.name }, chain, budget: { steps: `${task.used.steps}/${task.caps.steps}`, tokens: `${task.used.tokens}/${task.caps.tokens}`, activeMinutes: `${Math.round(task.used.activeMs / 60_000)}/${task.caps.activeMinutes}`, humanCalls: `${task.used.humanCalls}/${task.caps.humanCalls}`, }, stages: [], deliverables, results, comms, workOrders: gated, chat: merged, view: pushed?.blocks !== undefined ? [...pushed.blocks] : view, ...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }), 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')); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); } const parsed = workOrderReplySchema.safeParse((await c.req.json())['reply']); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues }; return c.json(body, 400); } try { /* * **提交闸在服务端也得成立**(2026-08-02)。 * * 前端把按钮灰掉是给人看的;而**说明书里向模型承诺了**「卡交回来时必填一定是齐的, * 所以不许再发同一张卡」——那句承诺只有服务端拦得住才算数。 * 拦不住的话:一次直调 API 就能把缺必填的卡交回来,模型照承诺不再追问, * 这一单一路走到放行门才被 L1 拦住 —— 而那时缺的是哪一格早已翻篇。 * 与卡面那道闸同一段(`submitGate`),同一个数、同一套算法。 */ /* * **闸要在收下回传之前把住**:收下之后再拒,那张单已经记成办结了。 * 这一单归哪个任务只有库里知道(回传载荷里只有工单号),所以先问一句。 */ const owner = rowsOf<{ task_id: string }>( await container.db.execute(sql`select task_id from sys.work_order where id = ${parsed.data.workOrderId} limit 1`), )[0]; if (owner !== undefined) { const held = taskIdSchema.parse(owner.task_id); const order = (await container.humanTool.allWorkOrders(held)).find((o) => o.id === parsed.data.workOrderId); const gate = order === undefined ? undefined : await submitGate(container, held, order); if (gate !== undefined && !gate.ready) { const body: ApiError = { code: 'validation-failed', message: `${gate.why}${gate.blanks.length === 0 ? '' : ` —— ${gate.blanks.slice(0, 5).join(';')}`}`, }; return c.json(body, 400); } } 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) { // 驳回必附意见 / 必填未回填 / 已答不得再答 —— 三规矩的回传面 const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.violations }; return c.json(body, 400); } throw error; } }); /** * 对话卡回传(决策 / 补全 / 评价)。 * 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。 */ app.post(replyChatCardRoute.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 = chatCardReplySchema.safeParse((await c.req.json())['reply']); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues }; return c.json(body, 400); } try { 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 = {}; 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) { // 补全两条路必走其一 / 已答不得再答 —— 三规矩的回传面 const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.violations }; return c.json(body, 400); } throw error; } }); /** 自由输入 —— 人事件的第二形态:写入任务记忆,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) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); } const taskId = taskIdSchema.parse(c.req.param('taskId')); const parsed = sayRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; return c.json(body, 400); } return c.json(await container.interaction.say(taskId, staff.id, parsed.data.text)); }); app.get(listMessagesRoute.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 messages = await container.message.inbox(staff.id); /* * 待确认起单(触发确认链路):`eventRef` 挂着待确认单号的消息, * 就地贴出那一票的状态 —— 界面靠它决定「确认起单」钮是活的还是已收线。 */ const pendingIds = messages .map((m) => m.eventRef) .filter((r): r is string => r !== undefined && r.startsWith('trigger-pending:')) .map((r) => r.slice('trigger-pending:'.length)); const pendings = new Map((await container.trigger.pendingByIds(pendingIds)).map((x) => [x.id, x])); return c.json({ messages: messages.map((m) => ({ id: m.id, kind: m.kind, title: m.title, body: m.body, at: m.at, read: m.readAt !== undefined, ...(() => { const ref = m.eventRef; if (ref === undefined || !ref.startsWith('trigger-pending:')) return {}; const pending = pendings.get(ref.slice('trigger-pending:'.length)); if (pending === undefined) return {}; return { confirm: { pendingId: pending.id, consumed: pending.consumedAt !== undefined, ...(pending.workflowId === undefined ? {} : { workflowId: pending.workflowId }), }, }; })(), // 跨任务征答卡随消息一并出:就地作答,不必跳进别人的任务 ...(m.card === undefined ? {} : { card: { cardId: m.card.cardId, sourceTaskId: m.card.sourceTaskId, fields: (m.card.payload['fields'] as unknown[] | undefined) ?? [], ask: String(m.card.payload['ask'] ?? ''), answered: m.card.answeredAt !== undefined, ...(m.card.rejectedReason === undefined ? {} : { rejectedReason: m.card.rejectedReason }), ...(m.card.supersededBy === undefined ? {} : { supersededBy: m.card.supersededBy }), }, }), })), }); }); /** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */ app.post(confirmTriggerPendingRoute.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 id = c.req.param('id'); const pending = (await container.trigger.pendingByIds([id]))[0]; if (pending === undefined) { const body: ApiError = { code: 'not-found', message: `无此待确认单:${id}` }; return c.json(body, 404); } if (pending.staffId !== staff.id) { const body: ApiError = { code: 'forbidden', message: '这一票点名的确认人不是你' }; return c.json(body, 403); } try { return c.json(await container.trigger.confirmPending(id, staff.id)); } catch (error) { const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) }; return c.json(body, 400); } }); app.post(markMessageReadRoute.path, async (c) => { const staff = await me(c.get('account')); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); } await container.message.markRead(c.req.param('messageId')); return c.json({ ok: true }); }); /** * 跨任务征答的**就地作答**(OS 2.1)。 * 「作答权限只及卡面与决策包,**不开放任务对话流**」——所以这里只收卡面回传, * 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。 */ app.post(answerMessageRoute.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 = answerMessageRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '回传不合协议' }; return c.json(body, 400); } const answered = await container.message.answer(c.req.param('messageId'), staff.id, parsed.data.answer); // 跨人卡一答,来源任务就可能续跑 —— 让那边的浏览器重取投影 if (answered.card !== undefined) { container.broadcaster.publish({ kind: 'task', id: answered.card.sourceTaskId, reason: 'elicitation-answer' }); } return c.json({ ok: true }); }); }