Archived
全分支终审(跨任务视角)逮出的十条 P0 + 八条 P1,一次修完。每条都配了测试。 P0-1 企业微信 token 缓存改按**凭证指纹**(sha256(corpId,corpSecret),secret 不进 Map 的键): 按 account.id 存有两条真实串号路径 —— 草稿探测共用 'draft'(探完 A 公司再探 B 公司会命中 A 的 token,回的是 A 的应用邮箱地址,凭证填错的 B 也显示成功);已存账号换了凭证 id 不变, 下一轮轮询继续拿旧 token 拉旧 corp 的收件箱,分发给新账号绑的通道(窗口 ≤ 7200s)。 P0-2 一封「毒信」不许卡死整条路:①domain 的 buildInboundRecord 把落 text/jsonb 的字段一律剥 NUL (PG 拒收它,MIME 解出二进制垃圾时真会出现);②单封失败先落**降级骨架**(原件原样存、 主题从原始字节扒、正文写明失败原因)→ 落成了就推进游标并把「第 N 封(providerRef)解析失败, 已降级存档」写进 last_poll_error;骨架也落不下才停在这一封,那时 error **点名是哪一封**。 降级行不自动分发(正文是失败原因,不是客户的话)。 P0-3 cursorReset 只比**收侧**键(imapHost/imapPort/imapTls/login/folder;corpId/apiBase): 从前比全部 settings,把 465 改成 587 也会清游标 → IMAP 重锚 → 上次游标到重锚之间的来信永远不拉。 P0-4 复用旧行重发**先抢**(OutboundStore.claimForResend,一条条件更新;真 store 与内存件同款): 两个后端连同一个库,两台同时重放同一次放行都判「超时可复用」→ 两封真邮件,破掉「同键不发两封」。 P0-5 列表与只要元信息的读**不取 raw**(一封上限 16MB × 200 行);rawAvailable 由 `raw is not null` 投影,不靠取回字节再判;只有「下载原件」走全列的 get()。InboundRecord 新增 rawAvailable。 P0-6 账号 save() 只写**配置列**:运行列(cursor/last_polled_at/last_poll_error/last_inbound_at/ polling_until)只经 savePoll/claimPolling/releasePolling 改 —— 整行 upsert 会把另一台正持着的租约 与刚推进的游标盖回读那一刻的快照(08-24 loop_state 整份覆盖同款指纹)。清游标改为显式单写那一列。 P0-7 UIDVALIDITY 变化不再静默:provider 回 note,用例落进 last_poll_error(「已重锚在当下; 期间来信未拉取」)—— 丢了一段信而账号页一片绿是界面撒谎。 P0-8 parseCursor 连取值范围一起查(非负安全整数 + 界内:UID ≤ 0xFFFFFFFF、秒 ≤ Date 上限)—— 1e13 正是安全整数,乘 1000 出界,一行脏游标能让 listAccounts 整页 500。debt ⑨ 划线销账。 P0-9 线程口径归一:threadIdOf 改成**取根**(references[0] ?? inReplyTo,ADR-013 决策 3), mail-wiring 调它、不再内联一份。原测试改断言,不删。 P0-10 文档说老实话:mail.send 工具路发出的信**不登记 outbound_ref**(recordOutbound 的唯一调用方 channel.sendMail 在 src 里零调用方)—— API 文档 §4 与 ADR-013 决策 6 各补一段,根治记 debt。 P1:imap anchor 的 Math.min(...uids) 改 reduce(十万级 UID 会栈溢出)· wecom 翻页加页数上限 (服务端一直回 has_more:1 会死循环)· MAIL_TOOL 导出并对**真契约**断言(原 v1/v2 是手抄夹具、 自证,且 v1 版本号写错)· reset-runs 清运行数据时把邮箱账号的游标与拉取状态一并清(留着游标而信没了, wecom 十分钟重叠窗会把最近的信当新信再投一次)· due.test 的 leaseMsFor(30) 断言改写死 300_000(原为自证)· cursor.test 删掉指向评审报告的注释 · 设计稿稿头标注三处已被 ADR-013 取代 · ADR-013 补记 wecom compose_send 不带 inReplyTo/references(它发出的信在对方那里不串线程)。 契约变更(§12.4):port.ts 的 InboundStore 加 getMeta、OutboundStore 加 claimForResend、 savePoll 的 cursor 允许显式 null、MailProvider.pull 回参可带 note —— 实现方只有本模块 adapter 与 __tests__/fakes.ts 的内存件(两边语义一致,已同改),CONTRACT.md 已同步,api-report 已刷新。 测试:1922 → 1942(mail 模块 102 → 113,含真 PG 的 save 不盖运行列 / 不搬原件 / 两条真连接抢同一行)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
118 lines
5.3 KiB
TypeScript
118 lines
5.3 KiB
TypeScript
/**
|
|
* 清运行数据 —— 开发期反复用:把跑过的单清空,配置原样留着。
|
|
*
|
|
* pnpm --filter @app/api reset-runs
|
|
*
|
|
* **清的是「跑出来的」**:任务、工作流、卡、任务记忆、事件、监听、台账行、活动记忆…
|
|
* **留的是「配置好的」**:员工与岗位授权、会话、工作组、流程包、活动库、工具、
|
|
* 触发器、通道、模型实例、纪要、**邮箱账号**(它身上的游标与拉取状态是运行痕,跟着清)。
|
|
*
|
|
* 直接走 SQL 而不绕模块:模块出口里没有、也不该有「删掉所有运行痕迹」这种动作 ——
|
|
* 留痕只增不改是系统的规矩,清库是**开发期的外科手术**,不是系统的一项能力。
|
|
*/
|
|
import { Pool } from 'pg';
|
|
import { loadConfig } from './config.ts';
|
|
|
|
/** 顺序无所谓:一条 TRUNCATE 里的表会一起截断,外键互指也不冲突 */
|
|
const RUN_TABLES = [
|
|
'sys.task',
|
|
'sys.task_memory',
|
|
'sys.task_memory_seal',
|
|
'sys.task_view',
|
|
'sys.work_order',
|
|
'sys.chat_card',
|
|
'sys.chat_message',
|
|
'sys.system_message',
|
|
/*
|
|
* 沟通记录与上传件是**跑出来的**,而且两张表都没有指向 task 的外键 ——
|
|
* `truncate ... cascade` 带不走它们(实测漏了 53 条沟通记录、1 个上传件)。
|
|
* 漏下的是指向已删任务的孤儿:那封信还在沟通记录里躺着,点开却没有单。
|
|
*/
|
|
'sys.task_comm',
|
|
'sys.upload',
|
|
// 产出的资料文件存档 —— 同样是跑出来的、同样没有指向 task 的外键
|
|
'sys.task_asset_file',
|
|
'sys.node_instance',
|
|
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
|
|
'sys.trace_span',
|
|
'sys.workflow',
|
|
'sys.flow_state',
|
|
'sys.loop_state',
|
|
'sys.listener',
|
|
'sys.action_intent',
|
|
'sys.event',
|
|
'sys.event_delivery',
|
|
'sys.event_consumption',
|
|
'sys.outbound_mail',
|
|
// 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着
|
|
'sys.mail_inbound',
|
|
'sys.mail_inbound_delivery',
|
|
'sys.mail_outbound',
|
|
'sys.outbound_ref',
|
|
'sys.trigger_claim',
|
|
// 待确认单是运行痕:命中未确认的那一票随运行数据一起清 —— 配置(trigger/node_owner)不动
|
|
'sys.trigger_pending',
|
|
'sys.report',
|
|
'sys.replay_model',
|
|
'sys.replay_tool',
|
|
/* 活动记忆是跑出来的蒸馏物,跟着运行数据一起清 */
|
|
'sys.activity_memory',
|
|
'sys.asset_increment',
|
|
/* 纪要是人记的,但开发期同属「跑出来的东西」—— 清库要清得干净,不留半场旧会 */
|
|
'sys.minute',
|
|
'sys.minute_resolution',
|
|
/* 试跑日志是试跑的存档:单没了,存档留着只会是一堆点不进去的空目录 */
|
|
'sys.testbed_run',
|
|
];
|
|
|
|
export async function resetRuns(): Promise<void> {
|
|
const pool = new Pool({ connectionString: loadConfig().databaseUrl, max: 2 });
|
|
try {
|
|
/*
|
|
* 台账的业务表是动态建的,名字问库不写死。
|
|
*
|
|
* **主数据留着**(2026-07-31):客户 / 项目 / 供应商 / 市场是先于活动存在的档案,
|
|
* 活动装了卸了它都在 —— 它属于「配置好的」那一半,不是「跑出来的」。
|
|
* 从前这里把整个 ledger schema 一并 truncate,后果是每清一次运行数据,
|
|
* 样例客户就没了,「老客户来函」那条样例第二天就试不出来(而它本该是常在的夹具)。
|
|
* `__object_meta` 同理:性质登记随声明走,不随跑单走;抹了它,
|
|
* 治理台上全部表都成了「无人声明」,主数据那一区直接空掉。
|
|
* 单据(record)、派生(derived)、没登记性质的,照旧清 —— 那些确是跑出来的。
|
|
*/
|
|
const ledger = await pool.query<{ name: string }>(
|
|
`select quote_ident(t.schemaname) || '.' || quote_ident(t.tablename) as name
|
|
from pg_tables t
|
|
left join ledger.__object_meta m on m.domain || '__' || m.object = t.tablename
|
|
where t.schemaname = 'ledger'
|
|
and t.tablename <> '__object_meta'
|
|
and (m.kind is null or m.kind <> 'master')`,
|
|
);
|
|
const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)];
|
|
await pool.query(`truncate table ${all.join(', ')} restart identity cascade`);
|
|
/*
|
|
* 邮件账号是**配置**(留着),但它身上挂着的**游标是运行痕** —— 清了 `sys.mail_inbound`
|
|
* 却留着游标,下一轮就从游标处往后拉:wecom 那十分钟重叠窗会把最近的信当新信**再投一次**
|
|
* (库里已经没有那一行了,唯一键挡不住),可能凭空再起一单。
|
|
* 游标清空 = 下一轮重锚在当下、不回填,与「第一次配上账号」同一个起点。
|
|
* 上次拉取 / 错误 / 最近来信也一并清:它们说的都是刚被清掉的那些信。
|
|
*/
|
|
await pool.query(
|
|
`update sys.mail_account
|
|
set cursor = null, last_polled_at = null, last_poll_error = null, last_inbound_at = null, polling_until = null`,
|
|
);
|
|
const kept = await pool.query<{ n: string }>(
|
|
`select count(*)::text as n from ledger.__object_meta where kind = 'master'`,
|
|
);
|
|
process.stdout.write(
|
|
`已清运行数据:${all.length} 张表(配置与组织数据原样保留;主数据 ${kept.rows[0]?.n ?? '0'} 张表未动)\n`,
|
|
);
|
|
} finally {
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
await resetRuns();
|
|
process.exit(0);
|
|
}
|