Archived
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:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 数据工单确认即入库(2026-08-27 用户定)—— 这段错了不报错:人按下确认、卡记成办结,
|
||||
* 而库里一行没有;要到整单放行才发现,或者永远发现不了(试跑单不落主数据)。
|
||||
*
|
||||
* 这里测的是编排(纯逻辑 + 假台账):落哪几张、落成什么样留什么痕;真库那一侧在
|
||||
* `ledger-store.it.test.ts`(范围放行三条)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId, WorkOrder } from '@contracts';
|
||||
import { workOrderSchema } from '@contracts';
|
||||
import type { ReleaseScope, ReleaseWriteResult } from '@modules/ledger';
|
||||
import { dataOrderLedgers, landDataOrder } from '../data-order-landing.ts';
|
||||
|
||||
const T = 'task-1' as TaskId;
|
||||
|
||||
/** 夹具走契约 schema 造 —— 造出来的就是一张合法工单,不靠 as 硬转 */
|
||||
const order = (shape: Record<string, unknown>): WorkOrder =>
|
||||
workOrderSchema.parse({
|
||||
id: 'wo-1',
|
||||
at: '2026-08-27T10:00:00Z',
|
||||
title: '数据工单 · 芯片主数据创建(P-08):LM2596S-3.3',
|
||||
basis: [{ k: '触发原因', v: '查无记录' }],
|
||||
ask: '请建齐',
|
||||
fields: [],
|
||||
...shape,
|
||||
});
|
||||
|
||||
const dataOrder = (extra: Record<string, unknown> = {}): WorkOrder =>
|
||||
order({ mode: 'execution', variant: 'data', gateLedgers: ['parts.profile', 'parts.parameter'], ...extra });
|
||||
|
||||
describe('dataOrderLedgers —— 确认之后该入库的表', () => {
|
||||
it('数据工单:与提交闸同源 —— 绑了 blocks 看绑的,否则看 gateLedgers;去重、去脏', () => {
|
||||
expect(dataOrderLedgers(dataOrder())).toEqual(['parts.profile', 'parts.parameter']);
|
||||
expect(dataOrderLedgers(dataOrder({ gateLedgers: [' parts.profile ', 'parts.profile', 'bad address', ''] }))).toEqual(['parts.profile']);
|
||||
const bound = dataOrder({ blocks: [{ t: 'table', title: '表', cols: ['a'], rows: [], ledger: 'parts.document' }] });
|
||||
expect(dataOrderLedgers(bound)).toEqual(['parts.document']);
|
||||
});
|
||||
|
||||
it('普通执行工单 / 验收工单 / 没说自己建哪张表的数据工单 → 空(不替它猜,更不回落到必交付物)', () => {
|
||||
expect(dataOrderLedgers(order({ mode: 'execution', gateLedgers: ['parts.profile'] }))).toEqual([]);
|
||||
expect(dataOrderLedgers(dataOrder({ gateLedgers: undefined }))).toEqual([]);
|
||||
expect(dataOrderLedgers(order({ mode: 'acceptance', criteria: [], actions: [], deliverable: 'x', gateLedgers: ['parts.profile'] }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function harness(reply: ReleaseWriteResult | Error) {
|
||||
const calls: Array<{ taskId: TaskId; scope: ReleaseScope | undefined }> = [];
|
||||
const notes: Array<{ kind: string; title: string; detail: Record<string, unknown> }> = [];
|
||||
let touched = 0;
|
||||
const deps = {
|
||||
ledger: {
|
||||
releaseWrite: async (taskId: TaskId, scope?: ReleaseScope) => {
|
||||
calls.push({ taskId, scope });
|
||||
if (reply instanceof Error) throw reply;
|
||||
return reply;
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
append: async (entry: { kind: string; title: string; detail: Record<string, unknown> }) => {
|
||||
notes.push({ kind: entry.kind, title: entry.title, detail: entry.detail });
|
||||
},
|
||||
},
|
||||
labelOf: async (address: string) => ({ 'parts.profile': '芯片主数据', 'parts.parameter': '标准化参数集' })[address] ?? address,
|
||||
touched: () => {
|
||||
touched += 1;
|
||||
},
|
||||
};
|
||||
return { deps, calls, notes, touched: () => touched };
|
||||
}
|
||||
|
||||
const merged = (rows: Array<[string, string]>): ReleaseWriteResult => ({
|
||||
mergedRows: rows.length,
|
||||
emittedEvents: rows.length,
|
||||
landedEarlierRows: 0,
|
||||
simulatedRows: 0,
|
||||
merged: rows.map(([object, pn]) => ({ domain: 'parts', object, key: { internal_pn: pn }, values: {} })),
|
||||
});
|
||||
|
||||
describe('landDataOrder —— 确认落下之后', () => {
|
||||
it('只并这张卡的那几张表(范围放行),归因带卡号;痕点名哪几张表各几行,并给模型指路;数据域跟着刷', async () => {
|
||||
const h = harness(merged([['profile', 'A'], ['parameter', 'A'], ['parameter', 'A']]));
|
||||
const got = await landDataOrder(h.deps, T, dataOrder());
|
||||
expect(got.landed).toBe(true);
|
||||
expect(h.calls).toEqual([
|
||||
{ taskId: T, scope: { only: [{ domain: 'parts', object: 'profile' }, { domain: 'parts', object: 'parameter' }], cause: 'data-order:wo-1' } },
|
||||
]);
|
||||
expect(h.notes).toHaveLength(1);
|
||||
expect(h.notes[0]?.kind).toBe('ledger-write');
|
||||
expect(h.notes[0]?.title).toContain('芯片主数据 1 行、标准化参数集 2 行已进正式表');
|
||||
expect(h.notes[0]?.title).toContain('别再往这几张表写它们');
|
||||
expect(h.notes[0]?.detail).toMatchObject({
|
||||
workOrderId: 'wo-1',
|
||||
rows: 3,
|
||||
tables: [
|
||||
{ address: 'parts.profile', rows: 1 },
|
||||
{ address: 'parts.parameter', rows: 2 },
|
||||
],
|
||||
});
|
||||
expect(h.touched()).toBe(1);
|
||||
});
|
||||
|
||||
it('试跑单挡下的主数据:痕如实说「只留在活动缓存」,数据域不刷', async () => {
|
||||
const h = harness({ mergedRows: 0, emittedEvents: 0, landedEarlierRows: 0, simulatedRows: 3, merged: [] });
|
||||
await landDataOrder(h.deps, T, dataOrder());
|
||||
expect(h.notes[0]?.title).toContain('试跑');
|
||||
expect(h.notes[0]?.title).toContain('未落正式表');
|
||||
expect(h.touched()).toBe(0);
|
||||
});
|
||||
|
||||
it('同一张卡确认两次:第二次无事发生,痕也这么说', async () => {
|
||||
const h = harness({ mergedRows: 0, emittedEvents: 0, landedEarlierRows: 3, simulatedRows: 0, merged: [] });
|
||||
await landDataOrder(h.deps, T, dataOrder());
|
||||
expect(h.notes[0]?.title).toContain('早已入库');
|
||||
});
|
||||
|
||||
it('不是数据工单就一个字不动(不调台账、不留痕)', async () => {
|
||||
const h = harness(merged([]));
|
||||
const got = await landDataOrder(h.deps, T, order({ mode: 'execution' }));
|
||||
expect(got).toEqual({ landed: false, reason: 'not-data-order' });
|
||||
expect(h.calls).toEqual([]);
|
||||
expect(h.notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('入库失败不抛:人已确认的事实不倒退;原话留痕、行仍在草稿层等放行', async () => {
|
||||
const h = harness(new Error('草稿与正式版并发冲突:1 处字段已被他单改动'));
|
||||
const got = await landDataOrder(h.deps, T, dataOrder());
|
||||
expect(got).toMatchObject({ landed: false, reason: 'failed' });
|
||||
expect(h.notes[0]?.title).toContain('没能入库');
|
||||
expect(h.notes[0]?.title).toContain('并发冲突');
|
||||
expect(h.notes[0]?.title).toContain('放行时再并');
|
||||
});
|
||||
});
|
||||
@@ -565,6 +565,14 @@ describe('createOnly:键已在正式表 → 拒并指路', () => {
|
||||
expect(why).toContain('drop');
|
||||
});
|
||||
|
||||
it('本单经数据工单确认已入库的那一行 → 拒回换一句话:建好了别再写,不点名 drop(撤了读面少一行)', () => {
|
||||
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal, [{ internal_pn: 'LM2596S-ADJ' }]);
|
||||
expect(why).toContain('已入库');
|
||||
expect(why).not.toContain('drop');
|
||||
// 另一颗在库的料号与本单无关,照旧是原来那句
|
||||
expect(createOnlyViolation(target, { internal_pn: 'LM2596S-5.0' }, formal, [{ internal_pn: 'LM2596S-ADJ' }])).toContain('drop');
|
||||
});
|
||||
|
||||
it('库里没有的料号 → 放行;复合主键按全部键列比', () => {
|
||||
expect(createOnlyViolation(target, { internal_pn: 'XL2596S-5.0' }, formal)).toBeUndefined();
|
||||
const sub = { label: '标准化参数集', keys: ['internal_pn', 'param_name'] };
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 开案时刻的两种写法 —— **同一个源头,两处消费**:
|
||||
* · `openedAtText`:`YYYY-MM-DD HH:mm:ss`(本地时区,给到秒),引擎注进 Agent 作用域的 `case:openedAt`,
|
||||
* 说明书让模型「去掉分隔符」铸案号(E04 `round_ref` / W05 `eval_ref` / `request_id`);
|
||||
* · `caseStamp`:上一串去掉分隔符 = 案号里那 14 位数字。运营中心沙箱看板要按案号从正式表圈出
|
||||
* 「这一单的行」,必须与模型铸主键用的是同一串 —— 所以两边都从这里取,不各写一份。
|
||||
* 时区取服务进程本地(引擎那份从来如此;两台后端一个库时得跑同一时区,否则案号就对不上)。
|
||||
*/
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
export function openedAtText(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return '';
|
||||
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** `openedAtText` 去掉分隔符:`20260827215103` 这一形 */
|
||||
export function caseStamp(iso: string): string {
|
||||
return openedAtText(iso).replace(/\D/g, '');
|
||||
}
|
||||
@@ -678,6 +678,8 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
|
||||
deliverable: input.deliverable as never,
|
||||
criteria: [...input.criteria],
|
||||
actions: input.actions.map((a) => ({ ...a })),
|
||||
// 人工验收清单照传 —— 与 fields / blocks 同一个坑:这里少一行,上游传了一路的东西就静默落地
|
||||
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
|
||||
/*
|
||||
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
|
||||
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 数据工单确认即入库(2026-08-27 用户定:「数据工单里面填的数据应该是确认这个数据工单后
|
||||
* 这些数据就应该入库了」)。
|
||||
*
|
||||
* 从前草稿层通往正式表只有整单放行这一道口子:人在数据工单的件上把主数据建齐、按下确认,
|
||||
* 那几张表照旧躺在 `ledger.__draft` 里,要等整条评估跑到 T_release 才并进库 ——
|
||||
* 卡上明写着「确认后这批主数据即入库」,而系统里没有一行代码兑现它;试跑单(不落主数据)更是永远不落。
|
||||
*
|
||||
* 现在:确认落下那一刻,把**这张卡的提交闸盯着的那几张表**并进正式表(`ledger.releaseWrite` 的范围形)。
|
||||
* 闸看哪几张表,落地就落哪几张 —— 同一份名单(卡上绑的 `blocks` > 卡自报的 `gateLedgers`),
|
||||
* 不回落到必交付物:那几张是整单放行才该落的单据,不归这张卡管。
|
||||
* 并过的行留在草稿层打记号(仍是本单写下的行,`mine:` / `_rows` / 任务视图照旧认它),整单放行时不再写。
|
||||
*/
|
||||
import type { TaskId, ViewBlock, WorkOrder } from '@contracts';
|
||||
import type { LedgerService, ReleaseWriteResult } from '@modules/ledger';
|
||||
|
||||
/**
|
||||
* 这张卡确认之后该入库的表 —— 只有**数据工单**(执行工单的 `variant:'data'`)才有;
|
||||
* 名单与提交闸同源:绑了 `blocks` 看绑的,否则看 `gateLedgers`。两样都没有就是空 ——
|
||||
* 这张卡没说自己是来建哪张表的,基座不替它猜(猜错就把不该落的东西落进了库)。
|
||||
*/
|
||||
export function dataOrderLedgers(order: WorkOrder): string[] {
|
||||
if (order.mode !== 'execution' || order.variant !== 'data') return [];
|
||||
const bound = (order.blocks ?? [])
|
||||
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
||||
.map((b) => b.ledger as string);
|
||||
const own = bound.length > 0 ? bound : (order.gateLedgers ?? []);
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of own) {
|
||||
const address = raw.trim();
|
||||
if (!/^[a-z0-9_]+\.[a-z0-9_]+$/i.test(address) || seen.has(address)) continue;
|
||||
seen.add(address);
|
||||
out.push(address);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type DataOrderLandingDeps = {
|
||||
readonly ledger: Pick<LedgerService, 'releaseWrite'>;
|
||||
/** 留痕:一条 `ledger-write`(工作日志与模型轨迹都读它) */
|
||||
readonly memory: {
|
||||
append(entry: { taskId: TaskId; kind: 'ledger-write'; title: string; detail: Record<string, unknown> }): Promise<unknown>;
|
||||
};
|
||||
/** 表的人读名(台账声明的 `label`);认不出退地址 */
|
||||
readonly labelOf: (address: string) => Promise<string>;
|
||||
/** 治理面(数据域)那一屏要跟着刷 —— 正式表刚多了几行 */
|
||||
readonly touched?: () => void;
|
||||
};
|
||||
|
||||
export type DataOrderLanding =
|
||||
| { readonly landed: true; readonly result: ReleaseWriteResult; readonly tables: ReadonlyArray<{ address: string; label: string; rows: number }> }
|
||||
| { readonly landed: false; readonly reason: 'not-data-order' | 'failed'; readonly error?: string };
|
||||
|
||||
/**
|
||||
* 确认落下之后调它。失败不抛:人已经确认了,那一笔**记成办结**是对的;入库没成就把原话留在痕上,
|
||||
* 那几行仍在草稿层,整单放行时照旧并进去(退回从前的时机,不丢数据)。
|
||||
*/
|
||||
export async function landDataOrder(deps: DataOrderLandingDeps, taskId: TaskId, order: WorkOrder): Promise<DataOrderLanding> {
|
||||
const addresses = dataOrderLedgers(order);
|
||||
if (addresses.length === 0) return { landed: false, reason: 'not-data-order' };
|
||||
const only = addresses.map((a) => {
|
||||
const [domain = '', object = ''] = a.split('.');
|
||||
return { domain, object };
|
||||
});
|
||||
let result: ReleaseWriteResult;
|
||||
try {
|
||||
result = await deps.ledger.releaseWrite(taskId, { only, cause: `data-order:${order.id}` });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'ledger-write',
|
||||
title: `数据工单已确认,但这批数据没能入库:${message} —— 仍留在草稿层,放行时再并`,
|
||||
detail: { workOrderId: order.id, tables: addresses, error: message },
|
||||
});
|
||||
return { landed: false, reason: 'failed', error: message };
|
||||
}
|
||||
|
||||
const byTable = new Map<string, number>();
|
||||
for (const row of result.merged) {
|
||||
const at = `${row.domain}.${row.object}`;
|
||||
byTable.set(at, (byTable.get(at) ?? 0) + 1);
|
||||
}
|
||||
const tables: Array<{ address: string; label: string; rows: number }> = [];
|
||||
for (const address of addresses) {
|
||||
tables.push({ address, label: await deps.labelOf(address), rows: byTable.get(address) ?? 0 });
|
||||
}
|
||||
const said = tables.map((t) => `${t.label} ${t.rows} 行`).join('、');
|
||||
/*
|
||||
* 一条痕,三种局面各说各的(界面不撒谎):
|
||||
* · 落了 —— 点名哪几张表各几行,并给模型一句行动指南:这几行从此在库,别再写它们(在库的键会被当场拒);
|
||||
* · 试跑挡下 —— 主数据只留在活动缓存,正式表一行没进;
|
||||
* · 早已落过(同一张卡确认两次) —— 无事发生。
|
||||
*/
|
||||
const title =
|
||||
result.mergedRows > 0
|
||||
? `数据工单确认入库:${said}已进正式表 —— 这几行从此在库,别再往这几张表写它们(在库的键会被当场拒);要改由人在件上改`
|
||||
: result.simulatedRows > 0
|
||||
? `数据工单已确认(试跑:${said}只留在活动缓存,未落正式表)`
|
||||
: `数据工单已确认(这批行早已入库,这次无事发生)`;
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'ledger-write',
|
||||
title,
|
||||
detail: {
|
||||
workOrderId: order.id,
|
||||
rows: result.mergedRows,
|
||||
simulated: result.simulatedRows,
|
||||
landedEarlier: result.landedEarlierRows,
|
||||
tables: tables.map((t) => ({ address: t.address, rows: t.rows })),
|
||||
},
|
||||
});
|
||||
if (result.mergedRows > 0) deps.touched?.();
|
||||
return { landed: true, result, tables };
|
||||
}
|
||||
@@ -32,6 +32,8 @@ const RUN_TABLES = [
|
||||
'sys.upload',
|
||||
// 产出的资料文件存档 —— 同样是跑出来的、同样没有指向 task 的外键
|
||||
'sys.task_asset_file',
|
||||
// 运营端启动的幂等键是跑出来的(它指着 task);活动档案(sys.activity_profile)是配置,留
|
||||
'sys.activity_launch',
|
||||
'sys.node_instance',
|
||||
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
|
||||
'sys.trace_span',
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { ReplyRejectedError } from '@modules/human-tool';
|
||||
import { advanceOrder } from '@modules/process';
|
||||
import { assetRowsOf, keyText, listArchived, readArchived } from '../asset-archive.ts';
|
||||
import { landDataOrder } from '../data-order-landing.ts';
|
||||
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
|
||||
import { toFileDoc } from '../file-doc.ts';
|
||||
import { displayLog } from './log-lines.ts';
|
||||
@@ -1441,6 +1442,29 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||
*/
|
||||
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
|
||||
/*
|
||||
* **数据工单确认即入库**(2026-08-27 用户定):这张卡让人从零建一张主数据表,
|
||||
* 确认落下那一刻,它的提交闸盯着的那几张表就并进正式表 —— 不等整单放行。
|
||||
* 从前卡上写着「确认后即入库」而系统里没有一行代码兑现它;试跑单更是永远不落。
|
||||
* 放在保存与回传都落定之后:并进去的就是人交卷那一刻件上的样子。失败不拦人(痕上说清,放行时再并)。
|
||||
*/
|
||||
await landDataOrder(
|
||||
{
|
||||
ledger: container.ledger,
|
||||
memory: {
|
||||
append: (entry) =>
|
||||
container.memory.append({ ...entry, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] } }),
|
||||
},
|
||||
labelOf: async (address) => {
|
||||
const task = await container.task.get(outcome.taskId).catch(() => undefined);
|
||||
const manifest = task === undefined ? undefined : await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
||||
return manifest?.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address)?.label ?? address;
|
||||
},
|
||||
touched: () => container.broadcaster.publish({ kind: 'system', id: 'ledger', reason: 'data-order-landed' }),
|
||||
},
|
||||
outcome.taskId,
|
||||
outcome.order,
|
||||
);
|
||||
/*
|
||||
* **返回之前就把这单标成「在办事」**(2026-08-25 用户:「右边聊天框先有反应,中间的
|
||||
* html 过一会儿才变成 AI 正在推进中,看上去很不流畅」)。
|
||||
|
||||
@@ -1305,7 +1305,8 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
/** 草稿层:一次查完按地址分组 —— 每张表单独查会把这一屏拖成几十条 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`,
|
||||
// 已在数据工单确认时入库的行(landed_at 非空)不算「在办草稿」—— 它们已经在正式行数里了
|
||||
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft where landed_at is null group by domain, object`,
|
||||
),
|
||||
);
|
||||
const drafts = new Map(
|
||||
|
||||
@@ -17,6 +17,7 @@ import { activityManifestSchema } from '@contracts';
|
||||
import type { Container } from './container.ts';
|
||||
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||
import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
|
||||
import { createActivityOpsStore } from './activity-ops/store.ts';
|
||||
|
||||
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
||||
/*
|
||||
@@ -34,6 +35,33 @@ import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
|
||||
*/
|
||||
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit', 'part-substitution-assessment'] as const;
|
||||
|
||||
/**
|
||||
* 运营侧活动档案(2026-08-27 用户定:负责人 / 说明 / 图标 / 色相另建 `sys.activity_profile`,活动包不载这些)。
|
||||
* 按包目录名登记;负责人按**岗位**挑第一个在职的人 —— 员工 id 是 Keycloak 的 sub、首登即建,
|
||||
* seed 拿不到真 sub,只能按岗位现查;**查不到就留空并说一声**,不编一个(界面不撒谎)。
|
||||
* 重复播种是覆盖不是堆叠;运营面的改口另起(见 debt)。
|
||||
*/
|
||||
const ACTIVITY_PROFILES: Readonly<Record<(typeof REAL_PACKAGES)[number], { description: string; ico: string; hue: string; ownerPosition: string }>> = {
|
||||
'crm-relationship-coverage': {
|
||||
description: '按客户线月度 / 事件驱动地评估关系覆盖度,识别关键人缺口,编排经营计划并回收执行回执。',
|
||||
ico: 'target',
|
||||
hue: 'blue',
|
||||
ownerPosition: 'BD',
|
||||
},
|
||||
'brand-content-toolkit': {
|
||||
description: '受理品牌形象与销售工具包的内容需求,产出初稿交审核,发布后入品牌内容库。',
|
||||
ico: 'palette',
|
||||
hue: 'amber',
|
||||
ownerPosition: 'OP',
|
||||
},
|
||||
'part-substitution-assessment': {
|
||||
description: '对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。',
|
||||
ico: 'git-compare',
|
||||
hue: 'teal',
|
||||
ownerPosition: 'FAE',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 活动包根目录。
|
||||
*
|
||||
@@ -122,6 +150,18 @@ export async function installRealActivities(container: Container): Promise<void>
|
||||
*/
|
||||
const declared = parsed.data.declarations.tools.map((t) => t.name);
|
||||
await container.toolbox.setAllowlist(parsed.data.identity.activityId, declared);
|
||||
await seedProfile(container, dir, parsed.data.identity.activityId, log);
|
||||
/*
|
||||
* **同版重装不许说「已装」**(2026-08-27,一天里静默丢了两次):两审过了、版本已在库,
|
||||
* 库里一个字没动 —— 这一句话就是人判断「改动上没上」的唯一依据,说错就是误导。
|
||||
*/
|
||||
if (!verdict.stored) {
|
||||
log(
|
||||
` 活动包已是这一版,未重装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
|
||||
' —— 改了内容要升 packageVersion 再装(同版重装是 no-op,库里仍是先前那份)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
log(
|
||||
` 活动包已装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
|
||||
(declared.length === 0 ? '' : ` · 已授权工具 ${declared.length} 件`) +
|
||||
@@ -155,3 +195,22 @@ export async function installRealActivities(container: Container): Promise<void>
|
||||
}
|
||||
log(` 样例主数据已就位:${MASTER_SAMPLE_ROWS.length} 行(客户 / 项目 / 联系人 / 料号)`);
|
||||
}
|
||||
|
||||
/** 运营侧档案落库:说明 / 图标 / 色相照表,负责人按岗位现查(取不到留空,如实报) */
|
||||
async function seedProfile(container: Container, dir: (typeof REAL_PACKAGES)[number], activityId: string, log: (line: string) => void): Promise<void> {
|
||||
const spec = ACTIVITY_PROFILES[dir];
|
||||
const candidates = await container.org.staffOfPosition(spec.ownerPosition).catch(() => []);
|
||||
const owner = candidates[0];
|
||||
await createActivityOpsStore(container.db).upsertProfile({
|
||||
activityId,
|
||||
ownerId: owner === undefined ? null : owner.id,
|
||||
description: spec.description,
|
||||
ico: spec.ico,
|
||||
hue: spec.hue,
|
||||
});
|
||||
log(
|
||||
owner === undefined
|
||||
? ` 活动档案已记(${activityId}):负责人留空 —— 岗位 ${spec.ownerPosition} 当前没有在职的人`
|
||||
: ` 活动档案已记(${activityId}):负责人 ${owner.name}(岗位 ${spec.ownerPosition})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
import { registerOrgDirectoryRoutes } from './routes/org-directory.ts';
|
||||
import { registerMailRoutes } from './routes/mail.ts';
|
||||
import { registerActivityRoutes } from './routes/activities.ts';
|
||||
import { createActivityOps } from './activity-ops/service.ts';
|
||||
import { createActivityOpsStore } from './activity-ops/store.ts';
|
||||
import { createLoopStateStore } from '@modules/loop-engine';
|
||||
import { readUpload } from './upload-store.ts';
|
||||
import { listArchived, readArchived } from './asset-archive.ts';
|
||||
import { registerStreamRoute } from './routes/stream.ts';
|
||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
|
||||
@@ -123,6 +129,27 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
registerRegistrationRoutes(app, container);
|
||||
registerGovernanceOpsRoutes(app, container);
|
||||
registerMailRoutes(app, container);
|
||||
/*
|
||||
* 运营中心 · 活动模块(2026-08-27):跨模块的读投影 + 编排住 app 层,依赖只取用到的那几面;
|
||||
* 写走既有 port(task.start / task.control / registry.setEnabled)。
|
||||
*/
|
||||
registerActivityRoutes(app, {
|
||||
activityOps: createActivityOps({
|
||||
registry: container.registry,
|
||||
task: container.task,
|
||||
org: container.org,
|
||||
memory: container.memory,
|
||||
ledger: container.ledger,
|
||||
loopState: createLoopStateStore(container.db),
|
||||
store: createActivityOpsStore(container.db),
|
||||
files: {
|
||||
readUpload: (ref) => readUpload(container.db, ref),
|
||||
listArchived: (taskId) => listArchived(container.db, taskId),
|
||||
readArchived: (taskId, address, key) => readArchived(container.db, taskId, address, key),
|
||||
},
|
||||
clock: systemClock,
|
||||
}),
|
||||
});
|
||||
registerIngestRoutes(app, container);
|
||||
registerStreamRoute(app, container.broadcaster);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { TriggerService } from '@modules/trigger';
|
||||
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||
import type { StaffId, TaskId } from '@contracts';
|
||||
import { cardIdSchema, staffIdSchema } from '@contracts';
|
||||
import { openedAtText } from '../case-stamp.ts';
|
||||
import { archiveAssetFiles } from '../asset-archive.ts';
|
||||
import { createAgentRunner } from '../agent-runner.ts';
|
||||
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
|
||||
@@ -198,10 +199,23 @@ export function createOnlyViolation(
|
||||
target: { readonly label: string; readonly keys: ReadonlyArray<string> },
|
||||
key: Readonly<Record<string, string | number>>,
|
||||
formalRows: ReadonlyArray<Record<string, unknown>>,
|
||||
/** 本单经数据工单确认**已入库**的行的键(2026-08-27)—— 它们也在正式表里,但拒回要说另一句话 */
|
||||
landedKeys: ReadonlyArray<Record<string, unknown>> = [],
|
||||
): string | undefined {
|
||||
const same = (row: Record<string, unknown>) => target.keys.every((k) => String(row[k] ?? '') === String(key[k] ?? ''));
|
||||
if (!formalRows.some(same)) return undefined;
|
||||
const shown = target.keys.map((k) => `${k}=${String(key[k] ?? '')}`).join('、');
|
||||
/*
|
||||
* 这一行是**本单自己**在数据工单里建好、人确认后入库的 —— 指它去 drop 就错了:
|
||||
* drop 撤的是草稿层那一笔,正式表一个字不动,撤了只会让本单的读面(`mine:` / `_rows`)少一行、哨兵开不了。
|
||||
* 该说的是:它已经建好了,别再写;要改由人在件上改。
|
||||
*/
|
||||
if (landedKeys.some(same)) {
|
||||
return (
|
||||
`${target.label}里「${shown}」这一行是本单在数据工单里建好、人确认后已入库的 —— 建好了就别再写它;` +
|
||||
'值要改由人在件上改(件上保存直落草稿层),你这一笔不用发,接着办下一件事。'
|
||||
);
|
||||
}
|
||||
return (
|
||||
`${target.label}里已经有「${shown}」这一行 —— 这张表在本单只许**新建库里没有的记录**(数据工单是从零建主数据用的),` +
|
||||
'在库的料号不用建、也不许改:别再写它;要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉,免得件上多出一行要人填的东西。'
|
||||
@@ -945,12 +959,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
* 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进
|
||||
* 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。
|
||||
*/
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
const opened = new Date(task.startedAt);
|
||||
const openedAt = Number.isNaN(opened.getTime())
|
||||
? ''
|
||||
: `${opened.getFullYear()}-${two(opened.getMonth() + 1)}-${two(opened.getDate())} ` +
|
||||
`${two(opened.getHours())}:${two(opened.getMinutes())}:${two(opened.getSeconds())}`;
|
||||
// 写法与运营中心沙箱看板圈行用的案号同源(`case-stamp.ts`):模型拿这串铸主键,看板拿同一串回查
|
||||
const openedAt = openedAtText(task.startedAt);
|
||||
const enriched: Record<string, unknown> = {
|
||||
'case:openedAt': openedAt,
|
||||
...scope,
|
||||
@@ -1033,7 +1043,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
});
|
||||
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>);
|
||||
const landedKeys = (await deps.ledger.taskDrafts(input.taskId))
|
||||
.filter((d) => d.landed && d.domain === input.domain && d.object === input.object)
|
||||
.map((d) => d.key);
|
||||
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>, landedKeys);
|
||||
if (why !== undefined) throw new Error(why);
|
||||
}
|
||||
|
||||
@@ -1135,6 +1148,24 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
})`,
|
||||
);
|
||||
}
|
||||
/*
|
||||
* **已入库的行撤不掉**(2026-08-27,数据工单确认即入库):那一行在数据工单确认那一刻就进了正式表,
|
||||
* 草稿层里那一笔只是「本单写过它」的记号 —— 撤掉记号正式表一个字不动,反而让本单的读面少一行
|
||||
* (`mine:` / `_rows` 全靠它;S1 的哨兵 `parts_profile_rows >= created` 会从此开不了)。
|
||||
*/
|
||||
const landedHit = (await deps.ledger.taskDrafts(input.taskId)).find(
|
||||
(d) =>
|
||||
d.landed &&
|
||||
d.domain === input.domain &&
|
||||
d.object === input.object &&
|
||||
Object.keys(input.key).every((k) => String(d.key[k] ?? '') === String(input.key[k] ?? '')),
|
||||
);
|
||||
if (landedHit !== undefined) {
|
||||
throw new Error(
|
||||
`${target.label}里这一行(${Object.entries(input.key).map(([k, v]) => `${k}=${String(v)}`).join('、')})` +
|
||||
'是本单在数据工单里建好、人确认后已入库的,撤不掉;要改由人在件上改,你接着办下一件事。',
|
||||
);
|
||||
}
|
||||
const dropped = await deps.ledger.draftDrop({
|
||||
taskId: input.taskId,
|
||||
address: { domain: input.domain, object: input.object },
|
||||
@@ -1242,6 +1273,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
};
|
||||
|
||||
const cards = {
|
||||
/** 这一单正等人交回的卡 —— 工单没办结的 + 对话卡没作答、没被顶替的(见 human-tool `pendingCards`) */
|
||||
async unanswered(taskId: TaskId) {
|
||||
return (await deps.humanTool.pendingCards(taskId)).map((c) => ({ id: c.id, title: c.title }));
|
||||
},
|
||||
/**
|
||||
* 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。
|
||||
* 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
||||
@@ -1352,6 +1387,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||
allowCustom?: boolean;
|
||||
itemId?: string | undefined;
|
||||
variant?: 'review' | undefined;
|
||||
checklist?: ReadonlyArray<string> | undefined;
|
||||
}) {
|
||||
const cardSpan = deps.trace.beginAction({
|
||||
taskId: input.taskId,
|
||||
@@ -1360,17 +1397,23 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
input: { options: input.options.map((o) => o.value) },
|
||||
});
|
||||
const recommended = input.options.find((o) => o.recommended === true);
|
||||
/* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄 */
|
||||
/*
|
||||
* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄。
|
||||
* 面孔**引擎按当下那一步定好递过来**(`input.variant`,2026-08-27:模型漏了 itemId,卡退成「需要你定」);
|
||||
* 没递才退回按 itemId 查剧本。
|
||||
*/
|
||||
const reviewFace =
|
||||
input.itemId !== undefined &&
|
||||
(await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||
(st) => st.item === input.itemId,
|
||||
)?.cardVariant === 'review';
|
||||
input.variant === 'review' ||
|
||||
(input.itemId !== undefined &&
|
||||
(await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||
(st) => st.item === input.itemId,
|
||||
)?.cardVariant === 'review');
|
||||
const issued = await deps.humanTool.issueChatCard(
|
||||
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
|
||||
{
|
||||
k: 'decision',
|
||||
...(reviewFace ? { variant: 'review' as const } : {}),
|
||||
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
|
||||
id: cardIdSchema.parse(ids.next()),
|
||||
at: clock.now().toISOString(),
|
||||
ask: input.ask,
|
||||
@@ -1383,7 +1426,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
...(o.consequence === undefined ? {} : { consequence: o.consequence }),
|
||||
...(o.recommended === undefined ? {} : { recommended: o.recommended }),
|
||||
})),
|
||||
allowCustom: input.allowCustom ?? false,
|
||||
/*
|
||||
* 审核卡一律收自定义意见(2026-08-27 用户实测:填了重做意见点「重做」,卡上却写着
|
||||
* 「你选了:驳回(不写具体意见)」)—— 模型常把骨架里的 allowCustom 抄丢,前端只好退回驳回项;
|
||||
* 「重做」本来就是带意见退回去改,这一档由面孔定,不靠模型抄。
|
||||
*/
|
||||
allowCustom: input.allowCustom ?? reviewFace,
|
||||
},
|
||||
);
|
||||
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
||||
@@ -1674,10 +1722,18 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
);
|
||||
const releaseBlocks = await fillBoundTables(input.taskId, editable);
|
||||
|
||||
/*
|
||||
* 终局验收卡的人工验收清单:剧本里声明了 `acceptanceChecklist` 且不是审核卡那一步的(放行那一步)。
|
||||
* 人核这份工单要走的步骤是固定的,由活动声明、这里带上卡,不靠模型抄。
|
||||
*/
|
||||
const releaseChecklist = manifest.assets.businessModel.steps?.find(
|
||||
(st) => st.acceptanceChecklist !== undefined && st.acceptanceChecklist.length > 0 && st.cardVariant !== 'review',
|
||||
)?.acceptanceChecklist;
|
||||
const submitted = await deps.release.submitForRelease({
|
||||
taskId: input.taskId,
|
||||
ownerId: input.ownerId,
|
||||
humanCallCap: input.humanCallCap,
|
||||
...(releaseChecklist === undefined ? {} : { checklist: releaseChecklist }),
|
||||
deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'),
|
||||
title,
|
||||
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }],
|
||||
|
||||
@@ -18,6 +18,8 @@ const taskConsumer: Consumer = async (delivery, container) => {
|
||||
kind,
|
||||
ref: envelope.correlation.cause.ref,
|
||||
payload: envelope.payload.data,
|
||||
// 发生时刻要带过去:人事件靠它判「这条回传是不是已经被上一拍处理过了」
|
||||
occurredAt: envelope.occurredAt,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user