Archived
E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2): - 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进 S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视 - 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo, 计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标) - 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源, 正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填 - 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键; 标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段 - 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数 - 阶段刻度对齐文档 7 步;起单样例预填满 基座侧: - 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY 广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻 - 多行 write 整批先验后写(dryRun),被拒 = 一行都没落 - 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors) - 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行) - 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端 - 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸 - 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组; JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖 - 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01 契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo, objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。 1700 测 / typecheck / lint / depcruise / api:check 绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 广播的跨进程桥 —— **别的后端动了同一张单,这边的浏览器也得知道**。
|
||||
*
|
||||
* 2026-08-24 用户报的第三件事:「一直卡在 AI 推进中,刷新后才发现已经推完了」。
|
||||
* 推送本身没坏 —— 坏在 `createBroadcaster()` 是进程内的一个 Set:那会儿本机与
|
||||
* 局域网另一台各跑一个后端连同一个库,单子被那一台推着走,这一台的浏览器一个字没收到。
|
||||
*
|
||||
* 这里钉两条:①自己推的顺手 NOTIFY 出去;②**别人发的才在本地转发,自己发的不收** ——
|
||||
* 不然一条推送在两个进程之间来回弹。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bridgeBroadcaster, TOUCH_CHANNEL } from '../broadcast-bridge.ts';
|
||||
import { createBroadcaster } from '../broadcast.ts';
|
||||
|
||||
/** 假连接池:记下 NOTIFY 出去的载荷,并留一个口子把「别人的通知」塞进来 */
|
||||
function fakePool() {
|
||||
const notified: Array<{ channel: string; payload: string }> = [];
|
||||
let onNotification: ((m: { channel: string; payload?: string }) => void) | undefined;
|
||||
const client = {
|
||||
on(event: string, handler: (arg: never) => void) {
|
||||
if (event === 'notification') onNotification = handler as never;
|
||||
},
|
||||
query: async () => ({ rows: [] }),
|
||||
release: () => undefined,
|
||||
};
|
||||
return {
|
||||
notified,
|
||||
/** 模拟别的进程(或自己)发来一条 */
|
||||
deliver(wire: Record<string, unknown> | string) {
|
||||
// 字符串原样送 —— 「载荷坏了」那条用例要的是真·不是 JSON 的字节
|
||||
onNotification?.({ channel: TOUCH_CHANNEL, payload: typeof wire === 'string' ? wire : JSON.stringify(wire) });
|
||||
},
|
||||
get armed() {
|
||||
return onNotification !== undefined;
|
||||
},
|
||||
pool: {
|
||||
query: async (_sql: string, params: unknown[]) => {
|
||||
notified.push({ channel: String(params[0]), payload: String(params[1]) });
|
||||
return { rows: [] };
|
||||
},
|
||||
connect: async () => client,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const settle = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe('bridgeBroadcaster', () => {
|
||||
it('本地推一条 → 顺手 NOTIFY 到库上(带着「谁发的」)', async () => {
|
||||
const fake = fakePool();
|
||||
const bus = createBroadcaster();
|
||||
bridgeBroadcaster(fake.pool as never, bus);
|
||||
bus.publish({ kind: 'task', id: 't-1', reason: 'advanced' });
|
||||
await settle();
|
||||
|
||||
expect(fake.notified).toHaveLength(1);
|
||||
expect(fake.notified[0]?.channel).toBe(TOUCH_CHANNEL);
|
||||
const wire = JSON.parse(fake.notified[0]?.payload ?? '{}') as Record<string, unknown>;
|
||||
expect(wire['id']).toBe('t-1');
|
||||
expect(typeof wire['from']).toBe('string'); // 认「谁发的」靠它
|
||||
});
|
||||
|
||||
it('**别的进程发来的,在本地转发一次** —— 这正是那一台推进、这一台刷新的那条路', async () => {
|
||||
const fake = fakePool();
|
||||
const bus = createBroadcaster();
|
||||
bridgeBroadcaster(fake.pool as never, bus);
|
||||
await settle();
|
||||
expect(fake.armed).toBe(true);
|
||||
|
||||
const got: string[] = [];
|
||||
bus.subscribe((t) => got.push(`${t.kind}:${t.id}:${t.reason}`));
|
||||
fake.deliver({ kind: 'task', id: 't-9', reason: 'advanced', from: '别的机器' });
|
||||
expect(got).toEqual(['task:t-9:advanced']);
|
||||
});
|
||||
|
||||
it('**自己发的不再收** —— 否则一条推送在两个进程之间来回弹', async () => {
|
||||
const fake = fakePool();
|
||||
const bus = createBroadcaster();
|
||||
bridgeBroadcaster(fake.pool as never, bus);
|
||||
bus.publish({ kind: 'task', id: 't-1', reason: 'advanced' });
|
||||
await settle();
|
||||
const self = (JSON.parse(fake.notified[0]?.payload ?? '{}') as { from?: string }).from;
|
||||
|
||||
const got: string[] = [];
|
||||
bus.subscribe((t) => got.push(t.id));
|
||||
fake.deliver({ kind: 'task', id: 't-1', reason: 'advanced', from: self });
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
|
||||
it('载荷坏了只丢这一条,连接照旧活着', async () => {
|
||||
const fake = fakePool();
|
||||
const bus = createBroadcaster();
|
||||
bridgeBroadcaster(fake.pool as never, bus);
|
||||
await settle();
|
||||
const got: string[] = [];
|
||||
bus.subscribe((t) => got.push(t.id));
|
||||
|
||||
expect(() => fake.deliver('这不是 JSON')).not.toThrow();
|
||||
// 是 JSON 但形状不对的也得丢掉 —— 频道全库公用,谁都能往里发一句话
|
||||
expect(() => fake.deliver('"光秃秃一个字符串"')).not.toThrow();
|
||||
expect(() => fake.deliver({ 没有: 'kind 与 id' })).not.toThrow();
|
||||
fake.deliver({ kind: 'task', id: 't-3', reason: 'advanced', from: '别处' });
|
||||
expect(got).toEqual(['t-3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 对话区按任务内序号排(2026-08-24):两台后端交替推同一单,时钟一偏,
|
||||
* 上一拍的话就排到下一拍后面 —— 序号与时钟无关,拿它排。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { orderChat } from '../chat-order.ts';
|
||||
|
||||
const H = [
|
||||
{ id: 'm1', seq: 1, kind: 'narration', at: '2026-08-24T10:00:30.000Z' }, // 另一台机器,钟快 30s
|
||||
{ id: 'c1', seq: 2, kind: 'card-sent', at: '2026-08-24T10:00:30.100Z', detail: { id: 'card-A' } },
|
||||
{ id: 'm2', seq: 3, kind: 'narration', at: '2026-08-24T10:00:05.000Z' }, // 本机,钟准
|
||||
{ id: 'h1', seq: 4, kind: 'human-input', at: '2026-08-24T10:00:06.000Z', detail: { id: 'say-1' } },
|
||||
];
|
||||
|
||||
describe('orderChat', () => {
|
||||
it('**时钟倒着、序号顺着:按序号排** —— 上一拍的话不再跑到下一拍后面', () => {
|
||||
const items = [
|
||||
{ id: 'm2', at: '2026-08-24T10:00:05.000Z', text: '下一拍' },
|
||||
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '上一拍' },
|
||||
];
|
||||
expect(orderChat(items, H).map((i) => i.text)).toEqual(['上一拍', '下一拍']);
|
||||
});
|
||||
|
||||
it('卡按它的 card-sent 落点排;人的自由输入按 human-input 落点排', () => {
|
||||
const items = [
|
||||
{ id: 'say-1', at: '2026-08-24T10:00:06.000Z', text: '人说' },
|
||||
{ id: 'card-A', at: '2026-08-24T10:00:30.050Z', text: '卡' },
|
||||
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '话' },
|
||||
];
|
||||
expect(orderChat(items, H).map((i) => i.text)).toEqual(['话', '卡', '人说']);
|
||||
});
|
||||
|
||||
it('找不到落点的老数据按时刻插位,不炸', () => {
|
||||
const items = [
|
||||
{ id: 'zzz', at: '2026-08-24T10:00:05.500Z', text: '老' },
|
||||
{ id: 'm2', at: '2026-08-24T10:00:05.000Z', text: '下一拍' },
|
||||
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '上一拍' },
|
||||
];
|
||||
// 老条目的时刻在 m2(seq 3)之后、h1 之前 → 插到 seq 3 那一档,时刻再排在 m2 之后
|
||||
expect(orderChat(items, H).map((i) => i.text)).toEqual(['上一拍', '下一拍', '老']);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
* 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings, toolCallReadings } from '../wiring/engines.ts';
|
||||
import { coerceToColumn, distinctReadings, localStamp, missingReadings, rowLabel, rowReadings, rowKeyList, tallyReadings, toolCallReadings, touchedRows } from '../wiring/engines.ts';
|
||||
|
||||
describe('空值 = 这一列没有值(不写)', () => {
|
||||
it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => {
|
||||
@@ -381,3 +381,108 @@ describe('工具调用读数', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 不同值计数 `域_对象_distinct_<列>` —— 覆盖闭合判据的落点(2026-08-24 柴浩实测:
|
||||
* 人在覆盖范围确认卡上加了一家客户,计划行数照样 > 0,「盖没盖住每一家」没有数说得出来)。
|
||||
*/
|
||||
describe('distinctReadings —— 这一列有几个不同的值,基座数', () => {
|
||||
const DECL = [
|
||||
{
|
||||
domain: 'crm_coverage',
|
||||
object: 'plan',
|
||||
fields: [{ name: 'customer_code', distinct: true }, { name: 'contact_code' }],
|
||||
},
|
||||
];
|
||||
|
||||
it('计划行数 ≠ 客户数:一家客户几位关键人就几行,distinct 数的是客户', () => {
|
||||
const rows = [
|
||||
{ customer_code: 'CU-0142', contact_code: 'CU-0142-01' },
|
||||
{ customer_code: 'CU-0142', contact_code: 'CU-0142-02' },
|
||||
{ customer_code: 'cu-1111', contact_code: 'cu-1111-00' },
|
||||
];
|
||||
expect(distinctReadings({ 'mine:crm_coverage.plan': rows }, DECL)).toEqual({
|
||||
crm_coverage_plan_distinct_customer_code: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('没标 distinct 的列不出读数;标了的零行也铺 0 —— 未绑定的名字参与比较求值为假', () => {
|
||||
const out = distinctReadings({ 'mine:crm_coverage.plan': [] }, DECL);
|
||||
expect(out).toEqual({ crm_coverage_plan_distinct_customer_code: 0 });
|
||||
});
|
||||
|
||||
it('空值不算一种 —— 「没写」不是一个取值', () => {
|
||||
const rows = [{ customer_code: 'CU-0142' }, { customer_code: '' }, { customer_code: ' ' }, {}];
|
||||
expect(distinctReadings({ 'mine:crm_coverage.plan': rows }, DECL)).toEqual({
|
||||
crm_coverage_plan_distinct_customer_code: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `touched:域.对象` —— 落库预览的数据源(2026-08-24 柴浩实测:绑 mine: 的话
|
||||
* Itelma 既有的客户类型/行业整片空白,像是数据丢了)。
|
||||
*/
|
||||
describe('touchedRows —— 本轮碰过的行,以叠加后全貌摆出来', () => {
|
||||
const merged = [
|
||||
{ customer_code: 'CU-0142', name: 'Itelma', cust_type: 'Tier1 OEM', industry: '车规电子' },
|
||||
{ customer_code: 'CU-0087', name: '深圳精密', cust_type: '模组厂' },
|
||||
{ customer_code: 'CU-2039', name: '测试11111' },
|
||||
];
|
||||
|
||||
it('按草稿键挑行:碰过的行带着正式现值的全部列,没碰的行不出现', () => {
|
||||
const out = touchedRows(merged, [{ customer_code: 'CU-0142' }, { customer_code: 'CU-2039' }]);
|
||||
expect(out).toHaveLength(2);
|
||||
// 关键在这一列:草稿里没写过 cust_type,叠加行里必须有 —— 这正是回显的来源
|
||||
expect((out[0] as Record<string, unknown>)['cust_type']).toBe('Tier1 OEM');
|
||||
expect((out[1] as Record<string, unknown>)['name']).toBe('测试11111');
|
||||
});
|
||||
|
||||
it('草稿新增的行(正式表没有)也在 —— 叠加视图本就含它;键对不上的丢弃不报错', () => {
|
||||
const withNew = [...merged, { customer_code: 'CU-NEW', contact_code: 'CU-NEW-01', name: '新人' }];
|
||||
const out = touchedRows(withNew, [
|
||||
{ customer_code: 'CU-NEW', contact_code: 'CU-NEW-01' },
|
||||
{ customer_code: '查无此人', contact_code: 'X' },
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect((out[0] as Record<string, unknown>)['name']).toBe('新人');
|
||||
});
|
||||
|
||||
it('数值键与字符串键按字面等值比(pg 回来的是 number,草稿键常是 string)', () => {
|
||||
const out = touchedRows([{ seq: 3, name: '三号' }], [{ seq: '3' }]);
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 摆给人看的行标识去掉单标识(2026-08-25 用户:「COV-…/CU-0142-01 只保留斜杠右边」)。
|
||||
*/
|
||||
describe('rowLabel —— 主键去掉单号那一段', () => {
|
||||
it('单据表:round_ref + contact_code → 只留 contact_code', () => {
|
||||
expect(rowLabel({ round_ref: 'COV-20260825002227', contact_code: 'CU-0142-01' }, 'round_ref')).toBe('CU-0142-01');
|
||||
});
|
||||
|
||||
it('主数据表没有单标识:全部保留', () => {
|
||||
expect(rowLabel({ customer_code: 'CU-0142', contact_code: 'CU-0142-01' }, undefined)).toBe('CU-0142/CU-0142-01');
|
||||
});
|
||||
|
||||
it('主键只有单号:去完一段都不剩就退回整段,不摆空串', () => {
|
||||
expect(rowLabel({ round_ref: 'COV-1' }, 'round_ref')).toBe('COV-1');
|
||||
});
|
||||
|
||||
it('缺项名单走同一把尺 —— 红带 / 提交闸 / 拒回都不再带单号', () => {
|
||||
const out = missingReadings({ 'mine:crm_coverage.receipt': [{ round_ref: 'COV-1', contact_code: 'CU-0142-01' }] }, [
|
||||
{
|
||||
domain: 'crm_coverage',
|
||||
object: 'receipt',
|
||||
caseKey: 'round_ref',
|
||||
fields: [
|
||||
{ name: 'round_ref', primaryKey: true },
|
||||
{ name: 'contact_code', primaryKey: true },
|
||||
{ name: 'completion_status', primaryKey: false, required: true, label: '完成状态' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(out['crm_coverage_receipt_missing_keys']).toBe('CU-0142-01 缺完成状态');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 驳回意见的**归属比对** —— 2026-08-24 柴浩实测:意见「测试111建议动作改成拜访CTO」,
|
||||
* 计划表里有一行 customer_name=测试111,模型却把改动落到了 Itelma 那一行。
|
||||
* 归属是纯字符串包含,引擎数得出来的事实别让模型从没标点的话里自己抠。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { noteAnchors, noteAnchorTitle } from '../note-anchors.ts';
|
||||
|
||||
const row = (object: string, key: Record<string, string>, patch: Record<string, unknown>) => ({
|
||||
domain: 'crm_coverage',
|
||||
object,
|
||||
key,
|
||||
patch,
|
||||
});
|
||||
|
||||
const PLAN = [
|
||||
row('plan', { round_ref: 'COV-1', contact_code: 'CU-0142-01' }, { customer_name: 'Itelma', contact_name: 'Ekaterina Volkova', action: '拜访采购' }),
|
||||
row('plan', { round_ref: 'COV-1', contact_code: 'cu-1234-00' }, { customer_name: '测试111', contact_name: '关键人待识别', action: '首访建档' }),
|
||||
];
|
||||
|
||||
describe('noteAnchors —— 意见按字面点名对上了哪几行', () => {
|
||||
it('**实测那一单**:意见里的「测试111」恰是一行的客户名 —— 命中它,不命中 Itelma', () => {
|
||||
const hits = noteAnchors('测试111建议动作改成拜访CTO', PLAN);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toContain('cu-1234-00');
|
||||
expect(hits[0]).toContain('测试111');
|
||||
});
|
||||
|
||||
it('点的是编码也认得(主键值同样参与比对)', () => {
|
||||
const hits = noteAnchors('CU-0142-01 的动作太保守,改激进些', PLAN);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toContain('CU-0142-01');
|
||||
});
|
||||
|
||||
it('一行都没点名 → 空数组(没有依据的时候不说话)', () => {
|
||||
expect(noteAnchors('整体排序不合理,重新排一遍', PLAN)).toEqual([]);
|
||||
});
|
||||
|
||||
it('纯数字与短值不算点名 —— 「111」「01」撞上纯属巧合', () => {
|
||||
const rows = [row('plan', { round_ref: 'COV-1', contact_code: 'X' }, { seq: '111', tag: '高' })];
|
||||
expect(noteAnchors('测试111建议动作改成拜访CTO', rows)).toEqual([]);
|
||||
});
|
||||
|
||||
it('命中太多行(> 5)当没命中 —— 什么都命中的提示是噪音', () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) =>
|
||||
row('plan', { round_ref: 'COV-1', contact_code: `C-${i}` }, { customer_name: 'Itelma' }),
|
||||
);
|
||||
expect(noteAnchors('Itelma 的动作都改一下', rows)).toEqual([]);
|
||||
});
|
||||
|
||||
it('同一行几格都命中时只出一次,展示最长的那个名字', () => {
|
||||
const rows = [row('plan', { round_ref: 'COV-1', contact_code: 'C-1' }, { customer_name: '测试111', short: '测试' })];
|
||||
const hits = noteAnchors('测试111建议动作改成拜访CTO', rows);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toContain('「测试111」');
|
||||
});
|
||||
|
||||
it('观察全文带行动指南:先认归属、点名的行缺什么改这一行自己的内容', () => {
|
||||
const title = noteAnchorTitle(['crm_coverage.plan COV-1/cu-1234-00(命中「测试111」)']);
|
||||
expect(title).toContain('先认这几行的归属');
|
||||
expect(title).toContain('不是把改动挪到别的行头上');
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,30 @@ describe('件上加行:主键凑得齐才落得下', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('**空着的自增列不许从别的行借号** —— 加的新联系人不再覆盖该客户已有的草稿行(2026-08-24)', () => {
|
||||
// 人加一行:customer_code 填了、contact_code(auto)在件上但空着;该客户已有一行草稿联系人
|
||||
const contactDrafts = [{ key: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' } }];
|
||||
const out = resolveRowKey({
|
||||
keys: ['customer_code', 'contact_code'],
|
||||
cells: { customer_code: 'CU-E2E-ADD', contact_code: '', name: '测试CTO王工' },
|
||||
drafts: contactDrafts,
|
||||
types: { customer_code: 'text', contact_code: 'text' },
|
||||
autos: ['contact_code'],
|
||||
});
|
||||
// 不许把 -01 借来当自己的钥匙(那是覆盖别人);认不出 → 交给 mint 那条路续号
|
||||
expect(out).toBeUndefined();
|
||||
// 编码真填了的(编辑既有行)照旧认得出
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys: ['customer_code', 'contact_code'],
|
||||
cells: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01', name: '改名' },
|
||||
drafts: contactDrafts,
|
||||
types: { customer_code: 'text', contact_code: 'text' },
|
||||
autos: ['contact_code'],
|
||||
}),
|
||||
).toEqual({ customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' });
|
||||
});
|
||||
|
||||
it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => {
|
||||
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
|
||||
round_ref: 'CTB-1',
|
||||
@@ -438,3 +462,61 @@ describe('parseRowKey —— 件上传来的主键认哪一行', () => {
|
||||
expect(parseRowKey('X', [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **保存说成功,就得真的都存下了**(2026-08-24 用户实测:
|
||||
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
|
||||
*
|
||||
* 主键那几格空着就凑不出钥匙 —— 从前基座一句 `continue` 悄悄跳过,接口照回 `{ok:true}`:
|
||||
* 人看到「保存成功」,东西却不在,而提交闸也拦不住(一行根本没写进台账,谈何缺列)。
|
||||
* 现在没落下的行要点名说出来,且**只点他填得了的那几列**。
|
||||
*/
|
||||
describe('落不下的行要点名 —— 不许悄悄丢', () => {
|
||||
const keys = ['round_ref', 'customer_code'];
|
||||
|
||||
it('主键那一格空着 → 认不出这一行(resolveRowKey 交白卷)', () => {
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys,
|
||||
cells: { customer_code: '', customer_name: '忘了填编号的客户' },
|
||||
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
||||
types: { round_ref: 'text', customer_code: 'text' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('主键填了 → 案号由「全表一致」补齐,行认得出来', () => {
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys,
|
||||
cells: { customer_code: 'CU-9527', customer_name: '人工加的客户' },
|
||||
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
||||
types: { round_ref: 'text', customer_code: 'text' },
|
||||
}),
|
||||
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
||||
});
|
||||
|
||||
it('**表里只有一行时也不许「继承」老行的编码** —— 那会当场把老行覆盖掉', () => {
|
||||
// 件上摆着 customer_code、人却空着:一行的表在「全表一致」眼里像个常量,
|
||||
// 从前新行会拿到 CU-0142(屏上两行、库里一行,而且内容还是新的那份)
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys,
|
||||
cells: { customer_code: '', customer_name: '新客户' },
|
||||
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
||||
types: { round_ref: 'text', customer_code: 'text' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('件上没摆的那把钥匙(案号)照旧由基座按全表一致补 —— 那是他碰不到的列', () => {
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys,
|
||||
cells: { customer_code: 'CU-9527' },
|
||||
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
||||
types: { round_ref: 'text', customer_code: 'text' },
|
||||
}),
|
||||
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,6 +272,8 @@ function restPart(scope: Record<string, unknown>, options?: { withoutAttachments
|
||||
const rest = Object.entries(scope).filter(
|
||||
([k]) =>
|
||||
!k.startsWith('mine:') &&
|
||||
// touched: 是给落库预览画面的(叠加后行),内容与 mine/整表重叠 —— 不进提示词
|
||||
!k.startsWith('touched:') &&
|
||||
!k.startsWith('focus:') &&
|
||||
!(options?.withoutAttachments === true && k === 'attachments'),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 广播的**跨进程桥** —— 别的后端动了同一张单,这边的浏览器也得知道。
|
||||
*
|
||||
* 起因(2026-08-24 用户报的第三件事):「一直卡在 AI 推进中,刷新后才发现是已经推完了、
|
||||
* 需要人类确认了」。查下来推送本身是好的 —— 可 `createBroadcaster()` 是**进程内**的
|
||||
* 一个 Set:谁推进的,只有谁自己的 SSE 客户端收得到。而那会儿本机与局域网另一台
|
||||
* 各跑着一个后端连同一个库(同一批症状的另一面,见 `workers/task-claim.ts`):
|
||||
* 单子被那一台推着走,状态在库里早变了,**这一台的浏览器一个字都没收到** ——
|
||||
* 界面就停在「AI 推进中」,直到人手动刷新。
|
||||
*
|
||||
* 桥怎么搭:Postgres 自带的 `LISTEN/NOTIFY` —— 不引消息队列、不加部署件。
|
||||
* · 本进程 publish 一条 → 顺手 `NOTIFY`(带上进程标识);
|
||||
* · 一条**专用连接**常年 `LISTEN`,收到别的进程发来的就在本地 publish 一次。
|
||||
* **自己发的自己不再收**(靠进程标识认),不然一条推送在两台之间来回弹。
|
||||
*
|
||||
* 桥断了不影响业务:推送本就是「告诉在线浏览器去重取」的提示,丢一条最坏是
|
||||
* 那一屏晚两秒(前端还有轮询兜底)。所以这里所有错误都只记一行,不往上抛。
|
||||
*/
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { Broadcaster, Touch } from './broadcast.ts';
|
||||
|
||||
/** 频道名 —— 全库一个就够,载荷里带着 kind/id,订阅方自己分 */
|
||||
export const TOUCH_CHANNEL = 'jcai_touch';
|
||||
|
||||
/** 这一次进程的身份 —— 用来认「这条是我自己发的」,不必稳定、不必持久 */
|
||||
const SELF = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
type Wire = Touch & { readonly from: string };
|
||||
|
||||
/**
|
||||
* 把本地广播接到库上。返回 `stop()`;起不来不抛 —— 单进程照常跑,只是没有跨进程那一段。
|
||||
*
|
||||
* `notify` 用参数化的 `pg_notify` 而不是拼 `NOTIFY` 语句:载荷里有中文与引号,
|
||||
* 拼字符串迟早出事(而这条路上的字符串全来自业务)。
|
||||
*/
|
||||
export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster): { stop(): void } {
|
||||
let listener: PoolClient | undefined;
|
||||
let stopped = false;
|
||||
|
||||
/** 本地 → 库:自己推的顺手 NOTIFY 一条(不 await,广播不该拖慢业务) */
|
||||
const unsubscribe = broadcaster.subscribe((touch) => {
|
||||
if (stopped) return;
|
||||
const wire: Wire = { ...touch, from: SELF };
|
||||
void pool
|
||||
.query('select pg_notify($1, $2)', [TOUCH_CHANNEL, JSON.stringify(wire)])
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(`[broadcast-bridge] notify 失败:${error instanceof Error ? error.message : String(error)}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
/** 库 → 本地:一条专用连接常年 LISTEN,收到别人的就在本地 publish */
|
||||
const connect = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
listener = client;
|
||||
client.on('notification', (message) => {
|
||||
if (message.channel !== TOUCH_CHANNEL || message.payload === undefined) return;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message.payload);
|
||||
/*
|
||||
* **形状不对就丢** —— 频道是全库公用的,谁都能往里 NOTIFY 一句话。
|
||||
* 不校形状的话,一条 `"hello"` 会被当成 touch 广播出去:
|
||||
* 前端拿到 `kind: undefined` 谁也失效不了,还白跑一趟(界面不撒谎那一条,
|
||||
* 对推送同样算数)。
|
||||
*/
|
||||
if (typeof parsed !== 'object' || parsed === null) return;
|
||||
const wire = parsed as Partial<Wire>;
|
||||
if (typeof wire.kind !== 'string' || typeof wire.id !== 'string') return;
|
||||
// **自己发的自己不收** —— 否则一条推送在进程之间来回弹
|
||||
if (wire.from === SELF) return;
|
||||
broadcaster.publish({ kind: wire.kind as Touch['kind'], id: wire.id, reason: wire.reason ?? 'remote' });
|
||||
} catch {
|
||||
/* 载荷根本不是 JSON:丢这一条,不拖垮连接 */
|
||||
}
|
||||
});
|
||||
client.on('error', (error) => {
|
||||
process.stderr.write(`[broadcast-bridge] 连接断了,5 秒后重连:${error.message}\n`);
|
||||
client.release();
|
||||
listener = undefined;
|
||||
setTimeout(() => void connect(), 5000).unref();
|
||||
});
|
||||
await client.query(`listen ${TOUCH_CHANNEL}`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`[broadcast-bridge] 接不上,5 秒后重试:${error instanceof Error ? error.message : String(error)}\n`);
|
||||
setTimeout(() => void connect(), 5000).unref();
|
||||
}
|
||||
};
|
||||
void connect();
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
listener?.release();
|
||||
listener = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* **对话区按任务内序号排,不按墙上时钟**(2026-08-24 柴浩:「上一个步骤的消息跑到这一个
|
||||
* 步骤的后面」)。
|
||||
*
|
||||
* 对话流 = 时间线(AI 发言 / 系统事件,来自任务记忆)+ 对话卡族(录入卡 / 决策卡),
|
||||
* 从前合并时按 `at` 字符串排序。同一台后端上它们出自同一只时钟,没问题;
|
||||
* 可两台后端连同一个库交替推同一张单时(本机 + 局域网另一台,当天实测的部署形态),
|
||||
* 各自的时钟差上几秒,上一拍的话就排到了下一拍的后面 —— 「有时候乱」正是这个形状。
|
||||
*
|
||||
* 任务记忆的 `seq` 是任务内单调的(取 max+1,与时钟无关):每条对话项都能找到它在记忆里
|
||||
* 的落点 —— AI/系统项本身就是记忆条目;卡对应 `card-sent` 那条(detail.id);
|
||||
* 人的自由输入对应 `human-input` 那条(detail.id)。找不到落点的(老数据)退回按
|
||||
* 「它的时刻之前最后一条记忆的序号」插位 —— 仍是一个总序,只是精度回到时钟。
|
||||
*/
|
||||
export type OrderEntry = {
|
||||
readonly id: string;
|
||||
readonly seq: number;
|
||||
readonly kind: string;
|
||||
readonly at: string;
|
||||
readonly detail?: Readonly<Record<string, unknown>> | undefined;
|
||||
};
|
||||
|
||||
export function orderChat<T extends { readonly id: string; readonly at: string }>(
|
||||
items: readonly T[],
|
||||
history: readonly OrderEntry[],
|
||||
): T[] {
|
||||
const byEntryId = new Map<string, number>();
|
||||
const byLinkedId = new Map<string, number>();
|
||||
for (const e of history) {
|
||||
byEntryId.set(e.id, e.seq);
|
||||
const linked = e.detail?.['id'];
|
||||
if (typeof linked === 'string' && (e.kind === 'card-sent' || e.kind === 'human-input')) {
|
||||
if (!byLinkedId.has(linked)) byLinkedId.set(linked, e.seq);
|
||||
}
|
||||
}
|
||||
const sortedByAt = [...history].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
||||
const seqBefore = (at: string): number => {
|
||||
let seq = 0;
|
||||
for (const e of sortedByAt) {
|
||||
if (e.at <= at) seq = Math.max(seq, e.seq);
|
||||
else break;
|
||||
}
|
||||
return seq;
|
||||
};
|
||||
const keyed = items.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
seq: byEntryId.get(item.id) ?? byLinkedId.get(item.id) ?? seqBefore(item.at),
|
||||
}));
|
||||
keyed.sort((a, b) => a.seq - b.seq || (a.item.at < b.item.at ? -1 : a.item.at > b.item.at ? 1 : 0) || a.index - b.index);
|
||||
return keyed.map((k) => k.item);
|
||||
}
|
||||
@@ -83,6 +83,8 @@ import type { AppConfig } from './config.ts';
|
||||
import { createIdGenerator, systemClock } from './runtime.ts';
|
||||
import { createSecretBox, loadSecretKey } from './secret-box.ts';
|
||||
import { createBroadcaster } from './broadcast.ts';
|
||||
import { noteAnchors, noteAnchorTitle } from './note-anchors.ts';
|
||||
import { bridgeBroadcaster } from './broadcast-bridge.ts';
|
||||
import type { Broadcaster } from './broadcast.ts';
|
||||
|
||||
export type Container = {
|
||||
@@ -188,6 +190,12 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
process.stderr.write(`[pg] 空闲连接异常(已剔除,下次取用自动重连):${error.message}\n`);
|
||||
});
|
||||
const db = drizzle(pool);
|
||||
/*
|
||||
* **广播接到库上** —— 多个后端连同一个库时,别的进程动了同一张单,
|
||||
* 这边的浏览器也得知道(不然就停在「AI 推进中」直到人手动刷新,2026-08-24 用户实测)。
|
||||
* 单进程部署时它只是空转一条 LISTEN 连接,没有副作用。
|
||||
*/
|
||||
const bridge = bridgeBroadcaster(pool, broadcaster);
|
||||
const ids = createIdGenerator();
|
||||
const secrets = createSecretBox(loadSecretKey());
|
||||
|
||||
@@ -259,10 +267,18 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
simulation: {
|
||||
isSimulated: async (taskId) => {
|
||||
const res = await pool.query<{ simulated: boolean }>(
|
||||
/*
|
||||
* **勾了「落真库」的试跑单不算试跑** —— 这道判断只服务一件事:
|
||||
* 放行合并时主数据要不要挡下来(见 `ledger-service`)。
|
||||
* 2026-08-24 用户要的正是这个:演示时要看见 `customer.contact` 的亲密度真的变了,
|
||||
* 而不是单据里写着「3 → 10」、主数据还是 3。默认仍然挡 —— 破例得有人按一下。
|
||||
*/
|
||||
`select exists (
|
||||
select 1 from sys.task t
|
||||
left join sys.workflow w on w.id = t.workflow_id
|
||||
where t.id = $1 and (t.origin = 'testbed' or w.source->>'ref' = 'testbed')
|
||||
where t.id = $1
|
||||
and (t.origin = 'testbed' or w.source->>'ref' = 'testbed')
|
||||
and coalesce(t.land_master, false) = false
|
||||
) as simulated`,
|
||||
[taskId],
|
||||
);
|
||||
@@ -408,11 +424,31 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
append: async (input) =>
|
||||
void (await memory.append({
|
||||
...input,
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
})),
|
||||
append: async (input) => {
|
||||
const stamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] };
|
||||
await memory.append({ ...input, stamp });
|
||||
/*
|
||||
* **驳回意见点了谁,基座当场比对好摆进轨迹**(2026-08-24 柴浩实测:意见写
|
||||
* 「测试111建议动作改成拜访CTO」,模型把改动落到了 Itelma 那一行)。
|
||||
* 归属是纯字符串包含 —— 引擎有现成事实的,不让模型从一句没标点的话里自己抠。
|
||||
* 观察挂了不拦回传:它是给下一拍的路标,不是这一笔的门。
|
||||
*/
|
||||
if (input.kind === 'card-reply') {
|
||||
const detail = (input.detail ?? {}) as { note?: unknown; value?: unknown };
|
||||
const custom = typeof detail.value === 'string' && detail.value.startsWith('custom:') ? detail.value.slice('custom:'.length) : '';
|
||||
const note = typeof detail.note === 'string' && detail.note.trim() !== '' ? detail.note : custom;
|
||||
if (note.trim() !== '') {
|
||||
try {
|
||||
const hits = noteAnchors(note, await ledger.taskDrafts(input.taskId));
|
||||
if (hits.length > 0) {
|
||||
await memory.append({ taskId: input.taskId, kind: 'observation', title: noteAnchorTitle(hits), stamp });
|
||||
}
|
||||
} catch {
|
||||
/* 比对失败只是少一条路标 */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
events: {
|
||||
publishHumanEvent: async (input) => {
|
||||
@@ -703,6 +739,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
report: deskElements.report,
|
||||
trace,
|
||||
async close() {
|
||||
bridge.stop();
|
||||
await pool.end();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* **驳回意见点了谁 —— 按字面比对,基座认得出来的归属别让模型猜**(2026-08-24 柴浩实测)。
|
||||
*
|
||||
* 那一单的意见原话是「测试111建议动作改成拜访CTO」,计划表里明明有一行
|
||||
* `customer_name = 测试111`;模型复述意见时把「测试111」整个丢掉(它把这三个字
|
||||
* 读成了「测试」+ 杂音),只剩「建议动作改成拜访CTO」,然后按自己的推理挑了
|
||||
* Itelma 的那一行去改 —— 人看到的是「我说了改测试111,它改到了 Itelma」。
|
||||
*
|
||||
* 治法与 E04 的 worldTouched 那一课同款:**指南让模型判的事,引擎有现成事实的,
|
||||
* 一律改成读事实**。「意见里那个名字对上台账哪一行」是纯字符串包含,机器一趟就
|
||||
* 数得出来;比对结果以观察进轨迹,落在 card-reply 旁边 —— 模型下一拍读意见时,
|
||||
* 归属已经是摆好的事实,不必再从一句没有标点的话里自己抠实体。
|
||||
*
|
||||
* 只做**字面比对**,不做任何语义猜测:
|
||||
* · 格子的值(字符串、去空白后 ≥ 3 字、非纯数字)是意见原文的子串 → 这一行被点名;
|
||||
* · 一行都没对上 → 没有这条观察(没有依据的时候不说话);
|
||||
* · 对上太多行(> 5)→ 也不说 —— 什么都命中的提示不是提示,是噪音。
|
||||
*/
|
||||
|
||||
/** 本单草稿层的一行 —— key 与 patch 合起来就是这一行现有的格子 */
|
||||
export type DraftRow = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly key: Readonly<Record<string, unknown>>;
|
||||
readonly patch: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** 一行至少要这么长的值才算「点名」—— 短于此的子串(如「高」「01」)碰上纯属巧合 */
|
||||
const MIN_ANCHOR = 3;
|
||||
/** 命中行数封顶 —— 超过就当没命中(意见里带了个太常见的词) */
|
||||
const MAX_HITS = 5;
|
||||
|
||||
/**
|
||||
* 意见按字面点名对上了哪几行。返回人读的行描述(空数组 = 别出声)。
|
||||
*
|
||||
* 每行取**命中的值里最长的那个**当展示(「测试111」比「111」更像人点的名);
|
||||
* 行的身份用 `域.对象 主键值` 说 —— 与拒回里主键名单同一口径。
|
||||
*/
|
||||
export function noteAnchors(note: string, rows: ReadonlyArray<DraftRow>): string[] {
|
||||
const text = note.trim();
|
||||
if (text.length < MIN_ANCHOR) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const cells: Record<string, unknown> = { ...row.key, ...row.patch };
|
||||
let best = '';
|
||||
for (const value of Object.values(cells)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const v = value.trim();
|
||||
if (v.length < MIN_ANCHOR) continue;
|
||||
if (/^\d+$/.test(v)) continue; // 纯数字撞上「2026」「111」这类年份/序号,不算点名
|
||||
if (!text.includes(v)) continue;
|
||||
if (v.length > best.length) best = v;
|
||||
}
|
||||
if (best === '') continue;
|
||||
const id = `${row.domain}.${row.object}|${JSON.stringify(row.key)}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(`${row.domain}.${row.object} ${Object.values(row.key).map(String).join('/')}(命中「${best}」)`);
|
||||
}
|
||||
return out.length > MAX_HITS ? [] : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中之后那条观察的全文 —— **它是模型下一拍的行动指南**(与拒回话术同一条纪律):
|
||||
* 只说归属这个事实与「别改到没点名的行上」,不替模型决定改什么。
|
||||
*/
|
||||
export function noteAnchorTitle(hits: readonly string[]): string {
|
||||
return (
|
||||
`意见按字面点名对上了本单写下的这几行:${hits.join('、')} —— ` +
|
||||
'要按意见改行,先认这几行的归属;点名的行缺什么(比如还没有那样的联系人),' +
|
||||
'改的是这一行自己的内容,不是把改动挪到别的行头上'
|
||||
);
|
||||
}
|
||||
+179
-26
@@ -42,21 +42,45 @@ function rowsOf<T>(result: unknown): T[] {
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { coerceToColumn, missingReadings } from '../wiring/engines.ts';
|
||||
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
||||
import { orderChat } from '../chat-order.ts';
|
||||
|
||||
|
||||
/** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */
|
||||
type EditedRow = Readonly<Record<string, string>>;
|
||||
|
||||
/** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */
|
||||
type WriteBack = { rows: number; added: number; dropped: string[]; bad: string[] };
|
||||
type WriteBack = {
|
||||
rows: number;
|
||||
added: number;
|
||||
/**
|
||||
* **人新增的是哪几行**(主键,2026-08-24 柴浩实测补):从前只有个数「新增 1 行」——
|
||||
* 模型看不见这一屏,只看得见轨迹;不点名,它就守着自己记忆里的「识别出 1 家」
|
||||
* 把人加的那家从头无视到尾(计划/回执/收口一行都没它)。撤行早就点名了
|
||||
* (「撤掉的是人定的,别再写回去」),加行是同一条道理的另一半。
|
||||
*/
|
||||
addedKeys: string[];
|
||||
dropped: string[];
|
||||
bad: string[];
|
||||
/**
|
||||
* **认不出是哪一行、因而没落下的那几行**(2026-08-24 用户实测:
|
||||
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
|
||||
*
|
||||
* 从前这里是一句 `continue` —— 行悄悄没了,而接口照旧回 `{ok:true}`:
|
||||
* 人看到「保存成功」,东西却不在。**保存说成功,就得真的都存下了**;
|
||||
* 存不下的必须点名说出来(是哪张表、缺哪几列钥匙),让他补得上。
|
||||
*/
|
||||
unkeyed: Array<{ address: string; keys: string[]; hint: string }>;
|
||||
};
|
||||
|
||||
const nothing = (): WriteBack => ({ rows: 0, added: 0, dropped: [], bad: [] });
|
||||
const nothing = (): WriteBack => ({ rows: 0, added: 0, addedKeys: [], dropped: [], bad: [], unkeyed: [] });
|
||||
const merge = (into: WriteBack, one: WriteBack): void => {
|
||||
into.rows += one.rows;
|
||||
into.added += one.added;
|
||||
into.addedKeys.push(...one.addedKeys);
|
||||
into.dropped.push(...one.dropped);
|
||||
into.bad.push(...one.bad);
|
||||
into.unkeyed.push(...one.unkeyed);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,6 +110,8 @@ export function resolveRowKey(input: {
|
||||
cells: Readonly<Record<string, string>>;
|
||||
drafts: ReadonlyArray<{ key: Record<string, unknown> }>;
|
||||
types: Readonly<Record<string, string>>;
|
||||
/** 声明了 `auto` 的主键列 —— 见下面那条「空着的自增列不许从别的行借号」 */
|
||||
autos?: readonly string[];
|
||||
}): Record<string, string> | undefined {
|
||||
const { keys, cells, drafts, types } = input;
|
||||
const key: Record<string, string> = {};
|
||||
@@ -95,7 +121,16 @@ export function resolveRowKey(input: {
|
||||
}
|
||||
if (Object.keys(key).length === keys.length) return key;
|
||||
|
||||
const known = findKnownRow(drafts, keys, key);
|
||||
/*
|
||||
* **空着的自增列不是身份线索,是「给我续个号」**(2026-08-24 确认屏补录联系人逼出):
|
||||
* 人加的新行联系人编码本来就该空着(自增列不问人),可 findKnownRow 拿着只有
|
||||
* customer_code 的半把钥匙,会把这一行错配到该客户**已有的草稿联系人行**上 ——
|
||||
* 编码从那行借来,「新增」当场变「覆盖」:模型刚建的那位被人加的这位顶掉,
|
||||
* 痕还写着「改了 1 行」。声明了 auto 的列,件上摆着却空着 → 一律不许从别的行
|
||||
* 借值,留给续号那条路(writeEditedRows 的 mint)。
|
||||
*/
|
||||
const barred = new Set((input.autos ?? []).filter((k) => k in cells && (cells[k] ?? '').trim() === ''));
|
||||
const known = barred.size > 0 ? undefined : findKnownRow(drafts, keys, key);
|
||||
const isSeq = (column: string): boolean => types[column] === 'integer' || types[column] === 'number';
|
||||
/*
|
||||
* **序号型主键不走「全表一致」这条路**(2026-08-23 浏览器实测逮到的):
|
||||
@@ -105,7 +140,19 @@ export function resolveRowKey(input: {
|
||||
*/
|
||||
for (const k of keys) {
|
||||
if (key[k] !== undefined) continue;
|
||||
if (barred.has(k)) continue;
|
||||
if (known === undefined && isSeq(k)) continue;
|
||||
/*
|
||||
* **件上摆着、他却空着的那一列,不许替他编一个值**(2026-08-24 用户实测的第二面)。
|
||||
*
|
||||
* 「全表一致」这条路是给**件上压根没摆**的主键用的(如案号 `round_ref`:
|
||||
* 一单一号,全表当然一致,基座补它天经地义)。可如果那一列就在件上、人能填、
|
||||
* 他没填 —— 补出来的值是**替他决定这一行是谁**:表里恰好只有一行时,
|
||||
* 新加的那行会拿到老行的客户编码,**当场把老行覆盖掉**(屏上两行,库里一行)。
|
||||
* 序号型主键 2026-08-23 已经因为同一个原因单独绕开过 —— 那是这条规律的一个特例,
|
||||
* 现在按「他填得了吗」统一判:填得了就必须他填,基座只补他碰不到的那几列。
|
||||
*/
|
||||
if (known === undefined && k in cells) continue;
|
||||
const v =
|
||||
known === undefined
|
||||
? (() => {
|
||||
@@ -133,14 +180,24 @@ export function resolveRowKey(input: {
|
||||
async function writeEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean }> },
|
||||
declared: { domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean; auto?: boolean | undefined }> },
|
||||
lines: readonly EditedRow[],
|
||||
): Promise<WriteBack> {
|
||||
const { domain, object } = declared;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const out = nothing();
|
||||
// 这一单现有的行 —— 三处要用:补主键(件上没摆全的那几列)、认出哪几行真改了、认出哪几行是新增
|
||||
// 本单草稿行 —— 补主键用(件上没摆全的那几列按它认)
|
||||
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
||||
/*
|
||||
* **「真改了吗」要对着叠加后的行比,不能只对草稿**(2026-08-24 落库预览逼出来的):
|
||||
* 预览回显的是正式现值 + 草稿的叠加 —— 人一格没动按下保存,回传里带着的是回显值;
|
||||
* 只对草稿比的话,草稿里没有的那几列(如 Itelma 既有的客户类型)全算「改了」,
|
||||
* 痕上写「改了 2 行」而人一个字没动(日志撒谎),还平白多出一笔草稿。
|
||||
* 「这一行是不是新增」同理:正式表里有、草稿里没有的行,不是新增。
|
||||
*/
|
||||
const page = await container.ledger
|
||||
.taskView(taskId, { address: { domain, object }, where: {}, orderBy: [], limit: 500, offset: 0 })
|
||||
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
||||
const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type]));
|
||||
for (const cells of lines) {
|
||||
/*
|
||||
@@ -150,7 +207,13 @@ async function writeEditedRows(
|
||||
* 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法,
|
||||
* 尤其序号型那一手不补,人在件上加的行会被静默丢掉。
|
||||
*/
|
||||
const key = resolveRowKey({ keys, cells, drafts, types });
|
||||
const key = resolveRowKey({
|
||||
keys,
|
||||
cells,
|
||||
drafts,
|
||||
types,
|
||||
autos: declared.fields.filter((f) => f.primaryKey && f.auto === true).map((f) => f.name),
|
||||
});
|
||||
/*
|
||||
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
|
||||
*
|
||||
@@ -169,12 +232,73 @@ async function writeEditedRows(
|
||||
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;
|
||||
if (key === undefined) {
|
||||
const filled = Object.entries(cells).filter(([, v]) => v.trim() !== '');
|
||||
/*
|
||||
* **自增主键空着不是「他没填」,是「不用他填」**(2026-08-24 用户在台账更新确认
|
||||
* 屏上补录联系人逼出来的):声明标了 `auto` 的主键(联系人编码)本就不该问人 ——
|
||||
* 录入卡那条路 2026-08-02 就立了这条规矩,件上「加一行」是同一件事的另一个入口。
|
||||
* 其余主键他都填了、缺的恰好全是 auto 列 → 由基座按声明的续号规则铸
|
||||
* (同组其余主键值-两位序号,序号数正式版 + 本单草稿,与录入卡同一段 autoKeys)。
|
||||
*/
|
||||
const autoCols = keys.filter((k) => declared.fields.find((f) => f.name === k)?.auto === true);
|
||||
const blankKeys = keys.filter((k) => (cells[k] ?? '').trim() === '');
|
||||
const mintable =
|
||||
filled.length > 0 &&
|
||||
autoCols.length > 0 &&
|
||||
blankKeys.length > 0 &&
|
||||
blankKeys.every((k) => autoCols.includes(k)) &&
|
||||
keys.filter((k) => !autoCols.includes(k)).every((k) => (cells[k] ?? '').trim() !== '');
|
||||
if (mintable) {
|
||||
const given = Object.fromEntries(keys.filter((k) => !autoCols.includes(k)).map((k) => [k, (cells[k] ?? '').trim()]));
|
||||
const minted = await autoKeys(container, taskId, declared as LedgerDeclaration, given);
|
||||
const newKey = { ...given, ...minted };
|
||||
if (keys.every((k) => (newKey[k] ?? '') !== '')) {
|
||||
await container.ledger.draftWrite({ taskId, address: { domain, object }, key: newKey, patch });
|
||||
out.added += 1;
|
||||
out.addedKeys.push(rowLabel(newKey, declared.caseKey));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **认不出这一行,就得说出来** —— 从前这里是一句 `continue`:人在件上加了一行、
|
||||
* 按了保存、接口回 `{ok:true}`,而那一行根本没落下(2026-08-24 用户实测)。
|
||||
* 键凑不齐只有一种情形:主键那几格他没填(件上摆着、标着必填,但空着)。
|
||||
* 空行不算 —— 那是「加一行」点出来还没动的骨架,人自己也知道它是空的。
|
||||
*/
|
||||
if (filled.length > 0) {
|
||||
/*
|
||||
* **只点名他填得了的那几列** —— 件上摆着、却空着的主键格。
|
||||
* 压根不在件上的主键(如案号 `round_ref`,由基座按全表一致补)不许出现在这句话里:
|
||||
* 那是基座的活,写进去等于把人指向一扇他打不开的门(拒回指错方向比不指更糟)。
|
||||
*/
|
||||
// 自增列不点名 —— 人填不了它(拒回指向打不开的门比不指更糟)
|
||||
const missing = keys.filter(
|
||||
(k) => k in cells && (cells[k] ?? '').trim() === '' && declared.fields.find((f) => f.name === k)?.auto !== true,
|
||||
);
|
||||
// 人读名优先(件上摆的是「客户编号」,不是 customer_code)—— 取不到就退列名
|
||||
const label = (name: string): string => {
|
||||
const found = declared.fields.find((f) => f.name === name) as { label?: string } | undefined;
|
||||
return found?.label ?? name;
|
||||
};
|
||||
out.unkeyed.push({
|
||||
address: `${domain}.${object}`,
|
||||
keys: missing,
|
||||
hint:
|
||||
missing.length > 0
|
||||
? `这一行没能落下 —— ${missing.map(label).join('、')}是它的钥匙,填上才存得住`
|
||||
: '这一行没能落下 —— 认不出它是哪一行(主键这几格件上没摆出来),请报给开发',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const was = page.rows.find((r) => keys.every((k) => String((r as Record<string, unknown>)[k] ?? '') === key[k]));
|
||||
if (!rowChanged(was as Record<string, unknown> | undefined, patch)) continue;
|
||||
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
|
||||
if (was === undefined) out.added += 1;
|
||||
else out.rows += 1;
|
||||
if (was === undefined) {
|
||||
out.added += 1;
|
||||
out.addedKeys.push(rowLabel(key, declared.caseKey));
|
||||
} else out.rows += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -192,7 +316,7 @@ async function writeEditedRows(
|
||||
async function dropEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> },
|
||||
declared: { domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> },
|
||||
lines: readonly EditedRow[],
|
||||
): Promise<WriteBack> {
|
||||
const { domain, object } = declared;
|
||||
@@ -208,7 +332,7 @@ async function dropEditedRows(
|
||||
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('/'));
|
||||
if (gone) out.dropped.push(rowLabel(one.key, declared.caseKey));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -483,23 +607,35 @@ export function parseRowKey(raw: string, keys: readonly string[]): Record<string
|
||||
*/
|
||||
async function noteEdits(container: Container, taskId: TaskId, got: WriteBack): Promise<number> {
|
||||
const touched = got.rows + got.added + got.dropped.length;
|
||||
if (touched === 0 && got.bad.length === 0) return touched;
|
||||
// 一行都没落下也要留痕 —— 那正是最该说出来的一次(从前这里把 unkeyed 一并吞了)
|
||||
if (touched === 0 && got.bad.length === 0 && got.unkeyed.length === 0) return touched;
|
||||
const parts: string[] = [];
|
||||
if (got.rows > 0) parts.push(`改了 ${got.rows} 行`);
|
||||
if (got.added > 0) parts.push(`新增 ${got.added} 行`);
|
||||
/*
|
||||
* **加的行要点名**(2026-08-24 柴浩实测):只写「新增 1 行」,模型守着轨迹里
|
||||
* 「识别出 1 家」把人加的那家从计划到收口全程无视 —— 这条痕是它下一拍的行动指南,
|
||||
* 得说清加的是谁、且与既有的行同等作数。
|
||||
*/
|
||||
if (got.added > 0) parts.push(`新增 ${got.added} 行(${got.addedKeys.slice(0, 5).join('、')})`);
|
||||
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('、')})`);
|
||||
// 没落下的行要在日志里点名 —— 下一拍模型看的是这条痕,人回看也是它
|
||||
if (got.unkeyed.length > 0) {
|
||||
parts.push(`${got.unkeyed.length} 行没落下(${got.unkeyed.map((u) => u.hint).slice(0, 2).join(';')})`);
|
||||
}
|
||||
await container.memory.append({
|
||||
taskId,
|
||||
kind: 'human-input',
|
||||
title:
|
||||
`就地修订成果:${parts.join(';')}` +
|
||||
(got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对'),
|
||||
(got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对') +
|
||||
(got.added === 0 ? '' : ' —— 新增的行是人定的,与既有的行同等作数:后续每一步按台账现读的全表办,别按你先前识别的行数办'),
|
||||
detail: {
|
||||
rows: got.rows,
|
||||
...(got.added === 0 ? {} : { added: got.added }),
|
||||
...(got.added === 0 ? {} : { added: got.added, addedKeys: got.addedKeys }),
|
||||
...(got.dropped.length === 0 ? {} : { dropped: got.dropped }),
|
||||
...(got.bad.length === 0 ? {} : { rejected: got.bad }),
|
||||
...(got.unkeyed.length === 0 ? {} : { unkeyed: got.unkeyed }),
|
||||
},
|
||||
// 版本戳:这条不由活动产出,与引擎那几条同一口径
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
@@ -529,9 +665,9 @@ async function applyViewTableEdits(
|
||||
tables: Readonly<Record<string, readonly EditedRow[]>>;
|
||||
drops?: Readonly<Record<string, readonly EditedRow[]>> | undefined;
|
||||
},
|
||||
): Promise<number> {
|
||||
): Promise<{ rows: number; unkeyed: WriteBack['unkeyed'] }> {
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return 0;
|
||||
if (declarations === undefined) return { rows: 0, unkeyed: [] };
|
||||
const got = nothing();
|
||||
/*
|
||||
* **先撤后写** —— 顺序有意义:人可能把一行删掉、又在同一次保存里加回一行同主键的。
|
||||
@@ -543,13 +679,15 @@ async function applyViewTableEdits(
|
||||
for (const { declared, lines } of writesOf(declarations, payload.tables)) {
|
||||
merge(got, await writeEditedRows(container, taskId, declared, lines));
|
||||
}
|
||||
return noteEdits(container, taskId, got);
|
||||
return { rows: await noteEdits(container, taskId, got), unkeyed: got.unkeyed };
|
||||
}
|
||||
|
||||
/** 台账声明的最小形状 —— 这一段只用到这几样 */
|
||||
type LedgerDeclaration = {
|
||||
domain: string;
|
||||
object: string;
|
||||
/** 单标识列 —— 摆给人看的行标识里去掉它(rowLabel) */
|
||||
caseKey?: string | undefined;
|
||||
fields: ReadonlyArray<{
|
||||
name: string;
|
||||
type: string;
|
||||
@@ -990,8 +1128,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
}
|
||||
|
||||
// 对话流 = 时间线 + 对话卡族,按时间全序 —— 与任务记忆同源、不同筛选
|
||||
const merged: ChatMessage[] = [...chat, ...cards].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
||||
// 对话流 = 时间线 + 对话卡族,**按任务内序号排**(时钟跨机器会偏,序号不会;见 chat-order.ts)
|
||||
const merged: ChatMessage[] = orderChat([...chat, ...cards], await container.memory.history(taskId));
|
||||
|
||||
// 视图区:活动推的 HTML 优先(它是活动按局面组织的完整一屏);
|
||||
// 尚无推送时给一份进度概览,不留空屏
|
||||
@@ -1191,10 +1329,16 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
const body: ApiError = { code: 'conflict', message: '这一单已收尾,改不动了' };
|
||||
return c.json(body, 409);
|
||||
}
|
||||
const rows = await applyViewTableEdits(container, taskId, parsed.data);
|
||||
const saved = await applyViewTableEdits(container, taskId, parsed.data);
|
||||
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
|
||||
if (rows > 0) await container.views.repush(taskId);
|
||||
return c.json({ ok: true, rows });
|
||||
if (saved.rows > 0) await container.views.repush(taskId);
|
||||
/*
|
||||
* **有行没落下就如实说** —— 保存说成功、东西却不在,是这一屏最伤人的一种谎
|
||||
* (2026-08-24 用户实测:件上加的那个客户推到下一步就不见了)。
|
||||
* 仍回 200:别的格子是真落下了,不该因为一行没钥匙把整次保存判死;
|
||||
* 但把没落下的那几行摆回去,前端据此红字点名,人补上主键再存一次。
|
||||
*/
|
||||
return c.json({ ok: true, rows: saved.rows, ...(saved.unkeyed.length === 0 ? {} : { unkeyed: saved.unkeyed }) });
|
||||
});
|
||||
|
||||
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
||||
@@ -1267,6 +1411,14 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||
*/
|
||||
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
|
||||
/*
|
||||
* **返回之前就把这单标成「在办事」**(2026-08-25 用户:「右边聊天框先有反应,中间的
|
||||
* html 过一会儿才变成 AI 正在推进中,看上去很不流畅」)。
|
||||
* 从前 runtime 要等派发器投递、推进器抢到租约、开跑前那一刻才翻成 running ——
|
||||
* 前端 onSuccess 那一次重取抓到的还是 awaiting-event:对话区(卡状态已落)
|
||||
* 先变,工作区(读 runtime)要等下一次推送。两块该读同一份事实。
|
||||
*/
|
||||
await container.task.wake(outcome.taskId).catch(() => false);
|
||||
// 人答完了就接着跑,别让他对着不动的屏幕等两轮轮询(见 workers/pulse.ts)
|
||||
void pulse();
|
||||
return c.json({ ok: true, producesCompletion: outcome.producesCompletion });
|
||||
@@ -1388,6 +1540,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动,
|
||||
* 人以为没点上,会再点一次。见 `workers/pulse.ts`。
|
||||
*/
|
||||
await container.task.wake(cardTaskId).catch(() => false); // 与工单回传同一条理:返回前先标「在办」
|
||||
void pulse();
|
||||
return c.json({ ok: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
listChannelsRoute,
|
||||
listActivityMemoryRoute,
|
||||
listGovTasksRoute,
|
||||
ledgerByActivityRoute,
|
||||
listLedgerObjectsRoute,
|
||||
installActivityRoute,
|
||||
listProcessPackagesRoute,
|
||||
@@ -699,6 +700,17 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
data,
|
||||
...(message === '' ? {} : { message }),
|
||||
});
|
||||
/*
|
||||
* **勾了「落真库」就在这一单上做个记号** —— 放行合并时主数据不再挡下来
|
||||
* (见 `container.ts` 的 `isSimulated` 与 `ledger-service` 那道门)。
|
||||
*
|
||||
* 为什么落在路由里直接改一列,而不是让 `task.start` 收这个参数:
|
||||
* 它是**试跑台这一处的破例**,不是任务本身的属性 ——
|
||||
* 让整个任务模块的契约为一个试跑开关让路,是把临时的东西修进地基里。
|
||||
*/
|
||||
if (parsed.data.landMaster) {
|
||||
await container.db.execute(sql`update sys.task set land_master = true where id = ${task.id}`);
|
||||
}
|
||||
container.broadcaster.publish({ kind: 'task', id: task.id, reason: 'testbed-start' });
|
||||
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
|
||||
return c.json({ taskId: task.id, title: task.title });
|
||||
@@ -1416,6 +1428,134 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
return c.json({ objects: await container.ledger.listObjects() });
|
||||
});
|
||||
|
||||
/**
|
||||
* **按活动看数据域** —— 「我这个活动写了哪几张表,现在各有几行,刚跑那一单落下了什么」。
|
||||
*
|
||||
* 数据只从三处来,一处不编:
|
||||
* · 表与主键 —— 活动清单的台账声明(谁声明的,就归谁);
|
||||
* · 行数 —— `describe` + `query` 的 total(正式表的真实行数;没物化就没有这个数,
|
||||
* **不填 0** —— 「还没建表」与「建了但空着」是两件事);
|
||||
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
|
||||
*/
|
||||
app.get(ledgerByActivityRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const [cards, materialized] = await Promise.all([
|
||||
container.registry.list(),
|
||||
container.ledger.listObjects(),
|
||||
]);
|
||||
const known = new Map(materialized.map((o) => [`${o.domain}.${o.object}`, o]));
|
||||
|
||||
/** 草稿层:一次查完按地址分组 —— 每张表单独查会把这一屏拖成几十条 SQL */
|
||||
const draftRows = rowsOf<{ domain: string; object: string; n: string; at: Date | string }>(
|
||||
await container.db.execute(
|
||||
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft group by domain, object`,
|
||||
),
|
||||
);
|
||||
const drafts = new Map(
|
||||
draftRows.map((r) => [
|
||||
`${r.domain}.${r.object}`,
|
||||
{ n: Number(r.n), at: r.at instanceof Date ? r.at.toISOString() : String(r.at) },
|
||||
]),
|
||||
);
|
||||
|
||||
/** 每个活动最近几单 —— 「刚跑完那一单」在这儿认得出来 */
|
||||
const recentRows = rowsOf<{
|
||||
activity_id: string;
|
||||
id: string;
|
||||
title: string;
|
||||
runtime: string;
|
||||
terminal: string | null;
|
||||
at: Date | string;
|
||||
}>(
|
||||
await container.db.execute(sql`
|
||||
select activity_id, id, title, runtime, terminal, coalesce(finished_at, started_at) as at
|
||||
from (select *, row_number() over (partition by activity_id order by coalesce(finished_at, started_at) desc) as rn
|
||||
from sys.task) t
|
||||
where rn <= 3`),
|
||||
);
|
||||
|
||||
const claimed = new Set<string>();
|
||||
const activities = await Promise.all(
|
||||
cards.map(async (card) => {
|
||||
const manifest = await container.registry.manifestOf(card.activityId).catch(() => undefined);
|
||||
const declared = manifest?.declarations.ledger ?? [];
|
||||
const tables = await Promise.all(
|
||||
declared.map(async (d) => {
|
||||
const address = `${d.domain}.${d.object}`;
|
||||
claimed.add(address);
|
||||
const meta = known.get(address);
|
||||
/*
|
||||
* 行数要**真去数**:`listObjects` 只说有没有这张表。数不出来(表还没物化)
|
||||
* 就不给这个数 —— 摆一个 0 会把「活动声明了但一次都没写过」说成「写过、现在空了」。
|
||||
*/
|
||||
const total =
|
||||
meta === undefined
|
||||
? undefined
|
||||
: await container.ledger
|
||||
.query({ address: { domain: d.domain, object: d.object }, where: {}, orderBy: [], limit: 1, offset: 0 })
|
||||
.then((p) => p.total)
|
||||
.catch(() => undefined);
|
||||
const draft = drafts.get(address);
|
||||
return {
|
||||
domain: d.domain,
|
||||
object: d.object,
|
||||
...(d.label === undefined ? {} : { label: d.label }),
|
||||
...(d.kind === undefined ? {} : { kind: d.kind }),
|
||||
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
|
||||
primaryKey: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
...(total === undefined ? {} : { rows: total }),
|
||||
drafts: draft?.n ?? 0,
|
||||
...(draft === undefined ? {} : { draftAt: draft.at }),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
activityId: card.activityId,
|
||||
name: card.name ?? card.activityId,
|
||||
/*
|
||||
* 版本取**此刻在用的那一版** —— 按装入时刻取最新的 active。
|
||||
* 不能拿「第一个 active」:一个活动装过二十几版,数组里前面躺的是老版本,
|
||||
* 屏上就会写着 1.0.0 而实际跑的是 2.1.0(**界面不撒谎**,版本号尤其不能错)。
|
||||
*/
|
||||
...(((): { version?: string } => {
|
||||
const usable = (card.packageVersions ?? []).filter((v) => v.state !== 'uninstalled');
|
||||
const newest = [...usable].sort((a, b) => (a.installedAt < b.installedAt ? 1 : -1))[0];
|
||||
return newest === undefined ? {} : { version: newest.version };
|
||||
})()),
|
||||
tables,
|
||||
recent: recentRows
|
||||
.filter((r) => r.activity_id === card.activityId)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
runtime: r.runtime,
|
||||
...(r.terminal === null ? {} : { terminal: r.terminal }),
|
||||
at: r.at instanceof Date ? r.at.toISOString() : String(r.at),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/* 库里有、没人声明的:多半是退役活动留下的空壳 —— 单列一档,不混进上面装作正常 */
|
||||
const unclaimed = await Promise.all(
|
||||
materialized
|
||||
.filter((o) => !claimed.has(`${o.domain}.${o.object}`))
|
||||
.map(async (o) => ({
|
||||
domain: o.domain,
|
||||
object: o.object,
|
||||
...(o.label === undefined ? {} : { label: o.label }),
|
||||
...((await container.ledger
|
||||
.query({ address: { domain: o.domain, object: o.object }, where: {}, orderBy: [], limit: 1, offset: 0 })
|
||||
.then((p) => ({ rows: p.total }))
|
||||
.catch(() => ({}))) as { rows?: number }),
|
||||
})),
|
||||
);
|
||||
|
||||
return c.json({ activities, unclaimed });
|
||||
});
|
||||
|
||||
app.get(browseLedgerRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
@@ -23,16 +23,14 @@ import { MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||
* A2/A3 于 2026-07-31 转正:它们的案例模型已简化为「一张执行单办完」,
|
||||
* 剧本与 A1 同一套机制,不再是治理台里那两个 viewTemplates 为空的占位槽。
|
||||
*/
|
||||
const REAL_PACKAGES = [
|
||||
'quote-intake',
|
||||
'quote-alternative',
|
||||
'quote-sourcing',
|
||||
'quote-pricing',
|
||||
'quote-negotiate',
|
||||
'quote-archive',
|
||||
'crm-relationship-coverage',
|
||||
'brand-content-toolkit',
|
||||
] as const;
|
||||
/*
|
||||
* 2026-08-24 用户定:**只装 E04 / I01**,A1–A7 报价链六个包从系统里卸载(源码留着,
|
||||
* 只是不再播种、不再安装;库里已装的版本已置 uninstalled)。要装回来把名字加回这里
|
||||
* 再跑 activities:install 即可 —— 它们的 dist 照旧随 activities:build 产出。
|
||||
* 退役名单:quote-intake · quote-alternative · quote-sourcing · quote-pricing ·
|
||||
* quote-negotiate · quote-archive
|
||||
*/
|
||||
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit'] as const;
|
||||
|
||||
/**
|
||||
* 活动包根目录。
|
||||
|
||||
@@ -344,8 +344,20 @@ const TRIGGER_REGISTRATION = triggerConfigSchema.parse({
|
||||
spec: { kind: 'registration' },
|
||||
});
|
||||
|
||||
/**
|
||||
* 2026-08-24 用户定:报价链 A1–A7 已从系统卸载,建在它们上面的「统一报价」流程包、
|
||||
* 两个 RFQ 触发器与两个询价邮箱**不再播种**(装不上的活动激活流程包会被闭合校验拦下)。
|
||||
* `MVP_PACKAGE` / 触发器常量原样留着 —— 它们是流程编译与触发装配那几组测试的夹具,
|
||||
* 也是日后装回报价链时的种子。要恢复播种把这一位拨回 true。
|
||||
*/
|
||||
const SEED_QUOTE_FLOW = false;
|
||||
|
||||
export async function seedMvpFlow(container: Container): Promise<void> {
|
||||
const log = (line: string) => process.stdout.write(line + '\n');
|
||||
if (!SEED_QUOTE_FLOW) {
|
||||
log('④ 流程包「统一报价」与 RFQ 触发器/邮箱:报价链已卸载(2026-08-24),不播种');
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* 保存即生效,且**内容没变就不再存新版**。
|
||||
|
||||
@@ -236,11 +236,27 @@ export function rowReadings(
|
||||
* 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里,
|
||||
* 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。
|
||||
*/
|
||||
/**
|
||||
* **摆给人看的行标识:主键去掉单标识那一段**(2026-08-25 用户:「只保留斜杠右边部分」)。
|
||||
*
|
||||
* 单据表的主键是 `单号 + 业务键`(如 round_ref + contact_code)。缺项红带、执行卡提交闸、
|
||||
* 拒回主键名单从前把两段都摆(`COV-20260825002227/CU-0142-01`)—— 可人正在**这一单里**
|
||||
* 看这张件,单号是他脚下的地面,不是区分行的信息;真正认得出行的是右边那一段。
|
||||
* `caseKey` 是声明里的单标识,照它去掉;去完一段都不剩(主键只有单号)就退回整段。
|
||||
*/
|
||||
export function rowLabel(id: Readonly<Record<string, unknown>>, caseKey: string | undefined): string {
|
||||
const parts = Object.entries(id)
|
||||
.filter(([k, v]) => k !== caseKey && v !== undefined && v !== null && String(v) !== '')
|
||||
.map(([, v]) => String(v));
|
||||
return parts.length > 0 ? parts.join('/') : Object.values(id).map(String).join('/');
|
||||
}
|
||||
|
||||
export function missingReadings(
|
||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||
declared: ReadonlyArray<{
|
||||
domain: string;
|
||||
object: string;
|
||||
caseKey?: string | undefined;
|
||||
fields: ReadonlyArray<{
|
||||
name: string;
|
||||
label?: string | undefined;
|
||||
@@ -252,7 +268,7 @@ export function missingReadings(
|
||||
}>,
|
||||
): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const { domain, object, fields } of declared) {
|
||||
for (const { domain, object, caseKey, fields } of declared) {
|
||||
const rows = mine[`mine:${domain}.${object}`] ?? [];
|
||||
const keys = fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const blank = (one: Record<string, unknown>, c: string): boolean =>
|
||||
@@ -282,7 +298,7 @@ export function missingReadings(
|
||||
if (gaps.length === 0) continue;
|
||||
const id: Record<string, string> = {};
|
||||
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
|
||||
bad.push(`${Object.values(id).join('/')} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
|
||||
bad.push(`${rowLabel(id, caseKey)} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
|
||||
}
|
||||
out[`${domain}_${object}_${suffix}`] = bad.length;
|
||||
if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';');
|
||||
@@ -331,6 +347,65 @@ export function tallyReadings(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一列有几个不同的值** —— `{ '域_对象_distinct_<列>': N }`,声明列标 `distinct: true` 才数。
|
||||
*
|
||||
* 为什么要有这一类(2026-08-24 柴浩实测:「新增了一行要覆盖的客户,后面推进直接无视了」):
|
||||
* 覆盖范围确认那张卡上人加的行落进了台账、focus 段里模型也看得见,可 S3 的收步判据
|
||||
* 只数「计划有没有」(`plan_rows > 0`)—— 计划盖住一家还是两家,没有一个基座数说得出来。
|
||||
* 模型锚在自己轨迹的「识别出 1 家」上把新行漏掉,引擎照样自动收步、放行一路绿灯。
|
||||
* 「每家候选客户都进了经营计划」要成为机检,就得有「计划里有几家客户」这个数;
|
||||
* 它必须由基座数:计划行数 ≠ 客户数(一家客户几位关键人就几行),而模型自报的数
|
||||
* 恰恰就是这次数漏的那一个。
|
||||
*
|
||||
* 只数**本单写下的行**(与 `_rows` 同一基准);空值不算一种 —— 「没写」不是一个取值。
|
||||
* 声明了的列**一律先铺 0**:未绑定的名字参与比较求值为假,零行也得是一个数。
|
||||
*/
|
||||
export function distinctReadings(
|
||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||
declared: ReadonlyArray<{
|
||||
domain: string;
|
||||
object: string;
|
||||
fields: ReadonlyArray<{ name: string; distinct?: boolean | undefined }>;
|
||||
}>,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const { domain, object, fields } of declared) {
|
||||
for (const field of fields) {
|
||||
if (field.distinct !== true) continue;
|
||||
const rows = mine[`mine:${domain}.${object}`] ?? [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
||||
const value = String(one[field.name] ?? '').trim();
|
||||
if (value !== '') seen.add(value);
|
||||
}
|
||||
out[`${domain}_${object}_distinct_${field.name}`] = seen.size;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **本轮碰过的行,以「落库后的样子」摆出来** —— `touched:域.对象`。
|
||||
*
|
||||
* 起因(2026-08-24 柴浩实测):台账更新确认屏的落库预览绑的是 `mine:`(本单草稿),
|
||||
* 而草稿行只有模型这轮写过的那几列 —— Itelma 档案里既有的客户类型/行业/区域、
|
||||
* Ekaterina 的职务/角色全不在里面:只读面一片空白,编辑框也空(像是数据丢了)。
|
||||
* 签字的人要看的是**签下去之后这一行长什么样** = 正式现值 + 本单草稿叠加;
|
||||
* 但整表不行(没碰过的客户不该出现在「将写入」里)—— 所以按草稿键过滤:
|
||||
* 本轮碰过哪几行,就摆那几行的叠加后全貌。
|
||||
*/
|
||||
export function touchedRows(
|
||||
merged: ReadonlyArray<unknown>,
|
||||
draftKeys: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
||||
): unknown[] {
|
||||
const rows = merged.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null);
|
||||
return draftKeys
|
||||
.map((key) => rows.find((row) => Object.entries(key).every(([k, v]) => String(row[k] ?? '') === String(v ?? ''))))
|
||||
.filter((r) => r !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
|
||||
*
|
||||
@@ -782,12 +857,25 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
* (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。
|
||||
*/
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
const draftKeysOf: Record<string, Array<Record<string, unknown>>> = {};
|
||||
for (const draft of await deps.ledger.taskDrafts(taskId)) {
|
||||
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
||||
(draftKeysOf[`${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key });
|
||||
for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) {
|
||||
if (value !== null && value !== undefined) scope[field] = value;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* `touched:域.对象` —— 本轮碰过的行的**叠加后全貌**(正式现值 + 草稿),
|
||||
* 给落库预览这类「签下去之后长什么样」的画面用;`域.对象` 整表里本就有
|
||||
* 叠加行,这里只按草稿键挑出本轮的那几行。不进提示词(restPart 滤掉 ——
|
||||
* 内容与 mine/整表重叠,白占截断窗)。
|
||||
*/
|
||||
const touched: Record<string, unknown[]> = {};
|
||||
for (const [address, draftKeys] of Object.entries(draftKeysOf)) {
|
||||
const merged = scope[address];
|
||||
if (Array.isArray(merged)) touched[`touched:${address}`] = touchedRows(merged, draftKeys);
|
||||
}
|
||||
|
||||
/*
|
||||
* **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。
|
||||
@@ -826,8 +914,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
...rowKeyList(mine, declared),
|
||||
...missingReadings(mine, declared),
|
||||
...tallyReadings(mine, declared),
|
||||
...distinctReadings(mine, declared),
|
||||
...toolCallReadings(trail, manifest.declarations.tools),
|
||||
...mine,
|
||||
...touched,
|
||||
};
|
||||
return enriched;
|
||||
},
|
||||
@@ -845,7 +935,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
object: d.object,
|
||||
label: d.label ?? `${d.domain}.${d.object}`,
|
||||
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type })),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
|
||||
}));
|
||||
},
|
||||
/**
|
||||
@@ -866,6 +956,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
const allowed = await taskData.writable(input.taskId);
|
||||
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
||||
@@ -882,6 +974,39 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
|
||||
}
|
||||
|
||||
/*
|
||||
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
|
||||
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
|
||||
* contact_code 本该指向真实联系人,模型三次给缺口节点**自造顺延引用码 +
|
||||
* 占位人名** —— 反例描述、说明书纪律、禁令逐层写全都拦不住;
|
||||
* 现在写入那一刻当场拒,拒回指路(墙要配路)。
|
||||
*/
|
||||
for (const field of target.fields) {
|
||||
if (field.refersTo === undefined) continue;
|
||||
const raw = input.key[field.name] ?? input.patch[field.name];
|
||||
const value = raw === undefined || raw === null ? '' : String(raw).trim();
|
||||
if (value === '') continue;
|
||||
const [rd, ro, rc] = field.refersTo.split('.');
|
||||
if (rd === undefined || ro === undefined || rc === undefined) continue;
|
||||
const referenced = await deps.ledger.taskView(input.taskId, {
|
||||
address: { domain: rd, object: ro },
|
||||
where: {},
|
||||
orderBy: [],
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
});
|
||||
const exists = referenced.rows.some((r) => String((r as Record<string, unknown>)[rc] ?? '') === value);
|
||||
if (!exists) {
|
||||
const refLabel = allowed.find((a) => a.domain === rd && a.object === ro)?.label ?? `${rd}.${ro}`;
|
||||
throw new Error(
|
||||
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
|
||||
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
|
||||
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
|
||||
'或者把这一笔挂到已存在的那一行名下',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。
|
||||
*
|
||||
@@ -921,6 +1046,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
before === undefined ||
|
||||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||
|
||||
if (input.dryRun === true) return { changed };
|
||||
await deps.ledger.draftWrite({
|
||||
taskId: input.taskId,
|
||||
address: { domain: input.domain, object: input.object },
|
||||
@@ -1933,6 +2059,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
}
|
||||
const domain = input.address.slice(0, at);
|
||||
const object = input.address.slice(at + 1);
|
||||
/*
|
||||
* **一笔 write 整批先验后写**(2026-08-24 柴浩实测逼出):从前逐行边验边写,
|
||||
* 第 2 行撞墙(refersTo 查无此码)时第 1 行已经落了草稿,而轨迹只记「行动被拒」——
|
||||
* 模型与人都以为一行没写,回执骨架从此少一行(计划 7 / 回执 6),
|
||||
* 到放行门撞成两堵墙(「每条计划都有回执」vs「每家候选都进了计划」)五拍熔断。
|
||||
* 被拒就得是一行都没落:先把每一行过一遍门,全过了再落。
|
||||
*/
|
||||
for (const row of input.rows) {
|
||||
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch, dryRun: true });
|
||||
}
|
||||
for (const row of input.rows) {
|
||||
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 跨进程租约 —— **两个后端连同一个库时,一张单只许一个在推**。
|
||||
*
|
||||
* 2026-08-24 柴浩那一单就死在这上头:本机与局域网另一台各跑一个后端,两边的推进器
|
||||
* 同时推同一张单,两拍并发写一份 `loop_state`,后写的整份覆盖先写的 ——
|
||||
* 「给 S2 发过卡」那个事实(worldTouched)被抹掉、S1 被收了两遍;
|
||||
* 随后 finish 那道门说「从没发过卡」、重发那道门说「人已答过」,两堵墙对着撞,五拍熔断。
|
||||
* 进程内那把 `taskMutex` 对这种局面无能为力(它自己的注释里就写着)。
|
||||
*
|
||||
* 这里测的是**发给库的那句话对不对**:判与占必须在同一条 SQL 里
|
||||
* (分两句写就是经典竞态),且带过期时间(进程崩了不锁死)。
|
||||
* SQL 的并发语义另以两条真连接对着真库跑过 20 轮 —— 每轮恰好一个赢。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { claimTask, releaseTask, LEASE_MS } from '../task-claim.ts';
|
||||
|
||||
/** 记下发出去的 SQL —— drizzle 的 sql`` 片段拼回可读字符串 */
|
||||
function fakeDb(rowCount: number) {
|
||||
const sent: string[] = [];
|
||||
return {
|
||||
sent,
|
||||
execute: async (q: unknown) => {
|
||||
const chunks = (q as { queryChunks?: unknown[] }).queryChunks ?? [];
|
||||
sent.push(
|
||||
chunks
|
||||
.map((c) => {
|
||||
const one = c as { value?: unknown };
|
||||
return Array.isArray(one.value) ? one.value.join('') : String(one.value ?? '?');
|
||||
})
|
||||
.join(''),
|
||||
);
|
||||
return { rows: Array.from({ length: rowCount }, () => ({ id: 't1' })) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 传给被测函数那一下再转 —— 测试桩不必长得跟真 Db 一样 */
|
||||
const asDb = (stub: unknown): Parameters<typeof claimTask>[0] => stub as Parameters<typeof claimTask>[0];
|
||||
|
||||
describe('claimTask —— 抢这一张单的推进权', () => {
|
||||
it('抢到了(UPDATE 命中一行)返回 true', async () => {
|
||||
expect(await claimTask(asDb(fakeDb(1)), 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('**没抢到返回 false** —— 别的进程正推着,这一轮就跳过', async () => {
|
||||
expect(await claimTask(asDb(fakeDb(0)), 't1')).toBe(false);
|
||||
});
|
||||
|
||||
it('判与占在同一条 SQL 里:where 带「没人占 或 已过期」,并 returning', async () => {
|
||||
const db = fakeDb(1);
|
||||
await claimTask(asDb(db), 't1');
|
||||
const sql = db.sent[0] ?? '';
|
||||
expect(sql).toContain('update sys.task');
|
||||
expect(sql).toContain('advancing_until is null or advancing_until < now()');
|
||||
expect(sql).toContain('returning id');
|
||||
});
|
||||
|
||||
it('**租约带过期时间** —— 进程崩了不把这张单锁死', async () => {
|
||||
const db = fakeDb(1);
|
||||
await claimTask(asDb(db), 't1');
|
||||
expect(db.sent[0] ?? '').toContain('milliseconds');
|
||||
// 默认租约比最慢的一轮宽得多,又不至于锁上半天
|
||||
expect(LEASE_MS).toBeGreaterThanOrEqual(60_000);
|
||||
expect(LEASE_MS).toBeLessThanOrEqual(15 * 60_000);
|
||||
});
|
||||
|
||||
it('还锁就是清空那一列', async () => {
|
||||
const db = fakeDb(1);
|
||||
await releaseTask(asDb(db), 't1');
|
||||
expect(db.sent[0] ?? '').toContain('advancing_until = null');
|
||||
});
|
||||
|
||||
it('**还锁失败不抛** —— 这一拍的成果不该被还锁连累掉', async () => {
|
||||
const broken = { execute: async () => Promise.reject(new Error('库连不上')) };
|
||||
await expect(releaseTask(asDb(broken), 't1')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type { Delivery } from '@modules/event';
|
||||
import { claimPendingDeliveries } from '@modules/event';
|
||||
import type { Container } from '../container.ts';
|
||||
import { taskMutex } from './task-lock.ts';
|
||||
import { claimTask, releaseTask } from './task-claim.ts';
|
||||
|
||||
/** 消费者:按目标类型分派 */
|
||||
export type Consumer = (delivery: Delivery, container: Container) => Promise<void>;
|
||||
@@ -31,6 +32,14 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
||||
for (const delivery of batch) {
|
||||
// 同任务按序:取到锁才处理,取不到说明同目标的另一条正在处理,留待下一轮
|
||||
await taskMutex.tryRun(`${delivery.target.kind}:${String(delivery.target.id)}`, async () => {
|
||||
/*
|
||||
* **投递也会推一拍**(consumer 里 `task.deliver` 末尾就是 `tick`)——
|
||||
* 所以它同样要抢库里那份租约:上面那把锁只在本进程内算数,而两个后端连同一个库时
|
||||
* (2026-08-24 实测)投递与另一台的推进器会并发写同一份 `loop_state`。
|
||||
* 抢不到就先不处理,这一条留给下一轮 —— 它还在 pending,不会丢。
|
||||
*/
|
||||
const taskId = delivery.target.kind === 'task' ? String(delivery.target.id) : undefined;
|
||||
if (taskId !== undefined && !(await claimTask(container.db, taskId))) return;
|
||||
try {
|
||||
const consumer = deps.consumers.get(delivery.target.kind);
|
||||
if (consumer !== undefined) {
|
||||
@@ -53,6 +62,9 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
||||
handled += 1;
|
||||
} catch (error) {
|
||||
await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
// 推完就还租约 —— 不还也不会锁死(会过期),还了下一条投递立刻接得上
|
||||
if (taskId !== undefined) await releaseTask(container.db, taskId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { and, eq, sql } from 'drizzle-orm';
|
||||
import { taskTable } from '@modules/task';
|
||||
import type { Container } from '../container.ts';
|
||||
import { taskMutex } from './task-lock.ts';
|
||||
import { claimTask, releaseTask } from './task-claim.ts';
|
||||
|
||||
export type AdvancerOptions = {
|
||||
readonly container: Container;
|
||||
@@ -51,6 +52,22 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
||||
* 抢不到就跳过 —— 那边正推着,这边再推就是两条线同时改一份运行时状态。
|
||||
*/
|
||||
const ran = await taskMutex.tryRun(`task:${row.id}`, async () => {
|
||||
/*
|
||||
* **再抢一次库里的租约** —— 上面那把锁只在本进程内算数。
|
||||
*
|
||||
* 两个后端连同一个库时(本机 + 局域网另一台,2026-08-24 实测),两边的推进器
|
||||
* 各扫各的、同时推同一张单:两拍并发写一份 `loop_state`,后写的整份覆盖先写的,
|
||||
* 「发过卡」这个事实就此消失,门与门对着撞,五拍熔断。
|
||||
* 抢不到说明别的进程正推着 —— 跳过,它推完自然轮得到。
|
||||
*/
|
||||
if (!(await claimTask(container.db, row.id))) return;
|
||||
try {
|
||||
return await advanceOne();
|
||||
} finally {
|
||||
await releaseTask(container.db, row.id);
|
||||
}
|
||||
|
||||
async function advanceOne(): Promise<void> {
|
||||
/** 这一轮之前它是什么样 —— 变了才广播,不然每两秒给在线浏览器发一次噪声 */
|
||||
const before = await container.task.get(row.id as never).catch(() => undefined);
|
||||
for (let tick = 0; tick < (options.maxTicks ?? 3); tick += 1) {
|
||||
@@ -95,7 +112,7 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
void ran;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 任务推进的**跨进程租约** —— 一张单同一时刻只许一个后端在推。
|
||||
*
|
||||
* `task-lock.ts` 那把是**进程内**的互斥,它开头就写着:「MVP 单进程,进程内互斥就够;
|
||||
* 多进程部署时再叠一层」。2026-08-24 那一天就撞上了:本机与局域网另一台机器上各跑着
|
||||
* 一个后端,连的是同一个库,两边的推进器各自 2 秒扫一次「running 的单」,
|
||||
* **同时推同一张**。两拍并发跑在同一份 `loop_state` 上,后写的整份覆盖先写的 ——
|
||||
* 「给 S2 发过卡」这个事实被抹掉、S1 被收了两遍,随后 finish 与重发两道门对着撞,
|
||||
* 连拒五拍熔断(柴浩那一单)。
|
||||
*
|
||||
* 为什么不用 `pg_try_advisory_lock`:它是**会话级**的,而我们经连接池取连接 ——
|
||||
* 加锁与解锁多半落在不同连接上。`pg_try_advisory_xact_lock` 是事务级的,而推一拍
|
||||
* 要调模型、要写好几张表,不该整个包在一个事务里。
|
||||
*
|
||||
* 所以用**库里的一列**当租约:抢到才推,推完就还;**带过期时间** —— 进程崩了不锁死,
|
||||
* 租约一过别的进程照样接得上。这是任务队列的老办法,跨进程、跨机器都算数。
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
|
||||
/**
|
||||
* 租约多久过期。推一拍最慢是一次模型调用(实测 1.5-5 秒,关思考之前有过 56 秒),
|
||||
* 而推进器一轮最多连推 3 拍 —— 给到 5 分钟:比最慢的一轮宽得多,又不至于
|
||||
* 让崩掉的进程把一张单锁上半天。
|
||||
*/
|
||||
export const LEASE_MS = 5 * 60 * 1000;
|
||||
|
||||
function rowsOf(result: unknown): unknown[] {
|
||||
if (Array.isArray(result)) return result;
|
||||
const rows = (result as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 抢这一张单的推进权。**抢到返回 true**,没抢到(别的进程正推着)返回 false。
|
||||
*
|
||||
* 一条 SQL 里判 + 占:`where advancing_until is null or advancing_until < now()` ——
|
||||
* 两个进程同时来,PG 的行锁保证只有一个的 UPDATE 命中,另一个 RETURNING 空。
|
||||
* 判和占分两句写是经典的竞态,这里不给它机会。
|
||||
*/
|
||||
export async function claimTask(db: Db, taskId: string, leaseMs: number = LEASE_MS): Promise<boolean> {
|
||||
const result = await db.execute(sql`
|
||||
update sys.task
|
||||
set advancing_until = now() + (${leaseMs} || ' milliseconds')::interval
|
||||
where id = ${taskId}
|
||||
and (advancing_until is null or advancing_until < now())
|
||||
returning id`);
|
||||
return rowsOf(result).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推完就还 —— 不还也不会锁死(租约会过期),但还了下一轮就能接着推,
|
||||
* 不必干等到过期。**失败不抛**:还锁失败不该把这一拍的成果连累掉。
|
||||
*/
|
||||
export async function releaseTask(db: Db, taskId: string): Promise<void> {
|
||||
await db
|
||||
.execute(sql`update sys.task set advancing_until = null where id = ${taskId}`)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user