harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条

human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-27 23:20:16 +08:00
co-authored by Claude Fable 5
parent 909b1fe4b7
commit 7b85a79323
79 changed files with 3903 additions and 1297 deletions
@@ -12,6 +12,7 @@ import type {
DraftWrite,
LedgerService,
ObjectAddress,
ReleaseScope,
ReleaseWriteResult,
SchemaApplyResult,
SystemWrite,
@@ -50,6 +51,20 @@ function groupDeclarations(declarations: readonly ObjectDeclaration[]): Map<stri
return groups;
}
/** 范围过滤:不给范围 = 全收 */
function inScope(only: readonly ObjectAddress[] | undefined): (draft: ObjectRef) => boolean {
if (only === undefined) return () => true;
const wanted = new Set(only.map((a) => `${a.domain}.${a.object}`));
return (draft) => wanted.has(`${draft.domain}.${draft.object}`);
}
/** 落地后的基线:只取这一笔碰过的那几列,值取表里那一份 */
function baselineOf(stored: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of Object.keys(patch)) out[field] = stored[field];
return out;
}
export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
function dataEvent(
ref: ObjectRef,
@@ -181,13 +196,29 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
return true;
},
/** ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件 */
async releaseWrite(taskId): Promise<ReleaseWriteResult> {
const conflicts = await this.checkConflicts(taskId);
/**
* ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件。
*
* `scope` 给了就是**数据工单确认那一道门**(2026-08-27 用户定:「数据工单里填的数据,
* 确认这个数据工单后就应该入库」):只并那几张表,并过的行留在草稿层打「已落地」记号 ——
* 它仍是本单写下的行(`mine:` / `_rows` / 任务视图照旧认它,不然 P-08 那张件当场变空、
* S1 的哨兵 `parts_profile_rows >= created` 永远开不了),只是整单放行时不再写它。
*/
async releaseWrite(taskId, scope?: ReleaseScope): Promise<ReleaseWriteResult> {
const conflicts = await this.checkConflicts(taskId, scope?.only);
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
const drafts = await deps.drafts.list(taskId);
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, simulatedRows: 0, merged: [] };
const drafts = (await deps.drafts.list(taskId)).filter(inScope(scope?.only));
const nothing: ReleaseWriteResult = { mergedRows: 0, emittedEvents: 0, landedEarlierRows: 0, simulatedRows: 0, merged: [] };
if (drafts.length === 0) return nothing;
const scoped = scope !== undefined;
const causeRef = scoped ? scope.cause : `release:${taskId}`;
/*
* 事件指不指回本单:整单放行指回(它正要收尾,与从前一样);数据工单那一道**不指回** ——
* 本单此刻正等着自己的人事件(卡的回传)叫醒,再喂它七八条 data 事件只会在日志里结巴,
* 而它对这些行的「读己之写」从没断过。别单的监听照旧按正式版事件认(跨任务只认过门的事实)。
*/
const target = scoped ? undefined : taskId;
/*
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
@@ -208,6 +239,7 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
const heldBack = (draft: { domain: string; object: string }): boolean =>
simulated && masters.has(`${draft.domain}.${draft.object}`);
let simulatedRows = 0;
let landedEarlierRows = 0;
const pending: EnvelopeDraft[] = [];
/*
@@ -224,6 +256,17 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
await deps.store.transaction(async (store, draftStore) => {
for (const draft of drafts) {
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
if (draft.landed === true) {
/*
* 早已在数据工单确认那一道门上落过地:这次不写、不抛事件、不计入合并数;
* 只把表里那一份读回来当事实(出口载荷要它)。表里读不到(别单后来删了)就不摆。
*/
landedEarlierRows += 1;
if (draft.tombstone) continue;
const current = (await store.selectByKeys(ref, [draft.key]))[0];
if (current !== undefined) landed.push({ domain: draft.domain, object: draft.object, key: draft.key, values: current });
continue;
}
if (heldBack(draft)) {
simulatedRows += 1;
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
@@ -243,9 +286,8 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
}
if (draft.tombstone) {
await store.deleteRow(ref, draft.key);
pending.push(
dataEvent(ref, draft.key, [], 'delete', taskId, { kind: 'ledger-write', ref: `release:${taskId}` }),
);
pending.push(dataEvent(ref, draft.key, [], 'delete', target, { kind: 'ledger-write', ref: causeRef }));
if (scoped) await draftStore.markLanded(taskId, ref, draft.key, {});
continue;
}
const existing = await store.selectByKeys(ref, [draft.key]);
@@ -264,18 +306,24 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
draft.key,
Object.keys(draft.patch),
existing.length === 0 ? 'insert' : 'update',
taskId,
{ kind: 'ledger-write', ref: `release:${taskId}` },
target,
{ kind: 'ledger-write', ref: causeRef },
),
);
/*
* 数据工单那一道门:条目留着、打记号,基线换成落地后表里的值(PG 归型后的那一份)——
* 之后再往这一行写一笔,冲突检查比的才是「我改之前表里是什么」。
*/
if (scoped) await draftStore.markLanded(taskId, ref, draft.key, baselineOf(stored ?? draft.patch, draft.patch));
}
await draftStore.clear(taskId);
if (!scoped) await draftStore.clear(taskId);
});
for (const envelope of pending) await deps.events.publish(envelope);
return {
mergedRows: drafts.length - simulatedRows,
mergedRows: drafts.length - simulatedRows - landedEarlierRows,
emittedEvents: pending.length,
landedEarlierRows,
simulatedRows,
merged: landed,
};
@@ -299,11 +347,11 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
async taskDrafts(taskId) {
const drafts = await deps.drafts.list(taskId);
return drafts.map((d) => ({ domain: d.domain, object: d.object, key: d.key, patch: d.patch }));
return drafts.map((d) => ({ domain: d.domain, object: d.object, key: d.key, patch: d.patch, landed: d.landed === true }));
},
async checkConflicts(taskId) {
const drafts = await deps.drafts.list(taskId);
async checkConflicts(taskId, only) {
const drafts = (await deps.drafts.list(taskId)).filter(inScope(only));
if (drafts.length === 0) return [];
const byObject = new Map<string, Array<(typeof drafts)[number]>>();
for (const d of drafts) {