Archived
feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params / summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts` (与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema); 迁移 0035_activity_ops;《API文档-活动模块》按契约回写。 设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*` 整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.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,52 @@
|
||||
/** 实例五态:对 task 运行态 / 终态的如实映射(含「驳回是环内回转」那一段) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { instanceStateOf, verdictFactOf } from '../activity-ops/instance-state.ts';
|
||||
|
||||
const base = { terminal: undefined, reason: undefined, runtime: 'running', usedSteps: 3, lastVerdict: undefined, pending: undefined };
|
||||
|
||||
describe('instanceStateOf', () => {
|
||||
it('终态:released 完成;terminated 失败且 kind=terminated;其余终态失败带原话', () => {
|
||||
expect(instanceStateOf({ ...base, terminal: 'released' })).toEqual({ state: 'completed', reason: null });
|
||||
expect(instanceStateOf({ ...base, terminal: 'terminated', reason: '由 柴浩 手动终止' })).toEqual({ state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } });
|
||||
// exhausted 存的是预算执行器的 JSON —— 摆给人的要说哪一项用了多少;认不出的形状原样交
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"kind":"exhausted","breaches":[{"item":"steps","used":90,"cap":90},{"item":"activeMs","used":7200000,"cap":7200000}]}' }).reason)
|
||||
.toEqual({ kind: 'failed', text: '预算耗尽:步数 90/90、活跃时长 120/120 分钟' });
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"breaches":[]}' }).reason?.text).toBe('{"breaches":[]}');
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: undefined }).reason?.text).toBe('预算耗尽');
|
||||
expect(instanceStateOf({ ...base, terminal: 'stalled' }).reason).toEqual({ kind: 'failed', text: '停滞熔断' });
|
||||
});
|
||||
|
||||
it('待执行 = 建了单还没被推进器领到;等人回传仍是进行中', () => {
|
||||
expect(instanceStateOf({ ...base, usedSteps: 0 }).state).toBe('pending');
|
||||
expect(instanceStateOf({ ...base, runtime: 'awaiting-human', usedSteps: 0 }).state).toBe('running');
|
||||
expect(instanceStateOf({ ...base, runtime: 'awaiting-event' }).state).toBe('running');
|
||||
});
|
||||
|
||||
it('驳回:裁决刚到(pending.verdict=reject)或重做已被消费(pending 清空)且最近裁决不是 pass;重新提交后回进行中', () => {
|
||||
const redo = { pass: false, comment: '毛利低于阈值,退回重算', byName: '柴浩' };
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: 'reject', comment: redo.comment } })).toEqual({
|
||||
state: 'rejected',
|
||||
reason: { kind: 'rejected', text: '验收驳回:毛利低于阈值,退回重算 —— 负责人 柴浩' },
|
||||
});
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: undefined }).state).toBe('rejected');
|
||||
// 新验收卡已发(pending 立起、还没裁决)→ running
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: undefined, comment: undefined } }).state).toBe('running');
|
||||
// 上一次裁决是 pass(落地中)→ running
|
||||
expect(instanceStateOf({ ...base, lastVerdict: { pass: true, comment: '', byName: '柴浩' }, pending: { verdict: 'pass', comment: undefined } }).state).toBe('running');
|
||||
// 没写意见也要说人话
|
||||
expect(instanceStateOf({ ...base, lastVerdict: { pass: false, comment: '', byName: undefined } }).reason?.text).toBe('验收驳回:负责人未写明具体意见,已退回重做');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdictFactOf', () => {
|
||||
it('取最近一条 `裁决:`;落地那一路同 kind 但标题不同,不算', () => {
|
||||
const entries = [
|
||||
{ kind: 'release-verdict', title: '裁决:redo', detail: { by: 's-1', comment: '再核一遍' } },
|
||||
{ kind: 'release-verdict', title: '裁决:pass', detail: { by: 's-1' } },
|
||||
{ kind: 'release-verdict', title: '落地收敛,发射输出口:assessment-released', detail: { ports: ['assessment-released'] } },
|
||||
];
|
||||
expect(verdictFactOf(entries, (id) => (id === 's-1' ? '柴浩' : undefined))).toEqual({ pass: true, comment: '', byName: '柴浩' });
|
||||
expect(verdictFactOf(entries.slice(0, 1))).toEqual({ pass: false, comment: '再核一遍', byName: undefined });
|
||||
expect(verdictFactOf([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 启动参数表:从清单 `input.data` 派生 + 服务端校验。
|
||||
* 夹具照 W05 / E04 的清单形状手写(apps 不得 import activities —— depcruise 在守)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { controlOf, fieldSpecsOf, launchParamOf, launchParamsOf, validateLaunchParams } from '../activity-ops/launch-params.ts';
|
||||
|
||||
/** W05 的四参 + 选填文件,按 2026-08-27 的清单写法 */
|
||||
const W05_INPUT = {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
|
||||
properties: {
|
||||
customerCode: {
|
||||
type: 'string',
|
||||
title: '客户',
|
||||
description: '台账里有就直接选;新客户自己填',
|
||||
examples: ['CU-0142'],
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' },
|
||||
},
|
||||
customerName: { type: 'string', title: '客户名称', examples: ['深圳恒星电源有限公司'] },
|
||||
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea', examples: ['工业电源项目在用 LM2596S-5.0'] },
|
||||
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' } },
|
||||
candidateInternalPns: { type: 'array', title: '候选内部料号', items: { type: 'string' }, examples: [['XL2596S-5.0', 'LM2596S-ADJ']] },
|
||||
datasheets: { type: 'array', format: 'file', title: 'datasheet', items: { type: 'object' } },
|
||||
},
|
||||
};
|
||||
|
||||
/** E04:两级下拉 + 数组(元素形状不声明)+ 选填说明 */
|
||||
const E04_INPUT = {
|
||||
type: 'object',
|
||||
required: ['trigger'],
|
||||
properties: {
|
||||
trigger: { type: 'string', enum: ['monthly', 'event'], enumNames: ['月度例行', '事件驱动'], title: '触发方式', default: 'monthly' },
|
||||
eventType: { type: 'string', enum: ['new_customer', 'major_deal'], title: '事件类型', showWhen: { field: 'trigger', equals: 'event' } },
|
||||
customers: { type: 'array', title: '本轮涉及的客户', examples: [['CU-0142']] },
|
||||
note: { type: 'string', title: '本轮说明' },
|
||||
budget: { type: 'integer', title: '预算' },
|
||||
},
|
||||
};
|
||||
|
||||
const noResolve = async () => undefined;
|
||||
|
||||
describe('fieldSpecsOf / controlOf', () => {
|
||||
it('保属性序;必填从 required[] 认;x-control / x-options / description 都读到', () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets']);
|
||||
expect(specs[0]).toMatchObject({ required: true, control: 'combo', description: '台账里有就直接选;新客户自己填', optionsSource: { ledger: 'customer.profile', value: 'customer_code', label: 'name' } });
|
||||
// label 列没写就用 value 列当标签
|
||||
expect(specs[3]?.optionsSource).toEqual({ ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' });
|
||||
expect(specs[1]?.required).toBe(false);
|
||||
});
|
||||
|
||||
it('控件推断:文件不上表单、字符串数组默认 list、enum 是 select、其余 text;数组标了 combo/select 就摆成单选', () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
expect(specs.map((s) => controlOf(s))).toEqual(['combo', 'text', 'textarea', 'combo', 'list', undefined]);
|
||||
const e04 = fieldSpecsOf(E04_INPUT);
|
||||
expect(e04.map((s) => controlOf(s))).toEqual(['select', 'select', 'list', 'text', 'text']);
|
||||
// 数组字段标 combo → 摆成单选(提交时后端包成数组);标 list 或不标 → list
|
||||
expect(controlOf({ ...specs[4]!, control: 'combo' })).toBe('combo');
|
||||
expect(controlOf({ ...specs[4]!, control: 'list' })).toBe('list');
|
||||
expect(controlOf({ ...specs[4]!, itemType: 'object' })).toBeUndefined();
|
||||
expect(controlOf({ ...specs[1]!, type: 'object' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('x-order', () => {
|
||||
it('声明了次序的按数字排,没声明的殿后保持原序(jsonb 不保键序,书写顺序靠不住)', () => {
|
||||
const specs = fieldSpecsOf({
|
||||
properties: {
|
||||
customerCode: { type: 'string', 'x-order': 1 },
|
||||
customerName: { type: 'string', 'x-order': 2 },
|
||||
originalInternalPn: { type: 'string', 'x-order': 4 },
|
||||
requestDescription: { type: 'string', 'x-order': 3 },
|
||||
zz: { type: 'string' },
|
||||
aa: { type: 'string' },
|
||||
},
|
||||
});
|
||||
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'zz', 'aa']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchParamsOf', () => {
|
||||
it('W05:五参(datasheets 省略);combo 候选来自 resolver;placeholder 取 examples[0]', async () => {
|
||||
const asked: string[] = [];
|
||||
const params = await launchParamsOf(W05_INPUT, async (src) => {
|
||||
asked.push(src.ledger);
|
||||
return src.ledger === 'customer.profile' ? [{ value: 'CU-0142', label: 'Itelma' }] : undefined;
|
||||
});
|
||||
expect(params.map((p) => p.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
|
||||
expect(asked).toEqual(['customer.profile', 'parts.profile']);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'customerCode',
|
||||
label: '客户',
|
||||
type: 'combo',
|
||||
required: true,
|
||||
desc: '台账里有就直接选;新客户自己填',
|
||||
placeholder: 'CU-0142',
|
||||
options: [{ value: 'CU-0142', label: 'Itelma' }],
|
||||
});
|
||||
// 读不到候选的 combo 仍是 combo(自由填),只是没有 options
|
||||
expect(params[3]).toEqual({ key: 'originalInternalPn', label: '原内部料号', type: 'combo', required: true });
|
||||
expect(params[4]).toMatchObject({ type: 'list', placeholder: 'XL2596S-5.0', required: true });
|
||||
expect(params[2]).toMatchObject({ type: 'textarea', placeholder: '工业电源项目在用 LM2596S-5.0' });
|
||||
});
|
||||
|
||||
it('E04:enum → select 带人读名与 default;没 enumNames 拿值当标签;数组元素形状不声明也当 list', async () => {
|
||||
const params = await launchParamsOf(E04_INPUT, noResolve);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'trigger',
|
||||
label: '触发方式',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [{ value: 'monthly', label: '月度例行' }, { value: 'event', label: '事件驱动' }],
|
||||
default: 'monthly',
|
||||
});
|
||||
expect(params[1]?.options).toEqual([{ value: 'new_customer', label: 'new_customer' }, { value: 'major_deal', label: 'major_deal' }]);
|
||||
expect(params[2]).toMatchObject({ type: 'list', placeholder: 'CU-0142' });
|
||||
});
|
||||
|
||||
it('x-control:select 却一个候选都没有 → 降成 text(只能选却无可选等于锁死)', () => {
|
||||
const spec = fieldSpecsOf({ properties: { k: { type: 'string', 'x-control': 'select' } } })[0]!;
|
||||
expect(launchParamOf(spec, undefined)).toEqual({ key: 'k', label: 'k', type: 'text' });
|
||||
expect(launchParamOf(spec, [])?.type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('数组字段摆成单选(2026-08-27 用户定:候选料号与原料号同款下拉,只选一颗)', () => {
|
||||
const INPUT = {
|
||||
required: ['candidateInternalPns'],
|
||||
properties: {
|
||||
candidateInternalPns: {
|
||||
type: 'array',
|
||||
title: '候选内部料号',
|
||||
items: { type: 'string' },
|
||||
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
|
||||
default: ['LM2596S-ADJ'],
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn' },
|
||||
},
|
||||
tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], 'x-control': 'select' },
|
||||
},
|
||||
};
|
||||
|
||||
it('数组 + x-control:combo → combo 控件,占位与预填取第一颗,候选照读', async () => {
|
||||
const params = await launchParamsOf(INPUT, async () => [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }]);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'candidateInternalPns',
|
||||
label: '候选内部料号',
|
||||
type: 'combo',
|
||||
required: true,
|
||||
placeholder: 'XL2596S-5.0',
|
||||
options: [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }],
|
||||
default: 'LM2596S-ADJ',
|
||||
});
|
||||
expect(params[1]).toMatchObject({ type: 'select', options: [{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }] });
|
||||
});
|
||||
|
||||
it('提交单个字符串包成一元数组;传数组也照收;空的按必填拦;select 逐项对候选', async () => {
|
||||
const specs = fieldSpecsOf(INPUT);
|
||||
const params = await launchParamsOf(INPUT, noResolve);
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: 'XL2596S-5.0' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0'] } });
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: ['XL2596S-5.0', ' LM2596S-ADJ '] }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] } });
|
||||
const empty = validateLaunchParams(params, { candidateInternalPns: ' ' }, specs);
|
||||
expect(empty).toEqual({ ok: false, issues: [{ key: 'candidateInternalPns', message: '必填' }] });
|
||||
const off = validateLaunchParams(params, { candidateInternalPns: 'x', tags: ['a', 'z'] }, specs);
|
||||
expect(off).toEqual({ ok: false, issues: [{ key: 'tags', message: '不在候选里(单选只能选候选)' }] });
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: 'x', tags: 'b' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['x'], tags: ['b'] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLaunchParams', () => {
|
||||
it('必填空 / 未知键 / 类型不符 / select 不在候选 各点名一格;combo 候选之外合法', async () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
const params = await launchParamsOf(W05_INPUT, async () => [{ value: 'CU-0142', label: 'Itelma' }]);
|
||||
const bad = validateLaunchParams(params, { customerCode: 'CU-NEW', requestDescription: '', candidateInternalPns: [' '], oops: 'x' }, specs);
|
||||
expect(bad.ok).toBe(false);
|
||||
if (!bad.ok) {
|
||||
expect(bad.issues.map((i) => i.key).sort()).toEqual(['candidateInternalPns', 'oops', 'originalInternalPn', 'requestDescription']);
|
||||
}
|
||||
const typed = validateLaunchParams(params, { customerCode: ['CU-1'], requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: 'PN-1' }, specs);
|
||||
expect(typed.ok).toBe(false);
|
||||
// 标量格传数组是错;list 格传单个字符串**照收**(包成一元数组)
|
||||
if (!typed.ok) expect(typed.issues).toEqual([{ key: 'customerCode', message: '要传字符串' }]);
|
||||
const wrapped = validateLaunchParams(params, { customerCode: 'CU-1', requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: ' PN-1 ' }, specs);
|
||||
expect(wrapped).toMatchObject({ ok: true, data: { candidateInternalPns: ['PN-1'] } });
|
||||
|
||||
const good = validateLaunchParams(
|
||||
params,
|
||||
{ customerCode: 'CU-NEW', customerName: '', requestDescription: ' 客户要求验证替代料 ', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', '', ' LM2596S-ADJ '] },
|
||||
specs,
|
||||
);
|
||||
expect(good).toEqual({
|
||||
ok: true,
|
||||
// 可选空串不落入参;list 逐项去空白、丢空项;combo 候选之外原样收
|
||||
data: { customerCode: 'CU-NEW', requestDescription: '客户要求验证替代料', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('select 只认候选;整数按清单类型转回数字', async () => {
|
||||
const specs = fieldSpecsOf(E04_INPUT);
|
||||
const params = await launchParamsOf(E04_INPUT, noResolve);
|
||||
const bad = validateLaunchParams(params, { trigger: 'weekly' }, specs);
|
||||
expect(bad.ok).toBe(false);
|
||||
if (!bad.ok) expect(bad.issues[0]).toMatchObject({ key: 'trigger', message: expect.stringContaining('候选') });
|
||||
expect(validateLaunchParams(params, { trigger: 'event', budget: '12' }, specs)).toEqual({ ok: true, data: { trigger: 'event', budget: 12 } });
|
||||
const nan = validateLaunchParams(params, { trigger: 'event', budget: '1.5' }, specs);
|
||||
expect(nan.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* 活动模块用例:投影 / 启动(幂等三档)/ 实例清单与详情 / 终止 / 禁用。
|
||||
* 依赖全是内存字面量 —— 服务只声明用到的那几面,不用造整本清单。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LaunchInput, TaskId } from '@contracts';
|
||||
import { staffIdSchema, taskIdSchema } from '@contracts';
|
||||
import type { ActivityCard } from '@modules/activity-registry';
|
||||
import { parseCmmn } from '@modules/loop-engine';
|
||||
import type { ItemRuntime } from '@modules/loop-engine';
|
||||
import { StaffNotFoundError } from '@modules/org';
|
||||
import type { StartTaskInput } from '@modules/task';
|
||||
import { createActivityOps, LAUNCH_CLAIM_STALE_MS } from '../activity-ops/service.ts';
|
||||
import type { ActivityOpsDeps, HistoryEntry, LoopFace, ManifestFace } from '../activity-ops/service.ts';
|
||||
import type { ActivityOpsStore, ActivityProfile, LaunchClaim, TaskRow } from '../activity-ops/store.ts';
|
||||
|
||||
const W05 = 'part-substitution-assessment';
|
||||
const OWNER = staffIdSchema.parse('s-owner');
|
||||
const ME = { id: staffIdSchema.parse('s-me'), name: '冯兵' };
|
||||
|
||||
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||
<case id="c" name="试"><casePlanModel id="p" name="p">
|
||||
<humanTask id="S1" name="归一"/>
|
||||
<humanTask id="S2" name="交人确认"><itemControl><manualActivationRule id="m"/></itemControl></humanTask>
|
||||
<humanTask id="T_release" name="放行"/>
|
||||
</casePlanModel></case>
|
||||
</definitions>`;
|
||||
|
||||
const card = (over: Partial<ActivityCard> = {}): ActivityCard => ({
|
||||
activityId: W05,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
category: '需求分析与方案匹配',
|
||||
packageVersions: [
|
||||
{ activityId: W05, version: '1.4.9', installedAt: '2026-08-26T01:00:00.000Z', state: 'active' },
|
||||
{ activityId: W05, version: '1.5.0', installedAt: '2026-08-27T01:00:00.000Z', state: 'active' },
|
||||
],
|
||||
assetVersions: [],
|
||||
enabled: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
const manifest = (over: Partial<ManifestFace> = {}): ManifestFace => ({
|
||||
identity: { name: 'ACT-WORK-05 · 差异评估' },
|
||||
contract: {
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'requestDescription', 'candidateInternalPns'],
|
||||
properties: {
|
||||
customerCode: { type: 'string', title: '客户', 'x-control': 'combo', 'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' } },
|
||||
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea' },
|
||||
candidateInternalPns: { type: 'array', title: '候选料号', items: { type: 'string' } },
|
||||
datasheets: { type: 'array', format: 'file', items: { type: 'object' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
ledger: [
|
||||
{ domain: 'sub_eval', object: 'verdict', fields: [{ name: 'eval_ref' }, { name: 'request_id' }] },
|
||||
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
|
||||
],
|
||||
},
|
||||
assets: { viewTemplates: [{ name: 'activity-ledger', html: '<html><body><h1>一览</h1></body></html>' }] },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** 一张带取数声明的看板模版:案号 → 核对表 → 料号圈主数据,页头摆实例与案号 */
|
||||
const BOARD_HTML = [
|
||||
'<html><body><div class="page" data-class-of="board:state">',
|
||||
'<p><b data-slot="case:instanceNo"></b> · <i data-slot="case:ref"></i> · <u data-slot="case:owner"></u></p>',
|
||||
'<p>需求 <span data-slot="sub_eval.verdict.request_id"></span> · 料号 <span data-slot="parts_profile_rows"></span> 条 · NRND <span data-slot="parts_profile_lifecycle_status_nrnd"></span></p>',
|
||||
'<table><tbody data-rows="parts.profile"><tr><td data-slot="internal_pn"></td><td><span class="st" data-class-of="lifecycle_status"></span></td></tr></tbody></table>',
|
||||
'</div></body></html>',
|
||||
].join('');
|
||||
const BOARD_DATA = {
|
||||
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
|
||||
rows: [
|
||||
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
|
||||
],
|
||||
};
|
||||
const boardManifest = (): ManifestFace => manifest({ assets: { viewTemplates: [{ name: 'activity-ledger', html: BOARD_HTML, data: BOARD_DATA }] } });
|
||||
/** 正式表的一角:两单的行并排躺着,看板只许圈出案号对上的那一单 */
|
||||
const FORMAL: Record<string, Array<Record<string, unknown>>> = {
|
||||
'sub_eval.verdict': [
|
||||
{ eval_ref: 'W05-20260827100000', request_id: 'REQ-20260827100000' },
|
||||
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000' },
|
||||
],
|
||||
'sub_eval.pn_check': [
|
||||
{ eval_ref: 'W05-20260827100000', internal_pn: 'LM2596S-5.0' },
|
||||
{ eval_ref: 'W05-20260827100000', internal_pn: 'XL2596S-5.0' },
|
||||
{ eval_ref: 'W05-20260827000000', internal_pn: 'OTHER' },
|
||||
],
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
|
||||
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
|
||||
{ internal_pn: 'OTHER', lifecycle_status: 'active' },
|
||||
],
|
||||
};
|
||||
const formalLedger: ActivityOpsDeps['ledger'] = {
|
||||
describe: async (address) => (FORMAL[`${address.domain}.${address.object}`] === undefined ? undefined : { columns: [] }),
|
||||
query: async (q) => ({
|
||||
rows: (FORMAL[`${q.address.domain}.${q.address.object}`] ?? []).filter((r) => Object.entries(q.where).every(([k, v]) => String(r[k]) === String(v))),
|
||||
}),
|
||||
};
|
||||
/** 本地时区里 2026-08-27 10:00:00 起的单 —— 案号 W05-20260827100000 */
|
||||
const RELEASED_AT = new Date(2026, 7, 27, 10, 0, 0).toISOString();
|
||||
|
||||
const row = (over: Partial<TaskRow> = {}): TaskRow => ({
|
||||
id: 't-1',
|
||||
no: 'INS-7',
|
||||
activityId: W05,
|
||||
workflowId: null,
|
||||
origin: 'ops',
|
||||
ownerId: OWNER,
|
||||
title: 'ACT-WORK-05 · 差异评估',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
data: { customerCode: 'CU-0142', requestDescription: '工业电源项目在用 LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0'], datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 3, ref: 'u-1' }] },
|
||||
usedSteps: 2,
|
||||
startedAt: '2026-08-27T02:00:00.000Z',
|
||||
finishedAt: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
function memoryStore(rows: TaskRow[] = [], profiles: ActivityProfile[] = []) {
|
||||
const claims = new Map<string, LaunchClaim>();
|
||||
const profileMap = new Map(profiles.map((p) => [p.activityId, p]));
|
||||
const store: ActivityOpsStore = {
|
||||
async countByActivity() {
|
||||
const m = new Map<string, number>();
|
||||
for (const r of rows) m.set(r.activityId, (m.get(r.activityId) ?? 0) + 1);
|
||||
return m;
|
||||
},
|
||||
async listTasks(activityId) {
|
||||
return rows.filter((r) => r.activityId === activityId);
|
||||
},
|
||||
async getTask(activityId, taskId) {
|
||||
return rows.find((r) => r.activityId === activityId && r.id === taskId);
|
||||
},
|
||||
async latestReleased(activityId) {
|
||||
return rows
|
||||
.filter((r) => r.activityId === activityId && r.terminal === 'released')
|
||||
.sort((a, b) => Date.parse(b.finishedAt ?? b.startedAt) - Date.parse(a.finishedAt ?? a.startedAt))[0];
|
||||
},
|
||||
async claimLaunch(input) {
|
||||
const hit = claims.get(input.key);
|
||||
if (hit !== undefined) return hit;
|
||||
claims.set(input.key, { ...input, taskId: null, claimedAt: clock.now().toISOString() });
|
||||
return 'claimed';
|
||||
},
|
||||
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
|
||||
const hit = claims.get(key);
|
||||
if (hit === undefined || hit.taskId !== null || clock.now().getTime() - Date.parse(hit.claimedAt) < staleMs) return false;
|
||||
claims.set(key, { ...hit, ownerId, launchedBy, claimedAt: clock.now().toISOString() });
|
||||
return true;
|
||||
},
|
||||
async bindLaunch(key, taskId) {
|
||||
const hit = claims.get(key);
|
||||
if (hit !== undefined) claims.set(key, { ...hit, taskId });
|
||||
},
|
||||
async launchOf(taskId) {
|
||||
return [...claims.values()].find((c) => c.taskId === taskId);
|
||||
},
|
||||
async profilesOf() {
|
||||
return profileMap;
|
||||
},
|
||||
async upsertProfile(p) {
|
||||
profileMap.set(p.activityId, p);
|
||||
},
|
||||
async workflowOf() {
|
||||
return { subject: '统一报价 · Itelma', source: { kind: 'event' } };
|
||||
},
|
||||
async inboundCommOf(taskId) {
|
||||
return taskId === 't-mail' ? { channel: 'mail', party: 'quotes@itelma.ru', subject: 'RFQ' } : undefined;
|
||||
},
|
||||
};
|
||||
const clock = { at: Date.parse('2026-08-27T03:00:00.000Z'), now: () => new Date(clock.at) };
|
||||
return { store, claims, clock, rows };
|
||||
}
|
||||
|
||||
function harness(
|
||||
opts: {
|
||||
rows?: TaskRow[];
|
||||
profiles?: ActivityProfile[];
|
||||
enabled?: boolean;
|
||||
history?: Record<string, HistoryEntry[]>;
|
||||
loops?: Record<string, LoopFace>;
|
||||
manifest?: ManifestFace;
|
||||
ledger?: ActivityOpsDeps['ledger'];
|
||||
} = {},
|
||||
) {
|
||||
const mem = memoryStore(opts.rows ?? [], opts.profiles ?? []);
|
||||
const started: StartTaskInput[] = [];
|
||||
const controlled: Array<{ taskId: TaskId; reason: string | undefined }> = [];
|
||||
let enabled = opts.enabled ?? true;
|
||||
let n = 0;
|
||||
const staff = [
|
||||
{ id: OWNER, name: '柴浩', state: 'active' },
|
||||
{ id: ME.id, name: ME.name, state: 'active' },
|
||||
{ id: 's-gone', name: '离职者', state: 'disabled' },
|
||||
];
|
||||
const deps: ActivityOpsDeps = {
|
||||
registry: {
|
||||
list: async () => [card({ enabled })],
|
||||
get: async (id) => (id === W05 ? card({ enabled }) : undefined),
|
||||
manifestOf: async () => opts.manifest ?? manifest(),
|
||||
setEnabled: async (_id, on) => {
|
||||
enabled = on;
|
||||
},
|
||||
},
|
||||
task: {
|
||||
start: async (input) => {
|
||||
started.push(input);
|
||||
n += 1;
|
||||
const id = taskIdSchema.parse(`t-new-${n}`);
|
||||
mem.rows.push(row({ id, no: `INS-${100 + n}`, ownerId: input.ownerId, data: input.data, usedSteps: 0, origin: input.origin ?? null }));
|
||||
return { id };
|
||||
},
|
||||
control: async (taskId, _semantic, _by, reason) => {
|
||||
controlled.push({ taskId, reason });
|
||||
const at = mem.rows.findIndex((r) => r.id === taskId);
|
||||
const hit = mem.rows[at];
|
||||
if (hit !== undefined) mem.rows.splice(at, 1, { ...hit, terminal: 'terminated', reason: reason ?? null, finishedAt: mem.clock.now().toISOString() });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
org: {
|
||||
listStaff: async () => ({ staff }),
|
||||
getStaff: async (id) => {
|
||||
const hit = staff.find((s) => s.id === id);
|
||||
if (hit === undefined) throw new StaffNotFoundError(id);
|
||||
return hit;
|
||||
},
|
||||
},
|
||||
memory: { history: async (taskId) => opts.history?.[taskId] ?? [] },
|
||||
ledger: opts.ledger ?? {
|
||||
describe: async (address) => (address.object === 'profile' ? { columns: [{ name: 'customer_code' }, { name: 'name' }] } : undefined),
|
||||
query: async () => ({ rows: [{ customer_code: 'CU-0142', name: 'Itelma' }, { customer_code: 'CU-0001', name: '' }] }),
|
||||
},
|
||||
loopState: { load: async (taskId) => opts.loops?.[taskId] },
|
||||
store: mem.store,
|
||||
files: {
|
||||
readUpload: async (ref) => (ref === 'u-1' ? { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : undefined),
|
||||
listArchived: async (taskId) => (taskId === 't-done' ? [{ address: 'content.asset', key: { id: '1' }, format: 'docx', name: '报告.docx', size: 9 }] : []),
|
||||
readArchived: async () => ({ bytes: new Uint8Array([9]), name: '报告.docx', format: 'docx' }),
|
||||
},
|
||||
clock: mem.clock,
|
||||
warn: () => undefined,
|
||||
};
|
||||
return { ops: createActivityOps(deps), mem, started, controlled };
|
||||
}
|
||||
|
||||
const launchInput = (over: Partial<LaunchInput> = {}): LaunchInput => ({
|
||||
idempotencyKey: 'k-1',
|
||||
ownerId: OWNER,
|
||||
params: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] },
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('活动投影', () => {
|
||||
it('清单:版本取最新一版、创建时刻取最早一版、负责人与图标来自档案、实例按活动数;status 过滤', async () => {
|
||||
const { ops } = harness({
|
||||
rows: [row(), row({ id: 't-2', no: 'INS-8' })],
|
||||
profiles: [{ activityId: W05, ownerId: OWNER, description: '逐项差异评估', ico: 'git-compare', hue: 'teal' }],
|
||||
});
|
||||
const [a] = await ops.listActivities();
|
||||
expect(a).toEqual({
|
||||
id: W05,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
biz: '需求分析与方案匹配',
|
||||
status: 'active',
|
||||
version: '1.5.0',
|
||||
owner: { id: OWNER, name: '柴浩' },
|
||||
llm: 'deepseek-v4-flash',
|
||||
instanceCount: 2,
|
||||
createdAt: '2026-08-26T01:00:00.000Z',
|
||||
ico: 'git-compare',
|
||||
hue: 'teal',
|
||||
});
|
||||
expect(await ops.listActivities('disabled')).toEqual([]);
|
||||
const detail = await ops.getActivity(W05);
|
||||
expect(detail).toMatchObject({ desc: '逐项差异评估', view: { url: `/api/gov/activities/${W05}/view` } });
|
||||
expect(detail).not.toHaveProperty('instanceCount');
|
||||
expect(await ops.getActivity('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('没建档:负责人 null、说明空串、图标色相缺席;色相不在四色里也不摆', async () => {
|
||||
const { ops } = harness({ profiles: [{ activityId: W05, ownerId: null, description: '', ico: null, hue: 'pink' }] });
|
||||
const [a] = await ops.listActivities();
|
||||
expect(a).toMatchObject({ owner: null, instanceCount: 0 });
|
||||
expect(a).not.toHaveProperty('ico');
|
||||
expect(a).not.toHaveProperty('hue');
|
||||
});
|
||||
|
||||
it('禁用 / 激活幂等,回活动详情;视图注入报高脚本、没配视图 404', async () => {
|
||||
const { ops } = harness();
|
||||
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
|
||||
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
|
||||
expect((await ops.setEnabled(W05, true))?.status).toBe('active');
|
||||
expect(await ops.setEnabled('nope', true)).toBeUndefined();
|
||||
const view = await ops.viewHtml(W05);
|
||||
expect(view.kind).toBe('html');
|
||||
if (view.kind === 'html') expect(view.html).toContain('jcai:view-height');
|
||||
expect((await ops.viewHtml('nope')).kind).toBe('no-activity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('沙箱看板:最近一次已放行实例的落账快照', () => {
|
||||
const released = (over: Partial<TaskRow> = {}) =>
|
||||
row({ id: 't-rel', no: 'INS-20', runtime: 'terminal', terminal: 'released', startedAt: RELEASED_AT, finishedAt: new Date(2026, 7, 27, 10, 6, 0).toISOString(), ...over });
|
||||
|
||||
it('按案号只圈这一单的行:页头实例号 / 案号 / 负责人,料号两颗、别单的 OTHER 不进来,徽标按值投影,读数摆出来', async () => {
|
||||
const older = released({ id: 't-old', no: 'INS-3', startedAt: new Date(2026, 7, 27, 0, 0, 0).toISOString(), finishedAt: new Date(2026, 7, 27, 0, 5, 0).toISOString() });
|
||||
const { ops } = harness({ rows: [row({ id: 't-live' }), older, released()], manifest: boardManifest(), ledger: formalLedger });
|
||||
const view = await ops.viewHtml(W05);
|
||||
expect(view.kind).toBe('html');
|
||||
if (view.kind !== 'html') return;
|
||||
expect(view.html).toContain('class="page v-ready"');
|
||||
expect(view.html).toContain('<b>INS-20</b> · <i>W05-20260827100000</i> · <u>柴浩</u>');
|
||||
expect(view.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>2</span> 条 · NRND <span>1</span>');
|
||||
expect(view.html).toContain('<td>LM2596S-5.0</td><td><span class="st v-nrnd"></span></td>');
|
||||
expect(view.html).toContain('<td>XL2596S-5.0</td><td><span class="st v-active"></span></td>');
|
||||
expect(view.html).not.toContain('OTHER');
|
||||
expect(view.html).not.toContain('data-rows=');
|
||||
expect(view.html).toContain('jcai:view-height');
|
||||
});
|
||||
|
||||
it('没放行过 → none 态、实例事实空着;案号在正式表里查无 → missing 态、表全空(不摆别单的行)', async () => {
|
||||
const none = harness({ rows: [row({ id: 't-live' })], manifest: boardManifest(), ledger: formalLedger });
|
||||
const v1 = await none.ops.viewHtml(W05);
|
||||
if (v1.kind === 'html') {
|
||||
expect(v1.html).toContain('class="page v-none"');
|
||||
expect(v1.html).toContain('<b>—</b>');
|
||||
expect(v1.html).toContain('料号 <span>0</span>');
|
||||
} else expect.unreachable();
|
||||
|
||||
const missing = harness({ rows: [released({ startedAt: new Date(2026, 7, 27, 11, 0, 0).toISOString() })], manifest: boardManifest(), ledger: formalLedger });
|
||||
const v2 = await missing.ops.viewHtml(W05);
|
||||
if (v2.kind === 'html') {
|
||||
expect(v2.html).toContain('class="page v-missing"');
|
||||
expect(v2.html).toContain('W05-20260827110000');
|
||||
expect(v2.html).not.toContain('LM2596S-5.0');
|
||||
} else expect.unreachable();
|
||||
});
|
||||
|
||||
it('一张表读不了不拖垮整屏:那一块空着,别的照摆;模版没带 data 就原样出', async () => {
|
||||
const half: ActivityOpsDeps['ledger'] = { ...formalLedger, describe: async (a) => (a.object === 'profile' ? undefined : formalLedger.describe(a)) };
|
||||
const { ops } = harness({ rows: [released()], manifest: boardManifest(), ledger: half });
|
||||
const v = await ops.viewHtml(W05);
|
||||
if (v.kind === 'html') {
|
||||
expect(v.html).toContain('class="page v-ready"');
|
||||
expect(v.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>0</span>');
|
||||
} else expect.unreachable();
|
||||
const plain = harness({ rows: [released()] });
|
||||
const p = await plain.ops.viewHtml(W05);
|
||||
if (p.kind === 'html') expect(p.html).toContain('<h1>一览</h1>');
|
||||
else expect.unreachable();
|
||||
});
|
||||
});
|
||||
|
||||
describe('启动', () => {
|
||||
it('参数表:combo 候选从台账读(空标签退回值);文件字段不上表', async () => {
|
||||
const { ops } = harness();
|
||||
const table = await ops.launchParams(W05);
|
||||
expect(table?.version).toBe('1.5.0');
|
||||
expect(table?.params.map((p) => p.key)).toEqual(['customerCode', 'requestDescription', 'candidateInternalPns']);
|
||||
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
|
||||
});
|
||||
|
||||
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
|
||||
const { ops, started } = harness({ enabled: false });
|
||||
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
|
||||
const live = harness();
|
||||
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-gone') }), ME)).toMatchObject({ kind: 'owner-invalid' });
|
||||
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-nobody') }), ME)).toMatchObject({ kind: 'owner-invalid', message: '责任人不在名册里' });
|
||||
const bad = await live.ops.launch(W05, launchInput({ params: { customerCode: 'CU-0142' } }), ME);
|
||||
expect(bad.kind).toBe('invalid');
|
||||
if (bad.kind === 'invalid') expect(bad.issues.map((i) => i.key)).toEqual(['requestDescription', 'candidateInternalPns']);
|
||||
expect((await live.ops.launch('nope', launchInput(), ME)).kind).toBe('not-found');
|
||||
expect(started).toEqual([]);
|
||||
expect(live.started).toEqual([]);
|
||||
});
|
||||
|
||||
it('起单:origin=ops、标题取清单名、入参归一后交 task.start;同键重放回同一单、不起第二个', async () => {
|
||||
const { ops, started } = harness();
|
||||
const first = await ops.launch(W05, launchInput(), ME);
|
||||
expect(first.kind).toBe('ok');
|
||||
if (first.kind !== 'ok') return;
|
||||
expect(first.created).toBe(true);
|
||||
expect(started).toEqual([
|
||||
{ activityId: W05, ownerId: OWNER, title: 'ACT-WORK-05 · 差异评估', origin: 'ops', data: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] } },
|
||||
]);
|
||||
expect(first.detail).toMatchObject({ id: 't-new-1', state: 'pending', owner: { id: OWNER, name: '柴浩' }, input: { source: { kind: 'manual', label: '手动启动 · 冯兵' } } });
|
||||
|
||||
const again = await ops.launch(W05, launchInput({ params: { customerCode: 'X', requestDescription: 'y', candidateInternalPns: ['z'] } }), ME);
|
||||
expect(again).toMatchObject({ kind: 'ok', created: false, detail: { id: 't-new-1' } });
|
||||
expect(started).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('键占了单还没建成:60 秒内 409;过期后重占再起', async () => {
|
||||
const h = harness();
|
||||
await h.mem.store.claimLaunch({ key: 'k-1', activityId: W05, ownerId: OWNER, launchedBy: 's-x' });
|
||||
expect(await h.ops.launch(W05, launchInput(), ME)).toMatchObject({ kind: 'busy' });
|
||||
h.mem.clock.at += LAUNCH_CLAIM_STALE_MS + 1;
|
||||
const late = await h.ops.launch(W05, launchInput(), ME);
|
||||
expect(late.kind).toBe('ok');
|
||||
expect(h.started).toHaveLength(1);
|
||||
expect(h.mem.claims.get('k-1')?.taskId).toBe('t-new-1');
|
||||
// 别的活动拿同一枚键 → 409
|
||||
await h.mem.store.claimLaunch({ key: 'k-2', activityId: 'other', ownerId: OWNER, launchedBy: 's-x' });
|
||||
expect(await h.ops.launch(W05, launchInput({ idempotencyKey: 'k-2' }), ME)).toMatchObject({ kind: 'busy' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('实例清单与详情', () => {
|
||||
const items = parseCmmn(XML).items;
|
||||
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||
const loop = (states: Record<string, ItemRuntime['state']>, pending?: LoopFace['pending']): LoopFace => ({
|
||||
model: { steps: [{ item: 'S1', intent: '把四项绑定' }] },
|
||||
parsed: { items },
|
||||
runtime: { items: rt(states) },
|
||||
...(pending === undefined ? {} : { pending }),
|
||||
});
|
||||
const line = (kind: string, title: string, at: string, detail?: Record<string, unknown>): HistoryEntry => ({ kind, title, at, ...(detail === undefined ? {} : { detail }) });
|
||||
|
||||
const rows = [
|
||||
row({ id: 't-1', no: 'INS-7' }),
|
||||
row({ id: 't-redo', no: 'INS-6', startedAt: '2026-08-26T02:00:00.000Z' }),
|
||||
row({ id: 't-done', no: 'INS-5', terminal: 'released', runtime: 'terminal', finishedAt: '2026-08-25T03:00:00.000Z', startedAt: '2026-08-25T02:00:00.000Z' }),
|
||||
row({ id: 't-dead', no: 'INS-4', terminal: 'terminated', reason: '由 冯兵 手动终止', finishedAt: '2026-08-24T02:30:00.000Z', startedAt: '2026-08-24T02:00:00.000Z', ownerId: ME.id }),
|
||||
row({ id: 't-mail', no: 'INS-3', origin: null, usedSteps: 0, data: {}, startedAt: '2026-08-23T02:00:00.000Z' }),
|
||||
];
|
||||
const history = {
|
||||
't-redo': [line('release-verdict', '裁决:redo', '2026-08-26T03:00:00.000Z', { by: OWNER, comment: '证据不足' })],
|
||||
't-done': [line('release-verdict', '落地收敛,发射输出口:assessment-released', '2026-08-25T03:00:00.000Z', { ports: ['assessment-released'] })],
|
||||
't-1': [line('engine-transition', '开始办:归一', '2026-08-27T02:00:10.000Z'), line('engine-transition', '完成:归一', '2026-08-27T02:01:10.000Z'), line('engine-transition', '开始办:交人确认', '2026-08-27T02:01:20.000Z')],
|
||||
};
|
||||
const loops = { 't-1': loop({ S1: 'Completed', S2: 'Active' }), 't-redo': loop({ S1: 'Completed', S2: 'Completed', T_release: 'Completed' }) };
|
||||
|
||||
it('五态各归其位;统计不随筛选动;产出只给已完成的;分页按筛后走', async () => {
|
||||
const { ops } = harness({ rows, history, loops });
|
||||
const all = await ops.listInstances(W05, { page: 1, size: 8 });
|
||||
expect(all?.stats).toEqual({ total: 5, byState: { pending: 1, running: 1, completed: 1, rejected: 1, failed: 1 } });
|
||||
expect(all?.items.map((i) => [i.no, i.state, i.output])).toEqual([
|
||||
['INS-7', 'running', null],
|
||||
['INS-6', 'rejected', null],
|
||||
['INS-5', 'completed', '报告.docx'],
|
||||
['INS-4', 'failed', null],
|
||||
['INS-3', 'pending', null],
|
||||
]);
|
||||
expect(all?.items[0]?.input).toBe('工业电源项目在用 LM2596S-5.0');
|
||||
// 入参空的那单退回来件主题
|
||||
expect(all?.items[4]?.input).toBe('RFQ');
|
||||
const page2 = await ops.listInstances(W05, { page: 2, size: 2, ownerId: OWNER });
|
||||
expect(page2).toMatchObject({ total: 4, page: 2, size: 2 });
|
||||
expect(page2?.items.map((i) => i.no)).toEqual(['INS-5', 'INS-3']);
|
||||
expect(page2?.stats.total).toBe(5);
|
||||
expect((await ops.listInstances(W05, { page: 1, size: 8, id: 'ins-4' }))?.items.map((i) => i.no)).toEqual(['INS-4']);
|
||||
expect(await ops.listInstances('nope', { page: 1, size: 8 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('详情:时间线 / 来源 / 文件 / 原因 / 用时', async () => {
|
||||
const { ops } = harness({ rows, history, loops });
|
||||
const live = await ops.instanceDetail(W05, 't-1');
|
||||
expect(live).toMatchObject({
|
||||
no: 'INS-7',
|
||||
state: 'running',
|
||||
reason: null,
|
||||
endedAt: null,
|
||||
durationMs: null,
|
||||
llm: 'deepseek-v4-flash',
|
||||
input: { content: '工业电源项目在用 LM2596S-5.0', source: { kind: 'manual', label: '手动启动 · 运营端' }, files: [{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 3 }] },
|
||||
output: { files: [] },
|
||||
});
|
||||
expect(live?.steps.map((s) => [s.name, s.by, s.byName, s.res, s.durationMs, s.note])).toEqual([
|
||||
['归一', 'agent', 'deepseek-v4-flash', 'done', 60_000, '把四项绑定'],
|
||||
['交人确认', 'human', '柴浩', 'current', null, ''],
|
||||
['放行', 'agent', 'deepseek-v4-flash', 'pending', null, ''],
|
||||
]);
|
||||
expect(live).not.toHaveProperty('input.output');
|
||||
|
||||
const redo = await ops.instanceDetail(W05, 't-redo');
|
||||
expect(redo?.reason).toEqual({ kind: 'rejected', text: '验收驳回:证据不足 —— 负责人 柴浩' });
|
||||
expect(redo?.steps.map((s) => s.res)).toEqual(['done', 'done', 'fail']);
|
||||
|
||||
const dead = await ops.instanceDetail(W05, 't-dead');
|
||||
expect(dead).toMatchObject({ state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' }, endedAt: '2026-08-24T02:30:00.000Z', durationMs: 30 * 60_000, owner: { name: '冯兵' } });
|
||||
|
||||
const done = await ops.instanceDetail(W05, 't-done');
|
||||
expect(done?.output.files).toEqual([{ index: 0, name: '报告.docx', contentType: expect.stringContaining('wordprocessingml'), size: 9 }]);
|
||||
|
||||
const mail = await ops.instanceDetail(W05, 't-mail');
|
||||
expect(mail?.input.source).toEqual({ kind: 'mail', label: '邮件 · quotes@itelma.ru' });
|
||||
expect(await ops.instanceDetail(W05, 'nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('上游工作流起的单标 upstream;测试台起的标 testbed', async () => {
|
||||
const { ops } = harness({ rows: [row({ id: 't-wf', workflowId: 'wf-1', origin: null }), row({ id: 't-tb', origin: 'testbed', data: {} })] });
|
||||
expect((await ops.instanceDetail(W05, 't-wf'))?.input.source).toEqual({ kind: 'upstream', label: '上游 · 统一报价 · Itelma' });
|
||||
expect((await ops.instanceDetail(W05, 't-tb'))?.input.source).toEqual({ kind: 'testbed', label: '测试台试跑' });
|
||||
});
|
||||
|
||||
it('文件:input 走上传件、output 走存档;越界 / 无单各回明确结果', async () => {
|
||||
const { ops } = harness({ rows });
|
||||
expect(await ops.instanceFile(W05, 't-1', 'input', 0)).toMatchObject({ kind: 'file', name: 'a.pdf', contentType: 'application/pdf' });
|
||||
expect((await ops.instanceFile(W05, 't-1', 'input', 1)).kind).toBe('no-file');
|
||||
expect(await ops.instanceFile(W05, 't-done', 'output', 0)).toMatchObject({ kind: 'file', name: '报告.docx' });
|
||||
expect((await ops.instanceFile(W05, 't-1', 'output', 0)).kind).toBe('no-file');
|
||||
expect((await ops.instanceFile(W05, 'nope', 'output', 0)).kind).toBe('no-instance');
|
||||
});
|
||||
|
||||
it('终止:走 task.control,理由带人名;已终态 412;之后详情是 failed + terminated', async () => {
|
||||
const { ops, controlled } = harness({ rows: [row({ id: 't-1' }), row({ id: 't-done', terminal: 'released', runtime: 'terminal' })] });
|
||||
const done = await ops.terminate(W05, 't-1', ME);
|
||||
expect(done).toMatchObject({ kind: 'ok', detail: { state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' } } });
|
||||
expect(controlled).toEqual([{ taskId: 't-1', reason: '由 冯兵 手动终止' }]);
|
||||
expect((await ops.terminate(W05, 't-done', ME)).kind).toBe('terminal');
|
||||
expect((await ops.terminate(W05, 't-1', ME)).kind).toBe('terminal');
|
||||
expect((await ops.terminate(W05, 'nope', ME)).kind).toBe('not-found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/** L3 动作过程记录:计划项 × 运行态 × 引擎痕 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCmmn } from '@modules/loop-engine';
|
||||
import type { ItemRuntime } from '@modules/loop-engine';
|
||||
import { stepsOf } from '../activity-ops/instance-steps.ts';
|
||||
|
||||
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||
<case id="c" name="试">
|
||||
<casePlanModel id="p" name="p">
|
||||
<humanTask id="S1" name="归一"/>
|
||||
<humanTask id="S2" name="交人确认">
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="S3" name="放行"/>
|
||||
<milestone id="M1" name="里程碑"/>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||
const line = (title: string, at: string) => ({ kind: 'engine-transition', title, at });
|
||||
const intents = new Map([['S1', '把来件归一成台账行'], ['S2', '派一张执行单交人']]);
|
||||
|
||||
describe('stepsOf', () => {
|
||||
const items = parseCmmn(XML).items;
|
||||
|
||||
it('只摆任务项(里程碑不算),按文档序;人工激活的项是人办的;时刻与用时来自引擎痕', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: rt({ S1: 'Completed', S2: 'Active', S3: 'Available' }),
|
||||
intents,
|
||||
history: [
|
||||
line('开始办:归一', '2026-08-27T02:00:00.000Z'),
|
||||
line('完成:归一', '2026-08-27T02:03:00.000Z'),
|
||||
line('开始办:交人确认', '2026-08-27T02:03:30.000Z'),
|
||||
],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'deepseek-v4-flash',
|
||||
});
|
||||
expect(steps.map((s) => [s.seq, s.name, s.by, s.byName, s.res])).toEqual([
|
||||
[1, '归一', 'agent', 'deepseek-v4-flash', 'done'],
|
||||
[2, '交人确认', 'human', '柴浩', 'current'],
|
||||
[3, '放行', 'agent', 'deepseek-v4-flash', 'pending'],
|
||||
]);
|
||||
expect(steps[0]).toMatchObject({ at: '2026-08-27T02:00:00.000Z', durationMs: 180_000, note: '把来件归一成台账行' });
|
||||
expect(steps[1]).toMatchObject({ at: '2026-08-27T02:03:30.000Z', durationMs: null, note: '派一张执行单交人' });
|
||||
expect(steps[2]).toMatchObject({ at: null, durationMs: null, note: '' });
|
||||
});
|
||||
|
||||
it('自动收步的项拿收步那一刻当时刻;没有 runtime 的项是 pending', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: rt({ S1: 'Completed' }),
|
||||
intents,
|
||||
history: [line('自动收步:S1 的成果已在台账里(rows > 0)—— 不必再问模型', '2026-08-27T02:01:00.000Z')],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'm',
|
||||
});
|
||||
expect(steps[0]).toMatchObject({ res: 'done', at: '2026-08-27T02:01:00.000Z', durationMs: null });
|
||||
expect(steps[1]?.res).toBe('pending');
|
||||
});
|
||||
|
||||
it('终止 / 失败:正在办的那一步记 fail;都没开就记第一个没到的;驳回记最后一个已完成的', () => {
|
||||
const facts = { items, intents, history: [], ownerName: '柴浩', llm: 'm' };
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Active' }), state: 'failed' }).map((s) => s.res)).toEqual(['done', 'fail', 'pending']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Terminated', S2: 'Terminated' }), state: 'failed' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Completed', S3: 'Completed' }), state: 'rejected' }).map((s) => s.res)).toEqual(['done', 'done', 'fail']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Failed' }), state: 'running' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
|
||||
});
|
||||
|
||||
it('重复实例化的项看最新那一实例', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: [{ id: 'S2', state: 'Completed', instance: 1 }, { id: 'S2', state: 'Active', instance: 2 }],
|
||||
intents,
|
||||
history: [],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'm',
|
||||
});
|
||||
expect(steps[1]?.res).toBe('current');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 启动幂等键在真库上的并发:两条连接同时占同一枚键,**恰一个赢**;单没建成的过期键可被重占。
|
||||
* 独占一个库(照 mail-store.it 的先例):建表 / 清表都在 `jcai_it_activity_ops` 里,谁的库都不碰。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import { createActivityOpsStore } from '../activity-ops/store.ts';
|
||||
|
||||
const IT_DB = 'jcai_it_activity_ops';
|
||||
|
||||
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
|
||||
const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||
try {
|
||||
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
|
||||
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
|
||||
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
|
||||
await admin.end();
|
||||
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
|
||||
} catch {
|
||||
/* 开发实例连不上,试 Testcontainers */
|
||||
}
|
||||
try {
|
||||
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
|
||||
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
|
||||
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
|
||||
} catch (error) {
|
||||
process.stderr.write(`⚠️ activity-ops store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const database = await provisionDatabase();
|
||||
let poolA: Pool | undefined;
|
||||
let poolB: Pool | undefined;
|
||||
let dbA: Db;
|
||||
let dbB: Db;
|
||||
|
||||
const MIGRATION = new URL('../../../../../db/migrations/0035_activity_ops.sql', import.meta.url);
|
||||
|
||||
beforeAll(async () => {
|
||||
if (database === undefined) return;
|
||||
poolA = new Pool({ connectionString: database.url, max: 2 });
|
||||
poolB = new Pool({ connectionString: database.url, max: 2 });
|
||||
dbA = drizzle(poolA);
|
||||
dbB = drizzle(poolB);
|
||||
await poolA.query('create schema if not exists sys');
|
||||
await poolA.query('drop table if exists sys.activity_launch; drop table if exists sys.activity_profile');
|
||||
await poolA.query(readFileSync(MIGRATION, 'utf8'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await poolA?.end();
|
||||
await poolB?.end();
|
||||
await database?.stop();
|
||||
});
|
||||
|
||||
describe.skipIf(database === undefined)('activity_launch 幂等键(真库)', () => {
|
||||
it('两条连接同时占同一枚键,恰一个 claimed,另一个读回那一行;绑定后重放拿到 task_id', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
const b = createActivityOpsStore(dbB);
|
||||
const input = { key: 'race-1', activityId: 'w05', ownerId: 'owner', launchedBy: 'me' };
|
||||
const results = await Promise.all(Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? a : b).claimLaunch(input)));
|
||||
expect(results.filter((r) => r === 'claimed')).toHaveLength(1);
|
||||
for (const r of results) if (r !== 'claimed') expect(r).toMatchObject({ key: 'race-1', activityId: 'w05', taskId: null });
|
||||
await a.bindLaunch('race-1', 't-1');
|
||||
expect(await b.claimLaunch(input)).toMatchObject({ taskId: 't-1' });
|
||||
expect(await b.launchOf('t-1')).toMatchObject({ key: 'race-1', launchedBy: 'me' });
|
||||
});
|
||||
|
||||
it('单没建成的键:没过期不许重占;过期后恰一个重占成功;已绑 task 的永远不许', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
const b = createActivityOpsStore(dbB);
|
||||
expect(await a.claimLaunch({ key: 'stale-1', activityId: 'w05', ownerId: 'o', launchedBy: 'x' })).toBe('claimed');
|
||||
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'y')).toBe(false);
|
||||
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
|
||||
const won = await Promise.all([a.reclaimLaunch('stale-1', 60_000, 'o', 'y'), b.reclaimLaunch('stale-1', 60_000, 'o', 'z')]);
|
||||
expect(won.filter(Boolean)).toHaveLength(1);
|
||||
await a.bindLaunch('stale-1', 't-2');
|
||||
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
|
||||
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'q')).toBe(false);
|
||||
});
|
||||
|
||||
it('活动档案:upsert 冲突即更新,负责人可空', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
await a.upsertProfile({ activityId: 'w05', ownerId: null, description: '一', ico: 'git-compare', hue: 'teal' });
|
||||
await a.upsertProfile({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
|
||||
expect((await a.profilesOf()).get('w05')).toEqual({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 活动模块的 app 层存取:SQL 不求值,只看「几笔、带了什么」与行 → 视图的映射(时间戳归一、seq → 实例号)。
|
||||
* 真库上的并发抢键在 `activity-ops-store.it.test.ts`。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createActivityOpsStore } from '../activity-ops/store.ts';
|
||||
|
||||
type Fake = { calls: string[]; execute: (query: unknown) => Promise<{ rows: unknown[] }> };
|
||||
|
||||
/** 每次 execute 按顺序吐一份 rows;SQL 片段拼成字符串留着断言 */
|
||||
function fakeDb(...results: unknown[][]): Fake {
|
||||
const calls: string[] = [];
|
||||
const queue = [...results];
|
||||
return {
|
||||
calls,
|
||||
execute: async (query: unknown) => {
|
||||
const q = query as { queryChunks?: unknown[] };
|
||||
calls.push(JSON.stringify(q.queryChunks ?? query));
|
||||
return { rows: queue.shift() ?? [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RAW_TASK = {
|
||||
id: 't-1',
|
||||
seq: '17',
|
||||
activity_id: 'part-substitution-assessment',
|
||||
workflow_id: null,
|
||||
origin: 'ops',
|
||||
owner_id: 's-1',
|
||||
title: 'W05',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
input_data: { customerCode: 'CU-0142' },
|
||||
used_steps: '3',
|
||||
// pg 的 timestamptz 在 raw execute 这一路回文本(实测),也可能是 Date —— 两种都要归一成 ISO
|
||||
started_at: '2026-08-27 16:55:05.123+08',
|
||||
finished_at: new Date('2026-08-27T08:55:14.383Z'),
|
||||
};
|
||||
|
||||
describe('createActivityOpsStore', () => {
|
||||
it('listTasks:seq → INS-n、时间戳归一成 ISO、jsonb 取成对象、数字列转数', async () => {
|
||||
const db = fakeDb([RAW_TASK]);
|
||||
const rows = await createActivityOpsStore(db).listTasks('part-substitution-assessment');
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: 't-1',
|
||||
no: 'INS-17',
|
||||
activityId: 'part-substitution-assessment',
|
||||
workflowId: null,
|
||||
origin: 'ops',
|
||||
ownerId: 's-1',
|
||||
title: 'W05',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
data: { customerCode: 'CU-0142' },
|
||||
usedSteps: 3,
|
||||
startedAt: '2026-08-27T08:55:05.123Z',
|
||||
finishedAt: '2026-08-27T08:55:14.383Z',
|
||||
},
|
||||
]);
|
||||
expect(db.calls[0]).toContain('order by started_at desc, seq desc');
|
||||
expect(db.calls[0]).toContain('activity_id = ');
|
||||
});
|
||||
|
||||
it('latestReleased 只看已放行的、按放行时刻倒序取一条;没有回 undefined', async () => {
|
||||
const db = fakeDb([RAW_TASK]);
|
||||
const one = await createActivityOpsStore(db).latestReleased('part-substitution-assessment');
|
||||
expect(one?.no).toBe('INS-17');
|
||||
expect(db.calls[0]).toContain("terminal = 'released'");
|
||||
expect(db.calls[0]).toContain('order by finished_at desc nulls last, started_at desc limit 1');
|
||||
expect(await createActivityOpsStore(fakeDb([])).latestReleased('x')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getTask 认活动 + 任务两把钥匙;没命中回 undefined', async () => {
|
||||
const db = fakeDb([]);
|
||||
expect(await createActivityOpsStore(db).getTask('a', 't')).toBeUndefined();
|
||||
expect(db.calls[0]).toContain('activity_id = ');
|
||||
expect(db.calls[0]).toContain('and id = ');
|
||||
});
|
||||
|
||||
it('countByActivity 按活动数;profilesOf 按活动键映射', async () => {
|
||||
const db = fakeDb([{ activity_id: 'a', n: '2' }, { activity_id: 'b', n: 1 }], [{ activity_id: 'a', owner_id: null, description: null, ico: 'target', hue: 'blue' }]);
|
||||
const store = createActivityOpsStore(db);
|
||||
expect([...(await store.countByActivity()).entries()]).toEqual([['a', 2], ['b', 1]]);
|
||||
const profiles = await store.profilesOf();
|
||||
expect(profiles.get('a')).toEqual({ activityId: 'a', ownerId: null, description: '', ico: 'target', hue: 'blue' });
|
||||
});
|
||||
|
||||
it('claimLaunch:一句 SQL 判 + 占(on conflict do nothing returning);插不进去就读回那一行', async () => {
|
||||
const claimed = fakeDb([{ key: 'k' }]);
|
||||
expect(await createActivityOpsStore(claimed).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toBe('claimed');
|
||||
expect(claimed.calls[0]).toContain('on conflict (key) do nothing');
|
||||
expect(claimed.calls[0]).toContain('returning key');
|
||||
expect(claimed.calls).toHaveLength(1);
|
||||
|
||||
const taken = fakeDb([], [{ key: 'k', activity_id: 'a', owner_id: 'o', launched_by: 'x', task_id: 't-9', claimed_at: new Date('2026-08-27T08:00:00.000Z') }]);
|
||||
expect(await createActivityOpsStore(taken).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toEqual({
|
||||
key: 'k',
|
||||
activityId: 'a',
|
||||
ownerId: 'o',
|
||||
launchedBy: 'x',
|
||||
taskId: 't-9',
|
||||
claimedAt: '2026-08-27T08:00:00.000Z',
|
||||
});
|
||||
expect(taken.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reclaimLaunch 只占「单没建成且已过期」的键;bindLaunch 落 task_id;upsertProfile 冲突即更新', async () => {
|
||||
const db = fakeDb([{ key: 'k' }], [], []);
|
||||
const store = createActivityOpsStore(db);
|
||||
expect(await store.reclaimLaunch('k', 60_000, 'o', 'me')).toBe(true);
|
||||
expect(db.calls[0]).toContain('task_id is null');
|
||||
expect(db.calls[0]).toContain("milliseconds')::interval");
|
||||
await store.bindLaunch('k', 't-1');
|
||||
expect(db.calls[1]).toContain('set task_id = ');
|
||||
await store.upsertProfile({ activityId: 'a', ownerId: null, description: 'd', ico: null, hue: null });
|
||||
expect(db.calls[2]).toContain('on conflict (activity_id) do update');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 清单一句话与检索 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstanceSummary } from '@contracts';
|
||||
import { staffIdSchema } from '@contracts';
|
||||
import { fieldSpecsOf } from '../activity-ops/launch-params.ts';
|
||||
import { applyInstanceFilters, clip, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from '../activity-ops/summaries.ts';
|
||||
|
||||
const specs = fieldSpecsOf({
|
||||
properties: {
|
||||
customerCode: { type: 'string' },
|
||||
requestDescription: { type: 'string', 'x-control': 'textarea' },
|
||||
datasheets: { type: 'array', format: 'file' },
|
||||
},
|
||||
});
|
||||
|
||||
describe('inputSummaryOf / inputFilesOf / outputSummaryOf', () => {
|
||||
it('textarea 那格优先;没有就最长的字符串;再没有退回来件主题;文件字段不算', () => {
|
||||
expect(inputSummaryOf({ customerCode: 'CU-0142', requestDescription: ' 工业电源项目\n在用 LM2596S-5.0 ' }, specs)).toBe('工业电源项目 在用 LM2596S-5.0');
|
||||
expect(inputSummaryOf({ customerCode: 'CU-0142', datasheets: [{ ref: 'r', name: 'a-very-long-file-name.pdf' }] }, specs)).toBe('CU-0142');
|
||||
expect(inputSummaryOf({}, specs, 'RE: 询价')).toBe('RE: 询价');
|
||||
expect(inputSummaryOf({}, specs)).toBe('');
|
||||
expect(clip('x'.repeat(100))).toHaveLength(80);
|
||||
expect(clip('x'.repeat(100)).endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('文件只认带 ref 的;index 跨字段连续', () => {
|
||||
const files = inputFilesOf({ datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }, { name: 'no-ref' }, 'junk'] }, specs);
|
||||
expect(files).toEqual([{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }]);
|
||||
});
|
||||
|
||||
it('产出:有文件摆文件名;没有摆发口;都没有 null', () => {
|
||||
expect(outputSummaryOf(['报告.docx', '对照表.xlsx'], ['x'])).toBe('报告.docx、对照表.xlsx');
|
||||
expect(outputSummaryOf([], ['assessment-released'])).toBe('已发口:assessment-released');
|
||||
expect(outputSummaryOf([], [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const owner = { id: 's-1', name: '柴浩' };
|
||||
const mk = (id: string, no: string, state: InstanceSummary['state'], startedAt: string, ownerId = 's-1'): InstanceSummary => ({
|
||||
id,
|
||||
no,
|
||||
state,
|
||||
owner: { id: staffIdSchema.parse(ownerId), name: owner.name },
|
||||
input: 'x',
|
||||
output: null,
|
||||
startedAt,
|
||||
});
|
||||
const items = [
|
||||
mk('t-aaa', 'INS-24', 'running', '2026-08-25T02:12:00.000Z'),
|
||||
mk('t-bbb', 'INS-17', 'completed', '2026-08-24T09:03:00.000Z', 's-2'),
|
||||
mk('t-ccc', 'INS-9', 'failed', '2026-08-20T01:00:00.000Z'),
|
||||
];
|
||||
|
||||
describe('applyInstanceFilters / statsOf / pageOf', () => {
|
||||
it('实例号模糊、负责人精确、状态精确、日期段含两端整日(本地时区)', () => {
|
||||
expect(applyInstanceFilters(items, { id: 'ins-2' }).map((i) => i.no)).toEqual(['INS-24']);
|
||||
expect(applyInstanceFilters(items, { id: 'bbb' }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { ownerId: 's-2' }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { state: 'failed' }).map((i) => i.no)).toEqual(['INS-9']);
|
||||
const day = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z'), startedTo: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
|
||||
expect(applyInstanceFilters(items, { startedTo: day('2026-08-20T01:00:00.000Z') }).map((i) => i.no)).toEqual(['INS-9']);
|
||||
expect(applyInstanceFilters(items, {})).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('统计五键齐;分页越界给空', () => {
|
||||
expect(statsOf(items)).toEqual({ total: 3, byState: { pending: 0, running: 1, completed: 1, rejected: 0, failed: 1 } });
|
||||
expect(pageOf(items, 1, 2).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
|
||||
expect(pageOf(items, 2, 2).map((i) => i.no)).toEqual(['INS-9']);
|
||||
expect(pageOf(items, 3, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 沙箱视图出口:模版原文不改,只在 </body> 前注入报高脚本 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
|
||||
import { injectHeightScript } from '../activity-ops/view-html.ts';
|
||||
|
||||
describe('injectHeightScript', () => {
|
||||
it('插在 </body> 之前;没有 body 就追加;原文一字不改', () => {
|
||||
const html = '<!doctype html><html><body><h1>一览</h1></body></html>';
|
||||
const out = injectHeightScript(html);
|
||||
expect(out.startsWith('<!doctype html><html><body><h1>一览</h1>')).toBe(true);
|
||||
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('</body>'));
|
||||
expect(out).toContain(ACTIVITY_VIEW_HEIGHT_MESSAGE);
|
||||
expect(out).toContain("postMessage({type:T,height:h()},'*')");
|
||||
const bare = injectHeightScript('<p>x</p>');
|
||||
expect(bare.startsWith('<p>x</p>')).toBe(true);
|
||||
expect(bare).toContain('<script>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 沙箱看板的取数与作用域:声明怎么求值、行集怎么摊平 —— 零 IO,读表用一张内存表替。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ViewData } from '@contracts';
|
||||
import { caseStamp, openedAtText } from '../case-stamp.ts';
|
||||
import { resolveViewData, viewScopeOf, slugOf, localMinute } from '../activity-ops/view-scope.ts';
|
||||
import type { ViewRead, ViewRow } from '../activity-ops/view-scope.ts';
|
||||
|
||||
const SPEC: ViewData = {
|
||||
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
|
||||
rows: [
|
||||
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }] },
|
||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
|
||||
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: 'sub_eval.verdict.request_id' } },
|
||||
{
|
||||
as: 'validation.history',
|
||||
ledger: 'validation.record',
|
||||
by: { column: 'validation_internal_pn', from: 'sub_eval.pn_check.internal_pn' },
|
||||
not: { column: 'request_id', from: 'sub_eval.verdict.request_id' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TABLES: Record<string, ViewRow[]> = {
|
||||
'sub_eval.verdict': [
|
||||
{ eval_ref: 'W05-20260827215103', request_id: 'REQ-20260827215103', original_internal_pn: 'LM2596S-5.0' },
|
||||
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000', original_internal_pn: 'X' },
|
||||
],
|
||||
'sub_eval.pn_check': [
|
||||
{ eval_ref: 'W05-20260827215103', internal_pn: 'XL2596S-5.0', role: 'candidate', exists_status: 'created' },
|
||||
{ eval_ref: 'W05-20260827215103', internal_pn: 'LM2596S-5.0', role: 'original', exists_status: 'found' },
|
||||
{ eval_ref: 'W05-20260827000000', internal_pn: 'X', role: 'original', exists_status: 'found' },
|
||||
],
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', brand_name: 'TI', lifecycle_status: 'nrnd' },
|
||||
{ internal_pn: 'XL2596S-5.0', brand_name: 'XLSEMI', lifecycle_status: 'active' },
|
||||
{ internal_pn: 'X', brand_name: '?', lifecycle_status: 'active' },
|
||||
],
|
||||
'substitution.relation': [
|
||||
{ substitution_relation_id: 'REL-1', eval_ref: 'W05-20260827215103', recommendation_level: 'conditional' },
|
||||
{ substitution_relation_id: 'REL-9', eval_ref: 'W05-20260827000000', recommendation_level: 'alternate' },
|
||||
],
|
||||
'validation.record': [
|
||||
{ validation_id: 'VAL-A', request_id: 'REQ-20260827215103', validation_internal_pn: 'XL2596S-5.0' },
|
||||
{ validation_id: 'VAL-OLD', request_id: 'REQ-20260101000000', validation_internal_pn: 'XL2596S-5.0' },
|
||||
{ validation_id: 'VAL-OTHER', request_id: 'REQ-20260101000001', validation_internal_pn: 'Z' },
|
||||
],
|
||||
};
|
||||
|
||||
/** 内存表:等值过滤 + 单列排序;记下每一次读 */
|
||||
function reader(tables: Record<string, ViewRow[]>, broken: ReadonlySet<string> = new Set()): { read: ViewRead; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const read: ViewRead = async (address, where, orderBy) => {
|
||||
const name = `${address.domain}.${address.object}`;
|
||||
calls.push(`${name} ${JSON.stringify(where)}`);
|
||||
if (broken.has(name)) return undefined;
|
||||
const rows = (tables[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => String(r[k]) === v));
|
||||
const order = orderBy[0];
|
||||
if (order === undefined) return rows;
|
||||
return [...rows].sort((a, b) => String(a[order.field]).localeCompare(String(b[order.field])) * (order.dir === 'asc' ? 1 : -1));
|
||||
};
|
||||
return { read, calls };
|
||||
}
|
||||
|
||||
const DECLARED = [
|
||||
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
|
||||
{ domain: 'substitution', object: 'relation', fields: [{ name: 'recommendation_level', tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }] },
|
||||
];
|
||||
|
||||
describe('caseStamp / openedAtText', () => {
|
||||
it('同一串:文本带分隔符、案号只留 14 位数字;认不出的时刻两者都是空串', () => {
|
||||
const iso = new Date(2026, 7, 27, 21, 51, 3).toISOString();
|
||||
expect(openedAtText(iso)).toBe('2026-08-27 21:51:03');
|
||||
expect(caseStamp(iso)).toBe('20260827215103');
|
||||
expect(openedAtText('not-a-date')).toBe('');
|
||||
expect(caseStamp('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveViewData', () => {
|
||||
it('按序链式求值:案号 → 核对表 → 料号圈主数据;行序随 from 值的顺序(原 PN 排第一)', async () => {
|
||||
const { read, calls } = reader(TABLES);
|
||||
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||
expect(out.caseFound).toBe(true);
|
||||
expect(out.skipped).toEqual([]);
|
||||
expect(out.sets['sub_eval.verdict']).toHaveLength(1);
|
||||
expect(out.sets['sub_eval.pn_check']?.map((r) => r['internal_pn'])).toEqual(['LM2596S-5.0', 'XL2596S-5.0']);
|
||||
expect(out.sets['parts.profile']?.map((r) => r['brand_name'])).toEqual(['TI', 'XLSEMI']);
|
||||
expect(out.sets['substitution.relation']?.map((r) => r['substitution_relation_id'])).toEqual(['REL-1']);
|
||||
// 每个值单独一次等值读:别单的行一条都不进来
|
||||
expect(calls).toContain('parts.profile {"internal_pn":"LM2596S-5.0"}');
|
||||
expect(calls).toContain('parts.profile {"internal_pn":"XL2596S-5.0"}');
|
||||
expect(calls.filter((c) => c.startsWith('parts.profile'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('not 排除:同料号的历史验证记录不含本单挂的那条;别的料号不进来', async () => {
|
||||
const { read } = reader(TABLES);
|
||||
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||
expect(out.sets['validation.record']?.map((r) => r['validation_id'])).toEqual(['VAL-A']);
|
||||
expect(out.sets['validation.history']?.map((r) => r['validation_id'])).toEqual(['VAL-OLD']);
|
||||
});
|
||||
|
||||
it('案号查无 → caseFound=false,下游集合全空、一次多余的读都不发', async () => {
|
||||
const { read, calls } = reader(TABLES);
|
||||
const out = await resolveViewData(SPEC, { caseRef: 'W05-19990101000000', read });
|
||||
expect(out.caseFound).toBe(false);
|
||||
expect(out.sets['parts.profile']).toEqual([]);
|
||||
expect(calls.some((c) => c.startsWith('parts.profile'))).toBe(false);
|
||||
});
|
||||
|
||||
it('某张表读不了:那个集合为空并点名,别的集合照常', async () => {
|
||||
const { read } = reader(TABLES, new Set(['parts.profile']));
|
||||
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||
expect(out.sets['parts.profile']).toEqual([]);
|
||||
expect(out.sets['substitution.relation']).toHaveLength(1);
|
||||
expect(out.skipped).toEqual(['parts.profile']);
|
||||
});
|
||||
|
||||
it('from 指向没定义过的集合 → 点名、集合为空(声明写错不炸)', async () => {
|
||||
const { read } = reader(TABLES);
|
||||
const spec: ViewData = { ...SPEC, rows: [{ as: 'x', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'nope.internal_pn' } }] };
|
||||
const out = await resolveViewData(spec, { caseRef: 'W05-20260827215103', read });
|
||||
expect(out.sets['x']).toEqual([]);
|
||||
expect(out.skipped).toEqual(['x']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewScopeOf', () => {
|
||||
it('行数组 / 首行标量 / 行数 / tally 计数(先铺 0)/ 实例事实 / 三态', () => {
|
||||
const sets = {
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
|
||||
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
|
||||
],
|
||||
'substitution.relation': [],
|
||||
};
|
||||
const scope = viewScopeOf({
|
||||
spec: SPEC,
|
||||
sets,
|
||||
declared: DECLARED,
|
||||
instance: { no: 'INS-20', startedAt: new Date(2026, 7, 27, 21, 51, 3).toISOString(), finishedAt: new Date(2026, 7, 27, 21, 57, 56).toISOString(), owner: '柴浩' },
|
||||
caseRef: 'W05-20260827215103',
|
||||
state: 'ready',
|
||||
});
|
||||
expect(scope['board:state']).toBe('ready');
|
||||
expect(scope['case:instanceNo']).toBe('INS-20');
|
||||
expect(scope['case:openedAt']).toBe('2026-08-27 21:51');
|
||||
expect(scope['case:releasedAt']).toBe('2026-08-27 21:57');
|
||||
expect(scope['case:owner']).toBe('柴浩');
|
||||
expect(scope['case:ref']).toBe('W05-20260827215103');
|
||||
expect(scope['parts.profile']).toBe(sets['parts.profile']);
|
||||
expect(scope['parts.profile.internal_pn']).toBe('LM2596S-5.0');
|
||||
expect(scope['parts_profile_rows']).toBe(2);
|
||||
expect(scope['parts_profile_lifecycle_status_nrnd']).toBe(1);
|
||||
expect(scope['parts_profile_lifecycle_status_active']).toBe(1);
|
||||
expect(scope['parts_profile_lifecycle_status_eol']).toBe(0);
|
||||
expect(scope['substitution_relation_rows']).toBe(0);
|
||||
expect(scope['substitution_relation_recommendation_level_not_recommended']).toBe(0);
|
||||
// 声明里没求出来的集合也铺空数组:模版的 data-rows 不会对着 undefined
|
||||
expect(scope['sub_eval.verdict']).toEqual([]);
|
||||
});
|
||||
|
||||
it('没有已放行实例:none 态,实例事实全空串', () => {
|
||||
const scope = viewScopeOf({ spec: SPEC, sets: {}, declared: DECLARED, state: 'none' });
|
||||
expect(scope['board:state']).toBe('none');
|
||||
expect(scope['case:instanceNo']).toBe('');
|
||||
expect(scope['case:releasedAt']).toBe('');
|
||||
expect(scope['parts_profile_rows']).toBe(0);
|
||||
});
|
||||
|
||||
it('slug 与时刻', () => {
|
||||
expect(slugOf('sub_eval.verdict')).toBe('sub_eval_verdict');
|
||||
expect(slugOf('validation.history')).toBe('validation_history');
|
||||
expect(slugOf('not_recommended')).toBe('not_recommended');
|
||||
expect(localMinute(null)).toBe('');
|
||||
expect(localMinute('garbage')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 活动模块路由的归属:11 条全部进契约、注册表建得起来(与设计端的 activity-packages 互不撞),全归运营中心。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as contracts from '@contracts';
|
||||
import { ACTIVITY_OPS_ROUTES } from '@contracts';
|
||||
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
|
||||
|
||||
describe('活动模块路由契约', () => {
|
||||
it('11 条全在契约命名空间里,注册表能建起来', () => {
|
||||
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||
expect(ACTIVITY_OPS_ROUTES).toHaveLength(11);
|
||||
for (const route of ACTIVITY_OPS_ROUTES) expect(registry.has(route.method, route.path), route.path).toBe(true);
|
||||
});
|
||||
|
||||
it('全归 ops;设计端活动库让位到 activity-packages 后仍归 design', () => {
|
||||
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||
for (const route of ACTIVITY_OPS_ROUTES) expect(route.access).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activities')).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activity-packages')).toBe('design');
|
||||
expect(registry.lookup('POST', '/api/gov/activity-packages')).toBe('design');
|
||||
expect(registry.lookup('GET', '/api/gov/activities/part-substitution-assessment/view')).toBe('ops');
|
||||
expect(registry.lookup('POST', '/api/gov/activities/part-substitution-assessment/instances/t-1/terminate')).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activities/x/instances/t-1/files/output/0')).toBe('ops');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 活动模块路由:zod 校验 → 用例 → 结果翻状态码。用例是替身(用例本身另有测试),不挂鉴权中间件,
|
||||
* 身份由测试自己塞进上下文(终止 / 启动要知道是谁按的)。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstanceDetail, Principal } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { registerActivityRoutes } from '../routes/activities.ts';
|
||||
import type { ActivityOps } from '../activity-ops/service.ts';
|
||||
|
||||
const W05 = 'part-substitution-assessment';
|
||||
const owner = { id: staffIdSchema.parse('s-1'), name: '柴浩' };
|
||||
const detail: InstanceDetail = {
|
||||
id: 't-1',
|
||||
no: 'INS-7',
|
||||
state: 'running',
|
||||
owner,
|
||||
output: { files: [] },
|
||||
startedAt: '2026-08-27T02:00:00.000Z',
|
||||
activityId: W05,
|
||||
llm: 'deepseek-v4-flash',
|
||||
endedAt: null,
|
||||
durationMs: null,
|
||||
reason: null,
|
||||
input: { content: 'x', source: { kind: 'manual', label: '手动启动 · 柴浩' }, submittedAt: '2026-08-27T02:00:00.000Z', files: [] },
|
||||
steps: [],
|
||||
};
|
||||
const activity = { id: W05, name: 'W05', biz: '需求分析', status: 'active' as const, version: '1.5.0', owner, llm: 'm', createdAt: 'c', desc: '', view: null };
|
||||
|
||||
function fakeOps(over: Partial<ActivityOps> = {}): ActivityOps {
|
||||
return {
|
||||
listActivities: async () => [{ ...activity, instanceCount: 1 }],
|
||||
getActivity: async (id) => (id === W05 ? activity : undefined),
|
||||
viewHtml: async (id) => (id === W05 ? { kind: 'html', html: '<html><body>一览<script>x</script></body></html>' } : { kind: 'no-view' }),
|
||||
listInstances: async (id, q) => (id === W05 ? { stats: { total: 1, byState: { pending: 0, running: 1, completed: 0, rejected: 0, failed: 0 } }, total: 1, page: q.page, size: q.size, items: [] } : undefined),
|
||||
launchParams: async (id) => (id === W05 ? { activityId: id, version: '1.5.0', params: [] } : undefined),
|
||||
launch: async (id, input) => {
|
||||
if (id !== W05) return { kind: 'not-found' };
|
||||
if (input.idempotencyKey === 'busy') return { kind: 'busy', message: '正在创建' };
|
||||
if (input.idempotencyKey === 'bad') return { kind: 'invalid', issues: [{ key: 'customerCode', message: '必填' }] };
|
||||
if (input.idempotencyKey === 'off') return { kind: 'disabled' };
|
||||
if (input.idempotencyKey === 'owner') return { kind: 'owner-invalid', message: '责任人不在名册里' };
|
||||
return { kind: 'ok', detail, created: true };
|
||||
},
|
||||
instanceDetail: async (id, iid) => (id === W05 && iid === 't-1' ? detail : undefined),
|
||||
instanceFile: async (id, iid, io, index) =>
|
||||
id === W05 && iid === 't-1' && io === 'input' && index === 0 ? { kind: 'file', name: '报 告.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : iid === 't-1' ? { kind: 'no-file' } : { kind: 'no-instance' },
|
||||
setEnabled: async (id, enabled) => (id === W05 ? { ...activity, status: enabled ? 'active' : 'disabled' } : undefined),
|
||||
terminate: async (id, iid) => (id !== W05 || iid === 'nope' ? { kind: 'not-found' } : iid === 't-done' ? { kind: 'terminal' } : { kind: 'ok', detail: { ...detail, state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } } }),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function harness(opts: { anonymous?: boolean } = {}) {
|
||||
const app = new Hono<AppEnv>();
|
||||
const principal: Principal = {
|
||||
staff: { id: owner.id, account: 'chaihao', name: owner.name, positionId: positionIdSchema.parse('pm'), state: 'active', roles: [], kind: 'person' },
|
||||
position: { id: positionIdSchema.parse('pm'), code: 'PM', name: '项目经理', note: '' },
|
||||
roles: [],
|
||||
ends: ['ops'],
|
||||
};
|
||||
if (!opts.anonymous) {
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('principal', principal);
|
||||
await next();
|
||||
});
|
||||
}
|
||||
registerActivityRoutes(app, { activityOps: fakeOps() });
|
||||
const json = (method: string, path: string, body?: unknown) =>
|
||||
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { app, json };
|
||||
}
|
||||
|
||||
describe('活动路由', () => {
|
||||
it('L1 / L2:清单按 status 过滤入参校验;详情 404;视图回 text/html', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('GET', '/api/gov/activities')).json()).toMatchObject({ activities: [{ id: W05, instanceCount: 1 }] });
|
||||
expect((await h.json('GET', '/api/gov/activities?status=paused')).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}`)).status).toBe(200);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope')).status).toBe(404);
|
||||
const view = await h.json('GET', `/api/gov/activities/${W05}/view`);
|
||||
expect(view.status).toBe(200);
|
||||
expect(view.headers.get('content-type')).toContain('text/html');
|
||||
expect(view.headers.get('cache-control')).toBe('no-store');
|
||||
expect(await view.text()).toContain('一览');
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/view')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('实例清单:查询默认值与上限;启动参数表 404', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances`)).json()).toMatchObject({ page: 1, size: 8 });
|
||||
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances?page=2&size=20&state=running`)).json()).toMatchObject({ page: 2, size: 20 });
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?size=99`)).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?startedFrom=8-1`)).status).toBe(400);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/instances')).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/launch-params`)).status).toBe(200);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/launch-params')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('启动:400 入参 / 412 禁用 / 409 在途 / 404 / 401 没身份 / 200 详情', async () => {
|
||||
const h = harness();
|
||||
const body = { idempotencyKey: 'k', ownerId: 's-1', params: { customerCode: 'CU-1' } };
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ownerId: 's-1' })).status).toBe(400);
|
||||
const bad = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'bad' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(await bad.json()).toMatchObject({ code: 'validation-failed', detail: [{ key: 'customerCode' }] });
|
||||
const owner = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'owner' });
|
||||
expect(owner.status).toBe(400);
|
||||
expect(await owner.json()).toMatchObject({ detail: [{ key: 'ownerId' }] });
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'off' })).status).toBe(412);
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'busy' })).status).toBe(409);
|
||||
expect((await h.json('POST', '/api/gov/activities/nope/instances', body)).status).toBe(404);
|
||||
const ok = await h.json('POST', `/api/gov/activities/${W05}/instances`, body);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.json()).toMatchObject({ id: 't-1', no: 'INS-7' });
|
||||
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances`, body)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('L3:详情 404;文件默认 JSON+base64、raw=1 直出字节带 RFC 5987 文件名;序号不合法 400', async () => {
|
||||
const h = harness();
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1`)).status).toBe(200);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope`)).status).toBe(404);
|
||||
const json = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0`);
|
||||
expect(await json.json()).toEqual({ name: '报 告.pdf', contentType: 'application/pdf', dataBase64: Buffer.from([1, 2, 3]).toString('base64') });
|
||||
const raw = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0?raw=1`);
|
||||
expect(raw.status).toBe(200);
|
||||
expect(raw.headers.get('content-type')).toBe('application/pdf');
|
||||
expect(raw.headers.get('content-disposition')).toBe(`attachment; filename*=UTF-8''${encodeURIComponent('报 告.pdf')}`);
|
||||
expect(new Uint8Array(await raw.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/output/0`)).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope/files/input/0`)).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/both/0`)).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/x`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('禁用 / 激活回活动详情;终止 412 已终态、404、401 没身份', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('POST', `/api/gov/activities/${W05}/disable`)).json()).toMatchObject({ status: 'disabled' });
|
||||
expect(await (await h.json('POST', `/api/gov/activities/${W05}/activate`)).json()).toMatchObject({ status: 'active' });
|
||||
expect((await h.json('POST', '/api/gov/activities/nope/disable')).status).toBe(404);
|
||||
const done = await h.json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`);
|
||||
expect(done.status).toBe(200);
|
||||
expect(await done.json()).toMatchObject({ state: 'failed', reason: { kind: 'terminated' } });
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/t-done/terminate`)).status).toBe(412);
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/nope/terminate`)).status).toBe(404);
|
||||
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 实例五态 —— 对 task 运行态 / 终态的**如实映射**(`docs/API文档-活动模块.md` §0)。
|
||||
*
|
||||
* pending 建了单、推进器还没领到(`used.steps = 0`;`task.start` 不同步跑第一拍)
|
||||
* running 在途:running / awaiting-human / awaiting-event / suspended / landing-blocked
|
||||
* completed 终态 released
|
||||
* rejected 验收被负责人打回、模型还没重新提交的那一段 —— 本引擎里驳回是环内回转不是终态
|
||||
* (`loop_state.pending` 为 null 或 `verdict='reject'`,且最近一条 `裁决:` 不是 pass);
|
||||
* 模型重新提交、新验收卡发出后 pending 再被立起,就回到 running
|
||||
* failed 终态 blocked / stalled / exhausted(kind=failed),以及 terminated(kind=terminated)
|
||||
*
|
||||
* 纯函数;读数由调用方从 `sys.task` / `sys.task_memory` / `sys.loop_state` 取来。
|
||||
*/
|
||||
import type { InstanceReason, InstanceState, TerminalState } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
|
||||
/** 最近一次人的裁决(`release-verdict` 里标题以 `裁决:` 开头的那条;落地那一路也记同 kind,不算) */
|
||||
export type VerdictFact = { readonly pass: boolean; readonly comment: string; readonly byName: string | undefined };
|
||||
/** 在途验收(`loop_state.pending`):卡发出即有;verdict 到达后填上;重做被消费后整个清空 */
|
||||
export type PendingFact = { readonly verdict: 'pass' | 'reject' | undefined; readonly comment: string | undefined };
|
||||
|
||||
export type StateFacts = {
|
||||
readonly terminal: TerminalState | undefined;
|
||||
readonly reason: string | undefined;
|
||||
readonly runtime: string;
|
||||
readonly usedSteps: number;
|
||||
readonly lastVerdict: VerdictFact | undefined;
|
||||
readonly pending: PendingFact | undefined;
|
||||
};
|
||||
|
||||
export type StateVerdict = { readonly state: InstanceState; readonly reason: InstanceReason | null };
|
||||
|
||||
export const VERDICT_PREFIX = '裁决:';
|
||||
|
||||
function rejectedReason(comment: string, byName: string | undefined): InstanceReason {
|
||||
if (comment === '') return { kind: 'rejected', text: '验收驳回:负责人未写明具体意见,已退回重做' };
|
||||
return { kind: 'rejected', text: `验收驳回:${comment}${byName === undefined ? '' : ` —— 负责人 ${byName}`}` };
|
||||
}
|
||||
|
||||
const BUDGET_ITEM_LABEL: Readonly<Record<string, string>> = { steps: '步数', tokens: 'token', activeMs: '活跃时长', humanCalls: '调人次数' };
|
||||
|
||||
/**
|
||||
* `exhausted` 那一路 `sys.task.reason` 存的是预算执行器裁决的 JSON(`{"kind":"exhausted","breaches":[{item,used,cap}]}`)——
|
||||
* 机器话;摆给人的一句要说「哪一项、用了多少、上限多少」。认不出的形状原样交出去(不编)。
|
||||
*/
|
||||
export function failedReasonText(terminal: TerminalState, reason: string | undefined): string {
|
||||
if (reason === undefined || reason === '') return TERMINAL_STATE_LABEL[terminal];
|
||||
if (terminal !== 'exhausted') return reason;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(reason);
|
||||
const breaches = typeof parsed === 'object' && parsed !== null ? (parsed as { breaches?: unknown }).breaches : undefined;
|
||||
if (!Array.isArray(breaches) || breaches.length === 0) return reason;
|
||||
const parts = breaches.map((b) => {
|
||||
const one = (typeof b === 'object' && b !== null ? b : {}) as { item?: unknown; used?: unknown; cap?: unknown };
|
||||
const item = typeof one.item === 'string' ? one.item : '';
|
||||
const scale = item === 'activeMs' ? 60_000 : 1;
|
||||
const fmt = (v: unknown) => (typeof v === 'number' ? String(Math.round(v / scale)) : '?');
|
||||
return `${BUDGET_ITEM_LABEL[item] ?? item} ${fmt(one.used)}/${fmt(one.cap)}${item === 'activeMs' ? ' 分钟' : ''}`;
|
||||
});
|
||||
return `预算耗尽:${parts.join('、')}`;
|
||||
} catch {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function instanceStateOf(f: StateFacts): StateVerdict {
|
||||
if (f.terminal === 'released') return { state: 'completed', reason: null };
|
||||
if (f.terminal === 'terminated') return { state: 'failed', reason: { kind: 'terminated', text: f.reason ?? '已手动终止' } };
|
||||
if (f.terminal !== undefined) {
|
||||
return { state: 'failed', reason: { kind: 'failed', text: failedReasonText(f.terminal, f.reason) } };
|
||||
}
|
||||
if (f.pending?.verdict === 'reject') {
|
||||
return { state: 'rejected', reason: rejectedReason((f.pending.comment ?? f.lastVerdict?.comment ?? '').trim(), f.lastVerdict?.byName) };
|
||||
}
|
||||
if (f.pending === undefined && f.lastVerdict !== undefined && !f.lastVerdict.pass) {
|
||||
return { state: 'rejected', reason: rejectedReason(f.lastVerdict.comment.trim(), f.lastVerdict.byName) };
|
||||
}
|
||||
if (f.runtime === 'running' && f.usedSteps === 0) return { state: 'pending', reason: null };
|
||||
return { state: 'running', reason: null };
|
||||
}
|
||||
|
||||
/** 从任务记忆里摘最近一次人的裁决;`nameOf` 把 `detail.by`(staffId)换成名字 */
|
||||
export function verdictFactOf(
|
||||
entries: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||
nameOf: (staffId: string) => string | undefined = () => undefined,
|
||||
): VerdictFact | undefined {
|
||||
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||
const entry = entries[i];
|
||||
if (entry === undefined || entry.kind !== 'release-verdict' || !entry.title.startsWith(VERDICT_PREFIX)) continue;
|
||||
const by = entry.detail?.['by'];
|
||||
const comment = entry.detail?.['comment'];
|
||||
return {
|
||||
pass: entry.title === `${VERDICT_PREFIX}pass`,
|
||||
comment: typeof comment === 'string' ? comment : '',
|
||||
byName: typeof by === 'string' ? nameOf(by) : undefined,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* L3 的「动作过程记录」—— 一个计划项一步,按案例模型的文档序排。
|
||||
*
|
||||
* 三处拼起来(没有哪一处单独够用):
|
||||
* · `loop_state.parsed.items`(CMMN 计划项:名字、谁办 —— `manualActivation` 的项是触达人的那几步);
|
||||
* · `loop_state.runtime.items[].state`(办到哪了:Completed / Active / Failed / …,**不带时间**);
|
||||
* · 任务记忆里引擎写的三种痕(`开始办:<名>` / `完成:<名>` / `自动收步:<id> …`)给出时刻与用时。
|
||||
* 阶段(stage)/ 里程碑 / 监听器不是「一步」,不摆。
|
||||
*/
|
||||
import type { InstanceState, InstanceStep } from '@contracts';
|
||||
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
|
||||
|
||||
export const STEP_ITEM_TYPES: ReadonlySet<string> = new Set(['humanTask', 'decisionTask', 'processTask', 'caseTask']);
|
||||
export const STEP_START = '开始办:';
|
||||
export const STEP_DONE = '完成:';
|
||||
export const STEP_AUTO = '自动收步:';
|
||||
|
||||
export type HistoryLine = { readonly kind: string; readonly title: string; readonly at: string };
|
||||
|
||||
export type StepFacts = {
|
||||
readonly items: readonly PlanItem[];
|
||||
readonly runtimeItems: readonly ItemRuntime[];
|
||||
/** 剧本 `steps[].intent`(item id → 一句话) */
|
||||
readonly intents: ReadonlyMap<string, string>;
|
||||
readonly history: readonly HistoryLine[];
|
||||
readonly state: InstanceState;
|
||||
readonly ownerName: string;
|
||||
readonly llm: string;
|
||||
};
|
||||
|
||||
type Res = InstanceStep['res'];
|
||||
|
||||
const IN_PROGRESS: ReadonlySet<string> = new Set(['Active', 'Enabled', 'Failed', 'Suspended']);
|
||||
|
||||
function baseRes(state: string | undefined): Res {
|
||||
if (state === 'Completed') return 'done';
|
||||
if (state === 'Active' || state === 'Enabled') return 'current';
|
||||
if (state === 'Failed') return 'fail';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** 同一项可能重复实例化(repetitionRule),看最新那一实例 */
|
||||
function latestRuntime(items: readonly ItemRuntime[], id: string): ItemRuntime | undefined {
|
||||
let best: ItemRuntime | undefined;
|
||||
for (const it of items) if (it.id === id && (best === undefined || it.instance > best.instance)) best = it;
|
||||
return best;
|
||||
}
|
||||
|
||||
function millis(iso: string): number | undefined {
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : undefined;
|
||||
}
|
||||
|
||||
export function stepsOf(f: StepFacts): InstanceStep[] {
|
||||
const rows = f.items
|
||||
.filter((item) => STEP_ITEM_TYPES.has(item.type))
|
||||
.map((item) => {
|
||||
const rt = latestRuntime(f.runtimeItems, item.id);
|
||||
return { item, rt, res: baseRes(rt?.state) as Res };
|
||||
});
|
||||
|
||||
/*
|
||||
* 失败 / 驳回的那一行:正在办的那一步记 fail(过程面「当前步记已终止 / 驳回」)。
|
||||
* 没有正在办的(终止前一步没开、或验收打回时项都已收)→ 失败取第一个没到的,驳回取最后一个已完成的。
|
||||
*/
|
||||
if (f.state === 'failed' || f.state === 'rejected') {
|
||||
const live = rows.findIndex((r) => r.rt !== undefined && IN_PROGRESS.has(r.rt.state));
|
||||
if (live >= 0) rows[live]!.res = 'fail';
|
||||
else if (f.state === 'failed') {
|
||||
const next = rows.findIndex((r) => r.res === 'pending');
|
||||
if (next >= 0) rows[next]!.res = 'fail';
|
||||
} else {
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
if (rows[i]!.res === 'done') {
|
||||
rows[i]!.res = 'fail';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map(({ item, res }, i) => {
|
||||
const start = f.history.find((h) => h.title === `${STEP_START}${item.name}`);
|
||||
const ends = f.history.filter((h) => h.title === `${STEP_DONE}${item.name}` || h.title.startsWith(`${STEP_AUTO}${item.id} `));
|
||||
const end = ends[ends.length - 1];
|
||||
const startMs = start === undefined ? undefined : millis(start.at);
|
||||
const endMs = end === undefined ? undefined : millis(end.at);
|
||||
const at = start?.at ?? (res === 'done' ? (end?.at ?? null) : null);
|
||||
const durationMs = startMs !== undefined && endMs !== undefined && endMs >= startMs && res !== 'current' ? endMs - startMs : null;
|
||||
return {
|
||||
seq: i + 1,
|
||||
name: item.name,
|
||||
by: item.manualActivation ? 'human' : 'agent',
|
||||
byName: item.manualActivation ? f.ownerName : f.llm,
|
||||
at,
|
||||
durationMs,
|
||||
res,
|
||||
note: f.intents.get(item.id) ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 启动参数表 —— 从活动契约的 `input.data`(JSON Schema)派生,一活动一份;前端只按它渲染,不写死字段。
|
||||
*
|
||||
* 清单里怎么写(都是 JSON Schema 的标准词或本仓已有的非标词,`jsonSchemaSchema` 是 `record(unknown)`,扩展键能过):
|
||||
* · `title` → label;`description` → desc;`examples[0]` → placeholder;`default` → default;`required[]` → required
|
||||
* · `enum` / `enumNames` → select 的候选(E04 / I01 已在用)
|
||||
* · `x-control`:`text | textarea | select | combo` —— 不写就按类型推:enum → select、`array<string>` → list、string → text;
|
||||
* **数组字段也可以标 combo / select**(表单只选一颗,提交单个字符串,后端包成一元数组;传数组也照收)
|
||||
* · `x-options`:`{ ledger: '域.对象', value: '列', label: '列' }` —— combo / select 的候选从正式台账现读(客户 / 料号这类「台账里可能有」的)
|
||||
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
||||
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
||||
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
||||
*
|
||||
* 校验与归一(`validateLaunchParams`)是服务端那一道:前端禁提交只是礼貌,拦住的得是这里。
|
||||
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
|
||||
*/
|
||||
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
||||
import { LAUNCH_PARAM_TYPES } from '@contracts';
|
||||
|
||||
export type OptionsSource = { readonly ledger: string; readonly value: string; readonly label: string };
|
||||
|
||||
/** 一个入参字段的规格 —— JSON Schema 里我们认的那几个词 */
|
||||
export type FieldSpec = {
|
||||
readonly key: string;
|
||||
readonly required: boolean;
|
||||
readonly title: string | undefined;
|
||||
readonly description: string | undefined;
|
||||
readonly type: string | undefined;
|
||||
readonly itemType: string | undefined;
|
||||
readonly format: string | undefined;
|
||||
readonly control: string | undefined;
|
||||
readonly enumValues: readonly unknown[] | undefined;
|
||||
readonly enumNames: readonly unknown[] | undefined;
|
||||
readonly example: unknown;
|
||||
readonly defaultValue: unknown;
|
||||
readonly optionsSource: OptionsSource | undefined;
|
||||
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
||||
readonly order: number | undefined;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
||||
const raw = asRecord(value);
|
||||
if (raw === undefined) return undefined;
|
||||
const ledger = asString(raw['ledger']);
|
||||
const valueCol = asString(raw['value']);
|
||||
const labelCol = asString(raw['label']);
|
||||
if (ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === '') return undefined;
|
||||
return { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol };
|
||||
}
|
||||
|
||||
/** `input.data` 的 properties 逐个读成规格,保属性序(表单顺序就是清单顺序) */
|
||||
export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): FieldSpec[] {
|
||||
const props = asRecord(inputSchema['properties']) ?? {};
|
||||
const requiredRaw = inputSchema['required'];
|
||||
const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k): k is string => typeof k === 'string') : [];
|
||||
const specs = Object.entries(props).map(([key, raw]): FieldSpec => {
|
||||
const spec = asRecord(raw) ?? {};
|
||||
const items = asRecord(spec['items']);
|
||||
const enumRaw = spec['enum'];
|
||||
const namesRaw = spec['enumNames'];
|
||||
const examples = spec['examples'];
|
||||
return {
|
||||
key,
|
||||
required: required.includes(key),
|
||||
title: asString(spec['title']),
|
||||
description: asString(spec['description']),
|
||||
type: asString(spec['type']),
|
||||
itemType: items === undefined ? undefined : asString(items['type']),
|
||||
format: asString(spec['format']),
|
||||
control: asString(spec['x-control']),
|
||||
enumValues: Array.isArray(enumRaw) ? enumRaw : undefined,
|
||||
enumNames: Array.isArray(namesRaw) ? namesRaw : undefined,
|
||||
example: Array.isArray(examples) ? examples[0] : undefined,
|
||||
defaultValue: spec['default'],
|
||||
optionsSource: optionsSourceOf(spec['x-options']),
|
||||
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
||||
};
|
||||
});
|
||||
// 稳定排序:声明了 x-order 的按数字,没声明的殿后并保持原序
|
||||
return specs
|
||||
.map((spec, i) => ({ spec, i }))
|
||||
.sort((a, b) => (a.spec.order ?? Number.POSITIVE_INFINITY) - (b.spec.order ?? Number.POSITIVE_INFINITY) || a.i - b.i)
|
||||
.map((x) => x.spec);
|
||||
}
|
||||
|
||||
function isLaunchType(value: string | undefined): value is LaunchParamType {
|
||||
return value !== undefined && (LAUNCH_PARAM_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一格该长成什么控件;`undefined` = 本期不上表单(文件 / 对象 / 非字符串数组)。
|
||||
* 数组一律 list(`x-control` 对数组不生效 —— 一串值只有一种摆法);标量按 `x-control` > enum > 文本。
|
||||
*/
|
||||
export function controlOf(spec: FieldSpec): LaunchParamType | undefined {
|
||||
if (spec.format === 'file') return undefined;
|
||||
if (spec.type === 'array') {
|
||||
if (spec.itemType !== undefined && spec.itemType !== 'string') return undefined;
|
||||
// 数组字段也可以摆成单选(`x-control: combo | select | text`):表单上只选一颗,提交时后端包成一元数组
|
||||
return isLaunchType(spec.control) && spec.control !== 'list' ? spec.control : 'list';
|
||||
}
|
||||
if (spec.type === 'object') return undefined;
|
||||
if (isLaunchType(spec.control) && spec.control !== 'list') return spec.control;
|
||||
if (spec.enumValues !== undefined && spec.enumValues.length > 0) return 'select';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/** 枚举 → 候选:有 `enumNames` 配对就给人读名,没有就拿枚举值本身当标签 */
|
||||
export function enumOptionsOf(spec: FieldSpec): LaunchOption[] | undefined {
|
||||
if (spec.enumValues === undefined || spec.enumValues.length === 0) return undefined;
|
||||
return spec.enumValues.map((v, i) => ({ value: String(v), label: String(spec.enumNames?.[i] ?? v) }));
|
||||
}
|
||||
|
||||
function placeholderOf(spec: FieldSpec, type: LaunchParamType): string | undefined {
|
||||
const example = spec.example;
|
||||
if (example === undefined || example === null) return undefined;
|
||||
// 数组字段(list,或摆成单选的数组)的示例是一串:占位摆第一颗
|
||||
if (type === 'list' || spec.type === 'array') {
|
||||
const first = Array.isArray(example) ? example[0] : example;
|
||||
return typeof first === 'string' ? first : undefined;
|
||||
}
|
||||
return typeof example === 'string' ? example : JSON.stringify(example);
|
||||
}
|
||||
|
||||
function defaultOf(spec: FieldSpec, type: LaunchParamType): string | string[] | undefined {
|
||||
const value = spec.defaultValue;
|
||||
if (type === 'list') {
|
||||
return Array.isArray(value) && value.every((v): v is string => typeof v === 'string') ? value : undefined;
|
||||
}
|
||||
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined;
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一格规格 → 一条参数。`resolved` 是从台账读回来的候选(`x-options`);没有就退回枚举。
|
||||
* select 没有任何候选就降成 text —— 「只能选」却无可选,等于把这一格锁死。
|
||||
*/
|
||||
export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[] | undefined): LaunchParam | undefined {
|
||||
let type = controlOf(spec);
|
||||
if (type === undefined) return undefined;
|
||||
const options = resolved === undefined ? enumOptionsOf(spec) : [...resolved];
|
||||
if (type === 'select' && (options === undefined || options.length === 0)) type = 'text';
|
||||
const withOptions = (type === 'select' || type === 'combo') && options !== undefined && options.length > 0;
|
||||
const placeholder = placeholderOf(spec, type);
|
||||
const fallback = defaultOf(spec, type);
|
||||
return {
|
||||
key: spec.key,
|
||||
label: spec.title ?? spec.key,
|
||||
type,
|
||||
...(spec.required ? { required: true } : {}),
|
||||
...(spec.description === undefined ? {} : { desc: spec.description }),
|
||||
...(placeholder === undefined ? {} : { placeholder }),
|
||||
...(withOptions ? { options } : {}),
|
||||
...(fallback === undefined ? {} : { default: fallback }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 整份参数表;候选读取由调用方注入(读不到就当没有候选,**不炸**) */
|
||||
export async function launchParamsOf(
|
||||
inputSchema: Readonly<Record<string, unknown>>,
|
||||
resolve: (source: OptionsSource) => Promise<readonly LaunchOption[] | undefined>,
|
||||
): Promise<LaunchParam[]> {
|
||||
const out: LaunchParam[] = [];
|
||||
for (const spec of fieldSpecsOf(inputSchema)) {
|
||||
const resolved = spec.optionsSource === undefined ? undefined : await resolve(spec.optionsSource);
|
||||
const param = launchParamOf(spec, resolved);
|
||||
if (param !== undefined) out.push(param);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type LaunchIssue = { readonly key: string; readonly message: string };
|
||||
/** 线上的形状:list 是字符串数组,其余是字符串 */
|
||||
export type LaunchWire = Readonly<Record<string, string | readonly string[]>>;
|
||||
export type LaunchValidation =
|
||||
| { readonly ok: true; readonly data: Record<string, unknown> }
|
||||
| { readonly ok: false; readonly issues: readonly LaunchIssue[] };
|
||||
|
||||
function coerceScalar(value: string, spec: FieldSpec | undefined): { value: unknown } | { error: string } {
|
||||
const type = spec?.type;
|
||||
if (type === 'integer' || type === 'number') {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || (type === 'integer' && !Number.isInteger(n))) return { error: `要填${type === 'integer' ? '整数' : '数字'}` };
|
||||
return { value: n };
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
if (value === 'true' || value === 'false') return { value: value === 'true' };
|
||||
return { error: '只能是 true / false' };
|
||||
}
|
||||
return { value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
|
||||
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
|
||||
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
|
||||
*/
|
||||
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
|
||||
const issues: LaunchIssue[] = [];
|
||||
const data: Record<string, unknown> = {};
|
||||
const known = new Set(params.map((p) => p.key));
|
||||
const specOf = new Map(specs.map((s) => [s.key, s]));
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!known.has(key)) issues.push({ key, message: '不是这个活动的启动参数' });
|
||||
}
|
||||
for (const param of params) {
|
||||
const raw = input[param.key];
|
||||
const required = param.required === true;
|
||||
const spec = specOf.get(param.key);
|
||||
/*
|
||||
* 清单里是数组的字段(list,或摆成单选的 combo / select):线上 string 与 string[] 都收,
|
||||
* 归一成 string[] 交活动 —— 前端单选传一个字符串,后端包成一元数组(2026-08-27 用户定)。
|
||||
*/
|
||||
if (param.type === 'list' || spec?.type === 'array') {
|
||||
const entries = (typeof raw === 'string' ? [raw] : (raw ?? [])).map((v) => v.trim()).filter((v) => v !== '');
|
||||
if (entries.length === 0) {
|
||||
if (required) issues.push({ key: param.key, message: param.type === 'list' ? '必填,至少一项' : '必填' });
|
||||
continue;
|
||||
}
|
||||
if (param.type === 'select' && param.options !== undefined) {
|
||||
const off = entries.find((v) => !param.options?.some((o) => o.value === v));
|
||||
if (off !== undefined) {
|
||||
issues.push({ key: param.key, message: '不在候选里(单选只能选候选)' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
data[param.key] = entries;
|
||||
continue;
|
||||
}
|
||||
if (raw !== undefined && typeof raw !== 'string') {
|
||||
issues.push({ key: param.key, message: '要传字符串' });
|
||||
continue;
|
||||
}
|
||||
const value = (raw ?? '').trim();
|
||||
if (value === '') {
|
||||
if (required) issues.push({ key: param.key, message: '必填' });
|
||||
continue;
|
||||
}
|
||||
if (param.type === 'select' && param.options !== undefined && !param.options.some((o) => o.value === value)) {
|
||||
issues.push({ key: param.key, message: '不在候选里(单选只能选候选)' });
|
||||
continue;
|
||||
}
|
||||
const coerced = coerceScalar(value, spec);
|
||||
if ('error' in coerced) {
|
||||
issues.push({ key: param.key, message: coerced.error });
|
||||
continue;
|
||||
}
|
||||
data[param.key] = coerced.value;
|
||||
}
|
||||
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 运营中心 · 活动模块的用例编排(app 层;`docs/API文档-活动模块.md`)。
|
||||
*
|
||||
* 读是跨模块的投影:活动 = 注册表卡片 + 清单 + 运营侧档案 + 实例计数;
|
||||
* 实例 = `sys.task` 一行 + 任务记忆里的裁决与引擎痕 + loop 运行态 + 输入 / 产出文件。
|
||||
* 写一律走既有 port:`task.start`(`origin:'ops'`)、`task.control('terminate')`、`registry.setEnabled`。
|
||||
*
|
||||
* 启动的幂等:`sys.activity_launch` 按键占位(一句 SQL 判 + 占),同键重放回第一次那一单;
|
||||
* 键占了、单还没建成的那 60 秒里再来 = 409(不是第二单,也不是无限等)。
|
||||
*/
|
||||
import type {
|
||||
LedgerQuery,
|
||||
ViewData,
|
||||
ActivityDetail,
|
||||
ActivityHue,
|
||||
ActivityStatus,
|
||||
ActivitySummary,
|
||||
InstanceDetail,
|
||||
InstanceFile,
|
||||
InstanceList,
|
||||
InstanceSource,
|
||||
InstanceState,
|
||||
InstanceSummary,
|
||||
LaunchInput,
|
||||
LaunchOption,
|
||||
LaunchParams,
|
||||
StaffId,
|
||||
StaffRef,
|
||||
TaskId,
|
||||
TerminalState,
|
||||
} from '@contracts';
|
||||
import { ACTIVITY_HUES, TERMINAL_STATES, staffIdSchema, taskIdSchema } from '@contracts';
|
||||
import type { ActivityCard } from '@modules/activity-registry';
|
||||
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
|
||||
import { StaffNotFoundError } from '@modules/org';
|
||||
import { renderView } from '@modules/interaction';
|
||||
import type { StartTaskInput } from '@modules/task';
|
||||
import { MIME_OF } from '../file-compose.ts';
|
||||
import type { FileFormat } from '../file-compose.ts';
|
||||
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
|
||||
import type { FieldSpec, LaunchIssue, OptionsSource } from './launch-params.ts';
|
||||
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
|
||||
import type { StateVerdict } from './instance-state.ts';
|
||||
import { stepsOf } from './instance-steps.ts';
|
||||
import { applyInstanceFilters, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from './summaries.ts';
|
||||
import type { InstanceQuery } from './summaries.ts';
|
||||
import { LEDGER_TEMPLATE, injectHeightScript } from './view-html.ts';
|
||||
import { resolveViewData, viewScopeOf } from './view-scope.ts';
|
||||
import type { ViewRead } from './view-scope.ts';
|
||||
import { caseStamp } from '../case-stamp.ts';
|
||||
import type { ActivityOpsStore, TaskRow } from './store.ts';
|
||||
|
||||
/** 键占了、单还没建成:这么久之后允许重占(进程崩在 claim 与 start 之间不该把这枚键锁死) */
|
||||
export const LAUNCH_CLAIM_STALE_MS = 60_000;
|
||||
/** combo 候选内联上限(文档 §4:本期一律内联;更大的表以后走检索接口) */
|
||||
export const OPTIONS_CAP = 500;
|
||||
|
||||
/** 输入 / 产出文件的存取 —— app 里现成的两段(`upload-store.ts` / `asset-archive.ts`),按接口注入好替身 */
|
||||
export type InstanceFileStore = {
|
||||
readUpload(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
|
||||
listArchived(taskId: TaskId): Promise<ReadonlyArray<{ address: string; key: Record<string, string>; format: FileFormat; name: string; size: number }>>;
|
||||
readArchived(taskId: TaskId, address: string, key: Readonly<Record<string, string>>): Promise<{ bytes: Uint8Array; name: string; format: FileFormat } | undefined>;
|
||||
};
|
||||
|
||||
/*
|
||||
* 依赖只声明**用到的那几面**(不是整个 `ActivityRegistryService` / `TaskService` …):
|
||||
* 真服务结构上都装得进来(server.ts 装配处由 tsc 核),测试只需拼几个字面量,不用造整本清单。
|
||||
*/
|
||||
/** 清单里运营面要看的几处(台账声明只为沙箱看板铺 tally 读数) */
|
||||
export type ManifestFace = {
|
||||
readonly identity: { readonly name: string };
|
||||
readonly contract: { readonly input: { readonly data: Readonly<Record<string, unknown>> } };
|
||||
readonly declarations: {
|
||||
readonly models: readonly string[];
|
||||
readonly ledger: ReadonlyArray<{ readonly domain: string; readonly object: string; readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }> }>;
|
||||
};
|
||||
readonly assets: { readonly viewTemplates: ReadonlyArray<{ readonly name: string; readonly html: string; readonly data?: ViewData | undefined }> };
|
||||
};
|
||||
export type RegistryFace = {
|
||||
list(): Promise<ActivityCard[]>;
|
||||
get(activityId: string): Promise<ActivityCard | undefined>;
|
||||
manifestOf(activityId: string, packageVersion?: string): Promise<ManifestFace>;
|
||||
setEnabled(activityId: string, enabled: boolean): Promise<void>;
|
||||
};
|
||||
export type TaskFace = {
|
||||
start(input: StartTaskInput): Promise<{ readonly id: TaskId }>;
|
||||
control(taskId: TaskId, semantic: 'terminate', by: StaffId, reason?: string): Promise<unknown>;
|
||||
};
|
||||
export type OrgFace = {
|
||||
listStaff(): Promise<{ staff: ReadonlyArray<{ readonly id: string; readonly name: string }> }>;
|
||||
getStaff(staffId: StaffId): Promise<{ readonly name: string; readonly state: string }>;
|
||||
};
|
||||
export type HistoryEntry = { readonly kind: string; readonly title: string; readonly at: string; readonly detail?: Record<string, unknown> | undefined };
|
||||
export type MemoryFace = { history(taskId: TaskId): Promise<ReadonlyArray<HistoryEntry>> };
|
||||
export type LedgerFace = {
|
||||
describe(address: { domain: string; object: string }): Promise<{ readonly columns: ReadonlyArray<{ readonly name: string }> } | undefined>;
|
||||
query(query: LedgerQuery): Promise<{ readonly rows: ReadonlyArray<Readonly<Record<string, unknown>>> }>;
|
||||
};
|
||||
export type LoopFace = {
|
||||
readonly model: { readonly steps?: ReadonlyArray<{ readonly item: string; readonly intent: string }> | undefined };
|
||||
readonly parsed: { readonly items: readonly PlanItem[] };
|
||||
readonly runtime: { readonly items: readonly ItemRuntime[] };
|
||||
readonly pending?: { readonly verdict?: 'pass' | 'reject' | undefined; readonly comment?: string | undefined } | undefined;
|
||||
};
|
||||
export type LoopStateFace = { load(taskId: TaskId): Promise<LoopFace | undefined> };
|
||||
|
||||
export type ActivityOpsDeps = {
|
||||
readonly registry: RegistryFace;
|
||||
readonly task: TaskFace;
|
||||
readonly org: OrgFace;
|
||||
readonly memory: MemoryFace;
|
||||
readonly ledger: LedgerFace;
|
||||
readonly loopState: LoopStateFace;
|
||||
readonly store: ActivityOpsStore;
|
||||
readonly files: InstanceFileStore;
|
||||
readonly clock: { now(): Date };
|
||||
/** 读不到候选这类**不该炸**的事,说一声就好 */
|
||||
readonly warn?: (line: string) => void;
|
||||
};
|
||||
|
||||
export type Actor = { readonly id: StaffId; readonly name: string };
|
||||
|
||||
export type LaunchOutcome =
|
||||
| { readonly kind: 'ok'; readonly detail: InstanceDetail; readonly created: boolean }
|
||||
| { readonly kind: 'not-found' }
|
||||
| { readonly kind: 'disabled' }
|
||||
| { readonly kind: 'invalid'; readonly issues: readonly LaunchIssue[] }
|
||||
| { readonly kind: 'owner-invalid'; readonly message: string }
|
||||
| { readonly kind: 'busy'; readonly message: string };
|
||||
|
||||
export type TerminateOutcome = { readonly kind: 'ok'; readonly detail: InstanceDetail } | { readonly kind: 'not-found' } | { readonly kind: 'terminal' };
|
||||
|
||||
export type FileOutcome =
|
||||
| { readonly kind: 'file'; readonly name: string; readonly contentType: string; readonly bytes: Uint8Array }
|
||||
| { readonly kind: 'no-instance' }
|
||||
| { readonly kind: 'no-file' };
|
||||
|
||||
export type ViewOutcome = { readonly kind: 'html'; readonly html: string } | { readonly kind: 'no-activity' } | { readonly kind: 'no-view' };
|
||||
|
||||
export type ActivityOps = {
|
||||
listActivities(status?: ActivityStatus): Promise<ActivitySummary[]>;
|
||||
getActivity(activityId: string): Promise<ActivityDetail | undefined>;
|
||||
viewHtml(activityId: string): Promise<ViewOutcome>;
|
||||
listInstances(activityId: string, query: InstanceQuery & { page: number; size: number }): Promise<InstanceList | undefined>;
|
||||
launchParams(activityId: string): Promise<LaunchParams | undefined>;
|
||||
launch(activityId: string, input: LaunchInput, by: Actor): Promise<LaunchOutcome>;
|
||||
instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined>;
|
||||
instanceFile(activityId: string, instanceId: string, io: 'input' | 'output', index: number): Promise<FileOutcome>;
|
||||
setEnabled(activityId: string, enabled: boolean): Promise<ActivityDetail | undefined>;
|
||||
terminate(activityId: string, instanceId: string, by: Actor): Promise<TerminateOutcome>;
|
||||
};
|
||||
|
||||
const NAME_FALLBACK = '员工';
|
||||
|
||||
function isTerminal(value: string | null): value is TerminalState {
|
||||
return value !== null && (TERMINAL_STATES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function hueOf(raw: string | null): ActivityHue | undefined {
|
||||
return raw !== null && (ACTIVITY_HUES as readonly string[]).includes(raw) ? (raw as ActivityHue) : undefined;
|
||||
}
|
||||
|
||||
/** 当前生效的包版本:非 uninstalled 的最新一版(store 按 installedAt 升序给) */
|
||||
function versionOf(card: ActivityCard): string {
|
||||
const live = card.packageVersions.filter((p) => p.state !== 'uninstalled');
|
||||
const pick = (live.length > 0 ? live : card.packageVersions).at(-1);
|
||||
return pick?.version ?? '';
|
||||
}
|
||||
|
||||
function createdAtOf(card: ActivityCard): string {
|
||||
let earliest: string | undefined;
|
||||
for (const p of card.packageVersions) if (earliest === undefined || p.installedAt < earliest) earliest = p.installedAt;
|
||||
return earliest ?? '';
|
||||
}
|
||||
|
||||
function llmOf(manifest: ManifestFace): string {
|
||||
return manifest.declarations.models[0] ?? '—';
|
||||
}
|
||||
|
||||
function channelLabel(channel: string): string {
|
||||
if (channel === 'mail') return '邮件';
|
||||
if (channel === 'im') return '即时消息';
|
||||
return channel === '' ? '通道' : channel;
|
||||
}
|
||||
|
||||
export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
||||
const warn = deps.warn ?? ((line: string) => process.stderr.write(`${line}\n`));
|
||||
|
||||
async function namesOf(): Promise<Map<string, string>> {
|
||||
const { staff } = await deps.org.listStaff();
|
||||
return new Map(staff.map((s) => [s.id, s.name]));
|
||||
}
|
||||
|
||||
function staffRef(id: string, names: ReadonlyMap<string, string>): StaffRef {
|
||||
return { id: staffIdSchema.parse(id), name: names.get(id) ?? NAME_FALLBACK };
|
||||
}
|
||||
|
||||
function summaryOf(
|
||||
card: ActivityCard,
|
||||
manifest: ManifestFace,
|
||||
profile: { ownerId: string | null; description: string; ico: string | null; hue: string | null } | undefined,
|
||||
instanceCount: number,
|
||||
names: ReadonlyMap<string, string>,
|
||||
): ActivitySummary {
|
||||
const hue = hueOf(profile?.hue ?? null);
|
||||
return {
|
||||
id: card.activityId,
|
||||
name: card.name,
|
||||
biz: card.category,
|
||||
status: card.enabled ? 'active' : 'disabled',
|
||||
version: versionOf(card),
|
||||
owner: profile?.ownerId === null || profile?.ownerId === undefined ? null : staffRef(profile.ownerId, names),
|
||||
llm: llmOf(manifest),
|
||||
instanceCount,
|
||||
createdAt: createdAtOf(card),
|
||||
...(profile?.ico === null || profile?.ico === undefined || profile.ico === '' ? {} : { ico: profile.ico }),
|
||||
...(hue === undefined ? {} : { hue }),
|
||||
};
|
||||
}
|
||||
|
||||
async function detailOf(card: ActivityCard): Promise<ActivityDetail> {
|
||||
const [manifest, profiles, names] = await Promise.all([deps.registry.manifestOf(card.activityId), deps.store.profilesOf(), namesOf()]);
|
||||
const profile = profiles.get(card.activityId);
|
||||
const { instanceCount: _drop, ...rest } = summaryOf(card, manifest, profile, 0, names);
|
||||
void _drop;
|
||||
const hasView = manifest.assets.viewTemplates.some((v) => v.name === LEDGER_TEMPLATE);
|
||||
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
|
||||
}
|
||||
|
||||
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
|
||||
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
|
||||
const [domain, object] = source.ledger.split('.', 2);
|
||||
if (domain === undefined || object === undefined || domain === '' || object === '') return undefined;
|
||||
const address = { domain, object };
|
||||
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||
if (described === undefined) {
|
||||
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 尚未物化`);
|
||||
return undefined;
|
||||
}
|
||||
const columns = new Set(described.columns.map((c) => c.name));
|
||||
if (!columns.has(source.value) || !columns.has(source.label)) {
|
||||
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 没有列 ${source.value} / ${source.label}`);
|
||||
return undefined;
|
||||
}
|
||||
const page = await deps.ledger
|
||||
.query({ address, where: {}, orderBy: [{ field: source.label, dir: 'asc' }], limit: OPTIONS_CAP, offset: 0 })
|
||||
.catch((error: unknown) => {
|
||||
warn(`[activity-ops] 启动参数候选查询失败:${source.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
if (page === undefined) return undefined;
|
||||
return page.rows
|
||||
.map((row) => {
|
||||
const value = row[source.value];
|
||||
const label = row[source.label];
|
||||
const v = value === undefined || value === null ? '' : String(value);
|
||||
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
|
||||
return { value: v, label: l };
|
||||
})
|
||||
.filter((o) => o.value !== '');
|
||||
}
|
||||
|
||||
type InstanceCtx = { readonly names: ReadonlyMap<string, string>; readonly specs: readonly FieldSpec[] };
|
||||
|
||||
/** 一行 task → 状态裁定;非终态才需要翻记忆与 loop 运行态(判驳回) */
|
||||
async function verdictOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<{ verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
|
||||
const terminal = isTerminal(row.terminal) ? row.terminal : undefined;
|
||||
if (terminal !== undefined) {
|
||||
return { verdict: instanceStateOf({ terminal, reason: row.reason ?? undefined, runtime: row.runtime, usedSteps: row.usedSteps, lastVerdict: undefined, pending: undefined }), history: undefined };
|
||||
}
|
||||
const taskId = taskIdSchema.parse(row.id);
|
||||
const [history, loop] = await Promise.all([deps.memory.history(taskId), deps.loopState.load(taskId)]);
|
||||
const pending = loop?.pending;
|
||||
return {
|
||||
verdict: instanceStateOf({
|
||||
terminal: undefined,
|
||||
reason: row.reason ?? undefined,
|
||||
runtime: row.runtime,
|
||||
usedSteps: row.usedSteps,
|
||||
lastVerdict: verdictFactOf(history, (id) => names.get(id)),
|
||||
pending: pending === undefined ? undefined : { verdict: pending.verdict, comment: pending.comment },
|
||||
}),
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
async function inputTextOf(row: TaskRow, specs: readonly FieldSpec[]): Promise<string> {
|
||||
const fromData = inputSummaryOf(row.data, specs);
|
||||
if (fromData !== '') return fromData;
|
||||
const comm = await deps.store.inboundCommOf(row.id);
|
||||
return inputSummaryOf({}, [], comm?.subject !== undefined && comm.subject !== '' ? comm.subject : row.title);
|
||||
}
|
||||
|
||||
async function outputTextOf(row: TaskRow, state: InstanceState, history: ReadonlyArray<HistoryEntry> | undefined): Promise<string | null> {
|
||||
if (state !== 'completed') return null;
|
||||
const taskId = taskIdSchema.parse(row.id);
|
||||
const archived = await deps.files.listArchived(taskId);
|
||||
const entries = history ?? (await deps.memory.history(taskId));
|
||||
const fired = [...entries]
|
||||
.reverse()
|
||||
.find((e) => e.kind === 'release-verdict' && Array.isArray(e.detail?.['ports']))
|
||||
?.detail?.['ports'];
|
||||
const ports = Array.isArray(fired) ? fired.filter((p): p is string => typeof p === 'string') : [];
|
||||
return outputSummaryOf(archived.map((f) => f.name), ports);
|
||||
}
|
||||
|
||||
async function summarizeRow(row: TaskRow, ctx: InstanceCtx): Promise<{ summary: InstanceSummary; verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
|
||||
const { verdict, history } = await verdictOf(row, ctx.names);
|
||||
return {
|
||||
summary: {
|
||||
id: row.id,
|
||||
no: row.no,
|
||||
state: verdict.state,
|
||||
owner: staffRef(row.ownerId, ctx.names),
|
||||
input: await inputTextOf(row, ctx.specs),
|
||||
output: null,
|
||||
startedAt: row.startedAt,
|
||||
},
|
||||
verdict,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
async function sourceOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<InstanceSource> {
|
||||
if (row.workflowId !== null) {
|
||||
const wf = await deps.store.workflowOf(row.workflowId);
|
||||
return { kind: 'upstream', label: wf === undefined || wf.subject === '' ? '上游工作流' : `上游 · ${wf.subject}` };
|
||||
}
|
||||
const claim = await deps.store.launchOf(row.id);
|
||||
if (claim !== undefined || row.origin === 'ops') {
|
||||
const who = claim === undefined ? '运营端' : (names.get(claim.launchedBy) ?? NAME_FALLBACK);
|
||||
return { kind: 'manual', label: `手动启动 · ${who}` };
|
||||
}
|
||||
const comm = await deps.store.inboundCommOf(row.id);
|
||||
if (comm !== undefined) {
|
||||
const kind = comm.channel === '' ? 'mail' : comm.channel;
|
||||
return { kind, label: `${channelLabel(comm.channel)} · ${comm.party !== '' ? comm.party : comm.subject}` };
|
||||
}
|
||||
if (row.origin === 'testbed') return { kind: 'testbed', label: '测试台试跑' };
|
||||
return { kind: 'system', label: '系统起单' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 沙箱看板:最近一次**已放行**实例的落账快照(2026-08-27 用户定;在办的单不摆,行全从正式表圈)。
|
||||
* 案号 = 声明的前缀 + 开案时刻(与模型铸主键那串同源);逐表读经 `describe → query → catch`,一张表读不了
|
||||
* 不拖垮整屏(说一声);案号查无 → `board:state = missing`,没放行过的活动 → `none`,模版自己亮横幅。
|
||||
*/
|
||||
async function renderBoard(activityId: string, manifest: ManifestFace, html: string, spec: ViewData): Promise<string> {
|
||||
const declared = manifest.declarations.ledger;
|
||||
const instance = await deps.store.latestReleased(activityId);
|
||||
if (instance === undefined) return renderView(html, viewScopeOf({ spec, sets: {}, declared, state: 'none' }));
|
||||
const caseRef = `${spec.case.prefix}${caseStamp(instance.startedAt)}`;
|
||||
const owner = await deps.org.getStaff(staffIdSchema.parse(instance.ownerId)).then((s) => s.name, () => '');
|
||||
const read: ViewRead = async (address, where, orderBy) => {
|
||||
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||
if (described === undefined) return undefined;
|
||||
const page = await deps.ledger.query({ address, where, orderBy: [...orderBy], limit: OPTIONS_CAP, offset: 0 }).catch(() => undefined);
|
||||
return page?.rows;
|
||||
};
|
||||
const resolved = await resolveViewData(spec, { caseRef, read });
|
||||
for (const name of new Set(resolved.skipped)) warn(`[activity-ops] 沙箱看板读不到 ${name}(${activityId} · ${caseRef}):表未物化或查询失败,这一块按空摆`);
|
||||
const scope = viewScopeOf({
|
||||
spec,
|
||||
sets: resolved.sets,
|
||||
declared,
|
||||
instance: { no: instance.no, startedAt: instance.startedAt, finishedAt: instance.finishedAt, owner },
|
||||
caseRef,
|
||||
state: resolved.caseFound ? 'ready' : 'missing',
|
||||
});
|
||||
return renderView(html, scope);
|
||||
}
|
||||
|
||||
async function activityOf(activityId: string): Promise<{ card: ActivityCard; manifest: ManifestFace } | undefined> {
|
||||
const card = await deps.registry.get(activityId);
|
||||
if (card === undefined) return undefined;
|
||||
return { card, manifest: await deps.registry.manifestOf(activityId) };
|
||||
}
|
||||
|
||||
async function detailRow(row: TaskRow, manifest: ManifestFace, names: ReadonlyMap<string, string>): Promise<InstanceDetail> {
|
||||
const specs = fieldSpecsOf(manifest.contract.input.data);
|
||||
const { summary, verdict, history } = await summarizeRow(row, { names, specs });
|
||||
const taskId = taskIdSchema.parse(row.id);
|
||||
const [entries, loop, source, archived] = await Promise.all([
|
||||
history === undefined ? deps.memory.history(taskId) : Promise.resolve(history),
|
||||
deps.loopState.load(taskId),
|
||||
sourceOf(row, names),
|
||||
deps.files.listArchived(taskId),
|
||||
]);
|
||||
const llm = llmOf(manifest);
|
||||
const steps = stepsOf({
|
||||
items: loop?.parsed.items ?? [],
|
||||
runtimeItems: loop?.runtime.items ?? [],
|
||||
intents: new Map((loop?.model.steps ?? []).map((s) => [s.item, s.intent])),
|
||||
history: entries.map((e) => ({ kind: e.kind, title: e.title, at: e.at })),
|
||||
state: verdict.state,
|
||||
ownerName: summary.owner.name,
|
||||
llm,
|
||||
});
|
||||
const startedMs = Date.parse(row.startedAt);
|
||||
const finishedMs = row.finishedAt === null ? undefined : Date.parse(row.finishedAt);
|
||||
const durationMs = finishedMs !== undefined && Number.isFinite(startedMs) && Number.isFinite(finishedMs) && finishedMs >= startedMs ? finishedMs - startedMs : null;
|
||||
const inputFiles: InstanceFile[] = inputFilesOf(row.data, specs).map((f) => ({ index: f.index, name: f.name, contentType: f.contentType, size: f.size }));
|
||||
const outputFiles: InstanceFile[] = archived.map((f, index) => ({ index, name: f.name, contentType: MIME_OF[f.format], size: f.size }));
|
||||
// 详情里 `output` 是文件清单(文档 §6),清单上那一句摘要在详情里不再出现
|
||||
const { input: content, output: _outputText, ...rest } = summary;
|
||||
void _outputText;
|
||||
return {
|
||||
...rest,
|
||||
activityId: row.activityId,
|
||||
llm,
|
||||
endedAt: row.finishedAt,
|
||||
durationMs,
|
||||
reason: verdict.reason,
|
||||
input: { content, source, submittedAt: row.startedAt, files: inputFiles },
|
||||
output: { files: outputFiles },
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
async function instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined> {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return undefined;
|
||||
const row = await deps.store.getTask(activityId, instanceId);
|
||||
if (row === undefined) return undefined;
|
||||
const names = await namesOf();
|
||||
return detailRow(row, found.manifest, names);
|
||||
}
|
||||
|
||||
return {
|
||||
async listActivities(status) {
|
||||
const cards = await deps.registry.list();
|
||||
const [profiles, counts, names] = await Promise.all([deps.store.profilesOf(), deps.store.countByActivity(), namesOf()]);
|
||||
const out: ActivitySummary[] = [];
|
||||
for (const card of cards) {
|
||||
const manifest = await deps.registry.manifestOf(card.activityId);
|
||||
const summary = summaryOf(card, manifest, profiles.get(card.activityId), counts.get(card.activityId) ?? 0, names);
|
||||
if (status === undefined || summary.status === status) out.push(summary);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async getActivity(activityId) {
|
||||
const card = await deps.registry.get(activityId);
|
||||
return card === undefined ? undefined : detailOf(card);
|
||||
},
|
||||
|
||||
async viewHtml(activityId) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return { kind: 'no-activity' };
|
||||
const template = found.manifest.assets.viewTemplates.find((v) => v.name === LEDGER_TEMPLATE);
|
||||
if (template === undefined) return { kind: 'no-view' };
|
||||
const html = template.data === undefined ? template.html : await renderBoard(activityId, found.manifest, template.html, template.data);
|
||||
return { kind: 'html', html: injectHeightScript(html) };
|
||||
},
|
||||
|
||||
async listInstances(activityId, query) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return undefined;
|
||||
const specs = fieldSpecsOf(found.manifest.contract.input.data);
|
||||
const names = await namesOf();
|
||||
const rows = await deps.store.listTasks(activityId);
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
const summarized = await Promise.all(rows.map((row) => summarizeRow(row, { names, specs })));
|
||||
const all = summarized.map((s) => s.summary);
|
||||
const stats = statsOf(all);
|
||||
const filtered = applyInstanceFilters(all, query);
|
||||
const page = pageOf(filtered, query.page, query.size);
|
||||
const items = await Promise.all(
|
||||
page.map(async (summary) => {
|
||||
const row = byId.get(summary.id);
|
||||
const hit = summarized.find((s) => s.summary.id === summary.id);
|
||||
const output = row === undefined || hit === undefined ? null : await outputTextOf(row, summary.state, hit.history);
|
||||
return { ...summary, output };
|
||||
}),
|
||||
);
|
||||
return { stats, total: filtered.length, page: query.page, size: query.size, items };
|
||||
},
|
||||
|
||||
async launchParams(activityId) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return undefined;
|
||||
const params = await launchParamsOf(found.manifest.contract.input.data, resolveOptions);
|
||||
return { activityId, version: versionOf(found.card), params };
|
||||
},
|
||||
|
||||
async launch(activityId, input, by) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return { kind: 'not-found' };
|
||||
if (!found.card.enabled) return { kind: 'disabled' };
|
||||
try {
|
||||
const owner = await deps.org.getStaff(input.ownerId);
|
||||
if (owner.state !== 'active') return { kind: 'owner-invalid', message: `责任人 ${owner.name} 已停用,换一位在职的人` };
|
||||
} catch (error) {
|
||||
if (error instanceof StaffNotFoundError) return { kind: 'owner-invalid', message: '责任人不在名册里' };
|
||||
throw error;
|
||||
}
|
||||
const schema = found.manifest.contract.input.data;
|
||||
const params = await launchParamsOf(schema, resolveOptions);
|
||||
const checked = validateLaunchParams(params, input.params, fieldSpecsOf(schema));
|
||||
if (!checked.ok) return { kind: 'invalid', issues: checked.issues };
|
||||
|
||||
const claim = await deps.store.claimLaunch({ key: input.idempotencyKey, activityId, ownerId: input.ownerId, launchedBy: by.id });
|
||||
if (claim !== 'claimed') {
|
||||
if (claim.activityId !== activityId) return { kind: 'busy', message: '这枚幂等键已用在别的活动上 —— 换一枚' };
|
||||
if (claim.taskId !== null) {
|
||||
const detail = await instanceDetail(activityId, claim.taskId);
|
||||
return detail === undefined ? { kind: 'busy', message: '同一枚幂等键起的那一单已不在,换一枚键再启动' } : { kind: 'ok', detail, created: false };
|
||||
}
|
||||
const age = deps.clock.now().getTime() - Date.parse(claim.claimedAt);
|
||||
if (!(age >= LAUNCH_CLAIM_STALE_MS)) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
|
||||
const won = await deps.store.reclaimLaunch(input.idempotencyKey, LAUNCH_CLAIM_STALE_MS, input.ownerId, by.id);
|
||||
if (!won) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
|
||||
}
|
||||
|
||||
const task = await deps.task.start({ activityId, ownerId: input.ownerId, title: found.manifest.identity.name, origin: 'ops', data: checked.data });
|
||||
await deps.store.bindLaunch(input.idempotencyKey, task.id);
|
||||
const detail = await instanceDetail(activityId, task.id);
|
||||
return detail === undefined ? { kind: 'busy', message: '实例已建、暂时读不到,稍后刷新' } : { kind: 'ok', detail, created: true };
|
||||
},
|
||||
|
||||
instanceDetail,
|
||||
|
||||
async instanceFile(activityId, instanceId, io, index) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return { kind: 'no-instance' };
|
||||
const row = await deps.store.getTask(activityId, instanceId);
|
||||
if (row === undefined) return { kind: 'no-instance' };
|
||||
const taskId = taskIdSchema.parse(row.id);
|
||||
if (io === 'input') {
|
||||
const file = inputFilesOf(row.data, fieldSpecsOf(found.manifest.contract.input.data))[index];
|
||||
if (file === undefined) return { kind: 'no-file' };
|
||||
const stored = await deps.files.readUpload(file.ref);
|
||||
return stored === undefined ? { kind: 'no-file' } : { kind: 'file', name: stored.name, contentType: stored.contentType, bytes: stored.bytes };
|
||||
}
|
||||
const archived = (await deps.files.listArchived(taskId))[index];
|
||||
if (archived === undefined) return { kind: 'no-file' };
|
||||
const read = await deps.files.readArchived(taskId, archived.address, archived.key);
|
||||
return read === undefined ? { kind: 'no-file' } : { kind: 'file', name: read.name, contentType: MIME_OF[read.format], bytes: read.bytes };
|
||||
},
|
||||
|
||||
async setEnabled(activityId, enabled) {
|
||||
const card = await deps.registry.get(activityId);
|
||||
if (card === undefined) return undefined;
|
||||
if (card.enabled !== enabled) await deps.registry.setEnabled(activityId, enabled);
|
||||
const fresh = await deps.registry.get(activityId);
|
||||
return fresh === undefined ? undefined : detailOf(fresh);
|
||||
},
|
||||
|
||||
async terminate(activityId, instanceId, by) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return { kind: 'not-found' };
|
||||
const row = await deps.store.getTask(activityId, instanceId);
|
||||
if (row === undefined) return { kind: 'not-found' };
|
||||
if (row.terminal !== null) return { kind: 'terminal' };
|
||||
await deps.task.control(taskIdSchema.parse(row.id), 'terminate', by.id, `由 ${by.name} 手动终止`);
|
||||
const detail = await instanceDetail(activityId, instanceId);
|
||||
return detail === undefined ? { kind: 'not-found' } : { kind: 'ok', detail };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 运营中心 · 活动模块的 app 层存取 —— 对 `sys.task` / `sys.activity_launch` / `sys.activity_profile` /
|
||||
* `sys.workflow` / `sys.task_comm` 的 raw SQL 读(先例 `asset-archive.ts` / `upload-store.ts` / `workers/task-claim.ts`)。
|
||||
*
|
||||
* 为什么不在 task 模块加 `listByActivity`:清单按活动取、带序号与计数,是**运营面的读投影**,
|
||||
* 不是任务运行时的能力;app 是唯一能同时认识多个模块的层(编码规范 §5)。写一律仍走 port。
|
||||
* `seq` / `activity_launch` / `activity_profile` 都是手写迁移(0034 / 0035)建的,不在 Drizzle schema 里。
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
type Db = { execute(query: unknown): Promise<unknown> };
|
||||
|
||||
function rowsOf<T>(result: unknown): T[] {
|
||||
if (Array.isArray(result)) return result as T[];
|
||||
const rows = (result as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 时刻一律回 ISO 8601 UTC(契约 §0)。pg 的 timestamptz 在 raw `db.execute` 这一路可能回**文本**
|
||||
* (`2026-08-27 16:55:14.383+08`,实测),Date 与文本两种都归一;认不出的原样交出去(不编)。
|
||||
*/
|
||||
function iso(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const text = String(value);
|
||||
const ms = Date.parse(text);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : text;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** `sys.task` 的一行,运营面要的那几列 */
|
||||
export type TaskRow = {
|
||||
readonly id: string;
|
||||
/** 人读实例号 `INS-<seq>` */
|
||||
readonly no: string;
|
||||
readonly activityId: string;
|
||||
readonly workflowId: string | null;
|
||||
readonly origin: string | null;
|
||||
readonly ownerId: string;
|
||||
readonly title: string;
|
||||
readonly runtime: string;
|
||||
readonly terminal: string | null;
|
||||
readonly reason: string | null;
|
||||
readonly envelope: Record<string, unknown>;
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly usedSteps: number;
|
||||
readonly startedAt: string;
|
||||
readonly finishedAt: string | null;
|
||||
};
|
||||
|
||||
export type LaunchClaim = {
|
||||
readonly key: string;
|
||||
readonly activityId: string;
|
||||
readonly ownerId: string;
|
||||
readonly launchedBy: string;
|
||||
readonly taskId: string | null;
|
||||
readonly claimedAt: string;
|
||||
};
|
||||
|
||||
export type ActivityProfile = {
|
||||
readonly activityId: string;
|
||||
readonly ownerId: string | null;
|
||||
readonly description: string;
|
||||
readonly ico: string | null;
|
||||
readonly hue: string | null;
|
||||
};
|
||||
|
||||
export type ActivityOpsStore = {
|
||||
countByActivity(): Promise<ReadonlyMap<string, number>>;
|
||||
listTasks(activityId: string): Promise<TaskRow[]>;
|
||||
getTask(activityId: string, taskId: string): Promise<TaskRow | undefined>;
|
||||
/** 最近一次已放行的实例(沙箱看板摆它):按放行时刻倒序,没放行过回 undefined */
|
||||
latestReleased(activityId: string): Promise<TaskRow | undefined>;
|
||||
/** 占键:插得进去回 `claimed`;键已在就回那一行(判与占一句 SQL,两台后端安全) */
|
||||
claimLaunch(input: { key: string; activityId: string; ownerId: string; launchedBy: string }): Promise<'claimed' | LaunchClaim>;
|
||||
/** 键在、单没建成(task_id 空)且已过 `staleMs` → 重占;抢到回 true */
|
||||
reclaimLaunch(key: string, staleMs: number, ownerId: string, launchedBy: string): Promise<boolean>;
|
||||
bindLaunch(key: string, taskId: string): Promise<void>;
|
||||
launchOf(taskId: string): Promise<LaunchClaim | undefined>;
|
||||
profilesOf(): Promise<ReadonlyMap<string, ActivityProfile>>;
|
||||
upsertProfile(profile: ActivityProfile): Promise<void>;
|
||||
workflowOf(workflowId: string): Promise<{ subject: string; source: Record<string, unknown> } | undefined>;
|
||||
inboundCommOf(taskId: string): Promise<{ channel: string; party: string; subject: string } | undefined>;
|
||||
};
|
||||
|
||||
type RawTask = {
|
||||
id: string;
|
||||
seq: unknown;
|
||||
activity_id: string;
|
||||
workflow_id: string | null;
|
||||
origin: string | null;
|
||||
owner_id: string;
|
||||
title: string;
|
||||
runtime: string;
|
||||
terminal: string | null;
|
||||
reason: string | null;
|
||||
envelope: unknown;
|
||||
input_data: unknown;
|
||||
used_steps: unknown;
|
||||
started_at: unknown;
|
||||
finished_at: unknown;
|
||||
};
|
||||
|
||||
function taskOf(r: RawTask): TaskRow {
|
||||
return {
|
||||
id: r.id,
|
||||
no: `INS-${String(r.seq ?? '')}`,
|
||||
activityId: r.activity_id,
|
||||
workflowId: r.workflow_id,
|
||||
origin: r.origin,
|
||||
ownerId: r.owner_id,
|
||||
title: r.title,
|
||||
runtime: r.runtime,
|
||||
terminal: r.terminal,
|
||||
reason: r.reason,
|
||||
envelope: record(r.envelope),
|
||||
data: record(r.input_data),
|
||||
usedSteps: Number(r.used_steps ?? 0),
|
||||
startedAt: iso(r.started_at),
|
||||
finishedAt: r.finished_at === null || r.finished_at === undefined ? null : iso(r.finished_at),
|
||||
};
|
||||
}
|
||||
|
||||
type RawClaim = { key: string; activity_id: string; owner_id: string; launched_by: string; task_id: string | null; claimed_at: unknown };
|
||||
|
||||
function claimOf(r: RawClaim): LaunchClaim {
|
||||
return { key: r.key, activityId: r.activity_id, ownerId: r.owner_id, launchedBy: r.launched_by, taskId: r.task_id, claimedAt: iso(r.claimed_at) };
|
||||
}
|
||||
|
||||
const TASK_COLUMNS = sql`id, seq, activity_id, workflow_id, origin, owner_id, title, runtime, terminal, reason, envelope, input_data, used_steps, started_at, finished_at`;
|
||||
|
||||
export function createActivityOpsStore(db: Db): ActivityOpsStore {
|
||||
return {
|
||||
async countByActivity() {
|
||||
const rows = rowsOf<{ activity_id: string; n: unknown }>(
|
||||
await db.execute(sql`select activity_id, count(*)::int as n from sys.task group by activity_id`),
|
||||
);
|
||||
return new Map(rows.map((r) => [r.activity_id, Number(r.n ?? 0)]));
|
||||
},
|
||||
|
||||
async listTasks(activityId) {
|
||||
const rows = rowsOf<RawTask>(
|
||||
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} order by started_at desc, seq desc`),
|
||||
);
|
||||
return rows.map(taskOf);
|
||||
},
|
||||
|
||||
async getTask(activityId, taskId) {
|
||||
const rows = rowsOf<RawTask>(
|
||||
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} and id = ${taskId} limit 1`),
|
||||
);
|
||||
const one = rows[0];
|
||||
return one === undefined ? undefined : taskOf(one);
|
||||
},
|
||||
|
||||
async latestReleased(activityId) {
|
||||
const rows = rowsOf<RawTask>(
|
||||
await db.execute(sql`
|
||||
select ${TASK_COLUMNS} from sys.task
|
||||
where activity_id = ${activityId} and terminal = 'released'
|
||||
order by finished_at desc nulls last, started_at desc limit 1`),
|
||||
);
|
||||
const one = rows[0];
|
||||
return one === undefined ? undefined : taskOf(one);
|
||||
},
|
||||
|
||||
async claimLaunch({ key, activityId, ownerId, launchedBy }) {
|
||||
const inserted = rowsOf<{ key: string }>(
|
||||
await db.execute(sql`
|
||||
insert into sys.activity_launch (key, activity_id, owner_id, launched_by)
|
||||
values (${key}, ${activityId}, ${ownerId}, ${launchedBy})
|
||||
on conflict (key) do nothing
|
||||
returning key`),
|
||||
);
|
||||
if (inserted.length > 0) return 'claimed';
|
||||
const existing = rowsOf<RawClaim>(
|
||||
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where key = ${key} limit 1`),
|
||||
)[0];
|
||||
// 插不进去又读不到:另一台刚回滚 —— 当作它还在创建,让调用方按 busy 处置
|
||||
return existing === undefined ? { key, activityId, ownerId, launchedBy, taskId: null, claimedAt: new Date(0).toISOString() } : claimOf(existing);
|
||||
},
|
||||
|
||||
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
|
||||
const rows = rowsOf<{ key: string }>(
|
||||
await db.execute(sql`
|
||||
update sys.activity_launch
|
||||
set claimed_at = now(), owner_id = ${ownerId}, launched_by = ${launchedBy}
|
||||
where key = ${key} and task_id is null and claimed_at < now() - (${staleMs} || ' milliseconds')::interval
|
||||
returning key`),
|
||||
);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async bindLaunch(key, taskId) {
|
||||
await db.execute(sql`update sys.activity_launch set task_id = ${taskId} where key = ${key}`);
|
||||
},
|
||||
|
||||
async launchOf(taskId) {
|
||||
const one = rowsOf<RawClaim>(
|
||||
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where task_id = ${taskId} limit 1`),
|
||||
)[0];
|
||||
return one === undefined ? undefined : claimOf(one);
|
||||
},
|
||||
|
||||
async profilesOf() {
|
||||
const rows = rowsOf<{ activity_id: string; owner_id: string | null; description: string; ico: string | null; hue: string | null }>(
|
||||
await db.execute(sql`select activity_id, owner_id, description, ico, hue from sys.activity_profile`),
|
||||
);
|
||||
return new Map(
|
||||
rows.map((r) => [r.activity_id, { activityId: r.activity_id, ownerId: r.owner_id, description: r.description ?? '', ico: r.ico, hue: r.hue }]),
|
||||
);
|
||||
},
|
||||
|
||||
async upsertProfile(p) {
|
||||
await db.execute(sql`
|
||||
insert into sys.activity_profile (activity_id, owner_id, description, ico, hue, updated_at)
|
||||
values (${p.activityId}, ${p.ownerId}, ${p.description}, ${p.ico}, ${p.hue}, now())
|
||||
on conflict (activity_id) do update
|
||||
set owner_id = excluded.owner_id, description = excluded.description, ico = excluded.ico, hue = excluded.hue, updated_at = now()`);
|
||||
},
|
||||
|
||||
async workflowOf(workflowId) {
|
||||
const one = rowsOf<{ subject: string; source: unknown }>(await db.execute(sql`select subject, source from sys.workflow where id = ${workflowId} limit 1`))[0];
|
||||
return one === undefined ? undefined : { subject: one.subject ?? '', source: record(one.source) };
|
||||
},
|
||||
|
||||
async inboundCommOf(taskId) {
|
||||
const one = rowsOf<{ channel: string; party: string; subject: string }>(
|
||||
await db.execute(sql`select channel, party, subject from sys.task_comm where task_id = ${taskId} and direction = 'in' order by at limit 1`),
|
||||
)[0];
|
||||
return one === undefined ? undefined : { channel: one.channel ?? '', party: one.party ?? '', subject: one.subject ?? '' };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 清单上的一句话与检索 —— 输入摘要 / 输入文件 / 产出摘要 / 过滤 / 统计 / 分页,全是纯函数。
|
||||
*/
|
||||
import type { InstanceState, InstanceStats, InstanceSummary } from '@contracts';
|
||||
import { INSTANCE_STATES } from '@contracts';
|
||||
import type { FieldSpec } from './launch-params.ts';
|
||||
|
||||
export const SUMMARY_MAX = 80;
|
||||
|
||||
/** 压空白、截到一行能摆下的长度;截了才加省略号(加了等于说「后面还有」) */
|
||||
export function clip(text: string, max = SUMMARY_MAX): string {
|
||||
const one = text.replace(/\s+/g, ' ').trim();
|
||||
return one.length > max ? `${one.slice(0, max - 1)}…` : one;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入一句话:清单标了 `x-control: textarea` 的那格最像「这单是什么」(客户需求 / 本轮说明),
|
||||
* 没有就取最长的那个顶层字符串;都没有退回来件主题;再没有就空串。文件字段不算。
|
||||
*/
|
||||
export function inputSummaryOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[], fallback?: string): string {
|
||||
const fileKeys = new Set(specs.filter((s) => s.format === 'file').map((s) => s.key));
|
||||
for (const spec of specs) {
|
||||
if (spec.control !== 'textarea') continue;
|
||||
const value = data[spec.key];
|
||||
if (typeof value === 'string' && value.trim() !== '') return clip(value);
|
||||
}
|
||||
let longest = '';
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (fileKeys.has(key) || typeof value !== 'string') continue;
|
||||
if (value.trim().length > longest.trim().length) longest = value;
|
||||
}
|
||||
if (longest.trim() !== '') return clip(longest);
|
||||
return fallback === undefined ? '' : clip(fallback);
|
||||
}
|
||||
|
||||
export type InputFileRef = { readonly index: number; readonly name: string; readonly contentType: string; readonly size: number; readonly ref: string };
|
||||
|
||||
/** `format: 'file'` 的字段里那些 `{name, contentType, size, ref}`;`index` 跨字段连续编号 */
|
||||
export function inputFilesOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[]): InputFileRef[] {
|
||||
const out: InputFileRef[] = [];
|
||||
for (const spec of specs) {
|
||||
if (spec.format !== 'file') continue;
|
||||
const raw = data[spec.key];
|
||||
const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||
for (const one of list) {
|
||||
if (typeof one !== 'object' || one === null) continue;
|
||||
const file = one as Record<string, unknown>;
|
||||
const ref = file['ref'];
|
||||
if (typeof ref !== 'string' || ref === '') continue;
|
||||
const size = file['size'];
|
||||
out.push({
|
||||
index: out.length,
|
||||
name: typeof file['name'] === 'string' ? file['name'] : '未命名',
|
||||
contentType: typeof file['contentType'] === 'string' ? file['contentType'] : 'application/octet-stream',
|
||||
size: typeof size === 'number' && Number.isFinite(size) ? size : 0,
|
||||
ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 产出一句话:有存档文件摆文件名;没有就摆发了哪几口;都没有 = null(前端摆 `-`) */
|
||||
export function outputSummaryOf(archivedNames: readonly string[], firedPorts: readonly string[]): string | null {
|
||||
if (archivedNames.length > 0) return clip(archivedNames.join('、'));
|
||||
if (firedPorts.length > 0) return clip(`已发口:${firedPorts.join('、')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export type InstanceQuery = {
|
||||
readonly id?: string | undefined;
|
||||
readonly ownerId?: string | undefined;
|
||||
readonly state?: InstanceState | undefined;
|
||||
readonly startedFrom?: string | undefined;
|
||||
readonly startedTo?: string | undefined;
|
||||
};
|
||||
|
||||
function dayBound(day: string, end: boolean): number | undefined {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
|
||||
if (m === null) return undefined;
|
||||
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
// 服务进程的本地时区:单时区部署;「含两端整日」
|
||||
return end ? new Date(y, mo - 1, d, 23, 59, 59, 999).getTime() : new Date(y, mo - 1, d, 0, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
/** 四条件 AND;`id` 模糊对实例号与任务 id 的子串(不分大小写) */
|
||||
export function applyInstanceFilters(items: readonly InstanceSummary[], q: InstanceQuery): InstanceSummary[] {
|
||||
const needle = q.id?.trim().toLowerCase() ?? '';
|
||||
const from = q.startedFrom === undefined ? undefined : dayBound(q.startedFrom, false);
|
||||
const to = q.startedTo === undefined ? undefined : dayBound(q.startedTo, true);
|
||||
return items.filter((it) => {
|
||||
if (needle !== '' && !it.no.toLowerCase().includes(needle) && !it.id.toLowerCase().includes(needle)) return false;
|
||||
if (q.ownerId !== undefined && q.ownerId !== '' && it.owner.id !== q.ownerId) return false;
|
||||
if (q.state !== undefined && it.state !== q.state) return false;
|
||||
if (from !== undefined || to !== undefined) {
|
||||
const t = Date.parse(it.startedAt);
|
||||
if (!Number.isFinite(t)) return false;
|
||||
if (from !== undefined && t < from) return false;
|
||||
if (to !== undefined && t > to) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function statsOf(items: readonly InstanceSummary[]): InstanceStats {
|
||||
const byState = Object.fromEntries(INSTANCE_STATES.map((s) => [s, 0])) as Record<InstanceState, number>;
|
||||
for (const it of items) byState[it.state] += 1;
|
||||
return { total: items.length, byState };
|
||||
}
|
||||
|
||||
export function pageOf<T>(items: readonly T[], page: number, size: number): T[] {
|
||||
const start = (Math.max(1, page) - 1) * Math.max(1, size);
|
||||
return items.slice(start, start + Math.max(1, size));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* L2 沙箱视图 —— 活动包里名为 `activity-ledger` 的视图模版(关键台账数据看板:最近一次已放行实例的落账快照,
|
||||
* 行由基座按清单 `viewTemplates[].data` 的声明从正式表圈出,见 `view-scope.ts`)。
|
||||
*
|
||||
* 模版本身**无脚本**(安装静态审 `templateIsSafe` 拦着);而 iframe 是 `sandbox="allow-scripts"`
|
||||
* 无同源,父页量不到它的高 —— 报高的脚本由服务端在**出口那一刻**注入,协议只有一条消息:
|
||||
* `postMessage({ type: 'jcai:view-height', height }, '*')`(load + ResizeObserver + 1s 兜底)。
|
||||
*/
|
||||
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
|
||||
|
||||
export const LEDGER_TEMPLATE = 'activity-ledger';
|
||||
|
||||
export function heightScript(): string {
|
||||
return (
|
||||
`<script>(function(){var T=${JSON.stringify(ACTIVITY_VIEW_HEIGHT_MESSAGE)};` +
|
||||
'function h(){var b=document.body;return Math.max(document.documentElement.scrollHeight,b?b.scrollHeight:0)}' +
|
||||
"function post(){try{window.parent.postMessage({type:T,height:h()},'*')}catch(e){}}" +
|
||||
"window.addEventListener('load',post);" +
|
||||
"if(typeof ResizeObserver!=='undefined'){new ResizeObserver(post).observe(document.documentElement)}" +
|
||||
'setInterval(post,1000);post()})();</script>'
|
||||
);
|
||||
}
|
||||
|
||||
/** 插在 `</body>` 之前(没有就追加在末尾);模版原文一个字不改 */
|
||||
export function injectHeightScript(html: string): string {
|
||||
const script = heightScript();
|
||||
const at = html.toLowerCase().lastIndexOf('</body>');
|
||||
return at < 0 ? `${html}\n${script}` : `${html.slice(0, at)}${script}${html.slice(at)}`;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 沙箱看板的取数与作用域(零 IO;读表经注入)—— 执行清单 `viewTemplates[].data` 那份声明。
|
||||
*
|
||||
* 分工:模版归活动、「哪几行是这一单的」归活动声明(案号前缀、哪张表按哪一列圈),这里只做两件机械的事:
|
||||
* ① `resolveViewData`:按声明逐条求集合 —— `by.column ∈ values(by.from)`,`from` 只能指 `case`
|
||||
* (案号本身)或前面已求出的 `<as>.<列>`;每个值单独查一次(正式表里历次运行的行并排躺着,
|
||||
* `where` 只有等值),结果按 `from` 值的顺序拼接,再按 `not` 排除;
|
||||
* ② `viewScopeOf`:把行集摊成渲染器要的扁平作用域 —— `<as>` 行数组、`<as>.<列>` 首行标量、
|
||||
* `<as 去符号>_rows` 行数、`<as 去符号>_<列>_<值>` tally 计数(先铺 0,与引擎读数同一套命名习惯)、
|
||||
* `case:*` 实例事实、`board:state` 三态(模版拿它亮横幅 / 藏区块)。
|
||||
*
|
||||
* 表未物化 / 查询失败 → 那个集合为空并记在 `skipped`(看板不因一张表 500);案号那一行查无 → `caseFound=false`
|
||||
* (多半是起单时关了「落真库」,主数据只留在活动缓存),看板如实说「没落账」而不是摆一屏空表装没事。
|
||||
*/
|
||||
import type { ViewData, ViewDataRows } from '@contracts';
|
||||
import { VIEW_DATA_CASE_REF } from '@contracts';
|
||||
|
||||
export type ViewRow = Readonly<Record<string, unknown>>;
|
||||
export type ViewAddress = { readonly domain: string; readonly object: string };
|
||||
export type ViewOrder = ReadonlyArray<{ readonly field: string; readonly dir: 'asc' | 'desc' }>;
|
||||
/** 读一张正式表(等值过滤);表读不了回 undefined —— 由调用方决定怎么说 */
|
||||
export type ViewRead = (address: ViewAddress, where: Readonly<Record<string, string>>, orderBy: ViewOrder) => Promise<readonly ViewRow[] | undefined>;
|
||||
|
||||
export type ViewSets = Readonly<Record<string, readonly ViewRow[]>>;
|
||||
export type ResolvedViewData = { readonly sets: ViewSets; readonly caseFound: boolean; readonly skipped: readonly string[] };
|
||||
|
||||
export type BoardState = 'ready' | 'missing' | 'none';
|
||||
export const BOARD_STATE_KEY = 'board:state';
|
||||
|
||||
export type DeclaredLedger = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }>;
|
||||
};
|
||||
|
||||
export type ViewInstance = {
|
||||
readonly no: string;
|
||||
readonly startedAt: string;
|
||||
readonly finishedAt: string | null;
|
||||
readonly owner: string;
|
||||
};
|
||||
|
||||
export function addressOf(ledger: string): ViewAddress | undefined {
|
||||
const at = ledger.indexOf('.');
|
||||
if (at <= 0 || at === ledger.length - 1) return undefined;
|
||||
return { domain: ledger.slice(0, at), object: ledger.slice(at + 1) };
|
||||
}
|
||||
|
||||
/** `<as>.<列>` 从最后一个点拆(列名是标识符,不含点;`as` 本身可以带点) */
|
||||
function refOf(from: string): { as: string; column: string } | undefined {
|
||||
const at = from.lastIndexOf('.');
|
||||
if (at <= 0 || at === from.length - 1) return undefined;
|
||||
return { as: from.slice(0, at), column: from.slice(at + 1) };
|
||||
}
|
||||
|
||||
/** 一列的值集:去空、转字符串、去重、保序 */
|
||||
function valuesOf(rows: readonly ViewRow[], column: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const row of rows) {
|
||||
const raw = row[column];
|
||||
if (raw === undefined || raw === null || raw === '') continue;
|
||||
const text = String(raw);
|
||||
if (seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
out.push(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sourceValues(from: string, caseRef: string, sets: Record<string, readonly ViewRow[]>): string[] | undefined {
|
||||
if (from === VIEW_DATA_CASE_REF) return [caseRef];
|
||||
const ref = refOf(from);
|
||||
if (ref === undefined) return undefined;
|
||||
const rows = sets[ref.as];
|
||||
return rows === undefined ? undefined : valuesOf(rows, ref.column);
|
||||
}
|
||||
|
||||
export async function resolveViewData(spec: ViewData, io: { readonly caseRef: string; readonly read: ViewRead }): Promise<ResolvedViewData> {
|
||||
const sets: Record<string, readonly ViewRow[]> = {};
|
||||
const skipped: string[] = [];
|
||||
const caseAddress = addressOf(spec.case.ledger);
|
||||
const caseRows = caseAddress === undefined ? undefined : await io.read(caseAddress, { [spec.case.key]: io.caseRef }, []);
|
||||
const caseFound = (caseRows?.length ?? 0) > 0;
|
||||
if (caseRows === undefined) skipped.push(spec.case.ledger);
|
||||
for (const entry of spec.rows) {
|
||||
sets[entry.as] = await resolveEntry(entry, io.caseRef, sets, io.read, skipped);
|
||||
}
|
||||
return { sets, caseFound, skipped };
|
||||
}
|
||||
|
||||
async function resolveEntry(entry: ViewDataRows, caseRef: string, sets: Record<string, readonly ViewRow[]>, read: ViewRead, skipped: string[]): Promise<readonly ViewRow[]> {
|
||||
const address = addressOf(entry.ledger);
|
||||
const values = sourceValues(entry.by.from, caseRef, sets);
|
||||
if (address === undefined || values === undefined) {
|
||||
skipped.push(entry.as);
|
||||
return [];
|
||||
}
|
||||
if (values.length === 0) return [];
|
||||
const orderBy = entry.orderBy ?? [];
|
||||
const out: ViewRow[] = [];
|
||||
for (const value of values) {
|
||||
const rows = await read(address, { [entry.by.column]: value }, orderBy);
|
||||
if (rows === undefined) {
|
||||
skipped.push(entry.as);
|
||||
return [];
|
||||
}
|
||||
out.push(...rows);
|
||||
}
|
||||
if (entry.not === undefined) return out;
|
||||
const banned = new Set(sourceValues(entry.not.from, caseRef, sets) ?? []);
|
||||
const notColumn = entry.not.column;
|
||||
return out.filter((row) => {
|
||||
const raw = row[notColumn];
|
||||
return raw === undefined || raw === null || !banned.has(String(raw));
|
||||
});
|
||||
}
|
||||
|
||||
/** 与引擎读数同一套习惯:非字母数字一律下划线(`sub_eval.verdict` → `sub_eval_verdict`) */
|
||||
export function slugOf(as: string): string {
|
||||
return as.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||||
}
|
||||
|
||||
/** 屏上摆的时刻:本地、到分、带年(看板隔着日子看,「08-27 21:51」不够) */
|
||||
export function localMinute(iso: string | null): string {
|
||||
if (iso === null) return '';
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return '';
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function viewScopeOf(input: {
|
||||
readonly spec: ViewData | undefined;
|
||||
readonly sets: ViewSets;
|
||||
readonly declared: ReadonlyArray<DeclaredLedger>;
|
||||
readonly instance?: ViewInstance | undefined;
|
||||
readonly caseRef?: string | undefined;
|
||||
readonly state: BoardState;
|
||||
}): Record<string, unknown> {
|
||||
const scope: Record<string, unknown> = {
|
||||
[BOARD_STATE_KEY]: input.state,
|
||||
'case:instanceNo': input.instance?.no ?? '',
|
||||
'case:openedAt': input.instance === undefined ? '' : localMinute(input.instance.startedAt),
|
||||
'case:releasedAt': input.instance === undefined ? '' : localMinute(input.instance.finishedAt),
|
||||
'case:owner': input.instance?.owner ?? '',
|
||||
'case:ref': input.caseRef ?? '',
|
||||
};
|
||||
for (const entry of input.spec?.rows ?? []) {
|
||||
const rows = input.sets[entry.as] ?? [];
|
||||
const slug = slugOf(entry.as);
|
||||
scope[entry.as] = rows;
|
||||
scope[`${slug}_rows`] = rows.length;
|
||||
const first = rows[0];
|
||||
if (first !== undefined) for (const [column, value] of Object.entries(first)) scope[`${entry.as}.${column}`] = value;
|
||||
const address = addressOf(entry.ledger);
|
||||
const declaration = address === undefined ? undefined : input.declared.find((d) => d.domain === address.domain && d.object === address.object);
|
||||
for (const field of declaration?.fields ?? []) {
|
||||
if (field.tally === undefined) continue;
|
||||
for (const value of field.tally) scope[`${slug}_${field.name}_${slugOf(value)}`] = 0;
|
||||
for (const row of rows) {
|
||||
const raw = row[field.name];
|
||||
if (raw === undefined || raw === null || raw === '') continue;
|
||||
const key = `${slug}_${field.name}_${slugOf(String(raw))}`;
|
||||
scope[key] = Number(scope[key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 运营中心 · 活动模块路由(`docs/API文档-活动模块.md`)。归属在契约的 access 上(全 ops),中间件统一判;
|
||||
* 这里只做 zod 校验、调用用例、把结果翻成状态码。写路由的广播由 `/api/gov/*` 中间件自动发。
|
||||
*/
|
||||
import type { Context, Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import {
|
||||
activateOpsActivityRoute,
|
||||
disableOpsActivityRoute,
|
||||
getOpsActivityRoute,
|
||||
getOpsInstanceRoute,
|
||||
launchOpsInstanceRoute,
|
||||
listOpsActivitiesRoute,
|
||||
listOpsInstancesRoute,
|
||||
opsActivityViewRoute,
|
||||
opsInstanceFileRoute,
|
||||
opsLaunchParamsRoute,
|
||||
terminateOpsInstanceRoute,
|
||||
} from '@contracts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { staffOf } from '../http-env.ts';
|
||||
import type { ActivityOps } from '../activity-ops/service.ts';
|
||||
|
||||
export type ActivityRouteDeps = { readonly activityOps: ActivityOps };
|
||||
|
||||
function invalid(c: Context<AppEnv>, message: string, detail: unknown): Response {
|
||||
const body: ApiError = { code: 'validation-failed', message, detail };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
|
||||
function notFound(c: Context<AppEnv>, message: string): Response {
|
||||
const body: ApiError = { code: 'not-found', message };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
|
||||
function precondition(c: Context<AppEnv>, message: string): Response {
|
||||
const body: ApiError = { code: 'precondition-failed', message };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
|
||||
function unauthenticated(c: Context<AppEnv>): Response {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '这一步要知道是谁按的 —— 请求没带身份' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
|
||||
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
|
||||
const text = await c.req.text();
|
||||
if (text.trim() === '') return {};
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerActivityRoutes(app: Hono<AppEnv>, deps: ActivityRouteDeps): void {
|
||||
const { activityOps } = deps;
|
||||
// 通用 Context 上 param 是 string | undefined;路径模版里有这一段,空串只会落成 404
|
||||
const activityId = (c: Context<AppEnv>): string => c.req.param('activityId') ?? '';
|
||||
const instanceId = (c: Context<AppEnv>): string => c.req.param('instanceId') ?? '';
|
||||
|
||||
/* ── L1 ── */
|
||||
|
||||
app.get(listOpsActivitiesRoute.path, async (c) => {
|
||||
const query = listOpsActivitiesRoute.query.safeParse(c.req.query());
|
||||
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
|
||||
return c.json({ activities: await activityOps.listActivities(query.data.status) });
|
||||
});
|
||||
|
||||
/* ── L2 ── */
|
||||
|
||||
app.get(getOpsActivityRoute.path, async (c) => {
|
||||
const detail = await activityOps.getActivity(activityId(c));
|
||||
return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail);
|
||||
});
|
||||
|
||||
app.get(opsActivityViewRoute.path, async (c) => {
|
||||
const view = await activityOps.viewHtml(activityId(c));
|
||||
if (view.kind === 'no-activity') return notFound(c, `活动不存在:${activityId(c)}`);
|
||||
if (view.kind === 'no-view') return notFound(c, '该活动没配视图');
|
||||
return c.html(view.html, 200, { 'cache-control': 'no-store' });
|
||||
});
|
||||
|
||||
app.get(listOpsInstancesRoute.path, async (c) => {
|
||||
const query = listOpsInstancesRoute.query.safeParse(c.req.query());
|
||||
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
|
||||
const list = await activityOps.listInstances(activityId(c), query.data);
|
||||
return list === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(list);
|
||||
});
|
||||
|
||||
/* ── 启动 ── */
|
||||
|
||||
app.get(opsLaunchParamsRoute.path, async (c) => {
|
||||
const table = await activityOps.launchParams(activityId(c));
|
||||
return table === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(table);
|
||||
});
|
||||
|
||||
app.post(launchOpsInstanceRoute.path, async (c) => {
|
||||
const parsed = launchOpsInstanceRoute.body.safeParse(await jsonBody(c));
|
||||
if (!parsed.success) return invalid(c, '启动入参不合法', parsed.error.issues);
|
||||
const me = staffOf(c);
|
||||
if (me === undefined) return unauthenticated(c);
|
||||
const outcome = await activityOps.launch(activityId(c), parsed.data, { id: me.id, name: me.name });
|
||||
switch (outcome.kind) {
|
||||
case 'ok':
|
||||
return c.json(outcome.detail);
|
||||
case 'not-found':
|
||||
return notFound(c, `活动不存在:${activityId(c)}`);
|
||||
case 'disabled':
|
||||
return precondition(c, '活动已禁用,先激活再启动');
|
||||
case 'invalid':
|
||||
return invalid(c, `启动参数不合要求:${outcome.issues.map((i) => `${i.key} ${i.message}`).join('; ')}`, outcome.issues);
|
||||
case 'owner-invalid':
|
||||
return invalid(c, outcome.message, [{ key: 'ownerId', message: outcome.message }]);
|
||||
case 'busy': {
|
||||
const body: ApiError = { code: 'conflict', message: outcome.message };
|
||||
return c.json(body, 409);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── L3 ── */
|
||||
|
||||
app.get(getOpsInstanceRoute.path, async (c) => {
|
||||
const detail = await activityOps.instanceDetail(activityId(c), instanceId(c));
|
||||
return detail === undefined ? notFound(c, `实例不存在:${instanceId(c)}`) : c.json(detail);
|
||||
});
|
||||
|
||||
app.get(opsInstanceFileRoute.path, async (c) => {
|
||||
const params = opsInstanceFileRoute.params.safeParse({
|
||||
activityId: activityId(c),
|
||||
instanceId: instanceId(c),
|
||||
io: c.req.param('io'),
|
||||
index: c.req.param('index'),
|
||||
});
|
||||
if (!params.success) return invalid(c, '文件序号不合法', params.error.issues);
|
||||
const file = await activityOps.instanceFile(params.data.activityId, params.data.instanceId, params.data.io, params.data.index);
|
||||
if (file.kind === 'no-instance') return notFound(c, `实例不存在:${params.data.instanceId}`);
|
||||
if (file.kind === 'no-file') return notFound(c, '这个文件不在了');
|
||||
if (c.req.query('raw') === '1') {
|
||||
return c.body(file.bytes.slice().buffer as ArrayBuffer, 200, {
|
||||
'content-type': file.contentType,
|
||||
// 文件名走 RFC 5987:中文名在各家浏览器上才落得对
|
||||
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||
});
|
||||
}
|
||||
return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') });
|
||||
});
|
||||
|
||||
/* ── 禁用 / 激活 / 终止 ── */
|
||||
|
||||
const setEnabled = (enabled: boolean) => async (c: Context<AppEnv>) => {
|
||||
const detail = await activityOps.setEnabled(activityId(c), enabled);
|
||||
return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail);
|
||||
};
|
||||
app.post(disableOpsActivityRoute.path, setEnabled(false));
|
||||
app.post(activateOpsActivityRoute.path, setEnabled(true));
|
||||
|
||||
app.post(terminateOpsInstanceRoute.path, async (c) => {
|
||||
const me = staffOf(c);
|
||||
if (me === undefined) return unauthenticated(c);
|
||||
const outcome = await activityOps.terminate(activityId(c), instanceId(c), { id: me.id, name: me.name });
|
||||
if (outcome.kind === 'not-found') return notFound(c, `实例不存在:${instanceId(c)}`);
|
||||
if (outcome.kind === 'terminal') return precondition(c, '实例已终态,不能再终止');
|
||||
return c.json(outcome.detail);
|
||||
});
|
||||
}
|
||||
@@ -41,7 +41,11 @@
|
||||
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
|
||||
- **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完
|
||||
|
||||
近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities` 出参加 `contract` 面
|
||||
近变(2026-08-27):模块面未变。app 层那两条路由改名 `GET/POST /api/gov/activity-packages`(从前叫 `/api/gov/activities`),
|
||||
`/api/gov/activities/*` 整个让给运营中心的活动模块(L1/L2/L3,`packages/contracts/src/api/activity-ops.ts`);
|
||||
运营端的禁用 / 激活走本模块既有的 `setEnabled`(从前只有 seed 调它,没有 HTTP 口)。
|
||||
|
||||
近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities`(现 `/api/gov/activity-packages`)出参加 `contract` 面
|
||||
(必填入参 / 入参字段 / 输出口与载荷字段 / 声明的工具·模型·域对象·岗位 / 调人预算),
|
||||
由 `manifestOf` 逐个读清单组装。此前治理台看不到活动的契约:
|
||||
编排的人引用一个活动却不知道它吃什么,边映射只能靠猜;
|
||||
|
||||
@@ -76,6 +76,7 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
||||
|
||||
const verdict: InstallVerdict = {
|
||||
accepted: mayTakeEffect(staticOk, replay),
|
||||
stored: false,
|
||||
staticFindings: findings,
|
||||
replay: {
|
||||
oldPassStillPass: replay.oldPassStillPass,
|
||||
@@ -90,6 +91,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
||||
|
||||
// 安装恒为新增、版本并存;在途任务按其启动时版本跑完
|
||||
const isNewPackageVersion = await deps.store.savePackage(manifest, artifact);
|
||||
// 同版重装 = 什么都没写:如实回给调用方,别让安装脚本再打印「已装」
|
||||
verdict.stored = isNewPackageVersion;
|
||||
|
||||
/*
|
||||
* 资产初始版随包播种。
|
||||
|
||||
Reference in New Issue
Block a user