Archived
四态与推荐等级现在活在 S2 配方的 when 链里(spec §3.5)。 再留一份 DMN 或一枚工具,就是同一件事两处各存一份,改一处就分叉而没人看得出以哪份为准。 技能戳是给模型看的,而这个活动全程没有 agent。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
206 lines
11 KiB
TypeScript
206 lines
11 KiB
TypeScript
/**
|
|
* 真活动包的安装 —— **经安装通道读文件,不经代码通道**(ADR-004 / 编码规范 §5.1)。
|
|
*
|
|
* 活动包在系统外开发:`activities/<pkg>/` 里写清单与资产,`pnpm activities:build`
|
|
* 产出 `dist/{manifest.json,index.js}`。api 只读这两个文件 —— 与 activities 之间
|
|
* 只有安装通道,没有 import(depcruise 的 `apps-not-into-activities` 在守)。
|
|
*
|
|
* 这就是「系统外开发、安装即用」的机器形态:装的时候过两审(静态审读清单、
|
|
* 回放审重演历史),清单里的台账声明顺带把表物化出来,资产随包播种。
|
|
*
|
|
* 装不上不掀翻播种:如实报出来。活动包是可选件 —— 没构建产物就是没装,
|
|
* 不是数据库坏了(而假装装上了才是真的坏)。
|
|
*/
|
|
import { readFile } from 'node:fs/promises';
|
|
import { join, resolve } from 'node:path';
|
|
import { activityManifestSchema } from '@contracts';
|
|
import type { Container } from './container.ts';
|
|
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
|
import { createActivityOpsStore } from './activity-ops/store.ts';
|
|
|
|
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
|
/*
|
|
* 真活动包 —— 从 `activities/<pkg>/dist/` 读文件装入,**不经 import**。
|
|
* A2/A3 于 2026-07-31 转正:它们的案例模型已简化为「一张执行单办完」,
|
|
* 剧本与 A1 同一套机制,不再是治理台里那两个 viewTemplates 为空的占位槽。
|
|
*/
|
|
/*
|
|
* 2026-08-24 用户定:**只装 E04 / I01**,A1–A7 报价链六个包从系统里卸载(源码留着,
|
|
* 只是不再播种、不再安装;库里已装的版本已置 uninstalled)。要装回来把名字加回这里
|
|
* 再跑 activities:install 即可 —— 它们的 dist 照旧随 activities:build 产出。
|
|
* 退役名单:quote-intake · quote-alternative · quote-sourcing · quote-pricing ·
|
|
* quote-negotiate · quote-archive
|
|
* 2026-08-26 加 ACT-WORK-05(part-substitution-assessment):差异评估活动开发完成,随本轮登记进安装通道。
|
|
*/
|
|
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit', 'part-substitution-assessment'] as const;
|
|
|
|
/**
|
|
* 运营侧活动档案(2026-08-27 用户定:负责人 / 说明 / 图标 / 色相另建 `sys.activity_profile`,活动包不载这些)。
|
|
* 按包目录名登记;负责人按**岗位**挑第一个在职的人 —— 员工 id 是 Keycloak 的 sub、首登即建,
|
|
* seed 拿不到真 sub,只能按岗位现查;**查不到就留空并说一声**,不编一个(界面不撒谎)。
|
|
* 重复播种是覆盖不是堆叠;运营面的改口另起(见 debt)。
|
|
*/
|
|
const ACTIVITY_PROFILES: Readonly<Record<(typeof REAL_PACKAGES)[number], { description: string; ico: string; hue: string; ownerPosition: string }>> = {
|
|
'crm-relationship-coverage': {
|
|
description: '按客户线月度 / 事件驱动地评估关系覆盖度,识别关键人缺口,编排经营计划并回收执行回执。',
|
|
ico: 'target',
|
|
hue: 'blue',
|
|
ownerPosition: 'BD',
|
|
},
|
|
'brand-content-toolkit': {
|
|
description: '受理品牌形象与销售工具包的内容需求,产出初稿交审核,发布后入品牌内容库。',
|
|
ico: 'palette',
|
|
hue: 'amber',
|
|
ownerPosition: 'OP',
|
|
},
|
|
'part-substitution-assessment': {
|
|
description: '对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。',
|
|
ico: 'git-compare',
|
|
hue: 'teal',
|
|
ownerPosition: 'FAE',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 活动包根目录。
|
|
*
|
|
* 播种以 `apps/api` 为 cwd 跑(`pnpm --filter @app/api seed`),所以默认往上两级找;
|
|
* 容器化或换布局时用 `JCAI_ACTIVITIES_DIR` 顶掉 —— 路径是部署事实,不该写死在代码里。
|
|
*/
|
|
function activitiesRoot(): string {
|
|
const override = process.env['JCAI_ACTIVITIES_DIR'];
|
|
return override === undefined || override.trim() === ''
|
|
? resolve(process.cwd(), '..', '..', 'activities')
|
|
: resolve(override);
|
|
}
|
|
|
|
export async function installRealActivities(container: Container): Promise<void> {
|
|
const log = (line: string) => process.stdout.write(line + '\n');
|
|
|
|
/*
|
|
* **主数据先于活动物化**(2026-07-31)。
|
|
*
|
|
* 客户 / 项目 / 供应商 / 市场四域不挂在任何活动名下:主数据不是谁的产出,
|
|
* 它先于活动存在、活动退役了它还在,活动只是读它做判断、提议更新它。
|
|
* 挂在活动清单下的后果刚踩过 —— 那个活动一退役,它声明的表就成了没人认领的空壳。
|
|
*
|
|
* 放在装活动**之前**:活动声明里若也提到同一张表(它要写的那几列),
|
|
* 声明合并是「最大化合并、只增不破坏」,平台给的全量结构先落地,活动只补自己那部分。
|
|
*/
|
|
const applied = await container.ledger.applyDeclarations(MASTER_OBJECTS);
|
|
const conflicts = applied.flatMap((r) => r.conflicts);
|
|
log(
|
|
` 主数据 ${MASTER_DOMAINS.length} 域已物化:${MASTER_OBJECTS.length} 张表` +
|
|
// 冲突要如实报:静默吞掉的话,表结构和声明对不上而没人知道
|
|
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
|
|
);
|
|
|
|
const root = activitiesRoot();
|
|
|
|
for (const dir of REAL_PACKAGES) {
|
|
const dist = join(root, dir, 'dist');
|
|
let manifestJson: string;
|
|
let entryJs: string;
|
|
try {
|
|
[manifestJson, entryJs] = await Promise.all([
|
|
readFile(join(dist, 'manifest.json'), 'utf8'),
|
|
readFile(join(dist, 'index.js'), 'utf8'),
|
|
]);
|
|
} catch {
|
|
log(` 活动包 ${dir} 没有构建产物(${dist})—— 先跑 pnpm activities:build;本次跳过`);
|
|
continue;
|
|
}
|
|
|
|
const parsed = activityManifestSchema.safeParse(JSON.parse(manifestJson));
|
|
if (!parsed.success) {
|
|
// 清单不合协议就别装:装进去的残清单会在起单那一刻才炸,那时更难查
|
|
log(` 活动包 ${dir} 的清单不合协议:${parsed.error.issues.map((i) => `${i.path.join('.')} ${i.message}`).join('; ')}`);
|
|
continue;
|
|
}
|
|
|
|
/*
|
|
* 安装恒新增、版本并存:同版本重装是 no-op(静默丢失 —— 踩过)。
|
|
* 所以改了内容必须在包里升 `identity.packageVersion`,不然播种看着成功、内容还是旧的。
|
|
*/
|
|
try {
|
|
const verdict = await container.registry.install(parsed.data, entryJs);
|
|
const version = parsed.data.identity.packageVersion;
|
|
/*
|
|
* **装的时候把声明的工具一并授权**(2026-07-31 补)。
|
|
*
|
|
* 从前授权只在 `seed-mvp` 里发 —— 那是治理台的整套配置播种。
|
|
* 于是经安装通道装进来的真活动包一件工具也调不动:A4 的说明书让它去取历史价,
|
|
* 清单里也白纸黑字声明了 `crm.history-price-read`,运行时却拒回
|
|
* 「工具不在本活动白名单」,连拒五拍熔断(实测 A4 死于此)。
|
|
* 「系统外开发、安装即用」里的「即用」,包含它声明过的那些工具能调得动;
|
|
* 声明了却不授权,等于装了一个结构上办不成事的活动。
|
|
*
|
|
* 授权面**就是清单声明**,不多给一件:越权在结构上仍然不可能。
|
|
*/
|
|
const declared = parsed.data.declarations.tools.map((t) => t.name);
|
|
await container.toolbox.setAllowlist(parsed.data.identity.activityId, declared);
|
|
await seedProfile(container, dir, parsed.data.identity.activityId, log);
|
|
/*
|
|
* **同版重装不许说「已装」**(2026-08-27,一天里静默丢了两次):两审过了、版本已在库,
|
|
* 库里一个字没动 —— 这一句话就是人判断「改动上没上」的唯一依据,说错就是误导。
|
|
*/
|
|
if (!verdict.stored) {
|
|
log(
|
|
` 活动包已是这一版,未重装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
|
|
' —— 改了内容要升 packageVersion 再装(同版重装是 no-op,库里仍是先前那份)',
|
|
);
|
|
continue;
|
|
}
|
|
log(
|
|
` 活动包已装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
|
|
(declared.length === 0 ? '' : ` · 已授权工具 ${declared.length} 件`) +
|
|
(verdict.staticFindings.length === 0
|
|
? ''
|
|
: ` —— 静态审提示:${verdict.staticFindings.map((f) => `[${f.severity}] ${f.message}`).join('; ')}`),
|
|
);
|
|
} catch (error) {
|
|
log(` 活动包 ${dir} 未过安装两审:${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* **样例客户 —— 装完全部活动包之后再播种**(2026-08-20 改序,踩过坑):
|
|
* `MASTER_SAMPLE_ROWS` 里带着 E04 追加的列(`cust_type`/`decision_chain`…)——
|
|
* 这些列要等 `crm-relationship-coverage` 装进 `REAL_PACKAGES` 循环、
|
|
* 它的台账声明经 `applyDeclarations` 做过 `add-column` 之后才真的存在于物理表。
|
|
* 原来放在装活动**之前**播种,种到这些新列时 Postgres 直接报
|
|
* `column "cust_type" of relation "customer__profile" does not exist`——
|
|
* 主数据物化(平台基线列)在活动之前没错,但样例数据要用到**活动追加的列**时,
|
|
* 必须排在对应活动装完之后。按主键写,重复播种是覆盖不是堆叠;写正式版
|
|
* (不是草稿层),因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。
|
|
*/
|
|
for (const row of MASTER_SAMPLE_ROWS) {
|
|
await container.ledger.systemWrite({
|
|
address: { domain: row.address.domain, object: row.address.object },
|
|
key: { ...row.key },
|
|
values: { ...row.values },
|
|
cause: { kind: 'governance', ref: 'seed:master-sample' },
|
|
});
|
|
}
|
|
log(` 样例主数据已就位:${MASTER_SAMPLE_ROWS.length} 行(客户 / 项目 / 联系人 / 料号)`);
|
|
}
|
|
|
|
/** 运营侧档案落库:说明 / 图标 / 色相照表,负责人按岗位现查(取不到留空,如实报) */
|
|
async function seedProfile(container: Container, dir: (typeof REAL_PACKAGES)[number], activityId: string, log: (line: string) => void): Promise<void> {
|
|
const spec = ACTIVITY_PROFILES[dir];
|
|
const candidates = await container.org.staffOfPosition(spec.ownerPosition).catch(() => []);
|
|
const owner = candidates[0];
|
|
await createActivityOpsStore(container.db).upsertProfile({
|
|
activityId,
|
|
ownerId: owner === undefined ? null : owner.id,
|
|
description: spec.description,
|
|
ico: spec.ico,
|
|
hue: spec.hue,
|
|
});
|
|
log(
|
|
owner === undefined
|
|
? ` 活动档案已记(${activityId}):负责人留空 —— 岗位 ${spec.ownerPosition} 当前没有在职的人`
|
|
: ` 活动档案已记(${activityId}):负责人 ${owner.name}(岗位 ${spec.ownerPosition})`,
|
|
);
|
|
}
|