/** * 工作台路由 —— 五要素是**同一份过程事实的五种投影**(编码规范 §3;语义出处 OS 2.1)。 * * 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。 */ import { sql } from 'drizzle-orm'; import type { Hono } from 'hono'; import type { ApiError, ChainNode, ChatMessage, DecisionCard, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts'; import { answerMessageRoute, chatCardReplySchema, confirmTriggerPendingRoute, deliverableIdSchema, listMessagesRoute, markMessageReadRoute, listTasksRoute, markCommReadRoute, replyChatCardRoute, replyWorkOrderRoute, openAssetFileRoute, saveTableRoute, sayRoute, taskDetailRoute, staffIdSchema, taskIdSchema, workOrderIdSchema, workOrderReplySchema, } from '@contracts'; import { ReplyRejectedError } from '@modules/human-tool'; import { advanceOrder } from '@modules/process'; import { keyText, listArchived, readArchived } from '../asset-archive.ts'; import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts'; import { toFileDoc } from '../file-doc.ts'; 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); }; /** * **把人改过的那几行落进本单草稿层** —— 两条路(件上按保存 / 卡上按提交)共用这一段。 * * 主键列取台账声明的主键 —— 人改主键那一格等于换了一行,所以**主键不参与写回**: * 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。 * 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。 */ /** * **人在件上改的/加的这一行,主键怎么凑齐** —— 凑不齐就落不下去,而屏上那一行还在 * (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。三类主键三种凑法: * * ① 件上摆了、人也填了 → 就用他填的(主键只认行、不改行); * ② **件上压根没摆的**(A1 的 `rfq_ref`、I01 的 `round_ref`:一单一个,不占版面)—— * 先按行认(`findKnownRow`:件上给了别的主键列就拿它去现有行里找), * 认不出再看这一列在本单现有行里是不是**全表一致**,是就是它; * 一单跨了两个号 → 取不出,如实跳过,不猜; * ③ **序号型(整数主键)**——「加一行」那一下所有格子都被清空 * (`ViewContainer.addRow` 如此),而各行序号本就互不相同,②那条永远取不出, * 于是整行被丢(2026-08-23 审 I01 需求清单 `round_ref + req_seq` 时逮到)。 * 序号的语义就是「本轮内第几条」,该由基座续:现有最大值 + 1,空表从 1 起。 * **只续新增行**:老行的序号件上摆着、人填着,走①就定了。 */ export function resolveRowKey(input: { keys: readonly string[]; cells: Readonly>; drafts: ReadonlyArray<{ key: Record }>; types: Readonly>; }): Record | undefined { const { keys, cells, drafts, types } = input; const key: Record = {}; for (const k of keys) { const v = cells[k]; if (v !== undefined && v.trim() !== '') key[k] = v.trim(); } if (Object.keys(key).length === keys.length) return key; const known = findKnownRow(drafts, keys, key); const isSeq = (column: string): boolean => types[column] === 'integer' || types[column] === 'number'; /* * **序号型主键不走「全表一致」这条路**(2026-08-23 浏览器实测逮到的): * 表里只有一行时,那一行的序号在「全表一致」眼里就是个常量 —— 人加的新行会拿到 * 同一个号,**当场覆盖掉原来那一行**(屏上两行,库里一行,而且内容还是新的那份)。 * 序号逐行不同是它的定义,一致只是行数太少的假象;它该走下面的续号那一支。 */ for (const k of keys) { if (key[k] !== undefined) continue; if (known === undefined && isSeq(k)) continue; const v = known === undefined ? (() => { const seen = new Set(drafts.map((d) => String(d.key[k] ?? ''))); const only = [...seen][0]; return seen.size === 1 && only !== undefined && only !== '' ? only : undefined; })() : String(known.key[k] ?? ''); if (v !== undefined && v !== '') key[k] = v; } if (Object.keys(key).length === keys.length) return key; // ③ 续号:只对整数型主键,且这一行确实是新增的(前两条都没认出来) for (const k of keys) { if (key[k] !== undefined) continue; if (!isSeq(k)) continue; const used = drafts .map((d) => Number(d.key[k])) .filter((n) => Number.isFinite(n)); key[k] = String((used.length === 0 ? 0 : Math.max(...used)) + 1); } return Object.keys(key).length === keys.length ? key : undefined; } 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); const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type])); for (const cells of lines) { /* * 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行, * 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。 * 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。 * 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法, * 尤其序号型那一手不补,人在件上加的行会被静默丢掉。 */ const key = resolveRowKey({ keys, cells, drafts, types }); /* * **人手改的值与 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 (key === undefined) 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; } /** * **主键摆不全时,认这一行是本单现有的哪一行**(2026-08-21 修死)。 * * `key` 是这一格已经认出来的那几个主键值(可能一个都没有)。**一格都没认出来的碎片 * 不许命中「随便找到的第一行」**——`.find()` 的谓词对着全 `undefined` 的 `key` 恒真, * 会把它错配给 `drafts` 里第一条,而那条八成风马牛不相及(手写的 radio/select 组 * 因为渲染嵌套多一层,曾经被 `collect()` 拆成不带主键的碎片,现已在 `ViewContainer` * 那一侧修死——这里补的是第二道:即便未来哪天又出现类似的欠主键碎片,也不会被这个 * `.find()` 悄悄按到错误的行上)。至少要有一格真对得上,不然交给调用方去按列补 * (`constant`),对不上就整行跳过。 */ export function findKnownRow>( drafts: ReadonlyArray<{ key: K }>, keys: readonly string[], key: Readonly>, ): { key: K } | undefined { return drafts.find( (d) => keys.some((k) => key[k] !== undefined) && keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]), ); } /** * **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。 * * 照单全写会留一条「就地修订成果: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, }; } /** * 这一单产出的**资料文件**一摞 —— 工作成果里与定稿报告并列。 * * 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻 * 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。 * 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。 */ async function assetFilesOf( container: Container, taskId: TaskId, ): Promise> { const archived = await listArchived(container.db, taskId).catch(() => []); if (archived.length > 0) { return archived.map((a) => ({ address: a.address, key: keyText(a.key), title: a.title, format: a.format, name: a.name, at: a.at, archived: true, })); } const declarations = await declarationsOf(container, taskId); const out: Array<{ address: string; key: string; title: string; format: string; name: string; archived: boolean }> = []; for (const declared of declarations ?? []) { const spec = declared.file; if (spec === undefined) continue; const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name); if (keys.length === 0) continue; const page = await container.ledger .taskView(taskId, { address: { domain: declared.domain, object: declared.object }, where: {}, orderBy: [], limit: 500, offset: 0, }) .catch(() => ({ rows: [] as Array> })); for (const row of page.rows) { // 没正文的行不是资料 —— 摆出来点开只会落一句「还没产出」 if (String(row[spec.bodyColumn] ?? '').trim() === '') continue; const key: Record = {}; for (const k of keys) key[k] = String(row[k] ?? ''); if (Object.values(key).some((v) => v === '')) continue; const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase(); const format = isFileFormat(rawFormat) ? rawFormat : 'md'; const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-'); out.push({ address: `${declared.domain}.${declared.object}`, key: keyText(key), title, format, name: safeFileName(title, format), archived: false, }); } } return out; } /** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */ 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; } /** * 件上传来的主键 → 一行的主键对。 * * 两种写法:单列主键直接给值(`CT-2026-0001`),多列主键写 `列=值` 以 `,` 连 * (`round_ref=BR-2026...,req_seq=1`)。**认的是声明里的主键列**,件上说的列名 * 若不在其中一律不认 —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。 */ export function parseRowKey(raw: string, keys: readonly string[]): Record | undefined { if (keys.length === 0) return undefined; if (!raw.includes('=')) { const only = keys[0]; return keys.length === 1 && only !== undefined && raw !== '' ? { [only]: raw } : undefined; } const out: Record = {}; for (const pair of raw.split(',')) { const at = pair.indexOf('='); if (at === -1) continue; const col = pair.slice(0, at).trim(); if (!keys.includes(col)) return undefined; out[col] = pair.slice(at + 1).trim(); } return keys.every((k) => out[k] !== undefined && out[k] !== '') ? out : undefined; } /** * 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。 * * **撤掉的那几行要点名**:那一条不只是给人看的账,也是**下一拍模型的行动指南** —— * 它看不见这一屏,只看得见轨迹。不说清「这一行是人撤的」,它会照着 * 「条目数 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`)。同源是要紧的:两处各算各的,就会出现 * 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。 * * 看哪几张表:卡上绑了表就看绑的那几张;**卡上没绑表就回落到必交付物的台账声明** * (`requiredDeliverables` 的 `ledgers`/`ledger`)—— 执行卡零输入之后(2026-08-02 * 用户定:「填写全去工作区」)卡面不再绑 blocks,闸若只认卡上的表就整个失效, * 缺着必填照样交得回来,一路走到放行门才被 L1 拦住(那正是当初立这道闸要治的病)。 * 两条路都算不出(连交付物都没声明台账)才不给闸 —— **没有依据的时候不拦人**。 */ 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 task = await container.task.get(taskId).catch(() => undefined); if (task === undefined) return undefined; const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined); if (manifest === undefined) return undefined; const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables); if (addresses.length === 0) return undefined; const watched = manifest.declarations.ledger.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); } /** * 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态) * 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。 * 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效, * 而失效的闸在页面上看不出来(按钮照常绿着)。 */ export function gateAddressesOf( blocks: ReadonlyArray | undefined, deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray | undefined }>, ): string[] { const bound = (blocks ?? []) .filter((b): b is Extract => b.t === 'table' && typeof b.ledger === 'string') .map((b) => b.ledger as string); if (bound.length > 0) return bound; return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]); } /** * 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。 * * 一格不缺就是 `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 }; }), ); /* * **决策卡答完的报告也算工作成果**(2026-08-21,`decisionCardSchema.finalHtml`)。 * * `decide` 是唯一能循环的中间验收机制(`issueAcceptance` 一个任务只能发一次), * 而它不是工单 —— 答完那一刻没有 `sys.work_order` 行,工作成果那一屏从前只认 * 工单,人在这张卡上通过的报告(如 E04 的经营计划)永远看不到。这里把答过的 * 决策卡拼成同一形状的条目,**单独一个字段**,不并进 `workOrders`: * `mode:'acceptance'` 在别处(`ViewRegion.tsx` 的 `signed`)是「终局放行卡」的 * 同义词——那道判断认的是「有一张办结的验收单 + 还没有终态」等于「字签下了正在落地」, * 前提是 `issueAcceptance` 只发一次、acceptance 恒是那一张。混进去会让 E04 一过 * S4(中间点,离终态还有 S5/S6 一大截)就误亮「正在落地」。前端只在工作成果那一屏 * 把两份列表并显,别处仍只读 `workOrders`。 * * 三值口径(见 E04 case-model 的注释:`approve`/`reject`/`custom:` 开头)粗分 * 「通过 = pass,别的都算驳回」,只影响列表上那一点点颜色,不影响任何机检或放行 —— * 那道门永远是 `issueAcceptance` 走的放行卡,这里只是回看用的一条历史记录。 */ const decided: WorkOrder[] = cards .filter((c): c is DecisionCard => c.k === 'decision') .flatMap((c) => { const { chosen, finalHtml } = c; if (chosen === undefined || finalHtml === undefined) return []; return [ { id: workOrderIdSchema.parse(c.id), at: c.at, title: c.ask, mode: 'acceptance' as const, basis: c.basis, deliverable: deliverableIdSchema.parse(c.id), criteria: [], fields: [], finalHtml, done: { at: chosen.at, by: chosen.by, result: chosen.value === 'approve' ? ('pass' as const) : ('redo' as const) }, }, ]; }); /* * 沟通记录 —— 一单往来的原件。附件那一格照抄基座读出来的 `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, decidedDeliverables: decided, assetFiles: await assetFilesOf(container, taskId), 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 类,底下的去向还写着「去选型」—— * 一半新一半旧,那不是没更新,是界面在撒谎。 * * **工单不办结**:保存只是保存。放行门的位置一点没变 —— * 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。 */ /* * **点开一份资料文件** —— 件上的资料名点下去,走到这里。 * * 三道关,一道都不能省: * ① 会话(是不是这个人); * ② **授权面 = 活动清单的台账声明** —— 那张表没声明 `file` 段就不给, * 与「写看台账声明」「立监听看清单声明」同款;声明里没有的列名也不认; * ③ 行取的是**任务视图**(正式版 + 本单草稿)—— 在办的初稿本来就还在草稿层, * 只读正式表的话,人在审的这一份恰恰打不开。 * * 拿到行之后**现造**:标题 + 正文 + 格式 → 真字节。`raw=1` 原样交出去(下载), * 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。 */ app.get(openAssetFileRoute.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 address = c.req.query('address') ?? ''; const rawKey = c.req.query('key') ?? ''; const [domain = '', object = ''] = address.split('.'); /* 单没了就直说单没了 —— 不然下面那句会把「这一单不在了」说成「活动没声明」 */ const task = await container.task.get(taskId).catch(() => undefined); if (task === undefined) { const body: ApiError = { code: 'not-found', message: '这一单已经不在了' }; return c.json(body, 404); } const declarations = await declarationsOf(container, taskId); const declared = declarations?.find((d) => d.domain === domain && d.object === object); if (declared?.file === undefined) { const body: ApiError = { code: 'forbidden', message: `这个活动没有把「${address}」声明成资料文件(清单的 declarations.ledger[].file)——不声明就点不开`, }; return c.json(body, 403); } const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name); const key = parseRowKey(rawKey, keys); if (key === undefined) { const body: ApiError = { code: 'validation-failed', message: `认不出这一行:主键要给全 ${keys.join('、')}` }; return c.json(body, 400); } /* * **先看存档** —— 放行那一刻这一份就冻住了(`asset-archive.ts`)。 * * 顺序是有讲究的:归档件上点开的必须是**他签字那一刻的那一份**, * 而不是台账后来被下一单改成的样子。在办期间还没有存档,自然落到下面现造那条路。 */ const archived = await readArchived(container.db, taskId, address, key).catch(() => undefined); if (archived !== undefined) { if (c.req.query('raw') === '1') { return c.body(archived.bytes.slice().buffer as ArrayBuffer, 200, { 'content-type': MIME_OF[archived.format], 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(archived.name)}`, }); } const doc = toFileDoc({ name: archived.name, title: archived.title, format: archived.format, bytes: archived.bytes }); if (doc !== undefined) return c.json({ file: doc, fileName: archived.name, format: archived.format }); } const page = await container.ledger .taskView(taskId, { address: { domain, object }, where: {}, orderBy: [], limit: 500, offset: 0 }) .catch(() => ({ rows: [] as Array> })); const row = page.rows.find((r) => keys.every((k) => String(r[k] ?? '') === key[k])); if (row === undefined) { /* * **收了工再来找不到,多半是试跑那条路**(2026-08-23 实测): * 资料这一行若落在**主数据**上,试跑单放行时它只留在活动缓存、不落正式表 * (免得试跑往品牌内容库里塞真资产,见 ledger 的 `simulation` 那段注释)—— * 于是放行前点得开、放行后点不开。正式单不受此限(放行合并进正式表,照旧查得到)。 * 两种情形说清楚,别拿一句「可能已被撤掉」把人支去翻列表找一行根本不会在的东西。 */ const body: ApiError = { code: 'not-found', message: task.terminal === undefined ? '这一行不在了 —— 可能已被撤掉,回列表看一眼' : '这一单已经收工,查不到这一行了 —— 若它是试跑单,放行时主数据只留在活动缓存、不落正式表' + '(试跑不往正式库里塞真资产),这份资料在放行前查得到;正式单不受此限', }; return c.json(body, 404); } const text = (col: string): string => String(row[col] ?? '').trim(); const title = text(declared.file.titleColumn) || keys.map((k) => key[k]).join('-'); const markdown = text(declared.file.bodyColumn); const rawFormat = text(declared.file.formatColumn).toLowerCase(); /* 格式列没填 / 填了个不认识的:退 md —— 有正文就该看得见,不因为一格没填就整份打不开 */ const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md'; if (markdown === '') { const body: ApiError = { code: 'not-found', message: `这一份还没有正文(${declared.file.bodyColumn} 是空的)—— 资料还没产出,不是打不开`, }; return c.json(body, 404); } const composed = composeFile({ title, markdown, format }); if (c.req.query('raw') === '1') { return c.body(composed.bytes.slice().buffer as ArrayBuffer, 200, { 'content-type': composed.mime, // 文件名走 RFC 5987:中文名在各家浏览器上才落得对 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(composed.fileName)}`, }); } const doc = toFileDoc({ name: composed.fileName, title, format, bytes: composed.bytes }); if (doc === undefined) { const body: ApiError = { code: 'internal', message: `这份 ${format} 造出来了却读不回来 —— 已留痕,请报给开发` }; return c.json(body, 500); } return c.json({ file: doc, fileName: composed.fileName, format }); }); 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-02 用户:「HTML UI、 * 数据、内容、布局都要 1:1 地从待处理转换为静态报告放到工作成果」)。 * 成果页渲的是这份快照 —— 不再渲「当下」的任务视图(随后续推进会变), * 更不退回卡壳。快照失败不拦办结(留痕即可,人已交卷)。 */ try { const shot = await container.interaction.viewOf(outcome.taskId); if (shot?.html !== undefined) { await container.db.execute( sql`update sys.work_order set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.workOrderId}`, ); } } catch { /* 件还没推过就没有快照 —— 成果页会退回旧路,不拦人交卷 */ } /* * **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(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)) ?? []; /* * **跨段共享主键**(2026-08-20 修死):卡面注释早就写着「两段的 customer_code * 都留空:人在第一段填了什么,第二段跟着是什么」,但代码从没兑现过这句话 —— * `also` 段的 `key` 只取卡面写死的字面占位(通常就是那个空字符串),从不看 * 人刚在**上一段**填的值。建一位新客户时,公司档正确落了 `customer_code` * (它作为一个 `field` 进了 patch),联系人档却带着空字符串的 `customer_code` * 落库 —— 新联系人从落地那一刻起就没挂在这位客户名下,`autoKeys` 算 * `contact_code` 时也因为拿不到前缀,生成裸序号(`01`)而不是 * `CU-0201-01`。现在按段落定的主键值顺着传给后面的段。 */ const sharedKey: Record = {}; 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); // 本段的非自增主键:这一段自己填的(patch)优先,没填就接上更早那段定下的值 const ownKeyFields = (declared?.fields ?? []).filter((f) => f.primaryKey && f.auto !== true).map((f) => f.name); const given: Record = { ...group.key }; for (const name of ownKeyFields) { const value = patch[name] ?? sharedKey[name]; if (value !== undefined && value !== '') given[name] = value; } const key = { ...given, ...(await autoKeys(container, cardTaskId, declared, given)) }; await container.ledger.draftWrite({ taskId: cardTaskId, address: { domain, object }, key, patch }); for (const name of ownKeyFields) { const value = key[name]; if (value !== undefined && value !== '') sharedKey[name] = value; } } { /* * 这里**不再另记一条** —— `replyChatCard` 已经把这一下记全了 * (谁、写入哪张表、几个格子各是什么)。从前两处各记一条, * 同一次点击在日志里出现两行:「柴浩 确认录入 customer.profile」 * 紧跟着「entry 卡回传」,读起来像人点了两次。 */ } } /* * **决策卡答完那一刻,把当时的件原样存进这张卡**(与工单同一条道理,见 * `decisionCardSchema.finalHtml` 的注释)。`decide` 是唯一能循环的中间验收 * 机制,E04 的经营计划审批(S4_plan_approval)就是靠它——没有这一段, * 人在这张卡上通过的报告永远进不了工作成果(那一屏从前只认工单)。 * 快照失败不拦人答卡(留痕即可)。 */ if (parsed.data.k === 'decision' && card.k === 'decision') { try { const shot = await container.interaction.viewOf(cardTaskId); if (shot?.html !== undefined) { await container.db.execute( sql`update sys.chat_card set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.cardId}`, ); } } catch { /* 件还没推过就没有快照 —— 不拦人答卡 */ } } /* * 人答完了就接着跑 —— **录入卡尤其要紧**:它是人按下确认那一刻, * 眼睛正盯着屏幕。等两轮轮询(实测 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 }); }); }