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:
xcs
2026-08-25 01:23:30 +08:00
co-authored by Claude Fable 5
parent 92dd729c36
commit f3397a60ef
91 changed files with 3919 additions and 350 deletions
+140
View File
@@ -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));